diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index 1ac04f672..9b6c6ea89 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.md
+++ b/.github/ISSUE_TEMPLATE/feature_request.md
@@ -15,6 +15,10 @@ A clear and concise description of what the feature or problem is.
How will it benefit GitHub MCP Server and its users?
+### Example prompts or workflows (for tools/toolsets only)
+
+If it's a new tool or improvement, share 3–5 example prompts or workflows it would enable. Just enough detail to show the value. Clear, valuable use cases are more likely to get approved.
+
### Additional context
-Add any other context like screenshots or mockups are helpful, if applicable.
\ No newline at end of file
+Add any other context like screenshots or mockups are helpful, if applicable.
diff --git a/Dockerfile b/Dockerfile
index 333ac0106..a26f19a81 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-FROM golang:1.24.3-alpine AS build
+FROM golang:1.24.4-alpine AS build
ARG VERSION="dev"
# Set the working directory
@@ -22,5 +22,7 @@ FROM gcr.io/distroless/base-debian12
WORKDIR /server
# Copy the binary from the build stage
COPY --from=build /bin/github-mcp-server .
-# Command to run the server
-CMD ["./github-mcp-server", "stdio"]
+# 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 7b9e20fc3..145966505 100644
--- a/README.md
+++ b/README.md
@@ -4,14 +4,128 @@ 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": {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/"
+ }
+ }
+}
+```
+
+ |
+
+
+```json
+{
+ "servers": {
+ "github": {
+ "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": {
+ "url": "https://api.githubcopilot.com/mcp/"
+ }
+ }
+}
+```
+
+ |
+
+
+```json
+{
+ "mcpServers": {
+ "github": {
+ "url": "https://api.githubcopilot.com/mcp/",
+ "authorization_token": "Bearer "
+ }
+ }
+}
+```
+
+ |
+
+
+
+> **Note:** The exact configuration format may vary by host. Refer to your host's documentation for the correct syntax and location for remote MCP server setup.
+
+### 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.
@@ -23,9 +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 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.
+
+### Usage in other MCP Hosts
-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)`.
+Add the following JSON block to your IDE MCP settings.
```json
{
@@ -141,19 +257,26 @@ 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) |
+| `actions` | GitHub Actions workflows and CI/CD operations |
+| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |
+| `code_security` | Code scanning alerts and security features |
| `issues` | Issue-related tools (create, read, update, comment) |
-| `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:
@@ -161,12 +284,12 @@ To specify toolsets you want available to the LLM, you can pass an allow-list in
1. **Using Command Line Argument**:
```bash
- github-mcp-server --toolsets repos,issues,pull_requests,code_security
+ github-mcp-server --toolsets repos,issues,pull_requests,actions,code_security
```
2. **Using Environment Variable**:
```bash
- GITHUB_TOOLSETS="repos,issues,pull_requests,code_security" ./github-mcp-server
+ GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security" ./github-mcp-server
```
The environment variable `GITHUB_TOOLSETS` takes precedence over the command line argument if both are provided.
@@ -178,7 +301,7 @@ When using Docker, you can pass the toolsets as environment variables:
```bash
docker run -i --rm \
-e GITHUB_PERSONAL_ACCESS_TOKEN= \
- -e GITHUB_TOOLSETS="repos,issues,pull_requests,code_security,experiments" \
+ -e GITHUB_TOOLSETS="repos,issues,pull_requests,actions,code_security,experiments" \
ghcr.io/github/github-mcp-server
```
@@ -219,12 +342,30 @@ 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.
-Prefix the hostname with the `https://` URI scheme, as it otherwise defaults to `http://` which GitHub Enterprise Server does not support.
+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",
@@ -240,7 +381,7 @@ Prefix the hostname with the `https://` URI scheme, as it otherwise defaults to
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}",
- "GITHUB_HOST": "https://"
+ "GITHUB_HOST": "https://"
}
}
```
@@ -351,6 +492,14 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `page`: Page number (number, optional)
- `perPage`: Results per page (number, optional)
+- **assign_copilot_to_issue** - Assign Copilot to a specific issue in a GitHub repository
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `issueNumber`: Issue number (number, required)
+ - _Note_: This tool can help with creating a Pull Request with source code changes to resolve the issue. More information can be found at [GitHub Copilot documentation](https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot)
+
+
### Pull Requests
- **get_pull_request** - Get details of a specific pull request
@@ -409,17 +558,58 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `repo`: Repository name (string, required)
- `pullNumber`: Pull request number (number, required)
-- **create_pull_request_review** - Create a review on a pull request review
+- **get_pull_request_diff** - Get the diff of a pull request
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `pullNumber`: Pull request number (number, required)
+
+- **create_pending_pull_request_review** - Create a pending review for a pull request that can be submitted later
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `pullNumber`: Pull request number (number, required)
+ - `commitID`: SHA of commit to review (string, optional)
+
+- **add_pull_request_review_comment_to_pending_review** - Add a comment to the requester's latest pending pull request review
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `pullNumber`: Pull request number (number, required)
+ - `path`: The relative path to the file that necessitates a comment (string, required)
+ - `body`: The text of the review comment (string, required)
+ - `subjectType`: The level at which the comment is targeted (string, required)
+ - Enum: "FILE", "LINE"
+ - `line`: The line of the blob in the pull request diff that the comment applies to (number, optional)
+ - `side`: The side of the diff to comment on (string, optional)
+ - Enum: "LEFT", "RIGHT"
+ - `startLine`: For multi-line comments, the first line of the range (number, optional)
+ - `startSide`: For multi-line comments, the starting side of the diff (string, optional)
+ - Enum: "LEFT", "RIGHT"
+
+- **submit_pending_pull_request_review** - Submit the requester's latest pending pull request review
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `pullNumber`: Pull request number (number, required)
+ - `event`: The event to perform (string, required)
+ - Enum: "APPROVE", "REQUEST_CHANGES", "COMMENT"
+ - `body`: The text of the review comment (string, optional)
+
+- **delete_pending_pull_request_review** - Delete the requester's latest pending pull request review
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `pullNumber`: Pull request number (number, required)
+
+- **create_and_submit_pull_request_review** - Create and submit a review for a pull request without review comments
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- `pullNumber`: Pull request number (number, required)
- - `body`: Review comment text (string, optional)
+ - `body`: Review comment text (string, required)
- `event`: Review action ('APPROVE', 'REQUEST_CHANGES', 'COMMENT') (string, required)
- - `commitId`: SHA of commit to review (string, optional)
- - `comments`: Line-specific comments array of objects to place comments on pull request changes (array, optional)
- - For inline comments: provide `path`, `position` (or `line`), and `body`
- - For multi-line comments: provide `path`, `start_line`, `line`, optional `side`/`start_side`, and `body`
+ - `commitID`: SHA of commit to review (string, optional)
- **create_pull_request** - Create a new pull request
@@ -432,21 +622,6 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `draft`: Create as draft PR (boolean, optional)
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
-- **add_pull_request_review_comment** - Add a review comment to a pull request or reply to an existing comment
-
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `pull_number`: Pull request number (number, required)
- - `body`: The text of the review comment (string, required)
- - `commit_id`: The SHA of the commit to comment on (string, required unless using in_reply_to)
- - `path`: The relative path to the file that necessitates a comment (string, required unless using in_reply_to)
- - `line`: The line of the blob in the pull request diff that the comment applies to (number, optional)
- - `side`: The side of the diff to comment on (LEFT or RIGHT) (string, optional)
- - `start_line`: For multi-line comments, the first line of the range (number, optional)
- - `start_side`: For multi-line comments, the starting side of the diff (LEFT or RIGHT) (string, optional)
- - `subject_type`: The level at which the comment is targeted (line or file) (string, optional)
- - `in_reply_to`: The ID of the review comment to reply to (number, optional). When specified, only body is required and other parameters are ignored.
-
- **update_pull_request** - Update an existing pull request in a GitHub repository
- `owner`: Repository owner (string, required)
@@ -476,6 +651,13 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `branch`: Branch name (string, optional)
- `sha`: File SHA if updating (string, optional)
+- **delete_file** - Delete a file from a GitHub repository
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `path`: Path to the file to delete (string, required)
+ - `message`: Commit message (string, required)
+ - `branch`: Branch to delete the file from (string, required)
+
- **list_branches** - List branches in a GitHub repository
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
@@ -534,6 +716,17 @@ 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)
+- **get_tag** - Get details about a specific git tag in a GitHub repository
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `tag`: Tag name (string, required)
+
+- **list_tags** - List git tags in a GitHub repository
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `page`: Page number (number, optional)
+ - `perPage`: Results per page (number, optional)
+
- **search_code** - Search for code across GitHub repositories
- `query`: Search query (string, required)
- `sort`: Sort field (string, optional)
@@ -550,6 +743,110 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `page`: Page number (number, optional)
- `perPage`: Results per page (number, optional)
+### Actions
+
+- **list_workflows** - List workflows in a repository
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `page`: Page number (number, optional)
+ - `perPage`: Results per page (number, optional)
+
+- **list_workflow_runs** - List workflow runs for a specific workflow
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `workflow_id`: Workflow ID or filename (string, required)
+ - `branch`: Filter by branch name (string, optional)
+ - `event`: Filter by event type (string, optional)
+ - `status`: Filter by run status (string, optional)
+ - `page`: Page number (number, optional)
+ - `perPage`: Results per page (number, optional)
+
+- **run_workflow** - Trigger a workflow via workflow_dispatch event
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `workflow_id`: Workflow ID or filename (string, required)
+ - `ref`: Git reference (branch, tag, or SHA) (string, required)
+ - `inputs`: Input parameters for the workflow (object, optional)
+
+- **get_workflow_run** - Get details of a specific workflow run
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `run_id`: Workflow run ID (number, required)
+
+- **get_workflow_run_logs** - Download logs for a workflow run
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `run_id`: Workflow run ID (number, required)
+
+- **list_workflow_jobs** - List jobs for a workflow run
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `run_id`: Workflow run ID (number, required)
+ - `filter`: Filter by job status (string, optional)
+ - `page`: Page number (number, optional)
+ - `perPage`: Results per page (number, optional)
+
+- **get_job_logs** - Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `job_id`: Job ID (number, required for single job logs)
+ - `run_id`: Workflow run ID (number, required when using failed_only)
+ - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional)
+ - `return_content`: Returns actual log content instead of URLs (boolean, optional)
+
+- **rerun_workflow_run** - Re-run an entire workflow
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `run_id`: Workflow run ID (number, required)
+ - `enable_debug_logging`: Enable debug logging for the re-run (boolean, optional)
+
+- **rerun_failed_jobs** - Re-run only the failed jobs in a workflow run
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `run_id`: Workflow run ID (number, required)
+ - `enable_debug_logging`: Enable debug logging for the re-run (boolean, optional)
+
+- **cancel_workflow_run** - Cancel a running workflow
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `run_id`: Workflow run ID (number, required)
+
+- **list_workflow_run_artifacts** - List artifacts from a workflow run
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `run_id`: Workflow run ID (number, required)
+ - `page`: Page number (number, optional)
+ - `perPage`: Results per page (number, optional)
+
+- **download_workflow_run_artifact** - Get download URL for a specific artifact
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `artifact_id`: Artifact ID (number, required)
+
+- **delete_workflow_run_logs** - Delete logs for a workflow run
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `run_id`: Workflow run ID (number, required)
+
+- **get_workflow_run_usage** - Get usage metrics for a workflow run
+
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `run_id`: Workflow run ID (number, required)
+
### Code Scanning
- **get_code_scanning_alert** - Get a code scanning alert
@@ -592,7 +889,6 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `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)
diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md
index 493ce5b18..317c2b8e5 100644
--- a/cmd/mcpcurl/README.md
+++ b/cmd/mcpcurl/README.md
@@ -31,7 +31,7 @@ The `--stdio-server-cmd` flag is required for all commands and specifies the com
### Examples
-List available tools in Anthropic's MCP server:
+List available tools in Github's MCP server:
```console
% ./mcpcurl --stdio-server-cmd "docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN mcp/github" tools --help
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/e2e_test.go b/e2e/e2e_test.go
index 71bd5a8ab..bc5a3fde3 100644
--- a/e2e/e2e_test.go
+++ b/e2e/e2e_test.go
@@ -4,7 +4,6 @@ package e2e_test
import (
"context"
- "encoding/base64"
"encoding/json"
"fmt"
"net/http"
@@ -19,7 +18,7 @@ import (
"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/translations"
- gogithub "github.com/google/go-github/v69/github"
+ 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"
@@ -508,17 +507,14 @@ func TestFileDeletion(t *testing.T) {
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")
+ embeddedResource, ok := resp.Content[1].(mcp.EmbeddedResource)
+ require.True(t, ok, "expected content to be of type EmbeddedResource")
- 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")
+ // raw api
+ textResource, ok := embeddedResource.Resource.(mcp.TextResourceContents)
+ require.True(t, ok, "expected embedded resource to be of type TextResourceContents")
+
+ require.Equal(t, fmt.Sprintf("Created by e2e test %s", t.Name()), textResource.Text, "expected file content to match")
// Delete the file
deleteFileRequest := mcp.CallToolRequest{}
@@ -703,17 +699,14 @@ func TestDirectoryDeletion(t *testing.T) {
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")
+ embeddedResource, ok := resp.Content[1].(mcp.EmbeddedResource)
+ require.True(t, ok, "expected content to be of type EmbeddedResource")
- 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")
+ // raw api
+ textResource, ok := embeddedResource.Resource.(mcp.TextResourceContents)
+ require.True(t, ok, "expected embedded resource to be of type TextResourceContents")
+
+ require.Equal(t, fmt.Sprintf("Created by e2e test %s", t.Name()), textResource.Text, "expected file content to match")
// Delete the directory containing the file
deleteFileRequest := mcp.CallToolRequest{}
diff --git a/go.mod b/go.mod
index 5c9bc081f..9cee56b5c 100644
--- a/go.mod
+++ b/go.mod
@@ -3,8 +3,9 @@ module github.com/github/github-mcp-server
go 1.23.7
require (
- github.com/google/go-github/v69 v69.2.0
- github.com/mark3labs/mcp-go v0.28.0
+ github.com/google/go-github/v72 v72.0.0
+ github.com/josephburnett/jd v1.9.2
+ github.com/mark3labs/mcp-go v0.32.0
github.com/migueleliasweb/go-github-mock v1.3.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
@@ -12,10 +13,20 @@ require (
github.com/stretchr/testify v1.10.0
)
+require (
+ 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/fsnotify/fsnotify v1.8.0 // indirect
- github.com/go-viper/mapstructure/v2 v2.2.1
+ github.com/go-viper/mapstructure/v2 v2.3.0
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
diff --git a/go.sum b/go.sum
index 6d3d29760..5e601d909 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,5 @@
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=
@@ -7,15 +8,20 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
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-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
-github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+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.3.0 h1:27XbWsHIqhbdR5TIC911OfYvgSaW93HM+dX7970Q7jk=
+github.com/go-viper/mapstructure/v2 v2.3.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/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=
@@ -24,6 +30,11 @@ github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/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=
@@ -31,10 +42,16 @@ 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.28.0 h1:7yl4y5D1KYU2f/9Uxp7xfLIggfunHoESCRbrjcytcLM=
-github.com/mark3labs/mcp-go v0.28.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
+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.32.0 h1:fgwmbfL2gbd67obg57OfV2Dnrhs1HtSdlY/i5fn7MU8=
+github.com/mark3labs/mcp-go v0.32.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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -64,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=
@@ -71,8 +90,12 @@ 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/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/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=
@@ -84,8 +107,14 @@ 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/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=
diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go
index a75a9e0cb..ca38e76b3 100644
--- a/internal/ghmcp/server.go
+++ b/internal/ghmcp/server.go
@@ -14,8 +14,9 @@ import (
"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/v69/github"
+ gogithub "github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
@@ -112,27 +113,27 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, 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
- toolsets, err := github.InitToolsets(
- enabledToolsets,
- cfg.ReadOnly,
- getClient,
- getGQLClient,
- cfg.Translator,
- )
+ tsg := github.DefaultToolsetGroup(cfg.ReadOnly, getClient, getGQLClient, getRawClient, cfg.Translator)
+ err = tsg.EnableToolsets(enabledToolsets)
+
if err != nil {
- return nil, fmt.Errorf("failed to initialize toolsets: %w", err)
+ return nil, fmt.Errorf("failed to enable toolsets: %w", err)
}
- context := github.InitContextToolset(getClient, cfg.Translator)
- github.RegisterResources(ghServer, getClient, cfg.Translator)
-
- // Register the tools with the server
- toolsets.RegisterTools(ghServer)
- context.RegisterTools(ghServer)
+ // Register all mcp functionality with the server
+ tsg.RegisterAll(ghServer)
if cfg.DynamicToolsets {
- dynamic := github.InitDynamicToolset(ghServer, toolsets, cfg.Translator)
+ dynamic := github.InitDynamicToolset(ghServer, tsg, cfg.Translator)
dynamic.RegisterTools(ghServer)
}
@@ -245,6 +246,7 @@ type apiHost struct {
baseRESTURL *url.URL
graphqlURL *url.URL
uploadURL *url.URL
+ rawURL *url.URL
}
func newDotcomHost() (apiHost, error) {
@@ -263,10 +265,16 @@ func newDotcomHost() (apiHost, error) {
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
}
@@ -296,10 +304,16 @@ func newGHECHost(hostname string) (apiHost, error) {
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
}
@@ -323,11 +337,16 @@ func newGHESHost(hostname string) (apiHost, error) {
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
}
diff --git a/internal/toolsnaps/toolsnaps.go b/internal/toolsnaps/toolsnaps.go
new file mode 100644
index 000000000..89d02e1ee
--- /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\nrun with `UPDATE_TOOLSNAPS=true` if this is expected", 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..be9cadf7f
--- /dev/null
+++ b/internal/toolsnaps/toolsnaps_test.go
@@ -0,0 +1,133 @@
+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)
+ // Ensure that UPDATE_TOOLSNAPS is not set for this test, which it might be if someone is running
+ // UPDATE_TOOLSNAPS=true go test ./...
+ t.Setenv("UPDATE_TOOLSNAPS", "false")
+
+ // 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)
+ // Ensure that UPDATE_TOOLSNAPS is not set for this test, which it might be if someone is running
+ // UPDATE_TOOLSNAPS=true go test ./...
+ t.Setenv("UPDATE_TOOLSNAPS", "false")
+
+ // 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)
+ // Ensure that UPDATE_TOOLSNAPS is not set for this test, which it might be if someone is running
+ // UPDATE_TOOLSNAPS=true go test ./...
+ t.Setenv("UPDATE_TOOLSNAPS", "false")
+
+ // 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__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap
new file mode 100644
index 000000000..92eeb1ce8
--- /dev/null
+++ b/pkg/github/__toolsnaps__/add_issue_comment.snap
@@ -0,0 +1,35 @@
+{
+ "annotations": {
+ "title": "Add comment to issue",
+ "readOnlyHint": false
+ },
+ "description": "Add a comment to a specific issue in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "body": {
+ "description": "Comment content",
+ "type": "string"
+ },
+ "issue_number": {
+ "description": "Issue number to comment on",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issue_number",
+ "body"
+ ],
+ "type": "object"
+ },
+ "name": "add_issue_comment"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment_to_pending_review.snap b/pkg/github/__toolsnaps__/add_pull_request_review_comment_to_pending_review.snap
new file mode 100644
index 000000000..454b9d0ba
--- /dev/null
+++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment_to_pending_review.snap
@@ -0,0 +1,73 @@
+{
+ "annotations": {
+ "title": "Add comment to the requester's latest pending pull request review",
+ "readOnlyHint": false
+ },
+ "description": "Add a comment to the requester's latest pending pull request review, a pending review needs to already exist to call this (check with the user if not sure).",
+ "inputSchema": {
+ "properties": {
+ "body": {
+ "description": "The text of the review comment",
+ "type": "string"
+ },
+ "line": {
+ "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",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "path": {
+ "description": "The relative path to the file that necessitates a comment",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "side": {
+ "description": "The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state",
+ "enum": [
+ "LEFT",
+ "RIGHT"
+ ],
+ "type": "string"
+ },
+ "startLine": {
+ "description": "For multi-line comments, the first line of the range that the comment applies to",
+ "type": "number"
+ },
+ "startSide": {
+ "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",
+ "enum": [
+ "LEFT",
+ "RIGHT"
+ ],
+ "type": "string"
+ },
+ "subjectType": {
+ "description": "The level at which the comment is targeted",
+ "enum": [
+ "FILE",
+ "LINE"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber",
+ "path",
+ "body",
+ "subjectType"
+ ],
+ "type": "object"
+ },
+ "name": "add_pull_request_review_comment_to_pending_review"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap
new file mode 100644
index 000000000..2d61ccfbd
--- /dev/null
+++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap
@@ -0,0 +1,31 @@
+{
+ "annotations": {
+ "title": "Assign Copilot to issue",
+ "readOnlyHint": false,
+ "idempotentHint": true
+ },
+ "description": "Assign Copilot to a specific issue in a GitHub repository.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot\n",
+ "inputSchema": {
+ "properties": {
+ "issueNumber": {
+ "description": "Issue number",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issueNumber"
+ ],
+ "type": "object"
+ },
+ "name": "assign_copilot_to_issue"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_and_submit_pull_request_review.snap b/pkg/github/__toolsnaps__/create_and_submit_pull_request_review.snap
new file mode 100644
index 000000000..85874cfc7
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_and_submit_pull_request_review.snap
@@ -0,0 +1,49 @@
+{
+ "annotations": {
+ "title": "Create and submit a pull request review without comments",
+ "readOnlyHint": false
+ },
+ "description": "Create and submit a review for a pull request without review comments.",
+ "inputSchema": {
+ "properties": {
+ "body": {
+ "description": "Review comment text",
+ "type": "string"
+ },
+ "commitID": {
+ "description": "SHA of commit to review",
+ "type": "string"
+ },
+ "event": {
+ "description": "Review action to perform",
+ "enum": [
+ "APPROVE",
+ "REQUEST_CHANGES",
+ "COMMENT"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber",
+ "body",
+ "event"
+ ],
+ "type": "object"
+ },
+ "name": "create_and_submit_pull_request_review"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_branch.snap b/pkg/github/__toolsnaps__/create_branch.snap
new file mode 100644
index 000000000..d5756fcc9
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_branch.snap
@@ -0,0 +1,34 @@
+{
+ "annotations": {
+ "title": "Create branch",
+ "readOnlyHint": false
+ },
+ "description": "Create a new branch in a GitHub repository",
+ "inputSchema": {
+ "properties": {
+ "branch": {
+ "description": "Name for new branch",
+ "type": "string"
+ },
+ "from_branch": {
+ "description": "Source branch (defaults to repo default)",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "branch"
+ ],
+ "type": "object"
+ },
+ "name": "create_branch"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_issue.snap b/pkg/github/__toolsnaps__/create_issue.snap
new file mode 100644
index 000000000..f065b0183
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_issue.snap
@@ -0,0 +1,52 @@
+{
+ "annotations": {
+ "title": "Open new issue",
+ "readOnlyHint": false
+ },
+ "description": "Create a new issue in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "assignees": {
+ "description": "Usernames to assign to this issue",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "body": {
+ "description": "Issue body content",
+ "type": "string"
+ },
+ "labels": {
+ "description": "Labels to apply to this issue",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "milestone": {
+ "description": "Milestone number",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "title": {
+ "description": "Issue title",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "title"
+ ],
+ "type": "object"
+ },
+ "name": "create_issue"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap
new file mode 100644
index 000000000..53f643df0
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_or_update_file.snap
@@ -0,0 +1,49 @@
+{
+ "annotations": {
+ "title": "Create or update file",
+ "readOnlyHint": false
+ },
+ "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.",
+ "inputSchema": {
+ "properties": {
+ "branch": {
+ "description": "Branch to create/update the file in",
+ "type": "string"
+ },
+ "content": {
+ "description": "Content of the file",
+ "type": "string"
+ },
+ "message": {
+ "description": "Commit message",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner (username or organization)",
+ "type": "string"
+ },
+ "path": {
+ "description": "Path where to create/update the file",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "sha": {
+ "description": "SHA of file being replaced (for updates)",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "path",
+ "content",
+ "message",
+ "branch"
+ ],
+ "type": "object"
+ },
+ "name": "create_or_update_file"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_pending_pull_request_review.snap b/pkg/github/__toolsnaps__/create_pending_pull_request_review.snap
new file mode 100644
index 000000000..3eea5e6af
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_pending_pull_request_review.snap
@@ -0,0 +1,34 @@
+{
+ "annotations": {
+ "title": "Create pending pull request review",
+ "readOnlyHint": false
+ },
+ "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.",
+ "inputSchema": {
+ "properties": {
+ "commitID": {
+ "description": "SHA of commit to review",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "create_pending_pull_request_review"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap
new file mode 100644
index 000000000..44142a79e
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_pull_request.snap
@@ -0,0 +1,52 @@
+{
+ "annotations": {
+ "title": "Open new pull request",
+ "readOnlyHint": false
+ },
+ "description": "Create a new pull request in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "base": {
+ "description": "Branch to merge into",
+ "type": "string"
+ },
+ "body": {
+ "description": "PR description",
+ "type": "string"
+ },
+ "draft": {
+ "description": "Create as draft PR",
+ "type": "boolean"
+ },
+ "head": {
+ "description": "Branch containing changes",
+ "type": "string"
+ },
+ "maintainer_can_modify": {
+ "description": "Allow maintainer edits",
+ "type": "boolean"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "title": {
+ "description": "PR title",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "title",
+ "head",
+ "base"
+ ],
+ "type": "object"
+ },
+ "name": "create_pull_request"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap
new file mode 100644
index 000000000..aaba75f3c
--- /dev/null
+++ b/pkg/github/__toolsnaps__/create_repository.snap
@@ -0,0 +1,32 @@
+{
+ "annotations": {
+ "title": "Create repository",
+ "readOnlyHint": false
+ },
+ "description": "Create a new GitHub repository in your account",
+ "inputSchema": {
+ "properties": {
+ "autoInit": {
+ "description": "Initialize with README",
+ "type": "boolean"
+ },
+ "description": {
+ "description": "Repository description",
+ "type": "string"
+ },
+ "name": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "private": {
+ "description": "Whether repo should be private",
+ "type": "boolean"
+ }
+ },
+ "required": [
+ "name"
+ ],
+ "type": "object"
+ },
+ "name": "create_repository"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/delete_file.snap b/pkg/github/__toolsnaps__/delete_file.snap
new file mode 100644
index 000000000..2588ea5c5
--- /dev/null
+++ b/pkg/github/__toolsnaps__/delete_file.snap
@@ -0,0 +1,41 @@
+{
+ "annotations": {
+ "title": "Delete file",
+ "readOnlyHint": false,
+ "destructiveHint": true
+ },
+ "description": "Delete a file from a GitHub repository",
+ "inputSchema": {
+ "properties": {
+ "branch": {
+ "description": "Branch to delete the file from",
+ "type": "string"
+ },
+ "message": {
+ "description": "Commit message",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner (username or organization)",
+ "type": "string"
+ },
+ "path": {
+ "description": "Path to the file to delete",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "path",
+ "message",
+ "branch"
+ ],
+ "type": "object"
+ },
+ "name": "delete_file"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap b/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap
new file mode 100644
index 000000000..9aff7356c
--- /dev/null
+++ b/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Delete the requester's latest pending pull request review",
+ "readOnlyHint": false
+ },
+ "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.",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "delete_pending_pull_request_review"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/dismiss_notification.snap b/pkg/github/__toolsnaps__/dismiss_notification.snap
new file mode 100644
index 000000000..80646a802
--- /dev/null
+++ b/pkg/github/__toolsnaps__/dismiss_notification.snap
@@ -0,0 +1,28 @@
+{
+ "annotations": {
+ "title": "Dismiss notification",
+ "readOnlyHint": false
+ },
+ "description": "Dismiss a notification by marking it as read or done",
+ "inputSchema": {
+ "properties": {
+ "state": {
+ "description": "The new state of the notification (read/done)",
+ "enum": [
+ "read",
+ "done"
+ ],
+ "type": "string"
+ },
+ "threadID": {
+ "description": "The ID of the notification thread",
+ "type": "string"
+ }
+ },
+ "required": [
+ "threadID"
+ ],
+ "type": "object"
+ },
+ "name": "dismiss_notification"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/fork_repository.snap b/pkg/github/__toolsnaps__/fork_repository.snap
new file mode 100644
index 000000000..6e4d27823
--- /dev/null
+++ b/pkg/github/__toolsnaps__/fork_repository.snap
@@ -0,0 +1,29 @@
+{
+ "annotations": {
+ "title": "Fork repository",
+ "readOnlyHint": false
+ },
+ "description": "Fork a GitHub repository to your account or specified organization",
+ "inputSchema": {
+ "properties": {
+ "organization": {
+ "description": "Organization to fork to",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "fork_repository"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap
new file mode 100644
index 000000000..eedc20b46
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get code scanning alert",
+ "readOnlyHint": true
+ },
+ "description": "Get details of a specific code scanning alert in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "alertNumber": {
+ "description": "The number of the alert.",
+ "type": "number"
+ },
+ "owner": {
+ "description": "The owner of the repository.",
+ "type": "string"
+ },
+ "repo": {
+ "description": "The name of the repository.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "alertNumber"
+ ],
+ "type": "object"
+ },
+ "name": "get_code_scanning_alert"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap
new file mode 100644
index 000000000..af0038110
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_commit.snap
@@ -0,0 +1,41 @@
+{
+ "annotations": {
+ "title": "Get commit details",
+ "readOnlyHint": true
+ },
+ "description": "Get details for a commit from a GitHub repository",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "sha": {
+ "description": "Commit SHA, branch name, or tag name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "sha"
+ ],
+ "type": "object"
+ },
+ "name": "get_commit"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap
new file mode 100644
index 000000000..c2c6f19f7
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_file_contents.snap
@@ -0,0 +1,34 @@
+{
+ "annotations": {
+ "title": "Get file or directory contents",
+ "readOnlyHint": true
+ },
+ "description": "Get the contents of a file or directory from a GitHub repository",
+ "inputSchema": {
+ "properties": {
+ "branch": {
+ "description": "Branch to get contents from",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner (username or organization)",
+ "type": "string"
+ },
+ "path": {
+ "description": "Path to file/directory (directories must end with a slash '/')",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "path"
+ ],
+ "type": "object"
+ },
+ "name": "get_file_contents"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_issue.snap b/pkg/github/__toolsnaps__/get_issue.snap
new file mode 100644
index 000000000..eab2b8722
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_issue.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get issue details",
+ "readOnlyHint": true
+ },
+ "description": "Get details of a specific issue in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "issue_number": {
+ "description": "The number of the issue",
+ "type": "number"
+ },
+ "owner": {
+ "description": "The owner of the repository",
+ "type": "string"
+ },
+ "repo": {
+ "description": "The name of the repository",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issue_number"
+ ],
+ "type": "object"
+ },
+ "name": "get_issue"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_issue_comments.snap b/pkg/github/__toolsnaps__/get_issue_comments.snap
new file mode 100644
index 000000000..fa1fb0d6c
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_issue_comments.snap
@@ -0,0 +1,38 @@
+{
+ "annotations": {
+ "title": "Get issue comments",
+ "readOnlyHint": true
+ },
+ "description": "Get comments for a specific issue in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "issue_number": {
+ "description": "Issue number",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number",
+ "type": "number"
+ },
+ "per_page": {
+ "description": "Number of records per page",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issue_number"
+ ],
+ "type": "object"
+ },
+ "name": "get_issue_comments"
+}
\ No newline at end of file
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/__toolsnaps__/get_notification_details.snap b/pkg/github/__toolsnaps__/get_notification_details.snap
new file mode 100644
index 000000000..62bc6bf1b
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_notification_details.snap
@@ -0,0 +1,20 @@
+{
+ "annotations": {
+ "title": "Get notification details",
+ "readOnlyHint": true
+ },
+ "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.",
+ "inputSchema": {
+ "properties": {
+ "notificationID": {
+ "description": "The ID of the notification",
+ "type": "string"
+ }
+ },
+ "required": [
+ "notificationID"
+ ],
+ "type": "object"
+ },
+ "name": "get_notification_details"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_pull_request.snap b/pkg/github/__toolsnaps__/get_pull_request.snap
new file mode 100644
index 000000000..cbcf1f425
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_pull_request.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get pull request details",
+ "readOnlyHint": true
+ },
+ "description": "Get details of a specific pull request in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "get_pull_request"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_pull_request_comments.snap b/pkg/github/__toolsnaps__/get_pull_request_comments.snap
new file mode 100644
index 000000000..6699f6d97
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_pull_request_comments.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get pull request comments",
+ "readOnlyHint": true
+ },
+ "description": "Get comments for a specific pull request.",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "get_pull_request_comments"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_pull_request_diff.snap b/pkg/github/__toolsnaps__/get_pull_request_diff.snap
new file mode 100644
index 000000000..e054eab92
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_pull_request_diff.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get pull request diff",
+ "readOnlyHint": true
+ },
+ "description": "Get the diff of a pull request.",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "get_pull_request_diff"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_pull_request_files.snap b/pkg/github/__toolsnaps__/get_pull_request_files.snap
new file mode 100644
index 000000000..c61f5f357
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_pull_request_files.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get pull request files",
+ "readOnlyHint": true
+ },
+ "description": "Get the files changed in a specific pull request.",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "get_pull_request_files"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_pull_request_reviews.snap b/pkg/github/__toolsnaps__/get_pull_request_reviews.snap
new file mode 100644
index 000000000..61dee53ee
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_pull_request_reviews.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get pull request reviews",
+ "readOnlyHint": true
+ },
+ "description": "Get reviews for a specific pull request.",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "get_pull_request_reviews"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_pull_request_status.snap b/pkg/github/__toolsnaps__/get_pull_request_status.snap
new file mode 100644
index 000000000..8ffebc3a4
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_pull_request_status.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get pull request status checks",
+ "readOnlyHint": true
+ },
+ "description": "Get the status of a specific pull request.",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "get_pull_request_status"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_tag.snap b/pkg/github/__toolsnaps__/get_tag.snap
new file mode 100644
index 000000000..42089f872
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_tag.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get tag details",
+ "readOnlyHint": true
+ },
+ "description": "Get details about a specific git tag in a GitHub repository",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "tag": {
+ "description": "Tag name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "tag"
+ ],
+ "type": "object"
+ },
+ "name": "get_tag"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_branches.snap b/pkg/github/__toolsnaps__/list_branches.snap
new file mode 100644
index 000000000..492b6d527
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_branches.snap
@@ -0,0 +1,36 @@
+{
+ "annotations": {
+ "title": "List branches",
+ "readOnlyHint": true
+ },
+ "description": "List branches in a GitHub repository",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "list_branches"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap
new file mode 100644
index 000000000..470f0d01f
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap
@@ -0,0 +1,57 @@
+{
+ "annotations": {
+ "title": "List code scanning alerts",
+ "readOnlyHint": true
+ },
+ "description": "List code scanning alerts in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "The owner of the repository.",
+ "type": "string"
+ },
+ "ref": {
+ "description": "The Git reference for the results you want to list.",
+ "type": "string"
+ },
+ "repo": {
+ "description": "The name of the repository.",
+ "type": "string"
+ },
+ "severity": {
+ "description": "Filter code scanning alerts by severity",
+ "enum": [
+ "critical",
+ "high",
+ "medium",
+ "low",
+ "warning",
+ "note",
+ "error"
+ ],
+ "type": "string"
+ },
+ "state": {
+ "default": "open",
+ "description": "Filter code scanning alerts by state. Defaults to open",
+ "enum": [
+ "open",
+ "closed",
+ "dismissed",
+ "fixed"
+ ],
+ "type": "string"
+ },
+ "tool_name": {
+ "description": "The name of the tool used for code scanning.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "list_code_scanning_alerts"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap
new file mode 100644
index 000000000..7be03a7fe
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_commits.snap
@@ -0,0 +1,40 @@
+{
+ "annotations": {
+ "title": "List commits",
+ "readOnlyHint": true
+ },
+ "description": "Get list of commits of a branch in a GitHub repository",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "sha": {
+ "description": "SHA or Branch name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "list_commits"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap
new file mode 100644
index 000000000..4fe155f09
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_issues.snap
@@ -0,0 +1,73 @@
+{
+ "annotations": {
+ "title": "List issues",
+ "readOnlyHint": true
+ },
+ "description": "List issues in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "direction": {
+ "description": "Sort direction",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "type": "string"
+ },
+ "labels": {
+ "description": "Filter by labels",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "since": {
+ "description": "Filter by date (ISO 8601 timestamp)",
+ "type": "string"
+ },
+ "sort": {
+ "description": "Sort order",
+ "enum": [
+ "created",
+ "updated",
+ "comments"
+ ],
+ "type": "string"
+ },
+ "state": {
+ "description": "Filter by state",
+ "enum": [
+ "open",
+ "closed",
+ "all"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "list_issues"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_notifications.snap b/pkg/github/__toolsnaps__/list_notifications.snap
new file mode 100644
index 000000000..92f25eb4c
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_notifications.snap
@@ -0,0 +1,49 @@
+{
+ "annotations": {
+ "title": "List notifications",
+ "readOnlyHint": true
+ },
+ "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.",
+ "inputSchema": {
+ "properties": {
+ "before": {
+ "description": "Only show notifications updated before the given time (ISO 8601 format)",
+ "type": "string"
+ },
+ "filter": {
+ "description": "Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created.",
+ "enum": [
+ "default",
+ "include_read_notifications",
+ "only_participating"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "Optional repository owner. If provided with repo, only notifications for this repository are listed.",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "repo": {
+ "description": "Optional repository name. If provided with owner, only notifications for this repository are listed.",
+ "type": "string"
+ },
+ "since": {
+ "description": "Only show notifications updated after the given time (ISO 8601 format)",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "name": "list_notifications"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_pull_requests.snap b/pkg/github/__toolsnaps__/list_pull_requests.snap
new file mode 100644
index 000000000..b8369784d
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_pull_requests.snap
@@ -0,0 +1,71 @@
+{
+ "annotations": {
+ "title": "List pull requests",
+ "readOnlyHint": true
+ },
+ "description": "List pull requests in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "base": {
+ "description": "Filter by base branch",
+ "type": "string"
+ },
+ "direction": {
+ "description": "Sort direction",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "type": "string"
+ },
+ "head": {
+ "description": "Filter by head user/org and branch",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "sort": {
+ "description": "Sort by",
+ "enum": [
+ "created",
+ "updated",
+ "popularity",
+ "long-running"
+ ],
+ "type": "string"
+ },
+ "state": {
+ "description": "Filter by state",
+ "enum": [
+ "open",
+ "closed",
+ "all"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "list_pull_requests"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_tags.snap b/pkg/github/__toolsnaps__/list_tags.snap
new file mode 100644
index 000000000..fcb9853fd
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_tags.snap
@@ -0,0 +1,36 @@
+{
+ "annotations": {
+ "title": "List tags",
+ "readOnlyHint": true
+ },
+ "description": "List git tags in a GitHub repository",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "list_tags"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/manage_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_notification_subscription.snap
new file mode 100644
index 000000000..0f7d91201
--- /dev/null
+++ b/pkg/github/__toolsnaps__/manage_notification_subscription.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Manage notification subscription",
+ "readOnlyHint": false
+ },
+ "description": "Manage a notification subscription: ignore, watch, or delete a notification thread subscription.",
+ "inputSchema": {
+ "properties": {
+ "action": {
+ "description": "Action to perform: ignore, watch, or delete the notification subscription.",
+ "enum": [
+ "ignore",
+ "watch",
+ "delete"
+ ],
+ "type": "string"
+ },
+ "notificationID": {
+ "description": "The ID of the notification thread.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "notificationID",
+ "action"
+ ],
+ "type": "object"
+ },
+ "name": "manage_notification_subscription"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap
new file mode 100644
index 000000000..9d09a5817
--- /dev/null
+++ b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap
@@ -0,0 +1,35 @@
+{
+ "annotations": {
+ "title": "Manage repository notification subscription",
+ "readOnlyHint": false
+ },
+ "description": "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository.",
+ "inputSchema": {
+ "properties": {
+ "action": {
+ "description": "Action to perform: ignore, watch, or delete the repository notification subscription.",
+ "enum": [
+ "ignore",
+ "watch",
+ "delete"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "The account owner of the repository.",
+ "type": "string"
+ },
+ "repo": {
+ "description": "The name of the repository.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "action"
+ ],
+ "type": "object"
+ },
+ "name": "manage_repository_notification_subscription"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap
new file mode 100644
index 000000000..5a1fe24a5
--- /dev/null
+++ b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap
@@ -0,0 +1,25 @@
+{
+ "annotations": {
+ "title": "Mark all notifications as read",
+ "readOnlyHint": false
+ },
+ "description": "Mark all notifications as read",
+ "inputSchema": {
+ "properties": {
+ "lastReadAt": {
+ "description": "Describes the last point that notifications were checked (optional). Default: Now",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Optional repository owner. If provided with repo, only notifications for this repository are marked as read.",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Optional repository name. If provided with owner, only notifications for this repository are marked as read.",
+ "type": "string"
+ }
+ },
+ "type": "object"
+ },
+ "name": "mark_all_notifications_read"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/merge_pull_request.snap b/pkg/github/__toolsnaps__/merge_pull_request.snap
new file mode 100644
index 000000000..a5a1474cb
--- /dev/null
+++ b/pkg/github/__toolsnaps__/merge_pull_request.snap
@@ -0,0 +1,47 @@
+{
+ "annotations": {
+ "title": "Merge pull request",
+ "readOnlyHint": false
+ },
+ "description": "Merge a pull request in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "commit_message": {
+ "description": "Extra detail for merge commit",
+ "type": "string"
+ },
+ "commit_title": {
+ "description": "Title for merge commit",
+ "type": "string"
+ },
+ "merge_method": {
+ "description": "Merge method",
+ "enum": [
+ "merge",
+ "squash",
+ "rebase"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "merge_pull_request"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/push_files.snap b/pkg/github/__toolsnaps__/push_files.snap
new file mode 100644
index 000000000..3ade75eeb
--- /dev/null
+++ b/pkg/github/__toolsnaps__/push_files.snap
@@ -0,0 +1,58 @@
+{
+ "annotations": {
+ "title": "Push files to repository",
+ "readOnlyHint": false
+ },
+ "description": "Push multiple files to a GitHub repository in a single commit",
+ "inputSchema": {
+ "properties": {
+ "branch": {
+ "description": "Branch to push to",
+ "type": "string"
+ },
+ "files": {
+ "description": "Array of file objects to push, each object with path (string) and content (string)",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "description": "file content",
+ "type": "string"
+ },
+ "path": {
+ "description": "path to the file",
+ "type": "string"
+ }
+ },
+ "required": [
+ "path",
+ "content"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "message": {
+ "description": "Commit message",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "branch",
+ "files",
+ "message"
+ ],
+ "type": "object"
+ },
+ "name": "push_files"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/request_copilot_review.snap b/pkg/github/__toolsnaps__/request_copilot_review.snap
new file mode 100644
index 000000000..1717ced01
--- /dev/null
+++ b/pkg/github/__toolsnaps__/request_copilot_review.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Request Copilot review",
+ "readOnlyHint": false
+ },
+ "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.",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "request_copilot_review"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_code.snap b/pkg/github/__toolsnaps__/search_code.snap
new file mode 100644
index 000000000..c85d6674d
--- /dev/null
+++ b/pkg/github/__toolsnaps__/search_code.snap
@@ -0,0 +1,43 @@
+{
+ "annotations": {
+ "title": "Search code",
+ "readOnlyHint": true
+ },
+ "description": "Search for code across GitHub repositories",
+ "inputSchema": {
+ "properties": {
+ "order": {
+ "description": "Sort order",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "q": {
+ "description": "Search query using GitHub code search syntax",
+ "type": "string"
+ },
+ "sort": {
+ "description": "Sort field ('indexed' only)",
+ "type": "string"
+ }
+ },
+ "required": [
+ "q"
+ ],
+ "type": "object"
+ },
+ "name": "search_code"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_issues.snap b/pkg/github/__toolsnaps__/search_issues.snap
new file mode 100644
index 000000000..4e2382a3c
--- /dev/null
+++ b/pkg/github/__toolsnaps__/search_issues.snap
@@ -0,0 +1,56 @@
+{
+ "annotations": {
+ "title": "Search issues",
+ "readOnlyHint": true
+ },
+ "description": "Search for issues in GitHub repositories.",
+ "inputSchema": {
+ "properties": {
+ "order": {
+ "description": "Sort order",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "q": {
+ "description": "Search query using GitHub issues search syntax",
+ "type": "string"
+ },
+ "sort": {
+ "description": "Sort field by number of matches of categories, defaults to best match",
+ "enum": [
+ "comments",
+ "reactions",
+ "reactions-+1",
+ "reactions--1",
+ "reactions-smile",
+ "reactions-thinking_face",
+ "reactions-heart",
+ "reactions-tada",
+ "interactions",
+ "created",
+ "updated"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "q"
+ ],
+ "type": "object"
+ },
+ "name": "search_issues"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap
new file mode 100644
index 000000000..b6b6d1d44
--- /dev/null
+++ b/pkg/github/__toolsnaps__/search_repositories.snap
@@ -0,0 +1,31 @@
+{
+ "annotations": {
+ "title": "Search repositories",
+ "readOnlyHint": true
+ },
+ "description": "Search for GitHub repositories",
+ "inputSchema": {
+ "properties": {
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "query": {
+ "description": "Search query",
+ "type": "string"
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "type": "object"
+ },
+ "name": "search_repositories"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_users.snap b/pkg/github/__toolsnaps__/search_users.snap
new file mode 100644
index 000000000..aad2970b6
--- /dev/null
+++ b/pkg/github/__toolsnaps__/search_users.snap
@@ -0,0 +1,48 @@
+{
+ "annotations": {
+ "title": "Search users",
+ "readOnlyHint": true
+ },
+ "description": "Search for GitHub users",
+ "inputSchema": {
+ "properties": {
+ "order": {
+ "description": "Sort order",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "q": {
+ "description": "Search query using GitHub users search syntax",
+ "type": "string"
+ },
+ "sort": {
+ "description": "Sort field by category",
+ "enum": [
+ "followers",
+ "repositories",
+ "joined"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "q"
+ ],
+ "type": "object"
+ },
+ "name": "search_users"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap
new file mode 100644
index 000000000..f3541922b
--- /dev/null
+++ b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap
@@ -0,0 +1,44 @@
+{
+ "annotations": {
+ "title": "Submit the requester's latest pending pull request review",
+ "readOnlyHint": false
+ },
+ "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.",
+ "inputSchema": {
+ "properties": {
+ "body": {
+ "description": "The text of the review comment",
+ "type": "string"
+ },
+ "event": {
+ "description": "The event to perform",
+ "enum": [
+ "APPROVE",
+ "REQUEST_CHANGES",
+ "COMMENT"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber",
+ "event"
+ ],
+ "type": "object"
+ },
+ "name": "submit_pending_pull_request_review"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/update_issue.snap b/pkg/github/__toolsnaps__/update_issue.snap
new file mode 100644
index 000000000..4bcae7ba7
--- /dev/null
+++ b/pkg/github/__toolsnaps__/update_issue.snap
@@ -0,0 +1,64 @@
+{
+ "annotations": {
+ "title": "Edit issue",
+ "readOnlyHint": false
+ },
+ "description": "Update an existing issue in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "assignees": {
+ "description": "New assignees",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "body": {
+ "description": "New description",
+ "type": "string"
+ },
+ "issue_number": {
+ "description": "Issue number to update",
+ "type": "number"
+ },
+ "labels": {
+ "description": "New labels",
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "milestone": {
+ "description": "New milestone number",
+ "type": "number"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "state": {
+ "description": "New state",
+ "enum": [
+ "open",
+ "closed"
+ ],
+ "type": "string"
+ },
+ "title": {
+ "description": "New title",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "issue_number"
+ ],
+ "type": "object"
+ },
+ "name": "update_issue"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap
new file mode 100644
index 000000000..765983afd
--- /dev/null
+++ b/pkg/github/__toolsnaps__/update_pull_request.snap
@@ -0,0 +1,54 @@
+{
+ "annotations": {
+ "title": "Edit pull request",
+ "readOnlyHint": false
+ },
+ "description": "Update an existing pull request in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "base": {
+ "description": "New base branch name",
+ "type": "string"
+ },
+ "body": {
+ "description": "New description",
+ "type": "string"
+ },
+ "maintainer_can_modify": {
+ "description": "Allow maintainer edits",
+ "type": "boolean"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number to update",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "state": {
+ "description": "New state",
+ "enum": [
+ "open",
+ "closed"
+ ],
+ "type": "string"
+ },
+ "title": {
+ "description": "New title",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "update_pull_request"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/update_pull_request_branch.snap b/pkg/github/__toolsnaps__/update_pull_request_branch.snap
new file mode 100644
index 000000000..60ec9c126
--- /dev/null
+++ b/pkg/github/__toolsnaps__/update_pull_request_branch.snap
@@ -0,0 +1,34 @@
+{
+ "annotations": {
+ "title": "Update pull request branch",
+ "readOnlyHint": false
+ },
+ "description": "Update the branch of a pull request with the latest changes from the base branch.",
+ "inputSchema": {
+ "properties": {
+ "expectedHeadSha": {
+ "description": "The expected SHA of the pull request's HEAD ref",
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "pullNumber": {
+ "description": "Pull request number",
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "pullNumber"
+ ],
+ "type": "object"
+ },
+ "name": "update_pull_request_branch"
+}
\ No newline at end of file
diff --git a/pkg/github/actions.go b/pkg/github/actions.go
new file mode 100644
index 000000000..527a426ed
--- /dev/null
+++ b/pkg/github/actions.go
@@ -0,0 +1,1223 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "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 (
+ DescriptionRepositoryOwner = "Repository owner"
+ DescriptionRepositoryName = "Repository name"
+)
+
+// ListWorkflows creates a tool to list workflows in a repository
+func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_workflows",
+ mcp.WithDescription(t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_WORKFLOWS_USER_TITLE", "List workflows"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("per_page",
+ mcp.Description("The number of results per page (max 100)"),
+ ),
+ mcp.WithNumber("page",
+ mcp.Description("The page number of the results to fetch"),
+ ),
+ ),
+ 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
+ }
+
+ // Get optional pagination parameters
+ perPage, err := OptionalIntParam(request, "per_page")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ page, err := OptionalIntParam(request, "page")
+ 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)
+ }
+
+ // Set up list options
+ opts := &github.ListOptions{
+ PerPage: perPage,
+ Page: page,
+ }
+
+ workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list workflows: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ r, err := json.Marshal(workflows)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// ListWorkflowRuns creates a tool to list workflow runs for a specific workflow
+func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_workflow_runs",
+ mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUNS_DESCRIPTION", "List workflow runs for a specific workflow")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_WORKFLOW_RUNS_USER_TITLE", "List workflow runs"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithString("workflow_id",
+ mcp.Required(),
+ mcp.Description("The workflow ID or workflow file name"),
+ ),
+ mcp.WithString("actor",
+ mcp.Description("Returns someone's workflow runs. Use the login for the user who created the workflow run."),
+ ),
+ mcp.WithString("branch",
+ mcp.Description("Returns workflow runs associated with a branch. Use the name of the branch."),
+ ),
+ mcp.WithString("event",
+ mcp.Description("Returns workflow runs for a specific event type"),
+ mcp.Enum(
+ "branch_protection_rule",
+ "check_run",
+ "check_suite",
+ "create",
+ "delete",
+ "deployment",
+ "deployment_status",
+ "discussion",
+ "discussion_comment",
+ "fork",
+ "gollum",
+ "issue_comment",
+ "issues",
+ "label",
+ "merge_group",
+ "milestone",
+ "page_build",
+ "public",
+ "pull_request",
+ "pull_request_review",
+ "pull_request_review_comment",
+ "pull_request_target",
+ "push",
+ "registry_package",
+ "release",
+ "repository_dispatch",
+ "schedule",
+ "status",
+ "watch",
+ "workflow_call",
+ "workflow_dispatch",
+ "workflow_run",
+ ),
+ ),
+ mcp.WithString("status",
+ mcp.Description("Returns workflow runs with the check run status"),
+ mcp.Enum("queued", "in_progress", "completed", "requested", "waiting"),
+ ),
+ mcp.WithNumber("per_page",
+ mcp.Description("The number of results per page (max 100)"),
+ ),
+ mcp.WithNumber("page",
+ mcp.Description("The page number of the results to fetch"),
+ ),
+ ),
+ 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
+ }
+ workflowID, err := RequiredParam[string](request, "workflow_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Get optional filtering parameters
+ actor, err := OptionalParam[string](request, "actor")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ branch, err := OptionalParam[string](request, "branch")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ event, err := OptionalParam[string](request, "event")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ status, err := OptionalParam[string](request, "status")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Get optional pagination parameters
+ perPage, err := OptionalIntParam(request, "per_page")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ page, err := OptionalIntParam(request, "page")
+ 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)
+ }
+
+ // Set up list options
+ opts := &github.ListWorkflowRunsOptions{
+ Actor: actor,
+ Branch: branch,
+ Event: event,
+ Status: status,
+ ListOptions: github.ListOptions{
+ PerPage: perPage,
+ Page: page,
+ },
+ }
+
+ workflowRuns, resp, err := client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, workflowID, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list workflow runs: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ r, err := json.Marshal(workflowRuns)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// RunWorkflow creates a tool to run an Actions workflow
+func RunWorkflow(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("run_workflow",
+ mcp.WithDescription(t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID or filename")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_RUN_WORKFLOW_USER_TITLE", "Run workflow"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithString("workflow_id",
+ mcp.Required(),
+ mcp.Description("The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml)"),
+ ),
+ mcp.WithString("ref",
+ mcp.Required(),
+ mcp.Description("The git reference for the workflow. The reference can be a branch or tag name."),
+ ),
+ mcp.WithObject("inputs",
+ mcp.Description("Inputs the workflow accepts"),
+ ),
+ ),
+ 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
+ }
+ workflowID, err := RequiredParam[string](request, "workflow_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ ref, err := RequiredParam[string](request, "ref")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Get optional inputs parameter
+ var inputs map[string]interface{}
+ if requestInputs, ok := request.GetArguments()["inputs"]; ok {
+ if inputsMap, ok := requestInputs.(map[string]interface{}); ok {
+ inputs = inputsMap
+ }
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ event := github.CreateWorkflowDispatchEventRequest{
+ Ref: ref,
+ Inputs: inputs,
+ }
+
+ var resp *github.Response
+ var workflowType string
+
+ if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil {
+ resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event)
+ workflowType = "workflow_id"
+ } else {
+ resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event)
+ workflowType = "workflow_file"
+ }
+
+ if err != nil {
+ return nil, fmt.Errorf("failed to run workflow: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ result := map[string]any{
+ "message": "Workflow run has been queued",
+ "workflow_type": workflowType,
+ "workflow_id": workflowID,
+ "ref": ref,
+ "inputs": inputs,
+ "status": resp.Status,
+ "status_code": resp.StatusCode,
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// GetWorkflowRun creates a tool to get details of a specific workflow run
+func GetWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_workflow_run",
+ mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_WORKFLOW_RUN_USER_TITLE", "Get workflow run"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("run_id",
+ mcp.Required(),
+ mcp.Description("The unique identifier of the workflow run"),
+ ),
+ ),
+ 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
+ }
+ runIDInt, err := RequiredInt(request, "run_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ runID := int64(runIDInt)
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, runID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get workflow run: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ r, err := json.Marshal(workflowRun)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// GetWorkflowRunLogs creates a tool to download logs for a specific workflow run
+func GetWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_workflow_run_logs",
+ mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_WORKFLOW_RUN_LOGS_USER_TITLE", "Get workflow run logs"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("run_id",
+ mcp.Required(),
+ mcp.Description("The unique identifier of the workflow run"),
+ ),
+ ),
+ 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
+ }
+ runIDInt, err := RequiredInt(request, "run_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ runID := int64(runIDInt)
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ // Get the download URL for the logs
+ url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get workflow run logs: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Create response with the logs URL and information
+ result := map[string]any{
+ "logs_url": url.String(),
+ "message": "Workflow run logs are available for download",
+ "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.",
+ "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.",
+ "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging",
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// ListWorkflowJobs creates a tool to list jobs for a specific workflow run
+func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_workflow_jobs",
+ mcp.WithDescription(t("TOOL_LIST_WORKFLOW_JOBS_DESCRIPTION", "List jobs for a specific workflow run")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_WORKFLOW_JOBS_USER_TITLE", "List workflow jobs"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("run_id",
+ mcp.Required(),
+ mcp.Description("The unique identifier of the workflow run"),
+ ),
+ mcp.WithString("filter",
+ mcp.Description("Filters jobs by their completed_at timestamp"),
+ mcp.Enum("latest", "all"),
+ ),
+ mcp.WithNumber("per_page",
+ mcp.Description("The number of results per page (max 100)"),
+ ),
+ mcp.WithNumber("page",
+ mcp.Description("The page number of the results to fetch"),
+ ),
+ ),
+ 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
+ }
+ runIDInt, err := RequiredInt(request, "run_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ runID := int64(runIDInt)
+
+ // Get optional filtering parameters
+ filter, err := OptionalParam[string](request, "filter")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Get optional pagination parameters
+ perPage, err := OptionalIntParam(request, "per_page")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ page, err := OptionalIntParam(request, "page")
+ 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)
+ }
+
+ // Set up list options
+ opts := &github.ListWorkflowJobsOptions{
+ Filter: filter,
+ ListOptions: github.ListOptions{
+ PerPage: perPage,
+ Page: page,
+ },
+ }
+
+ jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list workflow jobs: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Add optimization tip for failed job debugging
+ response := map[string]any{
+ "jobs": jobs,
+ "optimization_tip": "For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id=" + fmt.Sprintf("%d", runID) + " to get logs directly without needing to list jobs first",
+ }
+
+ r, err := json.Marshal(response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run
+func GetJobLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_job_logs",
+ mcp.WithDescription(t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_JOB_LOGS_USER_TITLE", "Get job logs"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("job_id",
+ mcp.Description("The unique identifier of the workflow job (required for single job logs)"),
+ ),
+ mcp.WithNumber("run_id",
+ mcp.Description("Workflow run ID (required when using failed_only)"),
+ ),
+ mcp.WithBoolean("failed_only",
+ mcp.Description("When true, gets logs for all failed jobs in run_id"),
+ ),
+ mcp.WithBoolean("return_content",
+ mcp.Description("Returns actual log content instead of URLs"),
+ ),
+ ),
+ 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
+ }
+
+ // Get optional parameters
+ jobID, err := OptionalIntParam(request, "job_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ runID, err := OptionalIntParam(request, "run_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ failedOnly, err := OptionalParam[bool](request, "failed_only")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ returnContent, err := OptionalParam[bool](request, "return_content")
+ 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)
+ }
+
+ // Validate parameters
+ if failedOnly && runID == 0 {
+ return mcp.NewToolResultError("run_id is required when failed_only is true"), nil
+ }
+ if !failedOnly && jobID == 0 {
+ return mcp.NewToolResultError("job_id is required when failed_only is false"), nil
+ }
+
+ if failedOnly && runID > 0 {
+ // Handle failed-only mode: get logs for all failed jobs in the workflow run
+ return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent)
+ } else if jobID > 0 {
+ // Handle single job mode
+ return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent)
+ }
+
+ return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil
+ }
+}
+
+// handleFailedJobLogs gets logs for all failed jobs in a workflow run
+func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool) (*mcp.CallToolResult, error) {
+ // First, get all jobs for the workflow run
+ jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{
+ Filter: "latest",
+ })
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("failed to list workflow jobs: %v", err)), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Filter for failed jobs
+ var failedJobs []*github.WorkflowJob
+ for _, job := range jobs.Jobs {
+ if job.GetConclusion() == "failure" {
+ failedJobs = append(failedJobs, job)
+ }
+ }
+
+ if len(failedJobs) == 0 {
+ result := map[string]any{
+ "message": "No failed jobs found in this workflow run",
+ "run_id": runID,
+ "total_jobs": len(jobs.Jobs),
+ "failed_jobs": 0,
+ }
+ r, _ := json.Marshal(result)
+ return mcp.NewToolResultText(string(r)), nil
+ }
+
+ // Collect logs for all failed jobs
+ var logResults []map[string]any
+ for _, job := range failedJobs {
+ jobResult, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent)
+ if err != nil {
+ // Continue with other jobs even if one fails
+ jobResult = map[string]any{
+ "job_id": job.GetID(),
+ "job_name": job.GetName(),
+ "error": err.Error(),
+ }
+ }
+ logResults = append(logResults, jobResult)
+ }
+
+ result := map[string]any{
+ "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)),
+ "run_id": runID,
+ "total_jobs": len(jobs.Jobs),
+ "failed_jobs": len(failedJobs),
+ "logs": logResults,
+ "return_format": map[string]bool{"content": returnContent, "urls": !returnContent},
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+// handleSingleJobLogs gets logs for a single job
+func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool) (*mcp.CallToolResult, error) {
+ jobResult, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ r, err := json.Marshal(jobResult)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+// getJobLogData retrieves log data for a single job, either as URL or content
+func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool) (map[string]any, error) {
+ // Get the download URL for the job logs
+ url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get job logs for job %d: %w", jobID, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ result := map[string]any{
+ "job_id": jobID,
+ }
+ if jobName != "" {
+ result["job_name"] = jobName
+ }
+
+ if returnContent {
+ // Download and return the actual log content
+ content, err := downloadLogContent(url.String())
+ if err != nil {
+ return nil, fmt.Errorf("failed to download log content for job %d: %w", jobID, err)
+ }
+ result["logs_content"] = content
+ result["message"] = "Job logs content retrieved successfully"
+ } else {
+ // Return just the URL
+ result["logs_url"] = url.String()
+ result["message"] = "Job logs are available for download"
+ result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content."
+ }
+
+ return result, nil
+}
+
+// downloadLogContent downloads the actual log content from a GitHub logs URL
+func downloadLogContent(logURL string) (string, error) {
+ httpResp, err := http.Get(logURL) //nolint:gosec // URLs are provided by GitHub API and are safe
+ if err != nil {
+ return "", fmt.Errorf("failed to download logs: %w", err)
+ }
+ defer func() { _ = httpResp.Body.Close() }()
+
+ if httpResp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode)
+ }
+
+ content, err := io.ReadAll(httpResp.Body)
+ if err != nil {
+ return "", fmt.Errorf("failed to read log content: %w", err)
+ }
+
+ // Clean up and format the log content for better readability
+ logContent := strings.TrimSpace(string(content))
+ return logContent, nil
+}
+
+// RerunWorkflowRun creates a tool to re-run an entire workflow run
+func RerunWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("rerun_workflow_run",
+ mcp.WithDescription(t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_RERUN_WORKFLOW_RUN_USER_TITLE", "Rerun workflow run"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("run_id",
+ mcp.Required(),
+ mcp.Description("The unique identifier of the workflow run"),
+ ),
+ ),
+ 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
+ }
+ runIDInt, err := RequiredInt(request, "run_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ runID := int64(runIDInt)
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to rerun workflow run: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ result := map[string]any{
+ "message": "Workflow run has been queued for re-run",
+ "run_id": runID,
+ "status": resp.Status,
+ "status_code": resp.StatusCode,
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run
+func RerunFailedJobs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("rerun_failed_jobs",
+ mcp.WithDescription(t("TOOL_RERUN_FAILED_JOBS_DESCRIPTION", "Re-run only the failed jobs in a workflow run")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_RERUN_FAILED_JOBS_USER_TITLE", "Rerun failed jobs"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("run_id",
+ mcp.Required(),
+ mcp.Description("The unique identifier of the workflow run"),
+ ),
+ ),
+ 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
+ }
+ runIDInt, err := RequiredInt(request, "run_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ runID := int64(runIDInt)
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to rerun failed jobs: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ result := map[string]any{
+ "message": "Failed jobs have been queued for re-run",
+ "run_id": runID,
+ "status": resp.Status,
+ "status_code": resp.StatusCode,
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// CancelWorkflowRun creates a tool to cancel a workflow run
+func CancelWorkflowRun(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("cancel_workflow_run",
+ mcp.WithDescription(t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_CANCEL_WORKFLOW_RUN_USER_TITLE", "Cancel workflow run"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("run_id",
+ mcp.Required(),
+ mcp.Description("The unique identifier of the workflow run"),
+ ),
+ ),
+ 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
+ }
+ runIDInt, err := RequiredInt(request, "run_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ runID := int64(runIDInt)
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to cancel workflow run: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ result := map[string]any{
+ "message": "Workflow run has been cancelled",
+ "run_id": runID,
+ "status": resp.Status,
+ "status_code": resp.StatusCode,
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run
+func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_workflow_run_artifacts",
+ mcp.WithDescription(t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_DESCRIPTION", "List artifacts for a workflow run")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_USER_TITLE", "List workflow artifacts"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("run_id",
+ mcp.Required(),
+ mcp.Description("The unique identifier of the workflow run"),
+ ),
+ mcp.WithNumber("per_page",
+ mcp.Description("The number of results per page (max 100)"),
+ ),
+ mcp.WithNumber("page",
+ mcp.Description("The page number of the results to fetch"),
+ ),
+ ),
+ 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
+ }
+ runIDInt, err := RequiredInt(request, "run_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ runID := int64(runIDInt)
+
+ // Get optional pagination parameters
+ perPage, err := OptionalIntParam(request, "per_page")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ page, err := OptionalIntParam(request, "page")
+ 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)
+ }
+
+ // Set up list options
+ opts := &github.ListOptions{
+ PerPage: perPage,
+ Page: page,
+ }
+
+ artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list workflow run artifacts: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ r, err := json.Marshal(artifacts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact
+func DownloadWorkflowRunArtifact(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("download_workflow_run_artifact",
+ mcp.WithDescription(t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_DESCRIPTION", "Get download URL for a workflow run artifact")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_USER_TITLE", "Download workflow artifact"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("artifact_id",
+ mcp.Required(),
+ mcp.Description("The unique identifier of the artifact"),
+ ),
+ ),
+ 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
+ }
+ artifactIDInt, err := RequiredInt(request, "artifact_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ artifactID := int64(artifactIDInt)
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ // Get the download URL for the artifact
+ url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, artifactID, 1)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get artifact download URL: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Create response with the download URL and information
+ result := map[string]any{
+ "download_url": url.String(),
+ "message": "Artifact is available for download",
+ "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.",
+ "artifact_id": artifactID,
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run
+func DeleteWorkflowRunLogs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("delete_workflow_run_logs",
+ mcp.WithDescription(t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_DELETE_WORKFLOW_RUN_LOGS_USER_TITLE", "Delete workflow logs"),
+ ReadOnlyHint: ToBoolPtr(false),
+ DestructiveHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("run_id",
+ mcp.Required(),
+ mcp.Description("The unique identifier of the workflow run"),
+ ),
+ ),
+ 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
+ }
+ runIDInt, err := RequiredInt(request, "run_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ runID := int64(runIDInt)
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to delete workflow run logs: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ result := map[string]any{
+ "message": "Workflow run logs have been deleted",
+ "run_id": runID,
+ "status": resp.Status,
+ "status_code": resp.StatusCode,
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run
+func GetWorkflowRunUsage(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_workflow_run_usage",
+ mcp.WithDescription(t("TOOL_GET_WORKFLOW_RUN_USAGE_DESCRIPTION", "Get usage metrics for a workflow run")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_WORKFLOW_RUN_USAGE_USER_TITLE", "Get workflow usage"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryOwner),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description(DescriptionRepositoryName),
+ ),
+ mcp.WithNumber("run_id",
+ mcp.Required(),
+ mcp.Description("The unique identifier of the workflow run"),
+ ),
+ ),
+ 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
+ }
+ runIDInt, err := RequiredInt(request, "run_id")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ runID := int64(runIDInt)
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, runID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get workflow run usage: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ r, err := json.Marshal(usage)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go
new file mode 100644
index 000000000..388c0bbe2
--- /dev/null
+++ b/pkg/github/actions_test.go
@@ -0,0 +1,1097 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "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_ListWorkflows(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListWorkflows(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "list_workflows", 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, "per_page")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful workflow listing",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsWorkflowsByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ workflows := &github.Workflows{
+ TotalCount: github.Ptr(2),
+ Workflows: []*github.Workflow{
+ {
+ ID: github.Ptr(int64(123)),
+ Name: github.Ptr("CI"),
+ Path: github.Ptr(".github/workflows/ci.yml"),
+ State: github.Ptr("active"),
+ CreatedAt: &github.Timestamp{},
+ UpdatedAt: &github.Timestamp{},
+ URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/123"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/ci.yml"),
+ BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/CI/badge.svg"),
+ NodeID: github.Ptr("W_123"),
+ },
+ {
+ ID: github.Ptr(int64(456)),
+ Name: github.Ptr("Deploy"),
+ Path: github.Ptr(".github/workflows/deploy.yml"),
+ State: github.Ptr("active"),
+ CreatedAt: &github.Timestamp{},
+ UpdatedAt: &github.Timestamp{},
+ URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/workflows/456"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/actions/workflows/deploy.yml"),
+ BadgeURL: github.Ptr("https://github.com/owner/repo/workflows/Deploy/badge.svg"),
+ NodeID: github.Ptr("W_456"),
+ },
+ },
+ }
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(workflows)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ },
+ {
+ name: "missing required parameter owner",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListWorkflows(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ require.NoError(t, err)
+ require.Equal(t, tc.expectError, result.IsError)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ if tc.expectedErrMsg != "" {
+ assert.Equal(t, tc.expectedErrMsg, textContent.Text)
+ return
+ }
+
+ // Unmarshal and verify the result
+ var response github.Workflows
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.NotNil(t, response.TotalCount)
+ assert.Greater(t, *response.TotalCount, 0)
+ assert.NotEmpty(t, response.Workflows)
+ })
+ }
+}
+
+func Test_RunWorkflow(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := RunWorkflow(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "run_workflow", 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, "workflow_id")
+ assert.Contains(t, tool.InputSchema.Properties, "ref")
+ assert.Contains(t, tool.InputSchema.Properties, "inputs")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "workflow_id", "ref"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful workflow run",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "workflow_id": "12345",
+ "ref": "main",
+ },
+ expectError: false,
+ },
+ {
+ name: "missing required parameter workflow_id",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "ref": "main",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: workflow_id",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ require.NoError(t, err)
+ require.Equal(t, tc.expectError, result.IsError)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ if tc.expectedErrMsg != "" {
+ assert.Equal(t, tc.expectedErrMsg, textContent.Text)
+ return
+ }
+
+ // Unmarshal and verify the result
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.Equal(t, "Workflow run has been queued", response["message"])
+ assert.Contains(t, response, "workflow_type")
+ })
+ }
+}
+
+func Test_RunWorkflow_WithFilename(t *testing.T) {
+ // Test the unified RunWorkflow function with filenames
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful workflow run by filename",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "workflow_id": "ci.yml",
+ "ref": "main",
+ },
+ expectError: false,
+ },
+ {
+ name: "successful workflow run by numeric ID as string",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "workflow_id": "12345",
+ "ref": "main",
+ },
+ expectError: false,
+ },
+ {
+ name: "missing required parameter workflow_id",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "ref": "main",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: workflow_id",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := RunWorkflow(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ require.NoError(t, err)
+ require.Equal(t, tc.expectError, result.IsError)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ if tc.expectedErrMsg != "" {
+ assert.Equal(t, tc.expectedErrMsg, textContent.Text)
+ return
+ }
+
+ // Unmarshal and verify the result
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.Equal(t, "Workflow run has been queued", response["message"])
+ assert.Contains(t, response, "workflow_type")
+ })
+ }
+}
+
+func Test_CancelWorkflowRun(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := CancelWorkflowRun(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "cancel_workflow_run", 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, "run_id")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful workflow run cancellation",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.EndpointPattern{
+ Pattern: "/repos/owner/repo/actions/runs/12345/cancel",
+ Method: "POST",
+ },
+ "", // Empty response body for 202 Accepted
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "run_id": float64(12345),
+ },
+ expectError: false,
+ },
+ {
+ name: "missing required parameter run_id",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: run_id",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := CancelWorkflowRun(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ require.NoError(t, err)
+ require.Equal(t, tc.expectError, result.IsError)
+
+ // Parse the result and get the text content
+ textContent := getTextResult(t, result)
+
+ if tc.expectedErrMsg != "" {
+ assert.Equal(t, tc.expectedErrMsg, textContent.Text)
+ return
+ }
+
+ // Unmarshal and verify the result
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.Equal(t, "Workflow run has been cancelled", response["message"])
+ assert.Equal(t, float64(12345), response["run_id"])
+ })
+ }
+}
+
+func Test_ListWorkflowRunArtifacts(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListWorkflowRunArtifacts(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "list_workflow_run_artifacts", 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, "run_id")
+ assert.Contains(t, tool.InputSchema.Properties, "per_page")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful artifacts listing",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsRunsArtifactsByOwnerByRepoByRunId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ artifacts := &github.ArtifactList{
+ TotalCount: github.Ptr(int64(2)),
+ Artifacts: []*github.Artifact{
+ {
+ ID: github.Ptr(int64(1)),
+ NodeID: github.Ptr("A_1"),
+ Name: github.Ptr("build-artifacts"),
+ SizeInBytes: github.Ptr(int64(1024)),
+ URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1"),
+ ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/1/zip"),
+ Expired: github.Ptr(false),
+ CreatedAt: &github.Timestamp{},
+ UpdatedAt: &github.Timestamp{},
+ ExpiresAt: &github.Timestamp{},
+ WorkflowRun: &github.ArtifactWorkflowRun{
+ ID: github.Ptr(int64(12345)),
+ RepositoryID: github.Ptr(int64(1)),
+ HeadRepositoryID: github.Ptr(int64(1)),
+ HeadBranch: github.Ptr("main"),
+ HeadSHA: github.Ptr("abc123"),
+ },
+ },
+ {
+ ID: github.Ptr(int64(2)),
+ NodeID: github.Ptr("A_2"),
+ Name: github.Ptr("test-results"),
+ SizeInBytes: github.Ptr(int64(512)),
+ URL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2"),
+ ArchiveDownloadURL: github.Ptr("https://api.github.com/repos/owner/repo/actions/artifacts/2/zip"),
+ Expired: github.Ptr(false),
+ CreatedAt: &github.Timestamp{},
+ UpdatedAt: &github.Timestamp{},
+ ExpiresAt: &github.Timestamp{},
+ WorkflowRun: &github.ArtifactWorkflowRun{
+ ID: github.Ptr(int64(12345)),
+ RepositoryID: github.Ptr(int64(1)),
+ HeadRepositoryID: github.Ptr(int64(1)),
+ HeadBranch: github.Ptr("main"),
+ HeadSHA: github.Ptr("abc123"),
+ },
+ },
+ },
+ }
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(artifacts)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "run_id": float64(12345),
+ },
+ expectError: false,
+ },
+ {
+ name: "missing required parameter run_id",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: run_id",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListWorkflowRunArtifacts(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ require.NoError(t, err)
+ require.Equal(t, tc.expectError, result.IsError)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ if tc.expectedErrMsg != "" {
+ assert.Equal(t, tc.expectedErrMsg, textContent.Text)
+ return
+ }
+
+ // Unmarshal and verify the result
+ var response github.ArtifactList
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.NotNil(t, response.TotalCount)
+ assert.Greater(t, *response.TotalCount, int64(0))
+ assert.NotEmpty(t, response.Artifacts)
+ })
+ }
+}
+
+func Test_DownloadWorkflowRunArtifact(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := DownloadWorkflowRunArtifact(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "download_workflow_run_artifact", 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, "artifact_id")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "artifact_id"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful artifact download URL",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.EndpointPattern{
+ Pattern: "/repos/owner/repo/actions/artifacts/123/zip",
+ Method: "GET",
+ },
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // GitHub returns a 302 redirect to the download URL
+ w.Header().Set("Location", "https://api.github.com/repos/owner/repo/actions/artifacts/123/download")
+ w.WriteHeader(http.StatusFound)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "artifact_id": float64(123),
+ },
+ expectError: false,
+ },
+ {
+ name: "missing required parameter artifact_id",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: artifact_id",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := DownloadWorkflowRunArtifact(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ require.NoError(t, err)
+ require.Equal(t, tc.expectError, result.IsError)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ if tc.expectedErrMsg != "" {
+ assert.Equal(t, tc.expectedErrMsg, textContent.Text)
+ return
+ }
+
+ // Unmarshal and verify the result
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.Contains(t, response, "download_url")
+ assert.Contains(t, response, "message")
+ assert.Equal(t, "Artifact is available for download", response["message"])
+ assert.Equal(t, float64(123), response["artifact_id"])
+ })
+ }
+}
+
+func Test_DeleteWorkflowRunLogs(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := DeleteWorkflowRunLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "delete_workflow_run_logs", 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, "run_id")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful logs deletion",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.DeleteReposActionsRunsLogsByOwnerByRepoByRunId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "run_id": float64(12345),
+ },
+ expectError: false,
+ },
+ {
+ name: "missing required parameter run_id",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: run_id",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := DeleteWorkflowRunLogs(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ require.NoError(t, err)
+ require.Equal(t, tc.expectError, result.IsError)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ if tc.expectedErrMsg != "" {
+ assert.Equal(t, tc.expectedErrMsg, textContent.Text)
+ return
+ }
+
+ // Unmarshal and verify the result
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.Equal(t, "Workflow run logs have been deleted", response["message"])
+ assert.Equal(t, float64(12345), response["run_id"])
+ })
+ }
+}
+
+func Test_GetWorkflowRunUsage(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := GetWorkflowRunUsage(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "get_workflow_run_usage", 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, "run_id")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful workflow run usage",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsRunsTimingByOwnerByRepoByRunId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ usage := &github.WorkflowRunUsage{
+ Billable: &github.WorkflowRunBillMap{
+ "UBUNTU": &github.WorkflowRunBill{
+ TotalMS: github.Ptr(int64(120000)),
+ Jobs: github.Ptr(2),
+ JobRuns: []*github.WorkflowRunJobRun{
+ {
+ JobID: github.Ptr(1),
+ DurationMS: github.Ptr(int64(60000)),
+ },
+ {
+ JobID: github.Ptr(2),
+ DurationMS: github.Ptr(int64(60000)),
+ },
+ },
+ },
+ },
+ RunDurationMS: github.Ptr(int64(120000)),
+ }
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(usage)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "run_id": float64(12345),
+ },
+ expectError: false,
+ },
+ {
+ name: "missing required parameter run_id",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: run_id",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := GetWorkflowRunUsage(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ require.NoError(t, err)
+ require.Equal(t, tc.expectError, result.IsError)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ if tc.expectedErrMsg != "" {
+ assert.Equal(t, tc.expectedErrMsg, textContent.Text)
+ return
+ }
+
+ // Unmarshal and verify the result
+ var response github.WorkflowRunUsage
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+ assert.NotNil(t, response.RunDurationMS)
+ assert.NotNil(t, response.Billable)
+ })
+ }
+}
+
+func Test_GetJobLogs(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := GetJobLogs(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "get_job_logs", 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, "job_id")
+ assert.Contains(t, tool.InputSchema.Properties, "run_id")
+ assert.Contains(t, tool.InputSchema.Properties, "failed_only")
+ assert.Contains(t, tool.InputSchema.Properties, "return_content")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]any
+ expectError bool
+ expectedErrMsg string
+ checkResponse func(t *testing.T, response map[string]any)
+ }{
+ {
+ name: "successful single job logs with URL",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsJobsLogsByOwnerByRepoByJobId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Location", "https://github.com/logs/job/123")
+ w.WriteHeader(http.StatusFound)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "job_id": float64(123),
+ },
+ expectError: false,
+ checkResponse: func(t *testing.T, response map[string]any) {
+ assert.Equal(t, float64(123), response["job_id"])
+ assert.Contains(t, response, "logs_url")
+ assert.Equal(t, "Job logs are available for download", response["message"])
+ assert.Contains(t, response, "note")
+ },
+ },
+ {
+ name: "successful failed jobs logs",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsRunsJobsByOwnerByRepoByRunId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ jobs := &github.Jobs{
+ TotalCount: github.Ptr(3),
+ Jobs: []*github.WorkflowJob{
+ {
+ ID: github.Ptr(int64(1)),
+ Name: github.Ptr("test-job-1"),
+ Conclusion: github.Ptr("success"),
+ },
+ {
+ ID: github.Ptr(int64(2)),
+ Name: github.Ptr("test-job-2"),
+ Conclusion: github.Ptr("failure"),
+ },
+ {
+ ID: github.Ptr(int64(3)),
+ Name: github.Ptr("test-job-3"),
+ Conclusion: github.Ptr("failure"),
+ },
+ },
+ }
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(jobs)
+ }),
+ ),
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsJobsLogsByOwnerByRepoByJobId,
+ http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:])
+ w.WriteHeader(http.StatusFound)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "run_id": float64(456),
+ "failed_only": true,
+ },
+ expectError: false,
+ checkResponse: func(t *testing.T, response map[string]any) {
+ assert.Equal(t, float64(456), response["run_id"])
+ assert.Equal(t, float64(3), response["total_jobs"])
+ assert.Equal(t, float64(2), response["failed_jobs"])
+ assert.Contains(t, response, "logs")
+ assert.Equal(t, "Retrieved logs for 2 failed jobs", response["message"])
+
+ logs, ok := response["logs"].([]interface{})
+ assert.True(t, ok)
+ assert.Len(t, logs, 2)
+ },
+ },
+ {
+ name: "no failed jobs found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsRunsJobsByOwnerByRepoByRunId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ jobs := &github.Jobs{
+ TotalCount: github.Ptr(2),
+ Jobs: []*github.WorkflowJob{
+ {
+ ID: github.Ptr(int64(1)),
+ Name: github.Ptr("test-job-1"),
+ Conclusion: github.Ptr("success"),
+ },
+ {
+ ID: github.Ptr(int64(2)),
+ Name: github.Ptr("test-job-2"),
+ Conclusion: github.Ptr("success"),
+ },
+ },
+ }
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(jobs)
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "run_id": float64(456),
+ "failed_only": true,
+ },
+ expectError: false,
+ checkResponse: func(t *testing.T, response map[string]any) {
+ assert.Equal(t, "No failed jobs found in this workflow run", response["message"])
+ assert.Equal(t, float64(456), response["run_id"])
+ assert.Equal(t, float64(2), response["total_jobs"])
+ assert.Equal(t, float64(0), response["failed_jobs"])
+ },
+ },
+ {
+ name: "missing job_id when not using failed_only",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "job_id is required when failed_only is false",
+ },
+ {
+ name: "missing run_id when using failed_only",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "failed_only": true,
+ },
+ expectError: true,
+ expectedErrMsg: "run_id is required when failed_only is true",
+ },
+ {
+ name: "missing required parameter owner",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "repo": "repo",
+ "job_id": float64(123),
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: owner",
+ },
+ {
+ name: "missing required parameter repo",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "job_id": float64(123),
+ },
+ expectError: true,
+ expectedErrMsg: "missing required parameter: repo",
+ },
+ {
+ name: "API error when getting single job logs",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsJobsLogsByOwnerByRepoByJobId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "message": "Not Found",
+ })
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "job_id": float64(999),
+ },
+ expectError: true,
+ },
+ {
+ name: "API error when listing workflow jobs for failed_only",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsRunsJobsByOwnerByRepoByRunId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _ = json.NewEncoder(w).Encode(map[string]string{
+ "message": "Not Found",
+ })
+ }),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "run_id": float64(999),
+ "failed_only": true,
+ },
+ expectError: true,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ require.NoError(t, err)
+ require.Equal(t, tc.expectError, result.IsError)
+
+ // Parse the result and get the text content
+ textContent := getTextResult(t, result)
+
+ if tc.expectedErrMsg != "" {
+ assert.Equal(t, tc.expectedErrMsg, textContent.Text)
+ return
+ }
+
+ if tc.expectError {
+ // For API errors, just verify we got an error
+ assert.True(t, result.IsError)
+ return
+ }
+
+ // Unmarshal and verify the result
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+
+ if tc.checkResponse != nil {
+ tc.checkResponse(t, response)
+ }
+ })
+ }
+}
+
+func Test_GetJobLogs_WithContentReturn(t *testing.T) {
+ // Test the return_content functionality with a mock HTTP server
+ logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully"
+
+ // Create a test server to serve log content
+ testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(logContent))
+ }))
+ defer testServer.Close()
+
+ mockedClient := mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsJobsLogsByOwnerByRepoByJobId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Location", testServer.URL)
+ w.WriteHeader(http.StatusFound)
+ }),
+ ),
+ )
+
+ client := github.NewClient(mockedClient)
+ _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ request := createMCPRequest(map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "job_id": float64(123),
+ "return_content": true,
+ })
+
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+
+ assert.Equal(t, float64(123), response["job_id"])
+ assert.Equal(t, logContent, response["logs_content"])
+ assert.Equal(t, "Job logs content retrieved successfully", response["message"])
+ assert.NotContains(t, response, "logs_url") // Should not have URL when returning content
+}
diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go
index 34a1b9eda..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"
)
@@ -18,7 +18,7 @@ func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelpe
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -34,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
}
@@ -80,7 +80,7 @@ func ListCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHel
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -90,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"),
@@ -107,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..5c0131a77 100644
--- a/pkg/github/code_scanning_test.go
+++ b/pkg/github/code_scanning_test.go
@@ -6,8 +6,9 @@ import (
"net/http"
"testing"
+ "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"
@@ -17,6 +18,7 @@ func Test_GetCodeScanningAlert(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetCodeScanningAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_code_scanning_alert", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -119,6 +121,7 @@ func Test_ListCodeScanningAlerts(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := ListCodeScanningAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_code_scanning_alerts", tool.Name)
assert.NotEmpty(t, tool.Description)
diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go
index 180f32dd4..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,41 +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 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 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 0b098fb39..e703a885e 100644
--- a/pkg/github/dynamic_tools.go
+++ b/pkg/github/dynamic_tools.go
@@ -25,7 +25,7 @@ func EnableToolset(s *server.MCPServer, toolsetGroup *toolsets.ToolsetGroup, t t
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("toolset",
mcp.Required(),
@@ -35,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
}
@@ -64,7 +64,7 @@ func ListAvailableToolsets(toolsetGroup *toolsets.ToolsetGroup, t translations.T
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
),
func(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -98,7 +98,7 @@ func GetToolsetsTools(toolsetGroup *toolsets.ToolsetGroup, t translations.Transl
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("toolset",
mcp.Required(),
@@ -108,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 06bc1d545..bc1ae412f 100644
--- a/pkg/github/helper_test.go
+++ b/pkg/github/helper_test.go
@@ -109,12 +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]any) mcp.CallToolRequest {
+func createMCPRequest(args any) mcp.CallToolRequest {
return mcp.CallToolRequest{
Params: struct {
- Name string `json:"name"`
- Arguments map[string]any `json:"arguments,omitempty"`
- Meta *mcp.Meta `json:"_meta,omitempty"`
+ Name string `json:"name"`
+ Arguments any `json:"arguments,omitempty"`
+ Meta *mcp.Meta `json:"_meta,omitempty"`
}{
Arguments: args,
},
@@ -132,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 68e7a36cd..ea068ed00 100644
--- a/pkg/github/issues.go
+++ b/pkg/github/issues.go
@@ -11,7 +11,7 @@ import (
"github.com/github/github-mcp-server/pkg/translations"
"github.com/go-viper/mapstructure/v2"
- "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"
"github.com/shurcooL/githubv4"
@@ -23,7 +23,7 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -39,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
}
@@ -85,7 +85,7 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -105,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
}
@@ -117,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
}
@@ -159,7 +159,7 @@ func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("q",
mcp.Required(),
@@ -188,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
}
@@ -247,7 +247,7 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -285,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
}
@@ -369,7 +369,7 @@ func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (to
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -405,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
}
@@ -450,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)
@@ -491,7 +491,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -536,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
}
@@ -637,7 +637,7 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -659,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
}
@@ -759,8 +759,8 @@ func AssignCopilotToIssue(getGQLClient GetGQLClientFn, t translations.Translatio
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),
+ ReadOnlyHint: ToBoolPtr(false),
+ IdempotentHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go
index cd715de68..7c76d90f9 100644
--- a/pkg/github/issues_test.go
+++ b/pkg/github/issues_test.go
@@ -9,8 +9,9 @@ import (
"time"
"github.com/github/github-mcp-server/internal/githubv4mock"
+ "github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
@@ -21,6 +22,7 @@ func Test_GetIssue(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_issue", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -117,6 +119,7 @@ func Test_AddIssueComment(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := AddIssueComment(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "add_issue_comment", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -230,6 +233,7 @@ func Test_SearchIssues(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := SearchIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "search_issues", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -386,6 +390,7 @@ func Test_CreateIssue(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := CreateIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "create_issue", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -560,6 +565,7 @@ func Test_ListIssues(t *testing.T) {
// Verify tool definition
mockClient := github.NewClient(nil)
tool, _ := ListIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_issues", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -736,6 +742,7 @@ func Test_UpdateIssue(t *testing.T) {
// Verify tool definition
mockClient := github.NewClient(nil)
tool, _ := UpdateIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "update_issue", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -993,6 +1000,7 @@ func Test_GetIssueComments(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetIssueComments(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_issue_comments", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1129,6 +1137,7 @@ func TestAssignCopilotToIssue(t *testing.T) {
// Verify tool definition
mockClient := githubv4.NewClient(nil)
tool, _ := AssignCopilotToIssue(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "assign_copilot_to_issue", tool.Name)
assert.NotEmpty(t, tool.Description)
diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go
index ba9c6bc2b..677ee99f0 100644
--- a/pkg/github/notifications.go
+++ b/pkg/github/notifications.go
@@ -10,7 +10,7 @@ import (
"time"
"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"
)
@@ -27,7 +27,7 @@ func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFu
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),
+ 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."),
@@ -146,7 +146,7 @@ func DismissNotification(getclient GetClientFn, t translations.TranslationHelper
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("threadID",
mcp.Required(),
@@ -160,12 +160,12 @@ func DismissNotification(getclient GetClientFn, t translations.TranslationHelper
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- threadID, err := requiredParam[string](request, "threadID")
+ threadID, err := RequiredParam[string](request, "threadID")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- state, err := requiredParam[string](request, "state")
+ state, err := RequiredParam[string](request, "state")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -209,7 +209,7 @@ func MarkAllNotificationsRead(getClient GetClientFn, t translations.TranslationH
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("lastReadAt",
mcp.Description("Describes the last point that notifications were checked (optional). Default: Now"),
@@ -284,7 +284,7 @@ func GetNotificationDetails(getClient GetClientFn, t translations.TranslationHel
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("notificationID",
mcp.Required(),
@@ -297,7 +297,7 @@ func GetNotificationDetails(getClient GetClientFn, t translations.TranslationHel
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- notificationID, err := requiredParam[string](request, "notificationID")
+ notificationID, err := RequiredParam[string](request, "notificationID")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -338,7 +338,7 @@ func ManageNotificationSubscription(getClient GetClientFn, t translations.Transl
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("notificationID",
mcp.Required(),
@@ -356,11 +356,11 @@ func ManageNotificationSubscription(getClient GetClientFn, t translations.Transl
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- notificationID, err := requiredParam[string](request, "notificationID")
+ notificationID, err := RequiredParam[string](request, "notificationID")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- action, err := requiredParam[string](request, "action")
+ action, err := RequiredParam[string](request, "action")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -373,10 +373,10 @@ func ManageNotificationSubscription(getClient GetClientFn, t translations.Transl
switch action {
case NotificationActionIgnore:
- sub := &github.Subscription{Ignored: toBoolPtr(true)}
+ 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)}
+ 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)
@@ -419,7 +419,7 @@ func ManageRepositoryNotificationSubscription(getClient GetClientFn, t translati
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -441,15 +441,15 @@ func ManageRepositoryNotificationSubscription(getClient GetClientFn, t translati
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- 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
}
- action, err := requiredParam[string](request, "action")
+ action, err := RequiredParam[string](request, "action")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -462,10 +462,10 @@ func ManageRepositoryNotificationSubscription(getClient GetClientFn, t translati
switch action {
case RepositorySubscriptionActionIgnore:
- sub := &github.Subscription{Ignored: toBoolPtr(true)}
+ 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)}
+ 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)
diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go
index 66400295a..77372f021 100644
--- a/pkg/github/notifications_test.go
+++ b/pkg/github/notifications_test.go
@@ -6,8 +6,9 @@ import (
"net/http"
"testing"
+ "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"
@@ -17,6 +18,8 @@ func Test_ListNotifications(t *testing.T) {
// Verify tool definition and schema
mockClient := github.NewClient(nil)
tool, _ := ListNotifications(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
assert.Equal(t, "list_notifications", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "filter")
@@ -147,6 +150,8 @@ func Test_ManageNotificationSubscription(t *testing.T) {
// Verify tool definition and schema
mockClient := github.NewClient(nil)
tool, _ := ManageNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
assert.Equal(t, "manage_notification_subscription", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "notificationID")
@@ -283,6 +288,8 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) {
// Verify tool definition and schema
mockClient := github.NewClient(nil)
tool, _ := ManageRepositoryNotificationSubscription(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
assert.Equal(t, "manage_repository_notification_subscription", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
@@ -444,6 +451,8 @@ func Test_DismissNotification(t *testing.T) {
// Verify tool definition and schema
mockClient := github.NewClient(nil)
tool, _ := DismissNotification(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
assert.Equal(t, "dismiss_notification", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "threadID")
@@ -574,6 +583,8 @@ func Test_MarkAllNotificationsRead(t *testing.T) {
// Verify tool definition and schema
mockClient := github.NewClient(nil)
tool, _ := MarkAllNotificationsRead(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
assert.Equal(t, "mark_all_notifications_read", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "lastReadAt")
@@ -672,6 +683,8 @@ func Test_GetNotificationDetails(t *testing.T) {
// Verify tool definition and schema
mockClient := github.NewClient(nil)
tool, _ := GetNotificationDetails(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
assert.Equal(t, "get_notification_details", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "notificationID")
diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go
index d6dd3f96e..b16920aa2 100644
--- a/pkg/github/pullrequests.go
+++ b/pkg/github/pullrequests.go
@@ -7,12 +7,13 @@ import (
"io"
"net/http"
- "github.com/github/github-mcp-server/pkg/translations"
"github.com/go-viper/mapstructure/v2"
- "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"
"github.com/shurcooL/githubv4"
+
+ "github.com/github/github-mcp-server/pkg/translations"
)
// GetPullRequest creates a tool to get details of a specific pull request.
@@ -21,7 +22,7 @@ func GetPullRequest(getClient GetClientFn, t translations.TranslationHelperFunc)
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -37,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
}
@@ -83,7 +84,7 @@ func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -116,23 +117,23 @@ func CreatePullRequest(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
}
- title, err := requiredParam[string](request, "title")
+ title, err := RequiredParam[string](request, "title")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- head, err := requiredParam[string](request, "head")
+ head, err := RequiredParam[string](request, "head")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- base, err := requiredParam[string](request, "base")
+ base, err := RequiredParam[string](request, "base")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -198,7 +199,7 @@ func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -230,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
}
@@ -319,7 +320,7 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -350,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
}
@@ -428,7 +429,7 @@ func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFun
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -454,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
}
@@ -517,7 +518,7 @@ func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelper
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -533,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
}
@@ -580,7 +581,7 @@ func GetPullRequestStatus(getClient GetClientFn, t translations.TranslationHelpe
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -596,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
}
@@ -657,7 +658,7 @@ func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHe
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -676,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
}
@@ -735,7 +736,7 @@ func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHel
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -751,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
}
@@ -803,7 +804,7 @@ func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelp
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -819,11 +820,11 @@ func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelp
),
),
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
}
@@ -864,7 +865,7 @@ func CreateAndSubmitPullRequestReview(getGQLClient GetGQLClientFn, t translation
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),
+ 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
@@ -964,7 +965,7 @@ func CreatePendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.
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),
+ 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
@@ -1050,10 +1051,10 @@ func CreatePendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.
// AddPullRequestReviewCommentToPendingReview creates a tool to add a comment to a pull request review.
func AddPullRequestReviewCommentToPendingReview(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("add_pull_request_review_comment_to_pending_review",
- mcp.WithDescription(t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_TO_PENDING_REVIEW_DESCRIPTION", "Add a comment to the requester's latest pending pull request review, a pending review needs to already exist to call this (check with the user if not sure). If you are using the LINE subjectType, use the get_line_number_in_pull_request_file tool to get an exact line number before commenting.")),
+ 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),
+ 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
@@ -1213,7 +1214,7 @@ func SubmitPendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.
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),
+ 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
@@ -1338,7 +1339,7 @@ func DeletePendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.
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),
+ 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
@@ -1451,7 +1452,7 @@ func GetPullRequestDiff(getClient GetClientFn, t translations.TranslationHelperF
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -1515,7 +1516,7 @@ func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelpe
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -1531,12 +1532,12 @@ func RequestCopilotReview(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
}
diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go
index 6202ec16c..144c6b384 100644
--- a/pkg/github/pullrequests_test.go
+++ b/pkg/github/pullrequests_test.go
@@ -8,8 +8,9 @@ import (
"time"
"github.com/github/github-mcp-server/internal/githubv4mock"
+ "github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/v69/github"
+ "github.com/google/go-github/v72/github"
"github.com/shurcooL/githubv4"
"github.com/migueleliasweb/go-github-mock/src/mock"
@@ -21,6 +22,7 @@ func Test_GetPullRequest(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetPullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_pull_request", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -133,6 +135,7 @@ func Test_UpdatePullRequest(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := UpdatePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "update_pull_request", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -315,6 +318,7 @@ func Test_ListPullRequests(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := ListPullRequests(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_pull_requests", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -445,6 +449,7 @@ func Test_MergePullRequest(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := MergePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "merge_pull_request", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -556,6 +561,7 @@ func Test_GetPullRequestFiles(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetPullRequestFiles(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_pull_request_files", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -672,6 +678,7 @@ func Test_GetPullRequestStatus(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetPullRequestStatus(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_pull_request_status", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -833,6 +840,7 @@ func Test_UpdatePullRequestBranch(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := UpdatePullRequestBranch(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "update_pull_request_branch", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -949,6 +957,7 @@ func Test_GetPullRequestComments(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetPullRequestComments(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_pull_request_comments", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1076,6 +1085,7 @@ func Test_GetPullRequestReviews(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetPullRequestReviews(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_pull_request_reviews", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1199,6 +1209,7 @@ func Test_CreatePullRequest(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := CreatePullRequest(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "create_pull_request", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1358,6 +1369,7 @@ func TestCreateAndSubmitPullRequestReview(t *testing.T) {
// Verify tool definition once
mockClient := githubv4.NewClient(nil)
tool, _ := CreateAndSubmitPullRequestReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "create_and_submit_pull_request_review", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1551,6 +1563,7 @@ func Test_RequestCopilotReview(t *testing.T) {
mockClient := github.NewClient(nil)
tool, _ := RequestCopilotReview(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "request_copilot_review", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1661,6 +1674,7 @@ func TestCreatePendingPullRequestReview(t *testing.T) {
// Verify tool definition once
mockClient := githubv4.NewClient(nil)
tool, _ := CreatePendingPullRequestReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "create_pending_pull_request_review", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1843,6 +1857,7 @@ func TestAddPullRequestReviewCommentToPendingReview(t *testing.T) {
// Verify tool definition once
mockClient := githubv4.NewClient(nil)
tool, _ := AddPullRequestReviewCommentToPendingReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "add_pull_request_review_comment_to_pending_review", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1955,6 +1970,7 @@ func TestSubmitPendingPullRequestReview(t *testing.T) {
// Verify tool definition once
mockClient := githubv4.NewClient(nil)
tool, _ := SubmitPendingPullRequestReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "submit_pending_pull_request_review", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -2052,6 +2068,7 @@ func TestDeletePendingPullRequestReview(t *testing.T) {
// Verify tool definition once
mockClient := githubv4.NewClient(nil)
tool, _ := DeletePendingPullRequestReview(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "delete_pending_pull_request_review", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -2143,6 +2160,7 @@ func TestGetPullRequestDiff(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetPullRequestDiff(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_pull_request_diff", tool.Name)
assert.NotEmpty(t, tool.Description)
diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go
index 4403e2a19..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"
)
@@ -18,7 +22,7 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -35,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
}
@@ -90,7 +94,7 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -106,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
}
@@ -164,7 +168,7 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -177,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
}
@@ -231,7 +235,7 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -262,27 +266,27 @@ 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
}
@@ -340,7 +344,7 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("name",
mcp.Required(),
@@ -357,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
}
@@ -409,12 +413,12 @@ 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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -426,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
}
@@ -450,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
}
}
@@ -491,7 +549,7 @@ func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc)
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -506,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
}
@@ -567,8 +625,8 @@ func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (to
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),
+ ReadOnlyHint: ToBoolPtr(false),
+ DestructiveHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -592,23 +650,23 @@ func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (to
),
),
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
}
- 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
}
@@ -722,7 +780,7 @@ func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -741,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
}
@@ -811,7 +869,7 @@ func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (too
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),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -851,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
}
@@ -963,7 +1021,7 @@ func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -976,11 +1034,11 @@ func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
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
}
@@ -1028,7 +1086,7 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -1044,15 +1102,15 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m
),
),
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
}
- tag, err := requiredParam[string](request, "tag")
+ tag, err := RequiredParam[string](request, "tag")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go
index e4edeee88..3ba0f1aa7 100644
--- a/pkg/github/repositories_test.go
+++ b/pkg/github/repositories_test.go
@@ -2,13 +2,18 @@ package github
import (
"context"
+ "encoding/base64"
"encoding/json"
"net/http"
+ "net/url"
"testing"
"time"
+ "github.com/github/github-mcp-server/internal/toolsnaps"
+ "github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
- "github.com/google/go-github/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"
"github.com/stretchr/testify/require"
@@ -17,7 +22,9 @@ 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)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_file_contents", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -27,17 +34,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{
@@ -65,17 +63,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{}{
@@ -84,8 +82,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",
@@ -96,11 +122,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,
@@ -115,6 +149,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",
@@ -122,8 +163,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."),
},
}
@@ -131,7 +172,8 @@ 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 := createMCPRequest(tc.requestArgs)
@@ -147,20 +189,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)
@@ -170,6 +209,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)
}
})
}
@@ -179,6 +221,7 @@ func Test_ForkRepository(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := ForkRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "fork_repository", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -277,6 +320,7 @@ func Test_CreateBranch(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := CreateBranch(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "create_branch", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -468,6 +512,7 @@ func Test_GetCommit(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetCommit(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_commit", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -593,6 +638,7 @@ func Test_ListCommits(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := ListCommits(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_commits", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -767,6 +813,7 @@ func Test_CreateOrUpdateFile(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := CreateOrUpdateFile(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "create_or_update_file", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -930,6 +977,7 @@ func Test_CreateRepository(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := CreateRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "create_repository", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1076,6 +1124,7 @@ func Test_PushFiles(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := PushFiles(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "push_files", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1412,6 +1461,7 @@ func Test_ListBranches(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_branches", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1522,6 +1572,7 @@ func Test_DeleteFile(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := DeleteFile(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "delete_file", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1699,6 +1750,7 @@ func Test_ListTags(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := ListTags(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "list_tags", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -1819,6 +1871,7 @@ func Test_GetTag(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := GetTag(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "get_tag", tool.Name)
assert.NotEmpty(t, tool.Description)
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 ac5e2994c..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"
)
@@ -18,7 +18,7 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("query",
mcp.Required(),
@@ -27,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
}
@@ -76,7 +76,7 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("q",
mcp.Required(),
@@ -92,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
}
@@ -146,13 +146,26 @@ 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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("q",
mcp.Required(),
@@ -169,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
}
@@ -200,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)
}
@@ -214,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..b76fe8047 100644
--- a/pkg/github/search_test.go
+++ b/pkg/github/search_test.go
@@ -6,8 +6,9 @@ import (
"net/http"
"testing"
+ "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"
@@ -17,6 +18,7 @@ func Test_SearchRepositories(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := SearchRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "search_repositories", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -164,6 +166,7 @@ func Test_SearchCode(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := SearchCode(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "search_code", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -315,6 +318,7 @@ func Test_SearchUsers(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
tool, _ := SearchUsers(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "search_users", tool.Name)
assert.NotEmpty(t, tool.Description)
@@ -335,9 +339,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 +346,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 +364,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 +390,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 +450,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 847fcfc6d..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"
)
@@ -19,7 +19,7 @@ func GetSecretScanningAlert(getClient GetClientFn, t translations.TranslationHel
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -35,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
}
@@ -82,7 +82,7 @@ func ListSecretScanningAlerts(getClient GetClientFn, t translations.TranslationH
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),
+ ReadOnlyHint: ToBoolPtr(true),
}),
mcp.WithString("owner",
mcp.Required(),
@@ -105,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 955377990..3f00d7b24 100644
--- a/pkg/github/server_test.go
+++ b/pkg/github/server_test.go
@@ -2,10 +2,14 @@ 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"
)
@@ -16,12 +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
@@ -99,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)
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 9c1ab34af..ba540d227 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -3,9 +3,10 @@ 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"
)
@@ -15,8 +16,7 @@ type GetGQLClientFn func(context.Context) (*githubv4.Client, error)
var DefaultTools = []string{"all"}
-func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, 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)
@@ -24,7 +24,7 @@ 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)),
@@ -39,6 +39,13 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
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(
@@ -104,35 +111,47 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn,
toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)),
)
+ actions := toolsets.NewToolset("actions", "GitHub Actions workflows and CI/CD operations").
+ AddReadTools(
+ toolsets.NewServerTool(ListWorkflows(getClient, t)),
+ toolsets.NewServerTool(ListWorkflowRuns(getClient, t)),
+ toolsets.NewServerTool(GetWorkflowRun(getClient, t)),
+ toolsets.NewServerTool(GetWorkflowRunLogs(getClient, t)),
+ toolsets.NewServerTool(ListWorkflowJobs(getClient, t)),
+ toolsets.NewServerTool(GetJobLogs(getClient, t)),
+ toolsets.NewServerTool(ListWorkflowRunArtifacts(getClient, t)),
+ toolsets.NewServerTool(DownloadWorkflowRunArtifact(getClient, t)),
+ toolsets.NewServerTool(GetWorkflowRunUsage(getClient, t)),
+ ).
+ AddWriteTools(
+ toolsets.NewServerTool(RunWorkflow(getClient, t)),
+ toolsets.NewServerTool(RerunWorkflowRun(getClient, t)),
+ toolsets.NewServerTool(RerunFailedJobs(getClient, t)),
+ toolsets.NewServerTool(CancelWorkflowRun(getClient, t)),
+ toolsets.NewServerTool(DeleteWorkflowRunLogs(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(actions)
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
@@ -150,6 +169,7 @@ func InitDynamicToolset(s *server.MCPServer, tsg *toolsets.ToolsetGroup, t trans
return dynamicToolSelection
}
-func toBoolPtr(b bool) *bool {
+// 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 7400119c8..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
@@ -150,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/third-party-licenses.darwin.md b/third-party-licenses.darwin.md
index cdb2af5b5..e616fa560 100644
--- a/third-party-licenses.darwin.md
+++ b/third-party-licenses.darwin.md
@@ -9,11 +9,19 @@ 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-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/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
+ - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
+ - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE))
+ - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE))
+ - [github.com/google/go-github/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.28.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/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE))
+ - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md))
+ - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE))
+ - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.32.0/LICENSE))
+ - [github.com/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))
@@ -27,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 cdb2af5b5..e616fa560 100644
--- a/third-party-licenses.linux.md
+++ b/third-party-licenses.linux.md
@@ -9,11 +9,19 @@ 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-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/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
+ - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
+ - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE))
+ - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE))
+ - [github.com/google/go-github/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.28.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/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE))
+ - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md))
+ - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE))
+ - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.32.0/LICENSE))
+ - [github.com/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))
@@ -27,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 74d13898c..d34ce2449 100644
--- a/third-party-licenses.windows.md
+++ b/third-party-licenses.windows.md
@@ -9,12 +9,20 @@ 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-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/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
+ - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
+ - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.3.0/LICENSE))
+ - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE))
+ - [github.com/google/go-github/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.28.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.32.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))
@@ -28,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/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.