diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md
index 1ac04f67..9b6c6ea8 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/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index 35ffc47d..cd2d923c 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -9,11 +9,11 @@ on:
schedule:
- cron: "27 0 * * *"
push:
- branches: ["main"]
+ branches: ["main", "next"]
# Publish semver tags as releases.
tags: ["v*.*.*"]
pull_request:
- branches: ["main"]
+ branches: ["main", "next"]
env:
# Use docker.io for Docker Hub if empty
diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml
new file mode 100644
index 00000000..c28c528b
--- /dev/null
+++ b/.github/workflows/docs-check.yml
@@ -0,0 +1,47 @@
+name: Documentation Check
+
+on:
+ push:
+ branches: [ main ]
+ pull_request:
+ branches: [ main ]
+
+permissions:
+ contents: read
+
+jobs:
+ docs-check:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: 'go.mod'
+
+ - name: Build docs generator
+ run: go build -o github-mcp-server ./cmd/github-mcp-server
+
+ - name: Generate documentation
+ run: ./github-mcp-server generate-docs
+
+ - name: Check for documentation changes
+ run: |
+ if ! git diff --exit-code README.md; then
+ echo "❌ Documentation is out of date!"
+ echo ""
+ echo "The generated documentation differs from what's committed."
+ echo "Please run the following command to update the documentation:"
+ echo ""
+ echo " go run ./cmd/github-mcp-server generate-docs"
+ echo ""
+ echo "Then commit the changes."
+ echo ""
+ echo "Changes detected:"
+ git diff README.md
+ exit 1
+ else
+ echo "✅ Documentation is up to date!"
+ fi
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index cd67b965..0a45569e 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -1,4 +1,4 @@
-name: Unit Tests
+name: Build and Test Go Project
on: [push, pull_request]
permissions:
diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml
deleted file mode 100644
index 9fa416ab..00000000
--- a/.github/workflows/lint.yaml
+++ /dev/null
@@ -1,45 +0,0 @@
-name: Lint
-on:
- push:
- pull_request:
-
-permissions:
- contents: read
-
-jobs:
- lint:
- runs-on: ubuntu-latest
-
- steps:
- - name: Check out code
- uses: actions/checkout@v4
-
- - name: Set up Go
- uses: actions/setup-go@v5
- with:
- go-version-file: 'go.mod'
-
- - name: Verify dependencies
- run: |
- go mod verify
- go mod download
-
- - name: Run checks
- run: |
- STATUS=0
- assert-nothing-changed() {
- local diff
- "$@" >/dev/null || return 1
- if ! diff="$(git diff -U1 --color --exit-code)"; then
- printf '\e[31mError: running `\e[1m%s\e[22m` results in modifications that you must check into version control:\e[0m\n%s\n\n' "$*" "$diff" >&2
- git checkout -- .
- STATUS=1
- fi
- }
- assert-nothing-changed go mod tidy
- exit $STATUS
-
- - name: golangci-lint
- uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9
- with:
- version: v2.1.6
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 00000000..b40193e7
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,23 @@
+name: golangci-lint
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+
+permissions:
+ contents: read
+
+jobs:
+ golangci:
+ name: lint
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-go@v5
+ with:
+ go-version: stable
+ - name: golangci-lint
+ uses: golangci/golangci-lint-action@v8
+ with:
+ version: v2.1
diff --git a/.gitignore b/.gitignore
index 12649366..df489c39 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,3 +11,4 @@ __debug_bin*
# Go
vendor
+bin/
diff --git a/.golangci.yml b/.golangci.yml
index 61302f6f..f86326cf 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -1,47 +1,37 @@
-# https://golangci-lint.run/usage/configuration
version: "2"
-
run:
- timeout: 5m
- tests: true
concurrency: 4
-
+ tests: true
linters:
enable:
- - govet
- - errcheck
- - staticcheck
- - revive
- - ineffassign
- - unused
- - misspell
- - nakedret
- bodyclose
- gocritic
- - makezero
- gosec
+ - makezero
+ - misspell
+ - nakedret
+ - revive
+ exclusions:
+ generated: lax
+ presets:
+ - comments
+ - common-false-positives
+ - legacy
+ - std-error-handling
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
settings:
staticcheck:
checks:
- - all
- - '-QF1008' # Allow embedded structs to be referenced by field
- - '-ST1000' # Do not require package comments
- revive:
- rules:
- - name: exported
- disabled: true
- - name: exported
- disabled: true
- - name: package-comments
- disabled: true
-
+ - "all"
+ - -QF1008
+ - -ST1000
formatters:
- enable:
- - gofmt
- - goimports
-
-output:
- formats:
- text:
- print-linter-name: true
- print-issued-lines: true
+ exclusions:
+ generated: lax
+ paths:
+ - third_party$
+ - builtin$
+ - examples$
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 11d63a38..6fa9c2eb 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -19,12 +19,14 @@ These are one time installations required to be able to test your changes locall
## Submitting a pull request
+> **Important**: Please open your pull request against the `next` branch, not `main`. The `next` branch is where we integrate new features and changes before they are merged to `main`.
+
1. [Fork][fork] and clone the repository
1. Make sure the tests pass on your machine: `go test -v ./...`
1. Make sure linter passes on your machine: `golangci-lint run`
1. Create a new branch: `git checkout -b my-branch-name`
1. Make your change, add tests, and make sure the tests and linter still pass
-1. Push to your fork and [submit a pull request][pr]
+1. Push to your fork and [submit a pull request][pr] targeting the `next` branch
1. Pat yourself on the back and wait for your pull request to be reviewed and merged.
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
diff --git a/Dockerfile b/Dockerfile
index 1281db4c..a26f19a8 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
diff --git a/README.md b/README.md
index 73e46cb6..44a82960 100644
--- a/README.md
+++ b/README.md
@@ -26,27 +26,100 @@ The remote GitHub MCP Server is hosted by GitHub and provides the easiest method
### 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.
+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.
-### Usage in other MCP Hosts
-For MCP Hosts that have been [configured to use the remote GitHub MCP Server](docs/host-integration.md), add the following JSON block to the host's configuration:
+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
{
- "mcp": {
- "servers": {
- "github": {
- "type": "http",
- "url": "https://api.githubcopilot.com/mcp/"
+ "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
@@ -190,18 +263,21 @@ _Toolsets are not limited to Tools. Relevant MCP Resources and Prompts are also
The following sets of tools are available (all are on by default):
+
| Toolset | Description |
| ----------------------- | ------------------------------------------------------------- |
| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |
-| `code_security` | Code scanning alerts and security features |
-| `issues` | Issue-related tools (create, read, update, comment) |
-| `notifications` | GitHub Notifications related tools |
-| `pull_requests` | Pull request operations (create, merge, review) |
-| `repos` | Repository-related tools (file operations, branches, commits) |
-| `secret_protection` | Secret protection related tools, such as GitHub Secret Scanning |
-| `users` | Anything relating to GitHub Users |
-| `experiments` | Experimental features (not considered stable) |
-
+| `actions` | GitHub Actions workflows and CI/CD operations |
+| `code_security` | Code security related tools, such as GitHub Code Scanning |
+| `experiments` | Experimental features that are not considered stable yet |
+| `issues` | GitHub Issues related tools |
+| `notifications` | GitHub Notifications related tools |
+| `orgs` | GitHub Organization related tools |
+| `pull_requests` | GitHub Pull Request related tools |
+| `repos` | GitHub Repository related tools |
+| `secret_protection` | Secret protection related tools, such as GitHub Secret Scanning |
+| `users` | GitHub User related tools |
+
#### Specifying Toolsets
@@ -210,12 +286,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.
@@ -227,7 +303,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
```
@@ -352,387 +428,511 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
## Tools
-### Users
-
-- **get_me** - Get details of the authenticated user
- - No parameters required
-### Issues
+
+
-- **get_issue** - Gets the contents of an issue within a repository
+Actions
+- **cancel_workflow_run** - Cancel workflow run
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `issue_number`: Issue number (number, required)
-
-- **get_issue_comments** - Get comments for a GitHub issue
+ - `run_id`: The unique identifier of the workflow run (number, required)
+- **delete_workflow_run_logs** - Delete workflow logs
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `issue_number`: Issue number (number, required)
-
-- **create_issue** - Create a new issue in a GitHub repository
+ - `run_id`: The unique identifier of the workflow run (number, required)
+- **download_workflow_run_artifact** - Download workflow artifact
+ - `artifact_id`: The unique identifier of the artifact (number, required)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `title`: Issue title (string, required)
- - `body`: Issue body content (string, optional)
- - `assignees`: Usernames to assign to this issue (string[], optional)
- - `labels`: Labels to apply to this issue (string[], optional)
-
-- **add_issue_comment** - Add a comment to an issue
+- **get_job_logs** - Get job logs
+ - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional)
+ - `job_id`: The unique identifier of the workflow job (required for single job logs) (number, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `issue_number`: Issue number (number, required)
- - `body`: Comment text (string, required)
-
-- **list_issues** - List and filter repository issues
+ - `return_content`: Returns actual log content instead of URLs (boolean, optional)
+ - `run_id`: Workflow run ID (required when using failed_only) (number, optional)
+ - `tail_lines`: Number of lines to return from the end of the log (number, optional)
+- **get_workflow_run** - Get workflow run
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `state`: Filter by state ('open', 'closed', 'all') (string, optional)
- - `labels`: Labels to filter by (string[], optional)
- - `sort`: Sort by ('created', 'updated', 'comments') (string, optional)
- - `direction`: Sort direction ('asc', 'desc') (string, optional)
- - `since`: Filter by date (ISO 8601 timestamp) (string, optional)
- - `page`: Page number (number, optional)
- - `perPage`: Results per page (number, optional)
-
-- **update_issue** - Update an existing issue in a GitHub repository
+ - `run_id`: The unique identifier of the workflow run (number, required)
+- **get_workflow_run_logs** - Get workflow run logs
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `issue_number`: Issue number to update (number, required)
- - `title`: New title (string, optional)
- - `body`: New description (string, optional)
- - `state`: New state ('open' or 'closed') (string, optional)
- - `labels`: New labels (string[], optional)
- - `assignees`: New assignees (string[], optional)
- - `milestone`: New milestone number (number, optional)
+ - `run_id`: The unique identifier of the workflow run (number, required)
-- **search_issues** - Search for issues and pull requests
- - `query`: Search query (string, required)
- - `sort`: Sort field (string, optional)
- - `order`: Sort order (string, optional)
- - `page`: Page number (number, optional)
- - `perPage`: Results per page (number, optional)
-
-### Pull Requests
-
-- **get_pull_request** - Get details of a specific pull request
+- **get_workflow_run_usage** - Get workflow usage
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `run_id`: The unique identifier of the workflow run (number, required)
+- **list_workflow_jobs** - List workflow jobs
+ - `filter`: Filters jobs by their completed_at timestamp (string, optional)
- `owner`: Repository owner (string, required)
+ - `page`: The page number of the results to fetch (number, optional)
+ - `per_page`: The number of results per page (max 100) (number, optional)
- `repo`: Repository name (string, required)
- - `pullNumber`: Pull request number (number, required)
+ - `run_id`: The unique identifier of the workflow run (number, required)
-- **list_pull_requests** - List and filter repository pull requests
+- **list_workflow_run_artifacts** - List workflow artifacts
+ - `owner`: Repository owner (string, required)
+ - `page`: The page number of the results to fetch (number, optional)
+ - `per_page`: The number of results per page (max 100) (number, optional)
+ - `repo`: Repository name (string, required)
+ - `run_id`: The unique identifier of the workflow run (number, required)
+- **list_workflow_runs** - List workflow runs
+ - `actor`: Returns someone's workflow runs. Use the login for the user who created the workflow run. (string, optional)
+ - `branch`: Returns workflow runs associated with a branch. Use the name of the branch. (string, optional)
+ - `event`: Returns workflow runs for a specific event type (string, optional)
- `owner`: Repository owner (string, required)
+ - `page`: The page number of the results to fetch (number, optional)
+ - `per_page`: The number of results per page (max 100) (number, optional)
- `repo`: Repository name (string, required)
- - `state`: PR state (string, optional)
- - `sort`: Sort field (string, optional)
- - `direction`: Sort direction (string, optional)
- - `perPage`: Results per page (number, optional)
- - `page`: Page number (number, optional)
+ - `status`: Returns workflow runs with the check run status (string, optional)
+ - `workflow_id`: The workflow ID or workflow file name (string, required)
-- **merge_pull_request** - Merge a pull request
+- **list_workflows** - List workflows
+ - `owner`: Repository owner (string, required)
+ - `page`: The page number of the results to fetch (number, optional)
+ - `per_page`: The number of results per page (max 100) (number, optional)
+ - `repo`: Repository name (string, required)
+- **rerun_failed_jobs** - Rerun failed jobs
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `pullNumber`: Pull request number (number, required)
- - `commit_title`: Title for the merge commit (string, optional)
- - `commit_message`: Message for the merge commit (string, optional)
- - `merge_method`: Merge method (string, optional)
+ - `run_id`: The unique identifier of the workflow run (number, required)
-- **get_pull_request_files** - Get the list of files changed in a pull request
+- **rerun_workflow_run** - Rerun workflow run
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `run_id`: The unique identifier of the workflow run (number, required)
+- **run_workflow** - Run workflow
+ - `inputs`: Inputs the workflow accepts (object, optional)
- `owner`: Repository owner (string, required)
+ - `ref`: The git reference for the workflow. The reference can be a branch or tag name. (string, required)
- `repo`: Repository name (string, required)
- - `pullNumber`: Pull request number (number, required)
+ - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml) (string, required)
+
+
+
+
-- **get_pull_request_status** - Get the combined status of all status checks for a pull request
+Code Security
+- **get_code_scanning_alert** - Get code scanning alert
+ - `alertNumber`: The number of the alert. (number, required)
+ - `owner`: The owner of the repository. (string, required)
+ - `repo`: The name of the repository. (string, required)
+
+- **list_code_scanning_alerts** - List code scanning alerts
+ - `owner`: The owner of the repository. (string, required)
+ - `ref`: The Git reference for the results you want to list. (string, optional)
+ - `repo`: The name of the repository. (string, required)
+ - `severity`: Filter code scanning alerts by severity (string, optional)
+ - `state`: Filter code scanning alerts by state. Defaults to open (string, optional)
+ - `tool_name`: The name of the tool used for code scanning. (string, optional)
+
+
+
+
+
+Context
+
+- **get_me** - Get my user profile
+ - `reason`: Optional: the reason for requesting the user information (string, optional)
+
+
+
+
+
+Issues
+
+- **add_issue_comment** - Add comment to issue
+ - `body`: Comment content (string, required)
+ - `issue_number`: Issue number to comment on (number, required)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `pullNumber`: Pull request number (number, required)
-- **update_pull_request_branch** - Update a pull request branch with the latest changes from the base branch
+- **assign_copilot_to_issue** - Assign Copilot to issue
+ - `issueNumber`: Issue number (number, required)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+- **create_issue** - Open new issue
+ - `assignees`: Usernames to assign to this issue (string[], optional)
+ - `body`: Issue body content (string, optional)
+ - `labels`: Labels to apply to this issue (string[], optional)
+ - `milestone`: Milestone number (number, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `pullNumber`: Pull request number (number, required)
- - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional)
+ - `title`: Issue title (string, required)
-- **get_pull_request_comments** - Get the review comments on a pull request
+- **get_issue** - Get issue details
+ - `issue_number`: The number of the issue (number, required)
+ - `owner`: The owner of the repository (string, required)
+ - `repo`: The name of the repository (string, required)
+- **get_issue_comments** - Get issue comments
+ - `issue_number`: Issue number (number, required)
- `owner`: Repository owner (string, required)
+ - `page`: Page number (number, optional)
+ - `per_page`: Number of records per page (number, optional)
- `repo`: Repository name (string, required)
- - `pullNumber`: Pull request number (number, required)
-- **get_pull_request_reviews** - Get the reviews on a pull request
+- **list_issues** - List issues
+ - `direction`: Sort direction (string, optional)
+ - `labels`: Filter by labels (string[], optional)
+ - `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `repo`: Repository name (string, required)
+ - `since`: Filter by date (ISO 8601 timestamp) (string, optional)
+ - `sort`: Sort order (string, optional)
+ - `state`: Filter by state (string, optional)
+- **search_issues** - Search issues
+ - `order`: Sort order (string, optional)
+ - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (string, optional)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `query`: Search query using GitHub issues search syntax (string, required)
+ - `repo`: Optional repository name. If provided with owner, only notifications for this repository are listed. (string, optional)
+ - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional)
+
+- **update_issue** - Edit issue
+ - `assignees`: New assignees (string[], optional)
+ - `body`: New description (string, optional)
+ - `issue_number`: Issue number to update (number, required)
+ - `labels`: New labels (string[], optional)
+ - `milestone`: New milestone number (number, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `pullNumber`: Pull request number (number, required)
+ - `state`: New state (string, optional)
+ - `title`: New title (string, optional)
+
+
+
+
+
+Notifications
+
+- **dismiss_notification** - Dismiss notification
+ - `state`: The new state of the notification (read/done) (string, optional)
+ - `threadID`: The ID of the notification thread (string, required)
+
+- **get_notification_details** - Get notification details
+ - `notificationID`: The ID of the notification (string, required)
+
+- **list_notifications** - List notifications
+ - `before`: Only show notifications updated before the given time (ISO 8601 format) (string, optional)
+ - `filter`: Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created. (string, optional)
+ - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (string, optional)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `repo`: Optional repository name. If provided with owner, only notifications for this repository are listed. (string, optional)
+ - `since`: Only show notifications updated after the given time (ISO 8601 format) (string, optional)
+
+- **manage_notification_subscription** - Manage notification subscription
+ - `action`: Action to perform: ignore, watch, or delete the notification subscription. (string, required)
+ - `notificationID`: The ID of the notification thread. (string, required)
+
+- **manage_repository_notification_subscription** - Manage repository notification subscription
+ - `action`: Action to perform: ignore, watch, or delete the repository notification subscription. (string, required)
+ - `owner`: The account owner of the repository. (string, required)
+ - `repo`: The name of the repository. (string, required)
-- **create_pull_request_review** - Create a review on a pull request review
+- **mark_all_notifications_read** - Mark all notifications as read
+ - `lastReadAt`: Describes the last point that notifications were checked (optional). Default: Now (string, optional)
+ - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are marked as read. (string, optional)
+ - `repo`: Optional repository name. If provided with owner, only notifications for this repository are marked as read. (string, optional)
+
+
+
+
+Organizations
+
+- **search_orgs** - Search organizations
+ - `order`: Sort order (string, optional)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `query`: Search query using GitHub organizations search syntax scoped to type:org (string, required)
+ - `sort`: Sort field by category (string, optional)
+
+
+
+
+
+Pull Requests
+
+- **add_pull_request_review_comment_to_pending_review** - Add comment to the requester's latest pending pull request review
+ - `body`: The text of the review comment (string, required)
+ - `line`: The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range (number, optional)
- `owner`: Repository owner (string, required)
+ - `path`: The relative path to the file that necessitates a comment (string, required)
+ - `pullNumber`: Pull request number (number, required)
- `repo`: Repository name (string, required)
+ - `side`: The side of the diff to comment on. LEFT indicates the previous state, RIGHT indicates the new state (string, optional)
+ - `startLine`: For multi-line comments, the first line of the range that the comment applies to (number, optional)
+ - `startSide`: For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state (string, optional)
+ - `subjectType`: The level at which the comment is targeted (string, required)
+
+- **create_and_submit_pull_request_review** - Create and submit a pull request review without comments
+ - `body`: Review comment text (string, required)
+ - `commitID`: SHA of commit to review (string, optional)
+ - `event`: Review action to perform (string, required)
+ - `owner`: Repository owner (string, required)
- `pullNumber`: Pull request number (number, required)
- - `body`: Review comment text (string, optional)
- - `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`
-
-- **create_pull_request** - Create a new pull request
+ - `repo`: Repository name (string, required)
+- **create_pending_pull_request_review** - Create pending pull request review
+ - `commitID`: SHA of commit to review (string, optional)
- `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number (number, required)
- `repo`: Repository name (string, required)
- - `title`: PR title (string, required)
- - `body`: PR description (string, optional)
- - `head`: Branch containing changes (string, required)
+
+- **create_pull_request** - Open new pull request
- `base`: Branch to merge into (string, required)
+ - `body`: PR description (string, optional)
- `draft`: Create as draft PR (boolean, optional)
+ - `head`: Branch containing changes (string, required)
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+ - `title`: PR title (string, required)
-- **add_pull_request_review_comment** - Add a review comment to a pull request or reply to an existing comment
-
+- **delete_pending_pull_request_review** - Delete the requester's latest pending pull request review
- `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number (number, 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
+- **get_pull_request** - Get pull request details
+ - `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number (number, required)
+ - `repo`: Repository name (string, required)
+- **get_pull_request_comments** - Get pull request comments
- `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number (number, required)
- `repo`: Repository name (string, required)
- - `pullNumber`: Pull request number to update (number, required)
- - `title`: New title (string, optional)
- - `body`: New description (string, optional)
- - `state`: New state ('open' or 'closed') (string, optional)
- - `base`: New base branch name (string, optional)
- - `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
-- **request_copilot_review** - Request a GitHub Copilot review for a pull request (experimental; subject to GitHub API support)
+- **get_pull_request_diff** - Get pull request diff
+ - `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number (number, required)
+ - `repo`: Repository name (string, required)
+- **get_pull_request_files** - Get pull request files
- `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `pullNumber`: Pull request number (number, required)
- `repo`: Repository name (string, required)
+
+- **get_pull_request_reviews** - Get pull request reviews
+ - `owner`: Repository owner (string, required)
- `pullNumber`: Pull request number (number, required)
- - _Note_: Currently, this tool will only work for github.com
+ - `repo`: Repository name (string, required)
-### Repositories
+- **get_pull_request_status** - Get pull request status checks
+ - `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number (number, required)
+ - `repo`: Repository name (string, required)
-- **create_or_update_file** - Create or update a single file in a repository
+- **list_pull_requests** - List pull requests
+ - `base`: Filter by base branch (string, optional)
+ - `direction`: Sort direction (string, optional)
+ - `head`: Filter by head user/org and branch (string, optional)
- `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
- - `path`: File path (string, required)
- - `message`: Commit message (string, required)
- - `content`: File content (string, required)
- - `branch`: Branch name (string, optional)
- - `sha`: File SHA if updating (string, optional)
+ - `sort`: Sort by (string, optional)
+ - `state`: Filter by state (string, optional)
-- **list_branches** - List branches in a GitHub repository
+- **merge_pull_request** - Merge pull request
+ - `commit_message`: Extra detail for merge commit (string, optional)
+ - `commit_title`: Title for merge commit (string, optional)
+ - `merge_method`: Merge method (string, optional)
- `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number (number, required)
- `repo`: Repository name (string, required)
- - `page`: Page number (number, optional)
- - `perPage`: Results per page (number, optional)
-- **push_files** - Push multiple files in a single commit
+- **request_copilot_review** - Request Copilot review
- `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number (number, required)
- `repo`: Repository name (string, required)
- - `branch`: Branch to push to (string, required)
- - `files`: Files to push, each with path and content (array, required)
- - `message`: Commit message (string, required)
-- **search_repositories** - Search for GitHub repositories
- - `query`: Search query (string, required)
- - `sort`: Sort field (string, optional)
+- **search_pull_requests** - Search pull requests
- `order`: Sort order (string, optional)
- - `page`: Page number (number, optional)
- - `perPage`: Results per page (number, optional)
-
-- **create_repository** - Create a new GitHub repository
- - `name`: Repository name (string, required)
- - `description`: Repository description (string, optional)
- - `private`: Whether the repository is private (boolean, optional)
- - `autoInit`: Auto-initialize with README (boolean, optional)
-
-- **get_file_contents** - Get contents of a file or directory
+ - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (string, optional)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `query`: Search query using GitHub pull request search syntax (string, required)
+ - `repo`: Optional repository name. If provided with owner, only notifications for this repository are listed. (string, optional)
+ - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional)
+
+- **submit_pending_pull_request_review** - Submit the requester's latest pending pull request review
+ - `body`: The text of the review comment (string, optional)
+ - `event`: The event to perform (string, required)
- `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number (number, required)
- `repo`: Repository name (string, required)
- - `path`: File path (string, required)
- - `ref`: Git reference (string, optional)
-- **fork_repository** - Fork a repository
+- **update_pull_request** - Edit pull request
+ - `base`: New base branch name (string, optional)
+ - `body`: New description (string, optional)
+ - `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
- `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number to update (number, required)
- `repo`: Repository name (string, required)
- - `organization`: Target organization name (string, optional)
+ - `state`: New state (string, optional)
+ - `title`: New title (string, optional)
-- **create_branch** - Create a new branch
+- **update_pull_request_branch** - Update pull request branch
+ - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional)
- `owner`: Repository owner (string, required)
+ - `pullNumber`: Pull request number (number, required)
- `repo`: Repository name (string, required)
- - `branch`: New branch name (string, required)
- - `sha`: SHA to create branch from (string, required)
-- **list_commits** - Get a list of commits of a branch in a repository
+
+
+
+
+Repositories
+
+- **create_branch** - Create branch
+ - `branch`: Name for new branch (string, required)
+ - `from_branch`: Source branch (defaults to repo default) (string, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `sha`: Branch name, tag, or commit SHA (string, optional)
- - `path`: Only commits containing this file path (string, optional)
- - `page`: Page number (number, optional)
- - `perPage`: Results per page (number, optional)
-- **get_commit** - Get details for a commit from a repository
- - `owner`: Repository owner (string, required)
+- **create_or_update_file** - Create or update file
+ - `branch`: Branch to create/update the file in (string, required)
+ - `content`: Content of the file (string, required)
+ - `message`: Commit message (string, required)
+ - `owner`: Repository owner (username or organization) (string, required)
+ - `path`: Path where to create/update the file (string, required)
- `repo`: Repository name (string, required)
- - `sha`: Commit SHA, branch name, or tag name (string, required)
- - `page`: Page number, for files in the commit (number, optional)
- - `perPage`: Results per page, for files in the commit (number, optional)
+ - `sha`: SHA of file being replaced (for updates) (string, optional)
-- **search_code** - Search for code across GitHub repositories
- - `query`: Search query (string, required)
- - `sort`: Sort field (string, optional)
- - `order`: Sort order (string, optional)
- - `page`: Page number (number, optional)
- - `perPage`: Results per page (number, optional)
+- **create_repository** - Create repository
+ - `autoInit`: Initialize with README (boolean, optional)
+ - `description`: Repository description (string, optional)
+ - `name`: Repository name (string, required)
+ - `private`: Whether repo should be private (boolean, optional)
-### Users
+- **delete_file** - Delete file
+ - `branch`: Branch to delete the file from (string, required)
+ - `message`: Commit message (string, required)
+ - `owner`: Repository owner (username or organization) (string, required)
+ - `path`: Path to the file to delete (string, required)
+ - `repo`: Repository name (string, required)
-- **search_users** - Search for GitHub users
- - `q`: Search query (string, required)
- - `sort`: Sort field (string, optional)
- - `order`: Sort order (string, optional)
- - `page`: Page number (number, optional)
- - `perPage`: Results per page (number, optional)
+- **fork_repository** - Fork repository
+ - `organization`: Organization to fork to (string, optional)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
-### Code Scanning
+- **get_commit** - Get commit details
+ - `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `repo`: Repository name (string, required)
+ - `sha`: Commit SHA, branch name, or tag name (string, required)
-- **get_code_scanning_alert** - Get a code scanning alert
+- **get_file_contents** - Get file or directory contents
+ - `owner`: Repository owner (username or organization) (string, required)
+ - `path`: Path to file/directory (directories must end with a slash '/') (string, required)
+ - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional)
+ - `repo`: Repository name (string, required)
+ - `sha`: Accepts optional git sha, if sha is specified it will be used instead of ref (string, optional)
+- **get_tag** - Get tag details
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `alertNumber`: Alert number (number, required)
+ - `tag`: Tag name (string, required)
-- **list_code_scanning_alerts** - List code scanning alerts for a repository
+- **list_branches** - List branches
- `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
- - `ref`: Git reference (string, optional)
- - `state`: Alert state (string, optional)
- - `severity`: Alert severity (string, optional)
- - `tool_name`: The name of the tool used for code scanning (string, optional)
-
-### Secret Scanning
-- **get_secret_scanning_alert** - Get a secret scanning alert
+- **list_commits** - List commits
+ - `author`: Author username or email address (string, optional)
+ - `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `repo`: Repository name (string, required)
+ - `sha`: SHA or Branch name (string, optional)
+- **list_tags** - List tags
- `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
- - `alertNumber`: Alert number (number, required)
-- **list_secret_scanning_alerts** - List secret scanning alerts for a repository
+- **push_files** - Push files to repository
+ - `branch`: Branch to push to (string, required)
+ - `files`: Array of file objects to push, each object with path (string) and content (string) (object[], required)
+ - `message`: Commit message (string, required)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
- - `state`: Alert state (string, optional)
- - `secret_type`: The secret types to be filtered for in a comma-separated list (string, optional)
- - `resolution`: The resolution status (string, optional)
-### Notifications
+- **search_code** - Search code
+ - `order`: Sort order (string, optional)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `q`: Search query using GitHub code search syntax (string, required)
+ - `sort`: Sort field ('indexed' only) (string, optional)
+
+- **search_repositories** - Search repositories
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `query`: Search query (string, required)
-- **list_notifications** – List notifications for a GitHub user
- - `filter`: Filter to apply to the response (`default`, `include_read_notifications`, `only_participating`)
- - `since`: Only show notifications updated after the given time (ISO 8601 format)
- - `before`: Only show notifications updated before the given time (ISO 8601 format)
- - `owner`: Optional repository owner (string)
- - `repo`: Optional repository name (string)
- - `page`: Page number (number, optional)
- - `perPage`: Results per page (number, optional)
+
+
-- **get_notification_details** – Get detailed information for a specific GitHub notification
- - `notificationID`: The ID of the notification (string, required)
+Secret Protection
-- **dismiss_notification** – Dismiss a notification by marking it as read or done
- - `threadID`: The ID of the notification thread (string, required)
- - `state`: The new state of the notification (`read` or `done`)
+- **get_secret_scanning_alert** - Get secret scanning alert
+ - `alertNumber`: The number of the alert. (number, required)
+ - `owner`: The owner of the repository. (string, required)
+ - `repo`: The name of the repository. (string, required)
-- **mark_all_notifications_read** – Mark all notifications as read
- - `lastReadAt`: Describes the last point that notifications were checked (optional, RFC3339/ISO8601 string, default: now)
- - `owner`: Optional repository owner (string)
- - `repo`: Optional repository name (string)
+- **list_secret_scanning_alerts** - List secret scanning alerts
+ - `owner`: The owner of the repository. (string, required)
+ - `repo`: The name of the repository. (string, required)
+ - `resolution`: Filter by resolution (string, optional)
+ - `secret_type`: A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter. (string, optional)
+ - `state`: Filter by state (string, optional)
-- **manage_notification_subscription** – Manage a notification subscription (ignore, watch, or delete) for a notification thread
- - `notificationID`: The ID of the notification thread (string, required)
- - `action`: Action to perform: `ignore`, `watch`, or `delete` (string, required)
+
-- **manage_repository_notification_subscription** – Manage a repository notification subscription (ignore, watch, or delete)
- - `owner`: The account owner of the repository (string, required)
- - `repo`: The name of the repository (string, required)
- - `action`: Action to perform: `ignore`, `watch`, or `delete` (string, required)
-
-## Resources
-
-### Repository Content
-
-- **Get Repository Content**
- Retrieves the content of a repository at a specific path.
-
- - **Template**: `repo://{owner}/{repo}/contents{/path*}`
- - **Parameters**:
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `path`: File or directory path (string, optional)
-
-- **Get Repository Content for a Specific Branch**
- Retrieves the content of a repository at a specific path for a given branch.
-
- - **Template**: `repo://{owner}/{repo}/refs/heads/{branch}/contents{/path*}`
- - **Parameters**:
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `branch`: Branch name (string, required)
- - `path`: File or directory path (string, optional)
-
-- **Get Repository Content for a Specific Commit**
- Retrieves the content of a repository at a specific path for a given commit.
-
- - **Template**: `repo://{owner}/{repo}/sha/{sha}/contents{/path*}`
- - **Parameters**:
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `sha`: Commit SHA (string, required)
- - `path`: File or directory path (string, optional)
-
-- **Get Repository Content for a Specific Tag**
- Retrieves the content of a repository at a specific path for a given tag.
-
- - **Template**: `repo://{owner}/{repo}/refs/tags/{tag}/contents{/path*}`
- - **Parameters**:
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `tag`: Tag name (string, required)
- - `path`: File or directory path (string, optional)
-
-- **Get Repository Content for a Specific Pull Request**
- Retrieves the content of a repository at a specific path for a given pull request.
-
- - **Template**: `repo://{owner}/{repo}/refs/pull/{prNumber}/head/contents{/path*}`
- - **Parameters**:
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `prNumber`: Pull request number (string, required)
- - `path`: File or directory path (string, optional)
+
+
+Users
+
+- **search_users** - Search users
+ - `order`: Sort order (string, optional)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `query`: Search query using GitHub users search syntax scoped to type:user (string, required)
+ - `sort`: Sort field by category (string, optional)
+
+
+
## Library Usage
diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go
new file mode 100644
index 00000000..dfd66d28
--- /dev/null
+++ b/cmd/github-mcp-server/generate_docs.go
@@ -0,0 +1,354 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "os"
+ "regexp"
+ "sort"
+ "strings"
+
+ "github.com/github/github-mcp-server/pkg/github"
+ "github.com/github/github-mcp-server/pkg/raw"
+ "github.com/github/github-mcp-server/pkg/toolsets"
+ "github.com/github/github-mcp-server/pkg/translations"
+ gogithub "github.com/google/go-github/v72/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/shurcooL/githubv4"
+ "github.com/spf13/cobra"
+)
+
+var generateDocsCmd = &cobra.Command{
+ Use: "generate-docs",
+ Short: "Generate documentation for tools and toolsets",
+ Long: `Generate the automated sections of README.md and docs/remote-server.md with current tool and toolset information.`,
+ RunE: func(_ *cobra.Command, _ []string) error {
+ return generateAllDocs()
+ },
+}
+
+func init() {
+ rootCmd.AddCommand(generateDocsCmd)
+}
+
+// mockGetClient returns a mock GitHub client for documentation generation
+func mockGetClient(_ context.Context) (*gogithub.Client, error) {
+ return gogithub.NewClient(nil), nil
+}
+
+// mockGetGQLClient returns a mock GraphQL client for documentation generation
+func mockGetGQLClient(_ context.Context) (*githubv4.Client, error) {
+ return githubv4.NewClient(nil), nil
+}
+
+// mockGetRawClient returns a mock raw client for documentation generation
+func mockGetRawClient(_ context.Context) (*raw.Client, error) {
+ return nil, nil
+}
+
+func generateAllDocs() error {
+ if err := generateReadmeDocs("README.md"); err != nil {
+ return fmt.Errorf("failed to generate README docs: %w", err)
+ }
+
+ if err := generateRemoteServerDocs("docs/remote-server.md"); err != nil {
+ return fmt.Errorf("failed to generate remote-server docs: %w", err)
+ }
+
+ return nil
+}
+
+func generateReadmeDocs(readmePath string) error {
+ // Create translation helper
+ t, _ := translations.TranslationHelper()
+
+ // Create toolset group with mock clients
+ tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t)
+
+ // Generate toolsets documentation
+ toolsetsDoc := generateToolsetsDoc(tsg)
+
+ // Generate tools documentation
+ toolsDoc := generateToolsDoc(tsg)
+
+ // Read the current README.md
+ // #nosec G304 - readmePath is controlled by command line flag, not user input
+ content, err := os.ReadFile(readmePath)
+ if err != nil {
+ return fmt.Errorf("failed to read README.md: %w", err)
+ }
+
+ // Replace toolsets section
+ updatedContent := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc)
+
+ // Replace tools section
+ updatedContent = replaceSection(updatedContent, "START AUTOMATED TOOLS", "END AUTOMATED TOOLS", toolsDoc)
+
+ // Write back to file
+ err = os.WriteFile(readmePath, []byte(updatedContent), 0600)
+ if err != nil {
+ return fmt.Errorf("failed to write README.md: %w", err)
+ }
+
+ fmt.Println("Successfully updated README.md with automated documentation")
+ return nil
+}
+
+func generateRemoteServerDocs(docsPath string) error {
+ content, err := os.ReadFile(docsPath) //#nosec G304
+ if err != nil {
+ return fmt.Errorf("failed to read docs file: %w", err)
+ }
+
+ toolsetsDoc := generateRemoteToolsetsDoc()
+
+ // Replace content between markers
+ startMarker := ""
+ endMarker := ""
+
+ contentStr := string(content)
+ startIndex := strings.Index(contentStr, startMarker)
+ endIndex := strings.Index(contentStr, endMarker)
+
+ if startIndex == -1 || endIndex == -1 {
+ return fmt.Errorf("automation markers not found in %s", docsPath)
+ }
+
+ newContent := contentStr[:startIndex] + startMarker + "\n" + toolsetsDoc + "\n" + endMarker + contentStr[endIndex+len(endMarker):]
+
+ return os.WriteFile(docsPath, []byte(newContent), 0600) //#nosec G306
+}
+
+func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string {
+ var lines []string
+
+ // Add table header and separator
+ lines = append(lines, "| Toolset | Description |")
+ lines = append(lines, "| ----------------------- | ------------------------------------------------------------- |")
+
+ // Add the context toolset row (handled separately in README)
+ lines = append(lines, "| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |")
+
+ // Get all toolsets except context (which is handled separately above)
+ var toolsetNames []string
+ for name := range tsg.Toolsets {
+ if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately
+ toolsetNames = append(toolsetNames, name)
+ }
+ }
+
+ // Sort toolset names for consistent output
+ sort.Strings(toolsetNames)
+
+ for _, name := range toolsetNames {
+ toolset := tsg.Toolsets[name]
+ lines = append(lines, fmt.Sprintf("| `%s` | %s |", name, toolset.Description))
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+func generateToolsDoc(tsg *toolsets.ToolsetGroup) string {
+ var sections []string
+
+ // Get all toolset names and sort them alphabetically for deterministic order
+ var toolsetNames []string
+ for name := range tsg.Toolsets {
+ if name != "dynamic" { // Skip dynamic toolset as it's handled separately
+ toolsetNames = append(toolsetNames, name)
+ }
+ }
+ sort.Strings(toolsetNames)
+
+ for _, toolsetName := range toolsetNames {
+ toolset := tsg.Toolsets[toolsetName]
+
+ tools := toolset.GetAvailableTools()
+ if len(tools) == 0 {
+ continue
+ }
+
+ // Sort tools by name for deterministic order
+ sort.Slice(tools, func(i, j int) bool {
+ return tools[i].Tool.Name < tools[j].Tool.Name
+ })
+
+ // Generate section header - capitalize first letter and replace underscores
+ sectionName := formatToolsetName(toolsetName)
+
+ var toolDocs []string
+ for _, serverTool := range tools {
+ toolDoc := generateToolDoc(serverTool.Tool)
+ toolDocs = append(toolDocs, toolDoc)
+ }
+
+ if len(toolDocs) > 0 {
+ section := fmt.Sprintf("\n\n%s
\n\n%s\n\n ",
+ sectionName, strings.Join(toolDocs, "\n\n"))
+ sections = append(sections, section)
+ }
+ }
+
+ return strings.Join(sections, "\n\n")
+}
+
+func formatToolsetName(name string) string {
+ switch name {
+ case "pull_requests":
+ return "Pull Requests"
+ case "repos":
+ return "Repositories"
+ case "code_security":
+ return "Code Security"
+ case "secret_protection":
+ return "Secret Protection"
+ case "orgs":
+ return "Organizations"
+ default:
+ // Fallback: capitalize first letter and replace underscores with spaces
+ parts := strings.Split(name, "_")
+ for i, part := range parts {
+ if len(part) > 0 {
+ parts[i] = strings.ToUpper(string(part[0])) + part[1:]
+ }
+ }
+ return strings.Join(parts, " ")
+ }
+}
+
+func generateToolDoc(tool mcp.Tool) string {
+ var lines []string
+
+ // Tool name only (using annotation name instead of verbose description)
+ lines = append(lines, fmt.Sprintf("- **%s** - %s", tool.Name, tool.Annotations.Title))
+
+ // Parameters
+ schema := tool.InputSchema
+ if len(schema.Properties) > 0 {
+ // Get parameter names and sort them for deterministic order
+ var paramNames []string
+ for propName := range schema.Properties {
+ paramNames = append(paramNames, propName)
+ }
+ sort.Strings(paramNames)
+
+ for _, propName := range paramNames {
+ prop := schema.Properties[propName]
+ required := contains(schema.Required, propName)
+ requiredStr := "optional"
+ if required {
+ requiredStr = "required"
+ }
+
+ // Get the type and description
+ typeStr := "unknown"
+ description := ""
+
+ if propMap, ok := prop.(map[string]interface{}); ok {
+ if typeVal, ok := propMap["type"].(string); ok {
+ if typeVal == "array" {
+ if items, ok := propMap["items"].(map[string]interface{}); ok {
+ if itemType, ok := items["type"].(string); ok {
+ typeStr = itemType + "[]"
+ }
+ } else {
+ typeStr = "array"
+ }
+ } else {
+ typeStr = typeVal
+ }
+ }
+
+ if desc, ok := propMap["description"].(string); ok {
+ description = desc
+ }
+ }
+
+ paramLine := fmt.Sprintf(" - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr)
+ lines = append(lines, paramLine)
+ }
+ } else {
+ lines = append(lines, " - No parameters required")
+ }
+
+ return strings.Join(lines, "\n")
+}
+
+func contains(slice []string, item string) bool {
+ for _, s := range slice {
+ if s == item {
+ return true
+ }
+ }
+ return false
+}
+
+func replaceSection(content, startMarker, endMarker, newContent string) string {
+ startPattern := fmt.Sprintf(``, regexp.QuoteMeta(startMarker))
+ endPattern := fmt.Sprintf(``, regexp.QuoteMeta(endMarker))
+
+ re := regexp.MustCompile(fmt.Sprintf(`(?s)%s.*?%s`, startPattern, endPattern))
+
+ replacement := fmt.Sprintf("\n%s\n", startMarker, newContent, endMarker)
+
+ return re.ReplaceAllString(content, replacement)
+}
+
+func generateRemoteToolsetsDoc() string {
+ var buf strings.Builder
+
+ // Create translation helper
+ t, _ := translations.TranslationHelper()
+
+ // Create toolset group with mock clients
+ tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t)
+
+ // Generate table header
+ buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n")
+ buf.WriteString("|----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n")
+
+ // Get all toolsets
+ toolsetNames := make([]string, 0, len(tsg.Toolsets))
+ for name := range tsg.Toolsets {
+ if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately
+ toolsetNames = append(toolsetNames, name)
+ }
+ }
+ sort.Strings(toolsetNames)
+
+ // Add "all" toolset first (special case)
+ buf.WriteString("| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |\n")
+
+ // Add individual toolsets
+ for _, name := range toolsetNames {
+ toolset := tsg.Toolsets[name]
+
+ formattedName := formatToolsetName(name)
+ description := toolset.Description
+ apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", name)
+ readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", name)
+
+ // Create install config JSON (URL encoded)
+ installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL))
+ readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL))
+
+ // Fix URL encoding to use %20 instead of + for spaces
+ installConfig = strings.ReplaceAll(installConfig, "+", "%20")
+ readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20")
+
+ installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", name, installConfig)
+ readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", name, readonlyConfig)
+
+ buf.WriteString(fmt.Sprintf("| %-14s | %-48s | %-53s | %-218s | %-110s | %-288s |\n",
+ formattedName,
+ description,
+ apiURL,
+ installLink,
+ fmt.Sprintf("[read-only](%s)", readonlyURL),
+ readonlyInstallLink,
+ ))
+ }
+
+ return buf.String()
+}
diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go
index fb716f78..cad00266 100644
--- a/cmd/github-mcp-server/main.go
+++ b/cmd/github-mcp-server/main.go
@@ -4,10 +4,12 @@ import (
"errors"
"fmt"
"os"
+ "strings"
"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/pkg/github"
"github.com/spf13/cobra"
+ "github.com/spf13/pflag"
"github.com/spf13/viper"
)
@@ -54,7 +56,6 @@ var (
EnableCommandLogging: viper.GetBool("enable-command-logging"),
LogFilePath: viper.GetString("log-file"),
}
-
return ghmcp.RunStdioServer(stdioServerConfig)
},
}
@@ -62,6 +63,7 @@ var (
func init() {
cobra.OnInitialize(initConfig)
+ rootCmd.SetGlobalNormalizationFunc(wordSepNormalizeFunc)
rootCmd.SetVersionTemplate("{{.Short}}\n{{.Version}}\n")
@@ -91,6 +93,7 @@ func initConfig() {
// Initialize Viper configuration
viper.SetEnvPrefix("github")
viper.AutomaticEnv()
+
}
func main() {
@@ -99,3 +102,12 @@ func main() {
os.Exit(1)
}
}
+
+func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
+ from := []string{"_"}
+ to := "-"
+ for _, sep := range from {
+ name = strings.ReplaceAll(name, sep, to)
+ }
+ return pflag.NormalizedName(name)
+}
diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md
index 493ce5b1..317c2b8e 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/error-handling.md b/docs/error-handling.md
new file mode 100644
index 00000000..9bb27e0f
--- /dev/null
+++ b/docs/error-handling.md
@@ -0,0 +1,125 @@
+# Error Handling
+
+This document describes the error handling patterns used in the GitHub MCP Server, specifically how we handle GitHub API errors and avoid direct use of mcp-go error types.
+
+## Overview
+
+The GitHub MCP Server implements a custom error handling approach that serves two primary purposes:
+
+1. **Tool Response Generation**: Return appropriate MCP tool error responses to clients
+2. **Middleware Inspection**: Store detailed error information in the request context for middleware analysis
+
+This dual approach enables better observability and debugging capabilities, particularly for remote server deployments where understanding the nature of failures (rate limiting, authentication, 404s, 500s, etc.) is crucial for validation and monitoring.
+
+## Error Types
+
+### GitHubAPIError
+
+Used for REST API errors from the GitHub API:
+
+```go
+type GitHubAPIError struct {
+ Message string `json:"message"`
+ Response *github.Response `json:"-"`
+ Err error `json:"-"`
+}
+```
+
+### GitHubGraphQLError
+
+Used for GraphQL API errors from the GitHub API:
+
+```go
+type GitHubGraphQLError struct {
+ Message string `json:"message"`
+ Err error `json:"-"`
+}
+```
+
+## Usage Patterns
+
+### For GitHub REST API Errors
+
+Instead of directly returning `mcp.NewToolResultError()`, use:
+
+```go
+return ghErrors.NewGitHubAPIErrorResponse(ctx, message, response, err), nil
+```
+
+This function:
+- Creates a `GitHubAPIError` with the provided message, response, and error
+- Stores the error in the context for middleware inspection
+- Returns an appropriate MCP tool error response
+
+### For GitHub GraphQL API Errors
+
+```go
+return ghErrors.NewGitHubGraphQLErrorResponse(ctx, message, err), nil
+```
+
+### Context Management
+
+The error handling system uses context to store errors for later inspection:
+
+```go
+// Initialize context with error tracking
+ctx = errors.ContextWithGitHubErrors(ctx)
+
+// Retrieve errors for inspection (typically in middleware)
+apiErrors, err := errors.GetGitHubAPIErrors(ctx)
+graphqlErrors, err := errors.GetGitHubGraphQLErrors(ctx)
+```
+
+## Design Principles
+
+### User-Actionable vs. Developer Errors
+
+- **User-actionable errors** (authentication failures, rate limits, 404s) should be returned as failed tool calls using the error response functions
+- **Developer errors** (JSON marshaling failures, internal logic errors) should be returned as actual Go errors that bubble up through the MCP framework
+
+### Context Limitations
+
+This approach was designed to work around current limitations in mcp-go where context is not propagated through each step of request processing. By storing errors in context values, middleware can inspect them without requiring context propagation.
+
+### Graceful Error Handling
+
+Error storage operations in context are designed to fail gracefully - if context storage fails, the tool will still return an appropriate error response to the client.
+
+## Benefits
+
+1. **Observability**: Middleware can inspect the specific types of GitHub API errors occurring
+2. **Debugging**: Detailed error information is preserved without exposing potentially sensitive data in logs
+3. **Validation**: Remote servers can use error types and HTTP status codes to validate that changes don't break functionality
+4. **Privacy**: Error inspection can be done programmatically using `errors.Is` checks without logging PII
+
+## Example Implementation
+
+```go
+func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_issue", /* ... */),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get issue",
+ resp,
+ err,
+ ), nil
+ }
+
+ return MarshalledTextResult(issue), nil
+ }
+}
+```
+
+This approach ensures that both the client receives an appropriate error response and any middleware can inspect the underlying GitHub API error for monitoring and debugging purposes.
diff --git a/docs/remote-server.md b/docs/remote-server.md
index 888caef4..50404ec8 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -14,17 +14,22 @@ The remote GitHub MCP server is built using this repository as a library, and bi
Below is a table of available toolsets for the remote GitHub MCP Server. Each toolset is provided as a distinct URL so you can mix and match to create the perfect combination of tools for your use-case. Add `/readonly` to the end of any URL to restrict the tools in the toolset to only those that enable read access. We also provide the option to use [headers](#headers) instead.
-
+
| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |
|----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |
-| code_security | Code security related tools, such as Code Scanning| https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D)|
-| issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) |
-| notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D)|
-| pull_requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D)|
-| repos | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) |
-| secret_protection | Secret protection related tools, e.g. Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D)|
-| users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) |
+| Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) |
+| Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) |
+| Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) |
+| Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) |
+| Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) |
+| Organizations | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) |
+| Pull Requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) |
+| Repositories | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) |
+| Secret Protection | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) |
+| Users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) |
+
+
### Headers
diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go
index e25dbda4..bc5a3fde 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"
@@ -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 ab2302ed..4cc7682f 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.23.7
require (
github.com/google/go-github/v72 v72.0.0
github.com/josephburnett/jd v1.9.2
- github.com/mark3labs/mcp-go v0.31.0
+ github.com/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
@@ -26,7 +26,7 @@ require (
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
@@ -41,7 +41,7 @@ require (
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
- github.com/spf13/pflag v1.0.6 // indirect
+ github.com/spf13/pflag v1.0.6
github.com/subosito/gotenv v1.6.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
diff --git a/go.sum b/go.sum
index e7f6794a..5e601d90 100644
--- a/go.sum
+++ b/go.sum
@@ -13,8 +13,8 @@ github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrKU=
github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
-github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
-github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/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=
@@ -47,8 +47,8 @@ github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
-github.com/mark3labs/mcp-go v0.31.0 h1:4UxSV8aM770OPmTvaVe/b1rA2oZAjBMhGBfUgOGut+4=
-github.com/mark3labs/mcp-go v0.31.0/go.mod h1:rXqOudj/djTORU/ThxYx8fqEVj/5pvTuuebQ2RC7uk4=
+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=
diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go
index ca38e76b..568af10d 100644
--- a/internal/ghmcp/server.go
+++ b/internal/ghmcp/server.go
@@ -12,6 +12,7 @@ import (
"strings"
"syscall"
+ "github.com/github/github-mcp-server/pkg/errors"
"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"
@@ -90,6 +91,13 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
hooks := &server.Hooks{
OnBeforeInitialize: []server.OnBeforeInitializeFunc{beforeInit},
+ OnBeforeAny: []server.BeforeAnyHookFunc{
+ func(ctx context.Context, _ any, _ mcp.MCPMethod, _ any) {
+ // Ensure the context is cleared of any previous errors
+ // as context isn't propagated through middleware
+ errors.ContextWithGitHubErrors(ctx)
+ },
+ },
}
ghServer := github.NewServer(cfg.Version, server.WithHooks(hooks))
@@ -222,7 +230,8 @@ func RunStdioServer(cfg StdioServerConfig) error {
loggedIO := mcplog.NewIOLogger(in, out, logrusLogger)
in, out = loggedIO, loggedIO
}
-
+ // enable GitHub errors in the context
+ ctx := errors.ContextWithGitHubErrors(ctx)
errC <- stdioServer.Listen(ctx, in, out)
}()
diff --git a/internal/toolsnaps/toolsnaps.go b/internal/toolsnaps/toolsnaps.go
index f24ffe58..89d02e1e 100644
--- a/internal/toolsnaps/toolsnaps.go
+++ b/internal/toolsnaps/toolsnaps.go
@@ -60,7 +60,7 @@ func Test(toolName string, tool any) error {
diff := toolNode.Diff(snapNode, jd.SET).Render()
if diff != "" {
// If there is a difference, we return an error with the diff
- return fmt.Errorf("tool schema for %s has changed unexpectedly:\n%s", toolName, diff)
+ return fmt.Errorf("tool schema for %s has changed unexpectedly:\n%s\nrun with `UPDATE_TOOLSNAPS=true` if this is expected", toolName, diff)
}
return nil
diff --git a/internal/toolsnaps/toolsnaps_test.go b/internal/toolsnaps/toolsnaps_test.go
index c664911f..be9cadf7 100644
--- a/internal/toolsnaps/toolsnaps_test.go
+++ b/internal/toolsnaps/toolsnaps_test.go
@@ -43,6 +43,9 @@ func TestSnapshotDoesNotExistNotInCI(t *testing.T) {
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")
@@ -74,6 +77,9 @@ func TestSnapshotExistsMatch(t *testing.T) {
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))
@@ -109,6 +115,9 @@ func TestUpdateToolsnaps(t *testing.T) {
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))
diff --git a/pkg/errors/error.go b/pkg/errors/error.go
new file mode 100644
index 00000000..9d81e901
--- /dev/null
+++ b/pkg/errors/error.go
@@ -0,0 +1,125 @@
+package errors
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/google/go-github/v72/github"
+ "github.com/mark3labs/mcp-go/mcp"
+)
+
+type GitHubAPIError struct {
+ Message string `json:"message"`
+ Response *github.Response `json:"-"`
+ Err error `json:"-"`
+}
+
+// NewGitHubAPIError creates a new GitHubAPIError with the provided message, response, and error.
+func newGitHubAPIError(message string, resp *github.Response, err error) *GitHubAPIError {
+ return &GitHubAPIError{
+ Message: message,
+ Response: resp,
+ Err: err,
+ }
+}
+
+func (e *GitHubAPIError) Error() string {
+ return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
+}
+
+type GitHubGraphQLError struct {
+ Message string `json:"message"`
+ Err error `json:"-"`
+}
+
+func newGitHubGraphQLError(message string, err error) *GitHubGraphQLError {
+ return &GitHubGraphQLError{
+ Message: message,
+ Err: err,
+ }
+}
+
+func (e *GitHubGraphQLError) Error() string {
+ return fmt.Errorf("%s: %w", e.Message, e.Err).Error()
+}
+
+type GitHubErrorKey struct{}
+type GitHubCtxErrors struct {
+ api []*GitHubAPIError
+ graphQL []*GitHubGraphQLError
+}
+
+// ContextWithGitHubErrors updates or creates a context with a pointer to GitHub error information (to be used by middleware).
+func ContextWithGitHubErrors(ctx context.Context) context.Context {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
+ // If the context already has GitHubCtxErrors, we just empty the slices to start fresh
+ val.api = []*GitHubAPIError{}
+ val.graphQL = []*GitHubGraphQLError{}
+ } else {
+ // If not, we create a new GitHubCtxErrors and set it in the context
+ ctx = context.WithValue(ctx, GitHubErrorKey{}, &GitHubCtxErrors{})
+ }
+
+ return ctx
+}
+
+// GetGitHubAPIErrors retrieves the slice of GitHubAPIErrors from the context.
+func GetGitHubAPIErrors(ctx context.Context) ([]*GitHubAPIError, error) {
+ if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
+ return val.api, nil // return the slice of API errors from the context
+ }
+ return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
+}
+
+// GetGitHubGraphQLErrors retrieves the slice of GitHubGraphQLErrors from the context.
+func GetGitHubGraphQLErrors(ctx context.Context) ([]*GitHubGraphQLError, error) {
+ if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
+ return val.graphQL, nil // return the slice of GraphQL errors from the context
+ }
+ return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
+}
+
+func NewGitHubAPIErrorToCtx(ctx context.Context, message string, resp *github.Response, err error) (context.Context, error) {
+ apiErr := newGitHubAPIError(message, resp, err)
+ if ctx != nil {
+ _, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling
+ }
+ return ctx, nil
+}
+
+func addGitHubAPIErrorToContext(ctx context.Context, err *GitHubAPIError) (context.Context, error) {
+ if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
+ val.api = append(val.api, err) // append the error to the existing slice in the context
+ return ctx, nil
+ }
+ return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
+}
+
+func addGitHubGraphQLErrorToContext(ctx context.Context, err *GitHubGraphQLError) (context.Context, error) {
+ if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok {
+ val.graphQL = append(val.graphQL, err) // append the error to the existing slice in the context
+ return ctx, nil
+ }
+ return nil, fmt.Errorf("context does not contain GitHubCtxErrors")
+}
+
+// NewGitHubAPIErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
+func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github.Response, err error) *mcp.CallToolResult {
+ apiErr := newGitHubAPIError(message, resp, err)
+ if ctx != nil {
+ _, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling
+ }
+ return mcp.NewToolResultErrorFromErr(message, err)
+}
+
+// NewGitHubGraphQLErrorResponse returns an mcp.NewToolResultError and retains the error in the context for access via middleware
+func NewGitHubGraphQLErrorResponse(ctx context.Context, message string, err error) *mcp.CallToolResult {
+ graphQLErr := newGitHubGraphQLError(message, err)
+ if ctx != nil {
+ _, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling
+ }
+ return mcp.NewToolResultErrorFromErr(message, err)
+}
diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go
new file mode 100644
index 00000000..e7a5b6ea
--- /dev/null
+++ b/pkg/errors/error_test.go
@@ -0,0 +1,379 @@
+package errors
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/google/go-github/v72/github"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestGitHubErrorContext(t *testing.T) {
+ t.Run("API errors can be added to context and retrieved", func(t *testing.T) {
+ // Given a context with GitHub error tracking enabled
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ // Create a mock GitHub response
+ resp := &github.Response{
+ Response: &http.Response{
+ StatusCode: 404,
+ Status: "404 Not Found",
+ },
+ }
+ originalErr := fmt.Errorf("resource not found")
+
+ // When we add an API error to the context
+ updatedCtx, err := NewGitHubAPIErrorToCtx(ctx, "failed to fetch resource", resp, originalErr)
+ require.NoError(t, err)
+
+ // Then we should be able to retrieve the error from the updated context
+ apiErrors, err := GetGitHubAPIErrors(updatedCtx)
+ require.NoError(t, err)
+ require.Len(t, apiErrors, 1)
+
+ apiError := apiErrors[0]
+ assert.Equal(t, "failed to fetch resource", apiError.Message)
+ assert.Equal(t, resp, apiError.Response)
+ assert.Equal(t, originalErr, apiError.Err)
+ assert.Equal(t, "failed to fetch resource: resource not found", apiError.Error())
+ })
+
+ t.Run("GraphQL errors can be added to context and retrieved", func(t *testing.T) {
+ // Given a context with GitHub error tracking enabled
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ originalErr := fmt.Errorf("GraphQL query failed")
+
+ // When we add a GraphQL error to the context
+ graphQLErr := newGitHubGraphQLError("failed to execute mutation", originalErr)
+ updatedCtx, err := addGitHubGraphQLErrorToContext(ctx, graphQLErr)
+ require.NoError(t, err)
+
+ // Then we should be able to retrieve the error from the updated context
+ gqlErrors, err := GetGitHubGraphQLErrors(updatedCtx)
+ require.NoError(t, err)
+ require.Len(t, gqlErrors, 1)
+
+ gqlError := gqlErrors[0]
+ assert.Equal(t, "failed to execute mutation", gqlError.Message)
+ assert.Equal(t, originalErr, gqlError.Err)
+ assert.Equal(t, "failed to execute mutation: GraphQL query failed", gqlError.Error())
+ })
+
+ t.Run("multiple errors can be accumulated in context", func(t *testing.T) {
+ // Given a context with GitHub error tracking enabled
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ // When we add multiple API errors
+ resp1 := &github.Response{Response: &http.Response{StatusCode: 404}}
+ resp2 := &github.Response{Response: &http.Response{StatusCode: 403}}
+
+ ctx, err := NewGitHubAPIErrorToCtx(ctx, "first error", resp1, fmt.Errorf("not found"))
+ require.NoError(t, err)
+
+ ctx, err = NewGitHubAPIErrorToCtx(ctx, "second error", resp2, fmt.Errorf("forbidden"))
+ require.NoError(t, err)
+
+ // And add a GraphQL error
+ gqlErr := newGitHubGraphQLError("graphql error", fmt.Errorf("query failed"))
+ ctx, err = addGitHubGraphQLErrorToContext(ctx, gqlErr)
+ require.NoError(t, err)
+
+ // Then we should be able to retrieve all errors
+ apiErrors, err := GetGitHubAPIErrors(ctx)
+ require.NoError(t, err)
+ assert.Len(t, apiErrors, 2)
+
+ gqlErrors, err := GetGitHubGraphQLErrors(ctx)
+ require.NoError(t, err)
+ assert.Len(t, gqlErrors, 1)
+
+ // Verify error details
+ assert.Equal(t, "first error", apiErrors[0].Message)
+ assert.Equal(t, "second error", apiErrors[1].Message)
+ assert.Equal(t, "graphql error", gqlErrors[0].Message)
+ })
+
+ t.Run("context pointer sharing allows middleware to inspect errors without context propagation", func(t *testing.T) {
+ // This test demonstrates the key behavior: even when the context itself
+ // isn't propagated through function calls, the pointer to the error slice
+ // is shared, allowing middleware to inspect errors that were added later.
+
+ // Given a context with GitHub error tracking enabled
+ originalCtx := ContextWithGitHubErrors(context.Background())
+
+ // Simulate a middleware that captures the context early
+ var middlewareCtx context.Context
+
+ // Middleware function that captures the context
+ middleware := func(ctx context.Context) {
+ middlewareCtx = ctx // Middleware saves the context reference
+ }
+
+ // Call middleware with the original context
+ middleware(originalCtx)
+
+ // Simulate some business logic that adds errors to the context
+ // but doesn't propagate the updated context back to middleware
+ businessLogic := func(ctx context.Context) {
+ resp := &github.Response{Response: &http.Response{StatusCode: 500}}
+
+ // Add an error to the context (this modifies the shared pointer)
+ _, err := NewGitHubAPIErrorToCtx(ctx, "business logic failed", resp, fmt.Errorf("internal error"))
+ require.NoError(t, err)
+
+ // Add another error
+ _, err = NewGitHubAPIErrorToCtx(ctx, "second failure", resp, fmt.Errorf("another error"))
+ require.NoError(t, err)
+ }
+
+ // Execute business logic - note that we don't propagate the returned context
+ businessLogic(originalCtx)
+
+ // Then the middleware should be able to see the errors that were added
+ // even though it only has a reference to the original context
+ apiErrors, err := GetGitHubAPIErrors(middlewareCtx)
+ require.NoError(t, err)
+ assert.Len(t, apiErrors, 2, "Middleware should see errors added after it captured the context")
+
+ assert.Equal(t, "business logic failed", apiErrors[0].Message)
+ assert.Equal(t, "second failure", apiErrors[1].Message)
+ })
+
+ t.Run("context without GitHub errors returns error", func(t *testing.T) {
+ // Given a regular context without GitHub error tracking
+ ctx := context.Background()
+
+ // When we try to retrieve errors
+ apiErrors, err := GetGitHubAPIErrors(ctx)
+
+ // Then it should return an error
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "context does not contain GitHubCtxErrors")
+ assert.Nil(t, apiErrors)
+
+ // Same for GraphQL errors
+ gqlErrors, err := GetGitHubGraphQLErrors(ctx)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "context does not contain GitHubCtxErrors")
+ assert.Nil(t, gqlErrors)
+ })
+
+ t.Run("ContextWithGitHubErrors resets existing errors", func(t *testing.T) {
+ // Given a context with existing errors
+ ctx := ContextWithGitHubErrors(context.Background())
+ resp := &github.Response{Response: &http.Response{StatusCode: 404}}
+ ctx, err := NewGitHubAPIErrorToCtx(ctx, "existing error", resp, fmt.Errorf("error"))
+ require.NoError(t, err)
+
+ // Verify error exists
+ apiErrors, err := GetGitHubAPIErrors(ctx)
+ require.NoError(t, err)
+ assert.Len(t, apiErrors, 1)
+
+ // When we call ContextWithGitHubErrors again
+ resetCtx := ContextWithGitHubErrors(ctx)
+
+ // Then the errors should be cleared
+ apiErrors, err = GetGitHubAPIErrors(resetCtx)
+ require.NoError(t, err)
+ assert.Len(t, apiErrors, 0, "Errors should be reset")
+ })
+
+ t.Run("NewGitHubAPIErrorResponse creates MCP error result and stores context error", func(t *testing.T) {
+ // Given a context with GitHub error tracking enabled
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ resp := &github.Response{Response: &http.Response{StatusCode: 422}}
+ originalErr := fmt.Errorf("validation failed")
+
+ // When we create an API error response
+ result := NewGitHubAPIErrorResponse(ctx, "API call failed", resp, originalErr)
+
+ // Then it should return an MCP error result
+ require.NotNil(t, result)
+ assert.True(t, result.IsError)
+
+ // And the error should be stored in the context
+ apiErrors, err := GetGitHubAPIErrors(ctx)
+ require.NoError(t, err)
+ require.Len(t, apiErrors, 1)
+
+ apiError := apiErrors[0]
+ assert.Equal(t, "API call failed", apiError.Message)
+ assert.Equal(t, resp, apiError.Response)
+ assert.Equal(t, originalErr, apiError.Err)
+ })
+
+ t.Run("NewGitHubGraphQLErrorResponse creates MCP error result and stores context error", func(t *testing.T) {
+ // Given a context with GitHub error tracking enabled
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ originalErr := fmt.Errorf("mutation failed")
+
+ // When we create a GraphQL error response
+ result := NewGitHubGraphQLErrorResponse(ctx, "GraphQL call failed", originalErr)
+
+ // Then it should return an MCP error result
+ require.NotNil(t, result)
+ assert.True(t, result.IsError)
+
+ // And the error should be stored in the context
+ gqlErrors, err := GetGitHubGraphQLErrors(ctx)
+ require.NoError(t, err)
+ require.Len(t, gqlErrors, 1)
+
+ gqlError := gqlErrors[0]
+ assert.Equal(t, "GraphQL call failed", gqlError.Message)
+ assert.Equal(t, originalErr, gqlError.Err)
+ })
+
+ t.Run("NewGitHubAPIErrorToCtx with uninitialized context does not error", func(t *testing.T) {
+ // Given a regular context without GitHub error tracking initialized
+ ctx := context.Background()
+
+ // Create a mock GitHub response
+ resp := &github.Response{
+ Response: &http.Response{
+ StatusCode: 500,
+ Status: "500 Internal Server Error",
+ },
+ }
+ originalErr := fmt.Errorf("internal server error")
+
+ // When we try to add an API error to an uninitialized context
+ updatedCtx, err := NewGitHubAPIErrorToCtx(ctx, "failed operation", resp, originalErr)
+
+ // Then it should not return an error (graceful handling)
+ assert.NoError(t, err, "NewGitHubAPIErrorToCtx should handle uninitialized context gracefully")
+ assert.Equal(t, ctx, updatedCtx, "Context should be returned unchanged when not initialized")
+
+ // And attempting to retrieve errors should still return an error since context wasn't initialized
+ apiErrors, err := GetGitHubAPIErrors(updatedCtx)
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "context does not contain GitHubCtxErrors")
+ assert.Nil(t, apiErrors)
+ })
+
+ t.Run("NewGitHubAPIErrorToCtx with nil context does not error", func(t *testing.T) {
+ // Given a nil context
+ var ctx context.Context
+
+ // Create a mock GitHub response
+ resp := &github.Response{
+ Response: &http.Response{
+ StatusCode: 400,
+ Status: "400 Bad Request",
+ },
+ }
+ originalErr := fmt.Errorf("bad request")
+
+ // When we try to add an API error to a nil context
+ updatedCtx, err := NewGitHubAPIErrorToCtx(ctx, "failed with nil context", resp, originalErr)
+
+ // Then it should not return an error (graceful handling)
+ assert.NoError(t, err, "NewGitHubAPIErrorToCtx should handle nil context gracefully")
+ assert.Nil(t, updatedCtx, "Context should remain nil when passed as nil")
+ })
+}
+
+func TestGitHubErrorTypes(t *testing.T) {
+ t.Run("GitHubAPIError implements error interface", func(t *testing.T) {
+ resp := &github.Response{Response: &http.Response{StatusCode: 404}}
+ originalErr := fmt.Errorf("not found")
+
+ apiErr := newGitHubAPIError("test message", resp, originalErr)
+
+ // Should implement error interface
+ var err error = apiErr
+ assert.Equal(t, "test message: not found", err.Error())
+ })
+
+ t.Run("GitHubGraphQLError implements error interface", func(t *testing.T) {
+ originalErr := fmt.Errorf("query failed")
+
+ gqlErr := newGitHubGraphQLError("test message", originalErr)
+
+ // Should implement error interface
+ var err error = gqlErr
+ assert.Equal(t, "test message: query failed", err.Error())
+ })
+}
+
+// TestMiddlewareScenario demonstrates a realistic middleware scenario
+func TestMiddlewareScenario(t *testing.T) {
+ t.Run("realistic middleware error collection scenario", func(t *testing.T) {
+ // Simulate a realistic HTTP middleware scenario
+
+ // 1. Request comes in, middleware sets up error tracking
+ ctx := ContextWithGitHubErrors(context.Background())
+
+ // 2. Middleware stores reference to context for later inspection
+ var middlewareCtx context.Context
+ setupMiddleware := func(ctx context.Context) context.Context {
+ middlewareCtx = ctx
+ return ctx
+ }
+
+ // 3. Setup middleware
+ ctx = setupMiddleware(ctx)
+
+ // 4. Simulate multiple service calls that add errors
+ simulateServiceCall1 := func(ctx context.Context) {
+ resp := &github.Response{Response: &http.Response{StatusCode: 403}}
+ _, err := NewGitHubAPIErrorToCtx(ctx, "insufficient permissions", resp, fmt.Errorf("forbidden"))
+ require.NoError(t, err)
+ }
+
+ simulateServiceCall2 := func(ctx context.Context) {
+ resp := &github.Response{Response: &http.Response{StatusCode: 404}}
+ _, err := NewGitHubAPIErrorToCtx(ctx, "resource not found", resp, fmt.Errorf("not found"))
+ require.NoError(t, err)
+ }
+
+ simulateGraphQLCall := func(ctx context.Context) {
+ gqlErr := newGitHubGraphQLError("mutation failed", fmt.Errorf("invalid input"))
+ _, err := addGitHubGraphQLErrorToContext(ctx, gqlErr)
+ require.NoError(t, err)
+ }
+
+ // 5. Execute service calls (without context propagation)
+ simulateServiceCall1(ctx)
+ simulateServiceCall2(ctx)
+ simulateGraphQLCall(ctx)
+
+ // 6. Middleware inspects errors at the end of request processing
+ finalizeMiddleware := func(ctx context.Context) ([]string, []string) {
+ var apiErrorMessages []string
+ var gqlErrorMessages []string
+
+ if apiErrors, err := GetGitHubAPIErrors(ctx); err == nil {
+ for _, apiErr := range apiErrors {
+ apiErrorMessages = append(apiErrorMessages, apiErr.Message)
+ }
+ }
+
+ if gqlErrors, err := GetGitHubGraphQLErrors(ctx); err == nil {
+ for _, gqlErr := range gqlErrors {
+ gqlErrorMessages = append(gqlErrorMessages, gqlErr.Message)
+ }
+ }
+
+ return apiErrorMessages, gqlErrorMessages
+ }
+
+ // 7. Middleware can see all errors that were added during request processing
+ apiMessages, gqlMessages := finalizeMiddleware(middlewareCtx)
+
+ // Verify all errors were captured
+ assert.Len(t, apiMessages, 2)
+ assert.Contains(t, apiMessages, "insufficient permissions")
+ assert.Contains(t, apiMessages, "resource not found")
+
+ assert.Len(t, gqlMessages, 1)
+ assert.Contains(t, gqlMessages, "mutation failed")
+ })
+}
diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap
new file mode 100644
index 00000000..92eeb1ce
--- /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 00000000..454b9d0b
--- /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 00000000..2d61ccfb
--- /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 00000000..85874cfc
--- /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 00000000..d5756fcc
--- /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 00000000..f065b018
--- /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 00000000..dfbb3442
--- /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. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.",
+ "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 00000000..3eea5e6a
--- /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 00000000..44142a79
--- /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 00000000..aaba75f3
--- /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 00000000..2588ea5c
--- /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 00000000..9aff7356
--- /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 00000000..80646a80
--- /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 00000000..6e4d2782
--- /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 00000000..eedc20b4
--- /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 00000000..af003811
--- /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 00000000..b3975abb
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_file_contents.snap
@@ -0,0 +1,38 @@
+{
+ "annotations": {
+ "title": "Get file or directory contents",
+ "readOnlyHint": true
+ },
+ "description": "Get the contents of a file or directory from a GitHub repository",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner (username or organization)",
+ "type": "string"
+ },
+ "path": {
+ "description": "Path to file/directory (directories must end with a slash '/')",
+ "type": "string"
+ },
+ "ref": {
+ "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "sha": {
+ "description": "Accepts optional git sha, if sha is specified it will be used instead of ref",
+ "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 00000000..eab2b872
--- /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 00000000..fa1fb0d6
--- /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_notification_details.snap b/pkg/github/__toolsnaps__/get_notification_details.snap
new file mode 100644
index 00000000..62bc6bf1
--- /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 00000000..cbcf1f42
--- /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 00000000..6699f6d9
--- /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 00000000..e054eab9
--- /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 00000000..148053b1
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_pull_request_files.snap
@@ -0,0 +1,41 @@
+{
+ "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"
+ },
+ "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"
+ },
+ "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 00000000..61dee53e
--- /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 00000000..8ffebc3a
--- /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 00000000..42089f87
--- /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 00000000..492b6d52
--- /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 00000000..470f0d01
--- /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 00000000..1e769c71
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_commits.snap
@@ -0,0 +1,44 @@
+{
+ "annotations": {
+ "title": "List commits",
+ "readOnlyHint": true
+ },
+ "description": "Get list of commits of a branch in a GitHub repository",
+ "inputSchema": {
+ "properties": {
+ "author": {
+ "description": "Author username or email address",
+ "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"
+ },
+ "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 00000000..4fe155f0
--- /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 00000000..92f25eb4
--- /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 00000000..b8369784
--- /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 00000000..fcb9853f
--- /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 00000000..0f7d9120
--- /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 00000000..9d09a581
--- /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 00000000..5a1fe24a
--- /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 00000000..a5a1474c
--- /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 00000000..3ade75ee
--- /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 00000000..1717ced0
--- /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 00000000..c85d6674
--- /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 00000000..7db502d9
--- /dev/null
+++ b/pkg/github/__toolsnaps__/search_issues.snap
@@ -0,0 +1,64 @@
+{
+ "annotations": {
+ "title": "Search issues",
+ "readOnlyHint": true
+ },
+ "description": "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue",
+ "inputSchema": {
+ "properties": {
+ "order": {
+ "description": "Sort order",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "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"
+ },
+ "query": {
+ "description": "Search query using GitHub issues search syntax",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Optional repository name. If provided with owner, only notifications for this repository are listed.",
+ "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": [
+ "query"
+ ],
+ "type": "object"
+ },
+ "name": "search_issues"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/search_pull_requests.snap b/pkg/github/__toolsnaps__/search_pull_requests.snap
new file mode 100644
index 00000000..6a8d8e0e
--- /dev/null
+++ b/pkg/github/__toolsnaps__/search_pull_requests.snap
@@ -0,0 +1,64 @@
+{
+ "annotations": {
+ "title": "Search pull requests",
+ "readOnlyHint": true
+ },
+ "description": "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr",
+ "inputSchema": {
+ "properties": {
+ "order": {
+ "description": "Sort order",
+ "enum": [
+ "asc",
+ "desc"
+ ],
+ "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"
+ },
+ "query": {
+ "description": "Search query using GitHub pull request search syntax",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Optional repository name. If provided with owner, only notifications for this repository are listed.",
+ "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": [
+ "query"
+ ],
+ "type": "object"
+ },
+ "name": "search_pull_requests"
+}
\ 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 00000000..b6b6d1d4
--- /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 00000000..5cf9796f
--- /dev/null
+++ b/pkg/github/__toolsnaps__/search_users.snap
@@ -0,0 +1,48 @@
+{
+ "annotations": {
+ "title": "Search users",
+ "readOnlyHint": true
+ },
+ "description": "Search for GitHub users exclusively",
+ "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"
+ },
+ "query": {
+ "description": "Search query using GitHub users search syntax scoped to type:user",
+ "type": "string"
+ },
+ "sort": {
+ "description": "Sort field by category",
+ "enum": [
+ "followers",
+ "repositories",
+ "joined"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "query"
+ ],
+ "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 00000000..f3541922
--- /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 00000000..4bcae7ba
--- /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 00000000..765983af
--- /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 00000000..60ec9c12
--- /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 00000000..8c7b08a8
--- /dev/null
+++ b/pkg/github/actions.go
@@ -0,0 +1,1266 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v72/github"
+ "github.com/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"),
+ ),
+ mcp.WithNumber("tail_lines",
+ mcp.Description("Number of lines to return from the end of the log"),
+ mcp.DefaultNumber(500),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ 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
+ }
+ tailLines, err := OptionalIntParam(request, "tail_lines")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ // Default to 500 lines if not specified
+ if tailLines == 0 {
+ tailLines = 500
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ 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, tailLines)
+ } else if jobID > 0 {
+ // Handle single job mode
+ return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines)
+ }
+
+ return mcp.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil
+ }
+}
+
+// handleFailedJobLogs gets logs for all failed jobs in a workflow run
+func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int) (*mcp.CallToolResult, error) {
+ // 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 ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, 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, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines)
+ if err != nil {
+ // Continue with other jobs even if one fails
+ jobResult = map[string]any{
+ "job_id": job.GetID(),
+ "job_name": job.GetName(),
+ "error": err.Error(),
+ }
+ // Enable reporting of status codes and error causes
+ _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling
+ }
+
+ 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, tailLines int) (*mcp.CallToolResult, error) {
+ jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil
+ }
+
+ 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, tailLines int) (map[string]any, *github.Response, error) {
+ // Get the download URL for the job logs
+ url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1)
+ if err != nil {
+ return nil, resp, 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, originalLength, httpResp, err := downloadLogContent(url.String(), tailLines) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp
+ if err != nil {
+ // To keep the return value consistent wrap the response as a GitHub Response
+ ghRes := &github.Response{
+ Response: httpResp,
+ }
+ return nil, ghRes, fmt.Errorf("failed to download log content for job %d: %w", jobID, err)
+ }
+ result["logs_content"] = content
+ result["message"] = "Job logs content retrieved successfully"
+ result["original_length"] = originalLength
+ } else {
+ // Return just the URL
+ result["logs_url"] = url.String()
+ 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, resp, nil
+}
+
+// downloadLogContent downloads the actual log content from a GitHub logs URL
+func downloadLogContent(logURL string, tailLines int) (string, int, *http.Response, error) {
+ httpResp, err := http.Get(logURL) //nolint:gosec // URLs are provided by GitHub API and are safe
+ if err != nil {
+ return "", 0, httpResp, fmt.Errorf("failed to download logs: %w", err)
+ }
+ defer func() { _ = httpResp.Body.Close() }()
+
+ if httpResp.StatusCode != http.StatusOK {
+ return "", 0, httpResp, fmt.Errorf("failed to download logs: HTTP %d", httpResp.StatusCode)
+ }
+
+ content, err := io.ReadAll(httpResp.Body)
+ if err != nil {
+ return "", 0, httpResp, fmt.Errorf("failed to read log content: %w", err)
+ }
+
+ // Clean up and format the log content for better readability
+ logContent := strings.TrimSpace(string(content))
+
+ trimmedContent, lineCount := trimContent(logContent, tailLines)
+ return trimmedContent, lineCount, httpResp, nil
+}
+
+// trimContent trims the content to a maximum length and returns the trimmed content and an original length
+func trimContent(content string, tailLines int) (string, int) {
+ // Truncate to tail_lines if specified
+ lineCount := 0
+ if tailLines > 0 {
+
+ // Count backwards to find the nth newline from the end and a total number of lines
+ for i := len(content) - 1; i >= 0 && lineCount < tailLines; i-- {
+ if content[i] == '\n' {
+ lineCount++
+ // If we have reached the tailLines, trim the content
+ if lineCount == tailLines {
+ content = content[i+1:]
+ }
+ }
+ }
+ }
+ return content, lineCount
+}
+
+// RerunWorkflowRun creates a tool to re-run an entire workflow run
+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 ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil
+ }
+ 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 ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil
+ }
+ 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 ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to cancel workflow run", resp, err), nil
+ }
+ 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 ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow run artifacts", resp, err), nil
+ }
+ 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 ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get artifact download URL", resp, err), nil
+ }
+ 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 ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil
+ }
+ 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 ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil
+ }
+ 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 00000000..1b904b9b
--- /dev/null
+++ b/pkg/github/actions_test.go
@@ -0,0 +1,1146 @@
+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, _ *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, _ *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
+}
+
+func Test_GetJobLogs_WithContentReturnAndTailLines(t *testing.T) {
+ // Test the return_content functionality with a mock HTTP server
+ logContent := "2023-01-01T10:00:00.000Z Starting job...\n2023-01-01T10:00:01.000Z Running tests...\n2023-01-01T10:00:02.000Z Job completed successfully"
+ expectedLogContent := "2023-01-01T10:00:02.000Z Job completed successfully"
+
+ // Create a test server to serve log content
+ testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte(logContent))
+ }))
+ defer testServer.Close()
+
+ mockedClient := mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposActionsJobsLogsByOwnerByRepoByJobId,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Location", testServer.URL)
+ w.WriteHeader(http.StatusFound)
+ }),
+ ),
+ )
+
+ client := github.NewClient(mockedClient)
+ _, handler := GetJobLogs(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ request := createMCPRequest(map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "job_id": float64(123),
+ "return_content": true,
+ "tail_lines": float64(1), // Requesting last 1 line
+ })
+
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+ var response map[string]any
+ err = json.Unmarshal([]byte(textContent.Text), &response)
+ require.NoError(t, err)
+
+ assert.Equal(t, float64(123), response["job_id"])
+ assert.Equal(t, float64(1), response["original_length"])
+ assert.Equal(t, expectedLogContent, response["logs_content"])
+ assert.Equal(t, "Job logs content retrieved successfully", response["message"])
+ assert.NotContains(t, response, "logs_url") // Should not have URL when returning content
+}
diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go
index 98714b6c..3b07692c 100644
--- a/pkg/github/code_scanning.go
+++ b/pkg/github/code_scanning.go
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
@@ -54,7 +55,11 @@ func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelpe
alert, resp, err := client.CodeScanning.GetAlert(ctx, owner, repo, int64(alertNumber))
if err != nil {
- return nil, fmt.Errorf("failed to get alert: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get alert",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -138,7 +143,11 @@ func ListCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHel
}
alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{Ref: ref, State: state, Severity: severity, ToolName: toolName})
if err != nil {
- return nil, fmt.Errorf("failed to list alerts: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list alerts",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go
index b5facbf6..bd76ccba 100644
--- a/pkg/github/code_scanning_test.go
+++ b/pkg/github/code_scanning_test.go
@@ -6,6 +6,7 @@ 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/v72/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
@@ -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)
@@ -92,12 +94,15 @@ func Test_GetCodeScanningAlert(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -119,6 +124,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)
@@ -214,12 +220,15 @@ func Test_ListCodeScanningAlerts(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go
index 62a953de..bed2f4a3 100644
--- a/pkg/github/context_tools.go
+++ b/pkg/github/context_tools.go
@@ -3,6 +3,7 @@ package github
import (
"context"
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
@@ -28,9 +29,13 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too
return mcp.NewToolResultErrorFromErr("failed to get GitHub client", err), nil
}
- user, _, err := client.Users.Get(ctx, "")
+ user, res, err := client.Users.Get(ctx, "")
if err != nil {
- return mcp.NewToolResultErrorFromErr("failed to get user", err), nil
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get user",
+ res,
+ err,
+ ), nil
}
return MarshalledTextResult(user), nil
diff --git a/pkg/github/issues.go b/pkg/github/issues.go
index ea068ed0..6121786d 100644
--- a/pkg/github/issues.go
+++ b/pkg/github/issues.go
@@ -153,18 +153,24 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc
}
}
-// SearchIssues creates a tool to search for issues and pull requests.
+// SearchIssues creates a tool to search for issues.
func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("search_issues",
- mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories.")),
+ mcp.WithDescription(t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_SEARCH_ISSUES_USER_TITLE", "Search issues"),
ReadOnlyHint: ToBoolPtr(true),
}),
- mcp.WithString("q",
+ mcp.WithString("query",
mcp.Required(),
mcp.Description("Search query using GitHub issues search syntax"),
),
+ mcp.WithString("owner",
+ mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."),
+ ),
+ mcp.WithString("repo",
+ mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."),
+ ),
mcp.WithString("sort",
mcp.Description("Sort field by number of matches of categories, defaults to best match"),
mcp.Enum(
@@ -188,56 +194,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")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- sort, err := OptionalParam[string](request, "sort")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- order, err := OptionalParam[string](request, "order")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- pagination, err := OptionalPaginationParams(request)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- opts := &github.SearchOptions{
- Sort: sort,
- Order: order,
- ListOptions: github.ListOptions{
- PerPage: pagination.perPage,
- Page: pagination.page,
- },
- }
-
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
- result, resp, err := client.Search.Issues(ctx, query, opts)
- if err != nil {
- return nil, fmt.Errorf("failed to search issues: %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 search issues: %s", string(body))), nil
- }
-
- r, err := json.Marshal(result)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
+ return searchHandler(ctx, getClient, request, "issue", "failed to search issues")
}
}
@@ -931,3 +888,42 @@ func parseISOTimestamp(timestamp string) (time.Time, error) {
// Return error with supported formats
return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp)
}
+
+func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler server.PromptHandlerFunc) {
+ return mcp.NewPrompt("AssignCodingAgent",
+ mcp.WithPromptDescription(t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository.")),
+ mcp.WithArgument("repo", mcp.ArgumentDescription("The repository to assign tasks in (owner/repo)."), mcp.RequiredArgument()),
+ ), func(_ context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
+ repo := request.Params.Arguments["repo"]
+
+ messages := []mcp.PromptMessage{
+ {
+ Role: "system",
+ Content: mcp.NewTextContent("You are a personal assistant for GitHub the Copilot GitHub Coding Agent. Your task is to help the user assign tasks to the Coding Agent based on their open GitHub issues. You can use `assign_copilot_to_issue` tool to assign the Coding Agent to issues that are suitable for autonomous work, and `search_issues` tool to find issues that match the user's criteria. You can also use `list_issues` to get a list of issues in the repository."),
+ },
+ {
+ Role: "user",
+ Content: mcp.NewTextContent(fmt.Sprintf("Please go and get a list of the most recent 10 issues from the %s GitHub repository", repo)),
+ },
+ {
+ Role: "assistant",
+ Content: mcp.NewTextContent(fmt.Sprintf("Sure! I will get a list of the 10 most recent issues for the repo %s.", repo)),
+ },
+ {
+ Role: "user",
+ Content: mcp.NewTextContent("For each issue, please check if it is a clearly defined coding task with acceptance criteria and a low to medium complexity to identify issues that are suitable for an AI Coding Agent to work on. Then assign each of the identified issues to Copilot."),
+ },
+ {
+ Role: "assistant",
+ Content: mcp.NewTextContent("Certainly! Let me carefully check which ones are clearly scoped issues that are good to assign to the coding agent, and I will summarize and assign them now."),
+ },
+ {
+ Role: "user",
+ Content: mcp.NewTextContent("Great, if you are unsure if an issue is good to assign, ask me first, rather than assigning copilot. If you are certain the issue is clear and suitable you can assign it to Copilot without asking."),
+ },
+ }
+ return &mcp.GetPromptResult{
+ Messages: messages,
+ }, nil
+ }
+}
diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go
index 251fc32b..056fa7ed 100644
--- a/pkg/github/issues_test.go
+++ b/pkg/github/issues_test.go
@@ -9,6 +9,7 @@ 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/v72/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
@@ -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,15 +233,18 @@ 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)
- assert.Contains(t, tool.InputSchema.Properties, "q")
+ assert.Contains(t, tool.InputSchema.Properties, "query")
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "sort")
assert.Contains(t, tool.InputSchema.Properties, "order")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.Contains(t, tool.InputSchema.Properties, "page")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"q"})
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"})
// Setup mock search results
mockSearchResult := &github.IssuesSearchResult{
@@ -286,7 +292,7 @@ func Test_SearchIssues(t *testing.T) {
expectQueryParams(
t,
map[string]string{
- "q": "repo:owner/repo is:issue is:open",
+ "q": "is:issue repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": "1",
@@ -298,7 +304,7 @@ func Test_SearchIssues(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "q": "repo:owner/repo is:issue is:open",
+ "query": "repo:owner/repo is:open",
"sort": "created",
"order": "desc",
"page": float64(1),
@@ -307,6 +313,83 @@ func Test_SearchIssues(t *testing.T) {
expectError: false,
expectedResult: mockSearchResult,
},
+ {
+ name: "issues search with owner and repo parameters",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "repo:test-owner/test-repo is:issue is:open",
+ "sort": "created",
+ "order": "asc",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "is:open",
+ "owner": "test-owner",
+ "repo": "test-repo",
+ "sort": "created",
+ "order": "asc",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "issues search with only owner parameter (should ignore it)",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "is:issue bug",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "bug",
+ "owner": "test-owner",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "issues search with only repo parameter (should ignore it)",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "is:issue feature",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "feature",
+ "repo": "test-repo",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
{
name: "issues search with minimal parameters",
mockedClient: mock.NewMockedHTTPClient(
@@ -316,7 +399,7 @@ func Test_SearchIssues(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "q": "repo:owner/repo is:issue is:open",
+ "query": "is:issue repo:owner/repo is:open",
},
expectError: false,
expectedResult: mockSearchResult,
@@ -333,7 +416,7 @@ func Test_SearchIssues(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "q": "invalid:query",
+ "query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search issues",
@@ -386,6 +469,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 +644,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 +821,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 +1079,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 +1216,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 677ee99f..b6b6bfd7 100644
--- a/pkg/github/notifications.go
+++ b/pkg/github/notifications.go
@@ -9,6 +9,7 @@ import (
"strconv"
"time"
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
@@ -118,7 +119,11 @@ func ListNotifications(getClient GetClientFn, t translations.TranslationHelperFu
notifications, resp, err = client.Activity.ListNotifications(ctx, opts)
}
if err != nil {
- return nil, fmt.Errorf("failed to get notifications: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list notifications",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -187,7 +192,11 @@ func DismissNotification(getclient GetClientFn, t translations.TranslationHelper
}
if err != nil {
- return nil, fmt.Errorf("failed to mark notification as %s: %w", state, err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to mark notification as %s", state),
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -262,7 +271,11 @@ func MarkAllNotificationsRead(getClient GetClientFn, t translations.TranslationH
resp, err = client.Activity.MarkNotificationsRead(ctx, markReadOptions)
}
if err != nil {
- return nil, fmt.Errorf("failed to mark all notifications as read: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to mark all notifications as read",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -304,7 +317,11 @@ func GetNotificationDetails(getClient GetClientFn, t translations.TranslationHel
thread, resp, err := client.Activity.GetThread(ctx, notificationID)
if err != nil {
- return nil, fmt.Errorf("failed to get notification details: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to get notification details for ID '%s'", notificationID),
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -385,7 +402,11 @@ func ManageNotificationSubscription(getClient GetClientFn, t translations.Transl
}
if apiErr != nil {
- return nil, fmt.Errorf("failed to %s notification subscription: %w", action, apiErr)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to %s notification subscription", action),
+ resp,
+ apiErr,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -474,7 +495,11 @@ func ManageRepositoryNotificationSubscription(getClient GetClientFn, t translati
}
if apiErr != nil {
- return nil, fmt.Errorf("failed to %s repository subscription: %w", action, apiErr)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to %s repository subscription", action),
+ resp,
+ apiErr,
+ ), nil
}
if resp != nil {
defer func() { _ = resp.Body.Close() }()
diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go
index 173f1a78..a83df3ed 100644
--- a/pkg/github/notifications_test.go
+++ b/pkg/github/notifications_test.go
@@ -6,6 +6,7 @@ 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/v72/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
@@ -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")
@@ -124,14 +127,17 @@ func Test_ListNotifications(t *testing.T) {
result, err := handler(context.Background(), request)
if tc.expectError {
- require.Error(t, err)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
if tc.expectedErrMsg != "" {
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
}
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
textContent := getTextResult(t, result)
t.Logf("textContent: %s", textContent.Text)
var returned []*github.Notification
@@ -147,6 +153,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 +291,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 +454,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 +586,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")
@@ -652,14 +666,17 @@ func Test_MarkAllNotificationsRead(t *testing.T) {
result, err := handler(context.Background(), request)
if tc.expectError {
- require.Error(t, err)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
if tc.expectedErrMsg != "" {
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
}
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
textContent := getTextResult(t, result)
if tc.expectMarked {
assert.Contains(t, textContent.Text, "All notifications marked as read")
@@ -672,6 +689,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")
@@ -725,14 +744,17 @@ func Test_GetNotificationDetails(t *testing.T) {
result, err := handler(context.Background(), request)
if tc.expectError {
- require.Error(t, err)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
if tc.expectedErrMsg != "" {
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
}
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
textContent := getTextResult(t, result)
var returned github.Notification
err = json.Unmarshal([]byte(textContent.Text), &returned)
diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go
index b16920aa..bad822b1 100644
--- a/pkg/github/pullrequests.go
+++ b/pkg/github/pullrequests.go
@@ -13,6 +13,7 @@ import (
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
)
@@ -57,7 +58,11 @@ func GetPullRequest(getClient GetClientFn, t translations.TranslationHelperFunc)
}
pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
if err != nil {
- return nil, fmt.Errorf("failed to get pull request: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get pull request",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -172,7 +177,11 @@ func CreatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
}
pr, resp, err := client.PullRequests.Create(ctx, owner, repo, newPR)
if err != nil {
- return nil, fmt.Errorf("failed to create pull request: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create pull request",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -293,7 +302,11 @@ func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
}
pr, resp, err := client.PullRequests.Edit(ctx, owner, repo, pullNumber, update)
if err != nil {
- return nil, fmt.Errorf("failed to update pull request: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to update pull request",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -402,7 +415,11 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun
}
prs, resp, err := client.PullRequests.List(ctx, owner, repo, opts)
if err != nil {
- return nil, fmt.Errorf("failed to list pull requests: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list pull requests",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -491,7 +508,11 @@ func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFun
}
result, resp, err := client.PullRequests.Merge(ctx, owner, repo, pullNumber, commitMessage, options)
if err != nil {
- return nil, fmt.Errorf("failed to merge pull request: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to merge pull request",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -512,6 +533,51 @@ func MergePullRequest(getClient GetClientFn, t translations.TranslationHelperFun
}
}
+// SearchPullRequests creates a tool to search for pull requests.
+func SearchPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("search_pull_requests",
+ mcp.WithDescription(t("TOOL_SEARCH_PULL_REQUESTS_DESCRIPTION", "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_SEARCH_PULL_REQUESTS_USER_TITLE", "Search pull requests"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("query",
+ mcp.Required(),
+ mcp.Description("Search query using GitHub pull request search syntax"),
+ ),
+ mcp.WithString("owner",
+ mcp.Description("Optional repository owner. If provided with repo, only notifications for this repository are listed."),
+ ),
+ mcp.WithString("repo",
+ mcp.Description("Optional repository name. If provided with owner, only notifications for this repository are listed."),
+ ),
+ mcp.WithString("sort",
+ mcp.Description("Sort field by number of matches of categories, defaults to best match"),
+ mcp.Enum(
+ "comments",
+ "reactions",
+ "reactions-+1",
+ "reactions--1",
+ "reactions-smile",
+ "reactions-thinking_face",
+ "reactions-heart",
+ "reactions-tada",
+ "interactions",
+ "created",
+ "updated",
+ ),
+ ),
+ mcp.WithString("order",
+ mcp.Description("Sort order"),
+ mcp.Enum("asc", "desc"),
+ ),
+ WithPagination(),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ return searchHandler(ctx, getClient, request, "pr", "failed to search pull requests")
+ }
+}
+
// GetPullRequestFiles creates a tool to get the list of files changed in a pull request.
func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("get_pull_request_files",
@@ -532,6 +598,7 @@ func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelper
mcp.Required(),
mcp.Description("Pull request number"),
),
+ WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
@@ -546,15 +613,26 @@ func GetPullRequestFiles(getClient GetClientFn, t translations.TranslationHelper
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- opts := &github.ListOptions{}
+ opts := &github.ListOptions{
+ PerPage: pagination.perPage,
+ Page: pagination.page,
+ }
files, resp, err := client.PullRequests.ListFiles(ctx, owner, repo, pullNumber, opts)
if err != nil {
- return nil, fmt.Errorf("failed to get pull request files: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get pull request files",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -616,7 +694,11 @@ func GetPullRequestStatus(getClient GetClientFn, t translations.TranslationHelpe
}
pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber)
if err != nil {
- return nil, fmt.Errorf("failed to get pull request: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get pull request",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -631,7 +713,11 @@ func GetPullRequestStatus(getClient GetClientFn, t translations.TranslationHelpe
// Get combined status for the head SHA
status, resp, err := client.Repositories.GetCombinedStatus(ctx, owner, repo, *pr.Head.SHA, nil)
if err != nil {
- return nil, fmt.Errorf("failed to get combined status: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get combined status",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -709,7 +795,11 @@ func UpdatePullRequestBranch(getClient GetClientFn, t translations.TranslationHe
if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) {
return mcp.NewToolResultText("Pull request branch update is in progress"), nil
}
- return nil, fmt.Errorf("failed to update pull request branch: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to update pull request branch",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -777,7 +867,11 @@ func GetPullRequestComments(getClient GetClientFn, t translations.TranslationHel
}
comments, resp, err := client.PullRequests.ListComments(ctx, owner, repo, pullNumber, opts)
if err != nil {
- return nil, fmt.Errorf("failed to get pull request comments: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get pull request comments",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -839,7 +933,11 @@ func GetPullRequestReviews(getClient GetClientFn, t translations.TranslationHelp
}
reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil)
if err != nil {
- return nil, fmt.Errorf("failed to get pull request reviews: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get pull request reviews",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -926,7 +1024,10 @@ func CreateAndSubmitPullRequestReview(getGQLClient GetGQLClientFn, t translation
"repo": githubv4.String(params.Repo),
"prNum": githubv4.Int(params.PullNumber),
}); err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
+ "failed to get pull request",
+ err,
+ ), nil
}
// Now we have the GQL ID, we can create a review
@@ -1017,7 +1118,10 @@ func CreatePendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.
"repo": githubv4.String(params.Repo),
"prNum": githubv4.Int(params.PullNumber),
}); err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
+ "failed to get pull request",
+ err,
+ ), nil
}
// Now we have the GQL ID, we can create a pending review
@@ -1135,7 +1239,10 @@ func AddPullRequestReviewCommentToPendingReview(getGQLClient GetGQLClientFn, t t
}
if err := client.Query(ctx, &getViewerQuery, nil); err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
+ "failed to get current user",
+ err,
+ ), nil
}
var getLatestReviewForViewerQuery struct {
@@ -1160,7 +1267,10 @@ func AddPullRequestReviewCommentToPendingReview(getGQLClient GetGQLClientFn, t t
}
if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
+ "failed to get latest review for current user",
+ err,
+ ), nil
}
// Validate there is one review and the state is pending
@@ -1266,7 +1376,10 @@ func SubmitPendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.
}
if err := client.Query(ctx, &getViewerQuery, nil); err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
+ "failed to get current user",
+ err,
+ ), nil
}
var getLatestReviewForViewerQuery struct {
@@ -1291,7 +1404,10 @@ func SubmitPendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.
}
if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
+ "failed to get latest review for current user",
+ err,
+ ), nil
}
// Validate there is one review and the state is pending
@@ -1324,7 +1440,10 @@ func SubmitPendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.
},
nil,
); err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
+ "failed to submit pull request review",
+ err,
+ ), nil
}
// Return nothing interesting, just indicate success for the time being.
@@ -1381,7 +1500,10 @@ func DeletePendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.
}
if err := client.Query(ctx, &getViewerQuery, nil); err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
+ "failed to get current user",
+ err,
+ ), nil
}
var getLatestReviewForViewerQuery struct {
@@ -1406,7 +1528,10 @@ func DeletePendingPullRequestReview(getGQLClient GetGQLClientFn, t translations.
}
if err := client.Query(context.Background(), &getLatestReviewForViewerQuery, vars); err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return ghErrors.NewGitHubGraphQLErrorResponse(ctx,
+ "failed to get latest review for current user",
+ err,
+ ), nil
}
// Validate there is one review and the state is pending
@@ -1490,7 +1615,11 @@ func GetPullRequestDiff(getClient GetClientFn, t translations.TranslationHelperF
github.RawOptions{Type: github.Diff},
)
if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get pull request diff",
+ resp,
+ err,
+ ), nil
}
if resp.StatusCode != http.StatusOK {
@@ -1563,7 +1692,11 @@ func RequestCopilotReview(getClient GetClientFn, t translations.TranslationHelpe
},
)
if err != nil {
- return nil, fmt.Errorf("failed to request copilot review: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to request copilot review",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go
index cdbccc28..30341e86 100644
--- a/pkg/github/pullrequests_test.go
+++ b/pkg/github/pullrequests_test.go
@@ -8,6 +8,7 @@ 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/v72/github"
"github.com/shurcooL/githubv4"
@@ -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)
@@ -107,12 +109,15 @@ func Test_GetPullRequest(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -133,6 +138,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)
@@ -269,23 +275,22 @@ func Test_UpdatePullRequest(t *testing.T) {
result, err := handler(context.Background(), request)
// Verify results
- if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ if tc.expectError || tc.expectedErrMsg != "" {
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ if tc.expectedErrMsg != "" {
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
+ }
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content
textContent := getTextResult(t, result)
- // Check for expected error message within the result text
- if tc.expectedErrMsg != "" {
- assert.Contains(t, textContent.Text, tc.expectedErrMsg)
- return
- }
-
// Unmarshal and verify the successful result
var returnedPR github.PullRequest
err = json.Unmarshal([]byte(textContent.Text), &returnedPR)
@@ -315,6 +320,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)
@@ -416,12 +422,15 @@ func Test_ListPullRequests(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -445,6 +454,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)
@@ -531,12 +541,15 @@ func Test_MergePullRequest(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -552,16 +565,254 @@ func Test_MergePullRequest(t *testing.T) {
}
}
+func Test_SearchPullRequests(t *testing.T) {
+ mockClient := github.NewClient(nil)
+ tool, _ := SearchPullRequests(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "search_pull_requests", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "query")
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Contains(t, tool.InputSchema.Properties, "sort")
+ assert.Contains(t, tool.InputSchema.Properties, "order")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"})
+
+ mockSearchResult := &github.IssuesSearchResult{
+ Total: github.Ptr(2),
+ IncompleteResults: github.Ptr(false),
+ Issues: []*github.Issue{
+ {
+ Number: github.Ptr(42),
+ Title: github.Ptr("Test PR 1"),
+ Body: github.Ptr("Updated tests."),
+ State: github.Ptr("open"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"),
+ Comments: github.Ptr(5),
+ User: &github.User{
+ Login: github.Ptr("user1"),
+ },
+ },
+ {
+ Number: github.Ptr(43),
+ Title: github.Ptr("Test PR 2"),
+ Body: github.Ptr("Updated build scripts."),
+ State: github.Ptr("open"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/pull/2"),
+ Comments: github.Ptr(3),
+ User: &github.User{
+ Login: github.Ptr("user2"),
+ },
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedResult *github.IssuesSearchResult
+ expectedErrMsg string
+ }{
+ {
+ name: "successful pull request search with all parameters",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "is:pr repo:owner/repo is:open",
+ "sort": "created",
+ "order": "desc",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "repo:owner/repo is:open",
+ "sort": "created",
+ "order": "desc",
+ "page": float64(1),
+ "perPage": float64(30),
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "pull request search with owner and repo parameters",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "repo:test-owner/test-repo is:pr draft:false",
+ "sort": "updated",
+ "order": "asc",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "draft:false",
+ "owner": "test-owner",
+ "repo": "test-repo",
+ "sort": "updated",
+ "order": "asc",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "pull request search with only owner parameter (should ignore it)",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "is:pr feature",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "feature",
+ "owner": "test-owner",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "pull request search with only repo parameter (should ignore it)",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ expectQueryParams(
+ t,
+ map[string]string{
+ "q": "is:pr review-required",
+ "page": "1",
+ "per_page": "30",
+ },
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "review-required",
+ "repo": "test-repo",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "pull request search with minimal parameters",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetSearchIssues,
+ mockSearchResult,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "is:pr repo:owner/repo is:open",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "search pull requests fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchIssues,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "invalid:query",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to search pull requests",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := SearchPullRequests(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedResult github.IssuesSearchResult
+ 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.Issues, len(tc.expectedResult.Issues))
+ for i, issue := range returnedResult.Issues {
+ assert.Equal(t, *tc.expectedResult.Issues[i].Number, *issue.Number)
+ assert.Equal(t, *tc.expectedResult.Issues[i].Title, *issue.Title)
+ assert.Equal(t, *tc.expectedResult.Issues[i].State, *issue.State)
+ assert.Equal(t, *tc.expectedResult.Issues[i].HTMLURL, *issue.HTMLURL)
+ assert.Equal(t, *tc.expectedResult.Issues[i].User.Login, *issue.User.Login)
+ }
+ })
+ }
+
+}
+
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)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "pullNumber")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "pullNumber"})
// Setup mock PR files for success case
@@ -608,6 +859,24 @@ func Test_GetPullRequestFiles(t *testing.T) {
expectError: false,
expectedFiles: mockFiles,
},
+ {
+ name: "successful files fetch with pagination",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposPullsFilesByOwnerByRepoByPullNumber,
+ mockFiles,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "pullNumber": float64(42),
+ "page": float64(2),
+ "perPage": float64(10),
+ },
+ expectError: false,
+ expectedFiles: mockFiles,
+ },
{
name: "files fetch fails",
mockedClient: mock.NewMockedHTTPClient(
@@ -643,12 +912,15 @@ func Test_GetPullRequestFiles(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -672,6 +944,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)
@@ -803,12 +1076,15 @@ func Test_GetPullRequestStatus(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -833,6 +1109,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)
@@ -930,12 +1207,15 @@ func Test_UpdatePullRequestBranch(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -949,6 +1229,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)
@@ -1046,12 +1327,15 @@ func Test_GetPullRequestComments(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -1076,6 +1360,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)
@@ -1169,12 +1454,15 @@ func Test_GetPullRequestReviews(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -1199,6 +1487,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 +1647,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 +1841,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)
@@ -1640,12 +1931,15 @@ func Test_RequestCopilotReview(t *testing.T) {
result, err := handler(context.Background(), request)
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
assert.NotNil(t, result)
assert.Len(t, result.Content, 1)
@@ -1661,6 +1955,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 +2138,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 +2251,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 +2349,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 +2441,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 3475167b..5b116745 100644
--- a/pkg/github/repositories.go
+++ b/pkg/github/repositories.go
@@ -8,8 +8,10 @@ import (
"io"
"net/http"
"net/url"
+ "strconv"
"strings"
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v72/github"
@@ -67,7 +69,11 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too
}
commit, resp, err := client.Repositories.GetCommit(ctx, owner, repo, sha, opts)
if err != nil {
- return nil, fmt.Errorf("failed to get commit: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to get commit: %s", sha),
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -107,6 +113,9 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
mcp.WithString("sha",
mcp.Description("SHA or Branch name"),
),
+ mcp.WithString("author",
+ mcp.Description("Author username or email address"),
+ ),
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -122,13 +131,18 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+ author, err := OptionalParam[string](request, "author")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
opts := &github.CommitsListOptions{
- SHA: sha,
+ SHA: sha,
+ Author: author,
ListOptions: github.ListOptions{
Page: pagination.page,
PerPage: pagination.perPage,
@@ -141,7 +155,11 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
}
commits, resp, err := client.Repositories.ListCommits(ctx, owner, repo, opts)
if err != nil {
- return nil, fmt.Errorf("failed to list commits: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to list commits: %s", sha),
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -208,7 +226,11 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (
branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts)
if err != nil {
- return nil, fmt.Errorf("failed to list branches: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list branches",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -232,7 +254,7 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (
// CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository.
func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("create_or_update_file",
- mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository. If updating, you must provide the SHA of the file you want to update.")),
+ 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. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"),
ReadOnlyHint: ToBoolPtr(false),
@@ -317,7 +339,11 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF
}
fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts)
if err != nil {
- return nil, fmt.Errorf("failed to create/update file: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create/update file",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -391,7 +417,11 @@ func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFun
}
createdRepo, resp, err := client.Repositories.Create(ctx, "", repo)
if err != nil {
- return nil, fmt.Errorf("failed to create repository: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create repository",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -432,8 +462,11 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
mcp.Required(),
mcp.Description("Path to file/directory (directories must end with a slash '/')"),
),
- mcp.WithString("branch",
- mcp.Description("Branch to get contents from"),
+ mcp.WithString("ref",
+ mcp.Description("Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`"),
+ ),
+ mcp.WithString("sha",
+ mcp.Description("Accepts optional git sha, if sha is specified it will be used instead of ref"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -449,17 +482,44 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- branch, err := OptionalParam[string](request, "branch")
+ ref, err := OptionalParam[string](request, "ref")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ sha, err := OptionalParam[string](request, "sha")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
+ rawOpts := &raw.ContentOpts{}
+
+ if strings.HasPrefix(ref, "refs/pull/") {
+ prNumber := strings.TrimSuffix(strings.TrimPrefix(ref, "refs/pull/"), "/head")
+ if len(prNumber) > 0 {
+ // fetch the PR from the API to get the latest commit and use SHA
+ githubClient, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+ prNum, err := strconv.Atoi(prNumber)
+ if err != nil {
+ return nil, fmt.Errorf("invalid pull request number: %w", err)
+ }
+ pr, _, err := githubClient.PullRequests.Get(ctx, owner, repo, prNum)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get pull request: %w", err)
+ }
+ sha = pr.GetHead().GetSHA()
+ ref = ""
+ }
+ }
+
+ rawOpts.SHA = sha
+ rawOpts.Ref = ref
+
// 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
@@ -472,9 +532,7 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
_ = resp.Body.Close()
}()
- if resp.StatusCode != http.StatusOK {
- // If the raw content is not found, we will fall back to the GitHub API (in case it is a directory)
- } else {
+ if resp.StatusCode == http.StatusOK {
// If the raw content is found, return it directly
body, err := io.ReadAll(resp.Body)
if err != nil {
@@ -483,18 +541,24 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
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)
+ switch {
+ case sha != "":
+ resourceURI, err = url.JoinPath("repo://", owner, repo, "sha", sha, "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)
+ case ref != "":
+ resourceURI, err = url.JoinPath("repo://", owner, repo, ref, "contents", path)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create resource URI: %w", err)
+ }
+ default:
+ resourceURI, err = url.JoinPath("repo://", owner, repo, "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,
@@ -517,8 +581,11 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
return mcp.NewToolResultError("failed to get GitHub client"), nil
}
+ if sha != "" {
+ ref = sha
+ }
if strings.HasSuffix(path, "/") {
- opts := &github.RepositoryContentGetOptions{Ref: branch}
+ opts := &github.RepositoryContentGetOptions{Ref: ref}
_, dirContent, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts)
if err != nil {
return mcp.NewToolResultError("failed to get file contents"), nil
@@ -593,7 +660,11 @@ func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc)
if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) {
return mcp.NewToolResultText("Fork is in progress"), nil
}
- return nil, fmt.Errorf("failed to fork repository: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to fork repository",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -686,7 +757,11 @@ func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (to
// Get the commit object that the branch points to
baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA)
if err != nil {
- return nil, fmt.Errorf("failed to get base commit: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get base commit",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -711,7 +786,11 @@ func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (to
// Create a new tree with the deletion
newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, treeEntries)
if err != nil {
- return nil, fmt.Errorf("failed to create tree: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create tree",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -731,7 +810,11 @@ func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (to
}
newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil)
if err != nil {
- return nil, fmt.Errorf("failed to create commit: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create commit",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -747,7 +830,11 @@ func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (to
ref.Object.SHA = newCommit.SHA
_, resp, err = client.Git.UpdateRef(ctx, owner, repo, ref, false)
if err != nil {
- return nil, fmt.Errorf("failed to update reference: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to update reference",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -828,7 +915,11 @@ func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (
// Get default branch if from_branch not specified
repository, resp, err := client.Repositories.Get(ctx, owner, repo)
if err != nil {
- return nil, fmt.Errorf("failed to get repository: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get repository",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -838,7 +929,11 @@ func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (
// Get SHA of source branch
ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+fromBranch)
if err != nil {
- return nil, fmt.Errorf("failed to get reference: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get reference",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -850,7 +945,11 @@ func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (
createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, newRef)
if err != nil {
- return nil, fmt.Errorf("failed to create branch: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create branch",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -940,14 +1039,22 @@ func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (too
// Get the reference for the branch
ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch)
if err != nil {
- return nil, fmt.Errorf("failed to get branch reference: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get branch reference",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
// Get the commit object that the branch points to
baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA)
if err != nil {
- return nil, fmt.Errorf("failed to get base commit: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get base commit",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -982,7 +1089,11 @@ func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (too
// Create a new tree with the file entries
newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries)
if err != nil {
- return nil, fmt.Errorf("failed to create tree: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create tree",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -994,7 +1105,11 @@ func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (too
}
newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil)
if err != nil {
- return nil, fmt.Errorf("failed to create commit: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create commit",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -1002,7 +1117,11 @@ func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (too
ref.Object.SHA = newCommit.SHA
updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, ref, false)
if err != nil {
- return nil, fmt.Errorf("failed to update reference: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to update reference",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -1059,7 +1178,11 @@ func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts)
if err != nil {
- return nil, fmt.Errorf("failed to list tags: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list tags",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -1123,7 +1246,11 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m
// First get the tag reference
ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+tag)
if err != nil {
- return nil, fmt.Errorf("failed to get tag reference: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get tag reference",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -1138,7 +1265,11 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m
// Then get the tag object
tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA)
if err != nil {
- return nil, fmt.Errorf("failed to get tag object: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get tag object",
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go
index c2585341..b621cec4 100644
--- a/pkg/github/repositories_test.go
+++ b/pkg/github/repositories_test.go
@@ -9,6 +9,7 @@ import (
"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/v72/github"
@@ -23,13 +24,15 @@ func Test_GetFileContents(t *testing.T) {
mockClient := github.NewClient(nil)
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)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "path")
- assert.Contains(t, tool.InputSchema.Properties, "branch")
+ assert.Contains(t, tool.InputSchema.Properties, "ref")
+ assert.Contains(t, tool.InputSchema.Properties, "sha")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path"})
// Mock response for raw content
@@ -75,10 +78,10 @@ func Test_GetFileContents(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "path": "README.md",
- "branch": "main",
+ "owner": "owner",
+ "repo": "repo",
+ "path": "README.md",
+ "ref": "refs/heads/main",
},
expectError: false,
expectedResult: mcp.TextResourceContents{
@@ -99,10 +102,10 @@ func Test_GetFileContents(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "path": "test.png",
- "branch": "main",
+ "owner": "owner",
+ "repo": "repo",
+ "path": "test.png",
+ "ref": "refs/heads/main",
},
expectError: false,
expectedResult: mcp.BlobResourceContents{
@@ -156,10 +159,10 @@ func Test_GetFileContents(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "path": "nonexistent.md",
- "branch": "main",
+ "owner": "owner",
+ "repo": "repo",
+ "path": "nonexistent.md",
+ "ref": "refs/heads/main",
},
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."),
@@ -219,6 +222,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)
@@ -298,12 +302,15 @@ func Test_ForkRepository(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -317,6 +324,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)
@@ -484,12 +492,15 @@ func Test_CreateBranch(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -508,6 +519,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)
@@ -606,12 +618,15 @@ func Test_GetCommit(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -633,12 +648,14 @@ 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)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "sha")
+ assert.Contains(t, tool.InputSchema.Properties, "author")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
@@ -706,6 +723,7 @@ func Test_ListCommits(t *testing.T) {
mock.WithRequestMatchHandler(
mock.GetReposCommitsByOwnerByRepo,
expectQueryParams(t, map[string]string{
+ "author": "username",
"sha": "main",
"page": "1",
"per_page": "30",
@@ -715,9 +733,10 @@ func Test_ListCommits(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "sha": "main",
+ "owner": "owner",
+ "repo": "repo",
+ "sha": "main",
+ "author": "username",
},
expectError: false,
expectedCommits: mockCommits,
@@ -778,12 +797,15 @@ func Test_ListCommits(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -794,6 +816,7 @@ func Test_ListCommits(t *testing.T) {
require.NoError(t, err)
assert.Len(t, returnedCommits, len(tc.expectedCommits))
for i, commit := range returnedCommits {
+ assert.Equal(t, *tc.expectedCommits[i].Author, *commit.Author)
assert.Equal(t, *tc.expectedCommits[i].SHA, *commit.SHA)
assert.Equal(t, *tc.expectedCommits[i].Commit.Message, *commit.Commit.Message)
assert.Equal(t, *tc.expectedCommits[i].Author.Login, *commit.Author.Login)
@@ -807,6 +830,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)
@@ -939,12 +963,15 @@ func Test_CreateOrUpdateFile(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -970,6 +997,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)
@@ -1087,12 +1115,15 @@ func Test_CreateRepository(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -1116,6 +1147,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)
@@ -1420,19 +1452,23 @@ func Test_PushFiles(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
if tc.expectedErrMsg != "" {
require.NotNil(t, result)
- textContent := getTextResult(t, result)
- assert.Contains(t, textContent.Text, tc.expectedErrMsg)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -1452,6 +1488,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)
@@ -1562,6 +1599,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)
@@ -1739,6 +1777,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)
@@ -1830,12 +1869,15 @@ func Test_ListTags(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -1859,6 +1901,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)
@@ -1980,12 +2023,15 @@ func Test_GetTag(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go
index fd2a04f8..a454db63 100644
--- a/pkg/github/repository_resource.go
+++ b/pkg/github/repository_resource.go
@@ -89,7 +89,7 @@ func RepositoryResourceContentsHandler(getClient GetClientFn, getRawClient raw.G
}
opts := &github.RepositoryContentGetOptions{}
- rawOpts := &raw.RawContentOpts{}
+ rawOpts := &raw.ContentOpts{}
sha, ok := request.Params.Arguments["sha"].([]string)
if ok && len(sha) > 0 {
diff --git a/pkg/github/search.go b/pkg/github/search.go
index 157675c1..5106b84d 100644
--- a/pkg/github/search.go
+++ b/pkg/github/search.go
@@ -6,6 +6,7 @@ import (
"fmt"
"io"
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
@@ -49,7 +50,11 @@ func SearchRepositories(getClient GetClientFn, t translations.TranslationHelperF
}
result, resp, err := client.Search.Repositories(ctx, query, opts)
if err != nil {
- return nil, fmt.Errorf("failed to search repositories: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to search repositories with query '%s'", query),
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -125,7 +130,11 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to
result, resp, err := client.Search.Code(ctx, query, opts)
if err != nil {
- return nil, fmt.Errorf("failed to search code: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to search code with query '%s'", query),
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -159,96 +168,139 @@ type MinimalSearchUsersResult struct {
Items []MinimalUser `json:"items"`
}
-// SearchUsers creates a tool to search for GitHub users.
-func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("search_users",
- mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Search for GitHub users")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"),
- ReadOnlyHint: ToBoolPtr(true),
- }),
- mcp.WithString("q",
- mcp.Required(),
- mcp.Description("Search query using GitHub users search syntax"),
- ),
- mcp.WithString("sort",
- mcp.Description("Sort field by category"),
- mcp.Enum("followers", "repositories", "joined"),
- ),
- mcp.WithString("order",
- mcp.Description("Sort order"),
- mcp.Enum("asc", "desc"),
- ),
- WithPagination(),
- ),
- func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- query, err := RequiredParam[string](request, "q")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- sort, err := OptionalParam[string](request, "sort")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- order, err := OptionalParam[string](request, "order")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- pagination, err := OptionalPaginationParams(request)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
+func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHandlerFunc {
+ return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ query, err := RequiredParam[string](request, "query")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ sort, err := OptionalParam[string](request, "sort")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ order, err := OptionalParam[string](request, "order")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- opts := &github.SearchOptions{
- Sort: sort,
- Order: order,
- ListOptions: github.ListOptions{
- PerPage: pagination.perPage,
- Page: pagination.page,
- },
- }
+ opts := &github.SearchOptions{
+ Sort: sort,
+ Order: order,
+ ListOptions: github.ListOptions{
+ PerPage: pagination.perPage,
+ Page: pagination.page,
+ },
+ }
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
- result, resp, err := client.Search.Users(ctx, "type:user "+query, opts)
+ searchQuery := "type:" + accountType + " " + query
+ result, resp, err := client.Search.Users(ctx, searchQuery, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to search %ss with query '%s'", accountType, query),
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != 200 {
+ body, err := io.ReadAll(resp.Body)
if err != nil {
- return nil, fmt.Errorf("failed to search users: %w", err)
+ return nil, fmt.Errorf("failed to read response body: %w", err)
}
- defer func() { _ = resp.Body.Close() }()
+ return mcp.NewToolResultError(fmt.Sprintf("failed to search %ss: %s", accountType, string(body))), nil
+ }
- if resp.StatusCode != 200 {
- 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 search users: %s", string(body))), nil
- }
+ minimalUsers := make([]MinimalUser, 0, len(result.Users))
- 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(),
+ for _, user := range result.Users {
+ if user.Login != nil {
+ mu := MinimalUser{Login: *user.Login}
+ if user.ID != nil {
+ mu.ID = *user.ID
+ }
+ if user.HTMLURL != nil {
+ mu.ProfileURL = *user.HTMLURL
+ }
+ if user.AvatarURL != nil {
+ mu.AvatarURL = *user.AvatarURL
}
-
minimalUsers = append(minimalUsers, mu)
}
+ }
+ minimalResp := &MinimalSearchUsersResult{
+ TotalCount: result.GetTotal(),
+ IncompleteResults: result.GetIncompleteResults(),
+ Items: minimalUsers,
+ }
+ if result.Total != nil {
+ minimalResp.TotalCount = *result.Total
+ }
+ if result.IncompleteResults != nil {
+ minimalResp.IncompleteResults = *result.IncompleteResults
+ }
- 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
+ r, err := json.Marshal(minimalResp)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
}
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// SearchUsers creates a tool to search for GitHub users.
+func SearchUsers(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("search_users",
+ mcp.WithDescription(t("TOOL_SEARCH_USERS_DESCRIPTION", "Search for GitHub users exclusively")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_SEARCH_USERS_USER_TITLE", "Search users"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("query",
+ mcp.Required(),
+ mcp.Description("Search query using GitHub users search syntax scoped to type:user"),
+ ),
+ mcp.WithString("sort",
+ mcp.Description("Sort field by category"),
+ mcp.Enum("followers", "repositories", "joined"),
+ ),
+ mcp.WithString("order",
+ mcp.Description("Sort order"),
+ mcp.Enum("asc", "desc"),
+ ),
+ WithPagination(),
+ ), userOrOrgHandler("user", getClient)
+}
+
+// SearchOrgs creates a tool to search for GitHub organizations.
+func SearchOrgs(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("search_orgs",
+ mcp.WithDescription(t("TOOL_SEARCH_ORGS_DESCRIPTION", "Search for GitHub organizations exclusively")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_SEARCH_ORGS_USER_TITLE", "Search organizations"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("query",
+ mcp.Required(),
+ mcp.Description("Search query using GitHub organizations search syntax scoped to type:org"),
+ ),
+ mcp.WithString("sort",
+ mcp.Description("Sort field by category"),
+ mcp.Enum("followers", "repositories", "joined"),
+ ),
+ mcp.WithString("order",
+ mcp.Description("Sort order"),
+ mcp.Enum("asc", "desc"),
+ ),
+ WithPagination(),
+ ), userOrOrgHandler("org", getClient)
}
diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go
index 62645e91..bfd01499 100644
--- a/pkg/github/search_test.go
+++ b/pkg/github/search_test.go
@@ -6,6 +6,7 @@ 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/v72/github"
"github.com/migueleliasweb/go-github-mock/src/mock"
@@ -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)
@@ -132,12 +134,15 @@ func Test_SearchRepositories(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -164,6 +169,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)
@@ -283,12 +289,15 @@ func Test_SearchCode(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -315,15 +324,16 @@ 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)
- assert.Contains(t, tool.InputSchema.Properties, "q")
+ assert.Contains(t, tool.InputSchema.Properties, "query")
assert.Contains(t, tool.InputSchema.Properties, "sort")
assert.Contains(t, tool.InputSchema.Properties, "order")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.Contains(t, tool.InputSchema.Properties, "page")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"q"})
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"})
// Setup mock search results
mockSearchResult := &github.UsersSearchResult{
@@ -371,7 +381,7 @@ func Test_SearchUsers(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "q": "location:finland language:go",
+ "query": "location:finland language:go",
"sort": "followers",
"order": "desc",
"page": float64(1),
@@ -395,7 +405,7 @@ func Test_SearchUsers(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "q": "location:finland language:go",
+ "query": "location:finland language:go",
},
expectError: false,
expectedResult: mockSearchResult,
@@ -412,7 +422,7 @@ func Test_SearchUsers(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "q": "invalid:query",
+ "query": "invalid:query",
},
expectError: true,
expectedErrMsg: "failed to search users",
@@ -433,12 +443,15 @@ func Test_SearchUsers(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
require.NotNil(t, result)
@@ -461,3 +474,127 @@ func Test_SearchUsers(t *testing.T) {
})
}
}
+
+func Test_SearchOrgs(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := SearchOrgs(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+ assert.Equal(t, "search_orgs", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "query")
+ assert.Contains(t, tool.InputSchema.Properties, "sort")
+ assert.Contains(t, tool.InputSchema.Properties, "order")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"query"})
+
+ // Setup mock search results
+ mockSearchResult := &github.UsersSearchResult{
+ Total: github.Ptr(int(2)),
+ IncompleteResults: github.Ptr(false),
+ Users: []*github.User{
+ {
+ Login: github.Ptr("org-1"),
+ ID: github.Ptr(int64(111)),
+ HTMLURL: github.Ptr("https://github.com/org-1"),
+ AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/111?v=4"),
+ },
+ {
+ Login: github.Ptr("org-2"),
+ ID: github.Ptr(int64(222)),
+ HTMLURL: github.Ptr("https://github.com/org-2"),
+ AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/222?v=4"),
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedResult *github.UsersSearchResult
+ expectedErrMsg string
+ }{
+ {
+ name: "successful org search",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchUsers,
+ expectQueryParams(t, map[string]string{
+ "q": "type:org github",
+ "page": "1",
+ "per_page": "30",
+ }).andThen(
+ mockResponse(t, http.StatusOK, mockSearchResult),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "github",
+ },
+ expectError: false,
+ expectedResult: mockSearchResult,
+ },
+ {
+ name: "org search fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetSearchUsers,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"message": "Validation Failed"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "query": "invalid:query",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to search orgs",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := SearchOrgs(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedResult MinimalSearchUsersResult
+ err = json.Unmarshal([]byte(textContent.Text), &returnedResult)
+ require.NoError(t, err)
+ 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, org := range returnedResult.Items {
+ assert.Equal(t, *tc.expectedResult.Users[i].Login, org.Login)
+ assert.Equal(t, *tc.expectedResult.Users[i].ID, org.ID)
+ assert.Equal(t, *tc.expectedResult.Users[i].HTMLURL, org.ProfileURL)
+ assert.Equal(t, *tc.expectedResult.Users[i].AvatarURL, org.AvatarURL)
+ }
+ })
+ }
+}
diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go
new file mode 100644
index 00000000..6642dad8
--- /dev/null
+++ b/pkg/github/search_utils.go
@@ -0,0 +1,88 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/google/go-github/v72/github"
+ "github.com/mark3labs/mcp-go/mcp"
+)
+
+func searchHandler(
+ ctx context.Context,
+ getClient GetClientFn,
+ request mcp.CallToolRequest,
+ searchType string,
+ errorPrefix string,
+) (*mcp.CallToolResult, error) {
+ query, err := RequiredParam[string](request, "query")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ query = fmt.Sprintf("is:%s %s", searchType, query)
+
+ owner, err := OptionalParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ repo, err := OptionalParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ if owner != "" && repo != "" {
+ query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query)
+ }
+
+ sort, err := OptionalParam[string](request, "sort")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ order, err := OptionalParam[string](request, "order")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ opts := &github.SearchOptions{
+ // Default to "created" if no sort is provided, as it's a common use case.
+ Sort: sort,
+ Order: order,
+ ListOptions: github.ListOptions{
+ Page: pagination.page,
+ PerPage: pagination.perPage,
+ },
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("%s: failed to get GitHub client: %w", errorPrefix, err)
+ }
+ result, resp, err := client.Search.Issues(ctx, query, opts)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", errorPrefix, err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("%s: failed to read response body: %w", errorPrefix, err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("%s: %s", errorPrefix, string(body))), nil
+ }
+
+ r, err := json.Marshal(result)
+ if err != nil {
+ return nil, fmt.Errorf("%s: failed to marshal response: %w", errorPrefix, err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go
index ec0eb15a..bea6df2a 100644
--- a/pkg/github/secret_scanning.go
+++ b/pkg/github/secret_scanning.go
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v72/github"
"github.com/mark3labs/mcp-go/mcp"
@@ -55,7 +56,11 @@ func GetSecretScanningAlert(getClient GetClientFn, t translations.TranslationHel
alert, resp, err := client.SecretScanning.GetAlert(ctx, owner, repo, int64(alertNumber))
if err != nil {
- return nil, fmt.Errorf("failed to get alert: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to get alert with number '%d'", alertNumber),
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
@@ -132,7 +137,11 @@ func ListSecretScanningAlerts(getClient GetClientFn, t translations.TranslationH
}
alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{State: state, SecretType: secretType, Resolution: resolution})
if err != nil {
- return nil, fmt.Errorf("failed to list alerts: %w", err)
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo),
+ resp,
+ err,
+ ), nil
}
defer func() { _ = resp.Body.Close() }()
diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go
index 4ec5539e..38b573e0 100644
--- a/pkg/github/secret_scanning_test.go
+++ b/pkg/github/secret_scanning_test.go
@@ -90,12 +90,15 @@ func Test_GetSecretScanningAlert(t *testing.T) {
// Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
@@ -217,12 +220,15 @@ func Test_ListSecretScanningAlerts(t *testing.T) {
result, err := handler(context.Background(), request)
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
require.NoError(t, err)
+ require.False(t, result.IsError)
textContent := getTextResult(t, result)
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 9569c439..76b31d47 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -59,16 +59,21 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(AddIssueComment(getClient, t)),
toolsets.NewServerTool(UpdateIssue(getClient, t)),
toolsets.NewServerTool(AssignCopilotToIssue(getGQLClient, t)),
- )
+ ).AddPrompts(toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)))
users := toolsets.NewToolset("users", "GitHub User related tools").
AddReadTools(
toolsets.NewServerTool(SearchUsers(getClient, t)),
)
+ orgs := toolsets.NewToolset("orgs", "GitHub Organization related tools").
+ AddReadTools(
+ toolsets.NewServerTool(SearchOrgs(getClient, t)),
+ )
pullRequests := toolsets.NewToolset("pull_requests", "GitHub Pull Request related tools").
AddReadTools(
toolsets.NewServerTool(GetPullRequest(getClient, t)),
toolsets.NewServerTool(ListPullRequests(getClient, t)),
toolsets.NewServerTool(GetPullRequestFiles(getClient, t)),
+ toolsets.NewServerTool(SearchPullRequests(getClient, t)),
toolsets.NewServerTool(GetPullRequestStatus(getClient, t)),
toolsets.NewServerTool(GetPullRequestComments(getClient, t)),
toolsets.NewServerTool(GetPullRequestReviews(getClient, t)),
@@ -111,6 +116,26 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
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")
@@ -123,8 +148,10 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
tsg.AddToolset(contextTools)
tsg.AddToolset(repos)
tsg.AddToolset(issues)
+ tsg.AddToolset(orgs)
tsg.AddToolset(users)
tsg.AddToolset(pullRequests)
+ tsg.AddToolset(actions)
tsg.AddToolset(codeSecurity)
tsg.AddToolset(secretProtection)
tsg.AddToolset(notifications)
diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go
index d604891b..17995cca 100644
--- a/pkg/raw/raw.go
+++ b/pkg/raw/raw.go
@@ -25,9 +25,13 @@ func NewClient(client *gogithub.Client, rawURL *url.URL) *Client {
return &Client{client: client, url: rawURL}
}
-func (c *Client) newRequest(method string, urlStr string, body interface{}, opts ...gogithub.RequestOption) (*http.Request, error) {
+func (c *Client) newRequest(ctx context.Context, method string, urlStr string, body interface{}, opts ...gogithub.RequestOption) (*http.Request, error) {
req, err := c.client.NewRequest(method, urlStr, body, opts...)
- return req, err
+ if err != nil {
+ return nil, err
+ }
+ req = req.WithContext(ctx)
+ return req, nil
}
func (c *Client) refURL(owner, repo, ref, path string) string {
@@ -37,9 +41,9 @@ func (c *Client) refURL(owner, repo, ref, path string) string {
return c.url.JoinPath(owner, repo, ref, path).String()
}
-func (c *Client) URLFromOpts(opts *RawContentOpts, owner, repo, path string) string {
+func (c *Client) URLFromOpts(opts *ContentOpts, owner, repo, path string) string {
if opts == nil {
- opts = &RawContentOpts{}
+ opts = &ContentOpts{}
}
if opts.SHA != "" {
return c.commitURL(owner, repo, opts.SHA, path)
@@ -52,15 +56,15 @@ func (c *Client) commitURL(owner, repo, sha, path string) string {
return c.url.JoinPath(owner, repo, sha, path).String()
}
-type RawContentOpts struct {
+type ContentOpts struct {
Ref string
SHA string
}
// GetRawContent fetches the raw content of a file from a GitHub repository.
-func (c *Client) GetRawContent(ctx context.Context, owner, repo, path string, opts *RawContentOpts) (*http.Response, error) {
+func (c *Client) GetRawContent(ctx context.Context, owner, repo, path string, opts *ContentOpts) (*http.Response, error) {
url := c.URLFromOpts(opts, owner, repo, path)
- req, err := c.newRequest("GET", url, nil)
+ req, err := c.newRequest(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
diff --git a/pkg/raw/raw_test.go b/pkg/raw/raw_test.go
index bb9b23a2..f0203315 100644
--- a/pkg/raw/raw_test.go
+++ b/pkg/raw/raw_test.go
@@ -17,7 +17,7 @@ func TestGetRawContent(t *testing.T) {
tests := []struct {
name string
pattern mock.EndpointPattern
- opts *RawContentOpts
+ opts *ContentOpts
owner, repo, path string
statusCode int
contentType string
@@ -36,7 +36,7 @@ func TestGetRawContent(t *testing.T) {
{
name: "branch fetch success",
pattern: GetRawReposContentsByOwnerByRepoByBranchByPath,
- opts: &RawContentOpts{Ref: "refs/heads/main"},
+ opts: &ContentOpts{Ref: "refs/heads/main"},
owner: "octocat", repo: "hello", path: "README.md",
statusCode: 200,
contentType: "text/plain",
@@ -45,7 +45,7 @@ func TestGetRawContent(t *testing.T) {
{
name: "tag fetch success",
pattern: GetRawReposContentsByOwnerByRepoByTagByPath,
- opts: &RawContentOpts{Ref: "refs/tags/v1.0.0"},
+ opts: &ContentOpts{Ref: "refs/tags/v1.0.0"},
owner: "octocat", repo: "hello", path: "README.md",
statusCode: 200,
contentType: "text/plain",
@@ -54,7 +54,7 @@ func TestGetRawContent(t *testing.T) {
{
name: "sha fetch success",
pattern: GetRawReposContentsByOwnerByRepoBySHAByPath,
- opts: &RawContentOpts{SHA: "abc123"},
+ opts: &ContentOpts{SHA: "abc123"},
owner: "octocat", repo: "hello", path: "README.md",
statusCode: 200,
contentType: "text/plain",
@@ -107,7 +107,7 @@ func TestUrlFromOpts(t *testing.T) {
tests := []struct {
name string
- opts *RawContentOpts
+ opts *ContentOpts
owner string
repo string
path string
@@ -121,19 +121,19 @@ func TestUrlFromOpts(t *testing.T) {
},
{
name: "ref branch",
- opts: &RawContentOpts{Ref: "refs/heads/main"},
+ opts: &ContentOpts{Ref: "refs/heads/main"},
owner: "octocat", repo: "hello", path: "README.md",
want: "https://raw.example.com/octocat/hello/refs/heads/main/README.md",
},
{
name: "ref tag",
- opts: &RawContentOpts{Ref: "refs/tags/v1.0.0"},
+ opts: &ContentOpts{Ref: "refs/tags/v1.0.0"},
owner: "octocat", repo: "hello", path: "README.md",
want: "https://raw.example.com/octocat/hello/refs/tags/v1.0.0/README.md",
},
{
name: "sha",
- opts: &RawContentOpts{SHA: "abc123"},
+ opts: &ContentOpts{SHA: "abc123"},
owner: "octocat", repo: "hello", path: "README.md",
want: "https://raw.example.com/octocat/hello/abc123/README.md",
},
diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go
index ad444c05..5d503b74 100644
--- a/pkg/toolsets/toolsets.go
+++ b/pkg/toolsets/toolsets.go
@@ -40,12 +40,25 @@ func NewServerResourceTemplate(resourceTemplate mcp.ResourceTemplate, handler se
}
}
+func NewServerPrompt(prompt mcp.Prompt, handler server.PromptHandlerFunc) ServerPrompt {
+ return ServerPrompt{
+ Prompt: prompt,
+ Handler: handler,
+ }
+}
+
// ServerResourceTemplate represents a resource template that can be registered with the MCP server.
type ServerResourceTemplate struct {
resourceTemplate mcp.ResourceTemplate
handler server.ResourceTemplateHandlerFunc
}
+// ServerPrompt represents a prompt that can be registered with the MCP server.
+type ServerPrompt struct {
+ Prompt mcp.Prompt
+ Handler server.PromptHandlerFunc
+}
+
// Toolset represents a collection of MCP functionality that can be enabled or disabled as a group.
type Toolset struct {
Name string
@@ -57,6 +70,8 @@ type Toolset struct {
// 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
+ // prompts are also not tools but are namespaced similarly
+ prompts []ServerPrompt
}
func (t *Toolset) GetActiveTools() []server.ServerTool {
@@ -95,6 +110,11 @@ func (t *Toolset) AddResourceTemplates(templates ...ServerResourceTemplate) *Too
return t
}
+func (t *Toolset) AddPrompts(prompts ...ServerPrompt) *Toolset {
+ t.prompts = append(t.prompts, prompts...)
+ return t
+}
+
func (t *Toolset) GetActiveResourceTemplates() []ServerResourceTemplate {
if !t.Enabled {
return nil
@@ -115,6 +135,15 @@ func (t *Toolset) RegisterResourcesTemplates(s *server.MCPServer) {
}
}
+func (t *Toolset) RegisterPrompts(s *server.MCPServer) {
+ if !t.Enabled {
+ return
+ }
+ for _, prompt := range t.prompts {
+ s.AddPrompt(prompt.Prompt, prompt.Handler)
+ }
+}
+
func (t *Toolset) SetReadOnly() {
// Set the toolset to read-only
t.readOnly = true
@@ -225,6 +254,7 @@ func (tg *ToolsetGroup) RegisterAll(s *server.MCPServer) {
for _, toolset := range tg.Toolsets {
toolset.RegisterTools(s)
toolset.RegisterResourcesTemplates(s)
+ toolset.RegisterPrompts(s)
}
}
diff --git a/script/generate-docs b/script/generate-docs
new file mode 100755
index 00000000..a2a7255d
--- /dev/null
+++ b/script/generate-docs
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+# This script generates documentation for the GitHub MCP server.
+# It needs to be run after tool updates to ensure the latest changes are reflected in the documentation.
+go run ./cmd/github-mcp-server generate-docs
\ No newline at end of file
diff --git a/script/lint b/script/lint
new file mode 100755
index 00000000..58884e3a
--- /dev/null
+++ b/script/lint
@@ -0,0 +1,15 @@
+set -eu
+
+# first run go fmt
+gofmt -s -w .
+
+BINDIR="$(git rev-parse --show-toplevel)"/bin
+BINARY=$BINDIR/golangci-lint
+GOLANGCI_LINT_VERSION=v2.2.1
+
+
+if [ ! -f "$BINARY" ]; then
+ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s "$GOLANGCI_LINT_VERSION"
+fi
+
+$BINARY run
\ No newline at end of file
diff --git a/script/tag-release b/script/tag-release
new file mode 100755
index 00000000..fd94167c
--- /dev/null
+++ b/script/tag-release
@@ -0,0 +1,151 @@
+#!/bin/bash
+
+# Exit immediately if a command exits with a non-zero status.
+set -e
+
+# Initialize variables
+TAG=""
+DRY_RUN=false
+
+# Parse arguments
+for arg in "$@"; do
+ case $arg in
+ --dry-run)
+ DRY_RUN=true
+ ;;
+ *)
+ # The first non-flag argument is the tag
+ if [[ ! $arg == --* ]]; then
+ if [ -z "$TAG" ]; then
+ TAG=$arg
+ fi
+ fi
+ ;;
+ esac
+done
+
+if [ "$DRY_RUN" = true ]; then
+ echo "DRY RUN: No changes will be pushed to the remote repository."
+ echo
+fi
+
+# 1. Validate input
+if [ -z "$TAG" ]; then
+ echo "Error: No tag specified."
+ echo "Usage: ./script/tag-release vX.Y.Z [--dry-run]"
+ exit 1
+fi
+
+# Regular expression for semantic versioning (vX.Y.Z or vX.Y.Z-suffix)
+if [[ ! $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-.*)?$ ]]; then
+ echo "Error: Tag must be in format vX.Y.Z or vX.Y.Z-suffix (e.g., v1.0.0 or v1.0.0-rc1)"
+ exit 1
+fi
+
+# 2. Check current branch
+CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
+if [ "$CURRENT_BRANCH" != "main" ]; then
+ echo "Error: You must be on the 'main' branch to create a release."
+ echo "Current branch is '$CURRENT_BRANCH'."
+ exit 1
+fi
+
+# 3. Fetch latest from origin
+echo "Fetching latest changes from origin..."
+git fetch origin main
+
+# 4. Check if the working directory is clean
+if ! git diff-index --quiet HEAD --; then
+ echo "Error: Working directory is not clean. Please commit or stash your changes."
+ exit 1
+fi
+
+# 5. Check if main is up-to-date with origin/main
+LOCAL_SHA=$(git rev-parse @)
+REMOTE_SHA=$(git rev-parse @{u})
+
+if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
+ echo "Error: Your local 'main' branch is not up-to-date with 'origin/main'. Please pull the latest changes."
+ exit 1
+fi
+echo "✅ Local 'main' branch is up-to-date with 'origin/main'."
+
+# 6. Check if tag already exists
+if git tag -l | grep -q "^${TAG}$"; then
+ echo "Error: Tag ${TAG} already exists locally."
+ exit 1
+fi
+if git ls-remote --tags origin | grep -q "refs/tags/${TAG}$"; then
+ echo "Error: Tag ${TAG} already exists on remote 'origin'."
+ exit 1
+fi
+
+# 7. Confirm release with user
+echo
+LATEST_TAG=$(git tag --sort=-version:refname | head -n 1)
+if [ -n "$LATEST_TAG" ]; then
+ echo "Current latest release: $LATEST_TAG"
+fi
+echo "Proposed new release: $TAG"
+echo
+read -p "Do you want to proceed with the release? (y/n) " -n 1 -r
+echo # Move to a new line
+if [[ ! $REPLY =~ ^[Yy]$ ]]; then
+ echo "Release cancelled."
+ exit 1
+fi
+echo
+
+# 8. Create the new release tag
+if [ "$DRY_RUN" = true ]; then
+ echo "DRY RUN: Skipping creation of tag $TAG."
+else
+ echo "Creating new release tag: $TAG"
+ git tag -a "$TAG" -m "Release $TAG"
+fi
+
+# 9. Push the new tag to the remote repository
+if [ "$DRY_RUN" = true ]; then
+ echo "DRY RUN: Skipping push of tag $TAG to origin."
+else
+ echo "Pushing tag $TAG to origin..."
+ git push origin "$TAG"
+fi
+
+# 10. Update and push the 'latest-release' tag
+if [ "$DRY_RUN" = true ]; then
+ echo "DRY RUN: Skipping update and push of 'latest-release' tag."
+else
+ echo "Updating 'latest-release' tag to point to $TAG..."
+ git tag -f latest-release "$TAG"
+ echo "Pushing 'latest-release' tag to origin..."
+ git push origin latest-release --force
+fi
+
+if [ "$DRY_RUN" = true ]; then
+ echo "✅ DRY RUN complete. No tags were created or pushed."
+else
+ echo "✅ Successfully tagged and pushed release $TAG."
+ echo "✅ 'latest-release' tag has been updated."
+fi
+
+# 11. Post-release instructions
+REPO_URL=$(git remote get-url origin)
+REPO_SLUG=$(echo "$REPO_URL" | sed -e 's/.*github.com[:\/]//' -e 's/\.git$//')
+
+cat << EOF
+
+## 🎉 Release $TAG has been initiated!
+
+### Next steps:
+1. 📋 Check https://github.com/$REPO_SLUG/releases and wait for the draft release to show up (after the goreleaser workflow completes)
+2. ✏️ Edit the new release, delete the existing notes and click the auto-generate button GitHub provides
+3. ✨ Add a section at the top calling out the main features
+4. 🚀 Publish the release
+5. 📢 Post message in #gh-mcp-releases channel in Slack and then share to the other mcp channels
+
+### Resources:
+- 📦 Draft Release: https://github.com/$REPO_SLUG/releases/tag/$TAG
+
+The release process is now ready for your review and completion!
+EOF
diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md
index 7ba187e1..e616fa56 100644
--- a/third-party-licenses.darwin.md
+++ b/third-party-licenses.darwin.md
@@ -11,7 +11,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
- [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
- [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
- - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.2.1/LICENSE))
+ - [github.com/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))
@@ -20,7 +20,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE))
- [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md))
- [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE))
- - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.31.0/LICENSE))
+ - [github.com/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))
diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md
index 7ba187e1..e616fa56 100644
--- a/third-party-licenses.linux.md
+++ b/third-party-licenses.linux.md
@@ -11,7 +11,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
- [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
- [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
- - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.2.1/LICENSE))
+ - [github.com/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))
@@ -20,7 +20,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE))
- [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md))
- [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE))
- - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.31.0/LICENSE))
+ - [github.com/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))
diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md
index 1c8b6c58..d34ce244 100644
--- a/third-party-licenses.windows.md
+++ b/third-party-licenses.windows.md
@@ -11,7 +11,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE))
- [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE))
- [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/LICENSE))
- - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.2.1/LICENSE))
+ - [github.com/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))
@@ -21,7 +21,7 @@ Some packages may only be included on certain architectures or operating systems
- [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE))
- [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md))
- [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE))
- - [github.com/mark3labs/mcp-go](https://pkg.go.dev/github.com/mark3labs/mcp-go) ([MIT](https://github.com/mark3labs/mcp-go/blob/v0.31.0/LICENSE))
+ - [github.com/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))