diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 0a45569ec..e3ef25022 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -26,7 +26,7 @@ jobs:
run: go mod download
- name: Run unit tests
- run: go test -race ./...
+ run: script/test
- name: Build
run: go build -v ./cmd/github-mcp-server
diff --git a/.gitignore b/.gitignore
index df489c390..0ad709cbf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,6 @@ __debug_bin*
# Go
vendor
bin/
+
+# macOS
+.DS_Store
\ No newline at end of file
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6fa9c2ebe..b4012f0b2 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -14,20 +14,21 @@ Please note that this project is released with a [Contributor Code of Conduct](C
These are one time installations required to be able to test your changes locally as part of the pull request (PR) submission process.
-1. install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go)
-1. [install golangci-lint v2](https://golangci-lint.run/welcome/install/#local-installation)
+1. Install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go)
+2. [Install golangci-lint v2](https://golangci-lint.run/welcome/install/#local-installation)
## Submitting a pull request
-> **Important**: Please open your pull request against the `next` branch, not `main`. The `next` branch is where we integrate new features and changes before they are merged to `main`.
-
1. [Fork][fork] and clone the repository
-1. Make sure the tests pass on your machine: `go test -v ./...`
-1. Make sure linter passes on your machine: `golangci-lint run`
-1. Create a new branch: `git checkout -b my-branch-name`
-1. Make your change, add tests, and make sure the tests and linter still pass
-1. Push to your fork and [submit a pull request][pr] targeting the `next` branch
-1. Pat yourself on the back and wait for your pull request to be reviewed and merged.
+2. Make sure the tests pass on your machine: `go test -v ./...`
+3. Make sure linter passes on your machine: `golangci-lint run`
+4. Create a new branch: `git checkout -b my-branch-name`
+5. Add your changes and tests, and make sure the Action workflows still pass
+ - Run linter: `script/lint`
+ - Update snapshots and run tests: `UPDATE_TOOLSNAPS=true go test ./...`
+ - Update readme documentation: `script/generate-docs`
+6. Push to your fork and [submit a pull request][pr] targeting the `main` branch
+7. Pat yourself on the back and wait for your pull request to be reviewed and merged.
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
diff --git a/README.md b/README.md
index 44a829601..8cff2e138 100644
--- a/README.md
+++ b/README.md
@@ -269,6 +269,8 @@ The following sets of tools are available (all are on by default):
| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |
| `actions` | GitHub Actions workflows and CI/CD operations |
| `code_security` | Code security related tools, such as GitHub Code Scanning |
+| `dependabot` | Dependabot tools |
+| `discussions` | GitHub Discussions related tools |
| `experiments` | Experimental features that are not considered stable yet |
| `issues` | GitHub Issues related tools |
| `notifications` | GitHub Notifications related tools |
@@ -476,15 +478,15 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- **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)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
- `run_id`: The unique identifier of the workflow run (number, required)
- **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)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
- `run_id`: The unique identifier of the workflow run (number, required)
@@ -493,16 +495,16 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `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)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
- `status`: Returns workflow runs with the check run status (string, optional)
- `workflow_id`: The workflow ID or workflow file name (string, required)
- **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)
+ - `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)
- **rerun_failed_jobs** - Rerun failed jobs
@@ -548,7 +550,53 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
Context
- **get_me** - Get my user profile
- - `reason`: Optional: the reason for requesting the user information (string, optional)
+ - No parameters required
+
+
+
+
+
+Dependabot
+
+- **get_dependabot_alert** - Get dependabot alert
+ - `alertNumber`: The number of the alert. (number, required)
+ - `owner`: The owner of the repository. (string, required)
+ - `repo`: The name of the repository. (string, required)
+
+- **list_dependabot_alerts** - List dependabot alerts
+ - `owner`: The owner of the repository. (string, required)
+ - `repo`: The name of the repository. (string, required)
+ - `severity`: Filter dependabot alerts by severity (string, optional)
+ - `state`: Filter dependabot alerts by state. Defaults to open (string, optional)
+
+
+
+
+
+Discussions
+
+- **get_discussion** - Get discussion
+ - `discussionNumber`: Discussion Number (number, required)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
+- **get_discussion_comments** - Get discussion comments
+ - `discussionNumber`: Discussion Number (number, required)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
+- **list_discussion_categories** - List discussion categories
+ - `after`: Cursor for pagination, use the 'after' field from the previous response (string, optional)
+ - `before`: Cursor for pagination, use the 'before' field from the previous response (string, optional)
+ - `first`: Number of categories to return per page (min 1, max 100) (number, optional)
+ - `last`: Number of categories to return from the end (min 1, max 100) (number, optional)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
+
+- **list_discussions** - List discussions
+ - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional)
+ - `owner`: Repository owner (string, required)
+ - `repo`: Repository name (string, required)
@@ -584,8 +632,8 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- **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)
+ - `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)
- **list_issues** - List issues
@@ -822,7 +870,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `owner`: Repository owner (username or organization) (string, required)
- `path`: Path where to create/update the file (string, required)
- `repo`: Repository name (string, required)
- - `sha`: SHA of file being replaced (for updates) (string, optional)
+ - `sha`: Required if updating an existing file. The blob SHA of the file being replaced. (string, optional)
- **create_repository** - Create repository
- `autoInit`: Initialize with README (boolean, optional)
@@ -854,7 +902,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `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)
+ - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional)
- **get_tag** - Get tag details
- `owner`: Repository owner (string, required)
@@ -868,12 +916,12 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
- `repo`: Repository name (string, required)
- **list_commits** - List commits
- - `author`: Author username or email address (string, optional)
+ - `author`: Author username or email address to filter commits by (string, optional)
- `owner`: Repository owner (string, required)
- `page`: Page number for pagination (min 1) (number, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
- - `sha`: SHA or Branch name (string, optional)
+ - `sha`: Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA. (string, optional)
- **list_tags** - List tags
- `owner`: Repository owner (string, required)
@@ -934,6 +982,21 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
+### Additional Tools in Remote Github MCP Server
+
+
+
+Copilot coding agent
+
+- **create_pull_request_with_copilot** - Perform task with GitHub Copilot coding agent
+ - `owner`: Repository owner. You can guess the owner, but confirm it with the user before proceeding. (string, required)
+ - `repo`: Repository name. You can guess the repository name, but confirm it with the user before proceeding. (string, required)
+ - `problem_statement`: Detailed description of the task to be performed (e.g., 'Implement a feature that does X', 'Fix bug Y', etc.) (string, required)
+ - `title`: Title for the pull request that will be created (string, required)
+ - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch (string, optional)
+
+
+
## Library Usage
The exported Go API of this module should currently be considered unstable, and subject to breaking changes. In the future, we may offer stability; please file an issue if there is a use case where this would be valuable.
diff --git a/docs/host-integration.md b/docs/host-integration.md
index d9f6d9050..9a1d9396f 100644
--- a/docs/host-integration.md
+++ b/docs/host-integration.md
@@ -64,7 +64,7 @@ flowchart LR
- **Local MCP Server**: An MCP Server running locally, side-by-side with the Application.
- **Remote MCP Server**: An MCP Server running remotely, accessed via the internet. Most Remote MCP Servers require authentication via OAuth.
-For more detail, see the [official MCP specification](https://modelcontextprotocol.io/specification/draft).
+For more detail, see the [official MCP specification](https://modelcontextprotocol.io/specification/2025-06-18).
> [!NOTE]
> GitHub offers both a Local MCP Server and a Remote MCP Server.
@@ -84,7 +84,7 @@ For the Remote GitHub MCP Server, the recommended way to obtain a valid access t
> The Remote GitHub MCP Server itself does not provide Authentication services.
> Your client application must obtain valid GitHub access tokens through one of the supported methods.
-The expected flow for obtaining a valid access token via OAuth is depicted in the [MCP Specification](https://modelcontextprotocol.io/specification/draft/basic/authorization#authorization-flow-steps). For convenience, we've embedded a copy of the authorization flow below. Please study it carefully as the remainder of this document is written with this flow in mind.
+The expected flow for obtaining a valid access token via OAuth is depicted in the [MCP Specification](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization#authorization-flow-steps). For convenience, we've embedded a copy of the authorization flow below. Please study it carefully as the remainder of this document is written with this flow in mind.
```mermaid
sequenceDiagram
diff --git a/docs/remote-server.md b/docs/remote-server.md
index 50404ec85..49794c605 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -10,6 +10,8 @@ Easily connect to the GitHub MCP Server using the hosted version – no local se
The remote GitHub MCP server is built using this repository as a library, and binding it into GitHub server infrastructure with an internal repository. You can open issues and propose changes in this repository, and we regularly update the remote server to include the latest version of this code.
+The remote server has [additional tools](#toolsets-only-available-in-the-remote-mcp-server) that are not available in the local MCP server, such as the `create_pull_request_with_copilot` tool for invoking Copilot coding agent.
+
## Remote MCP Toolsets
Below is a table of available toolsets for the remote GitHub MCP Server. Each toolset is provided as a distinct URL so you can mix and match to create the perfect combination of tools for your use-case. Add `/readonly` to the end of any URL to restrict the tools in the toolset to only those that enable read access. We also provide the option to use [headers](#headers) instead.
@@ -20,6 +22,8 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
| 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) |
| Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) |
| Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) |
+| Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) |
+| Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) |
| Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) |
| Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) |
| Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) |
@@ -31,6 +35,14 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
+### Additional _Remote_ Server Toolsets
+
+These toolsets are only available in the remote GitHub MCP Server and are not included in the local MCP server.
+
+| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |
+| -------------------- | --------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| Copilot coding agent | Perform task with GitHub Copilot coding agent | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) |
+
### Headers
You can configure toolsets and readonly mode by providing HTTP headers in your server configuration.
diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap
index dfbb34423..61adef72c 100644
--- a/pkg/github/__toolsnaps__/create_or_update_file.snap
+++ b/pkg/github/__toolsnaps__/create_or_update_file.snap
@@ -31,7 +31,7 @@
"type": "string"
},
"sha": {
- "description": "SHA of file being replaced (for updates)",
+ "description": "Required if updating an existing file. The blob SHA of the file being replaced.",
"type": "string"
}
},
diff --git a/pkg/github/__toolsnaps__/get_dependabot_alert.snap b/pkg/github/__toolsnaps__/get_dependabot_alert.snap
new file mode 100644
index 000000000..76b5ef126
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_dependabot_alert.snap
@@ -0,0 +1,30 @@
+{
+ "annotations": {
+ "title": "Get dependabot alert",
+ "readOnlyHint": true
+ },
+ "description": "Get details of a specific dependabot alert in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "alertNumber": {
+ "description": "The number of the alert.",
+ "type": "number"
+ },
+ "owner": {
+ "description": "The owner of the repository.",
+ "type": "string"
+ },
+ "repo": {
+ "description": "The name of the repository.",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo",
+ "alertNumber"
+ ],
+ "type": "object"
+ },
+ "name": "get_dependabot_alert"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap
index b3975abbc..e550e8db8 100644
--- a/pkg/github/__toolsnaps__/get_file_contents.snap
+++ b/pkg/github/__toolsnaps__/get_file_contents.snap
@@ -23,7 +23,7 @@
"type": "string"
},
"sha": {
- "description": "Accepts optional git sha, if sha is specified it will be used instead of ref",
+ "description": "Accepts optional commit SHA. If specified, it will be used instead of ref",
"type": "string"
}
},
diff --git a/pkg/github/__toolsnaps__/get_issue_comments.snap b/pkg/github/__toolsnaps__/get_issue_comments.snap
index fa1fb0d6c..b28f45204 100644
--- a/pkg/github/__toolsnaps__/get_issue_comments.snap
+++ b/pkg/github/__toolsnaps__/get_issue_comments.snap
@@ -15,11 +15,14 @@
"type": "string"
},
"page": {
- "description": "Page number",
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
"type": "number"
},
- "per_page": {
- "description": "Number of records per page",
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
"type": "number"
},
"repo": {
diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap
index fc098f9d1..13b061741 100644
--- a/pkg/github/__toolsnaps__/get_me.snap
+++ b/pkg/github/__toolsnaps__/get_me.snap
@@ -3,14 +3,9 @@
"title": "Get my user profile",
"readOnlyHint": true
},
- "description": "Get details of the authenticated GitHub user. Use this when a request includes \"me\", \"my\". The output will not change unless the user changes their profile, so only call this once.",
+ "description": "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.",
"inputSchema": {
- "properties": {
- "reason": {
- "description": "Optional: the reason for requesting the user information",
- "type": "string"
- }
- },
+ "properties": {},
"type": "object"
},
"name": "get_me"
diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap
index 1e769c718..a802436c2 100644
--- a/pkg/github/__toolsnaps__/list_commits.snap
+++ b/pkg/github/__toolsnaps__/list_commits.snap
@@ -3,11 +3,11 @@
"title": "List commits",
"readOnlyHint": true
},
- "description": "Get list of commits of a branch in a GitHub repository",
+ "description": "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).",
"inputSchema": {
"properties": {
"author": {
- "description": "Author username or email address",
+ "description": "Author username or email address to filter commits by",
"type": "string"
},
"owner": {
@@ -30,7 +30,7 @@
"type": "string"
},
"sha": {
- "description": "SHA or Branch name",
+ "description": "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA.",
"type": "string"
}
},
diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap
new file mode 100644
index 000000000..681d640b7
--- /dev/null
+++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap
@@ -0,0 +1,46 @@
+{
+ "annotations": {
+ "title": "List dependabot alerts",
+ "readOnlyHint": true
+ },
+ "description": "List dependabot alerts in a GitHub repository.",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "The owner of the repository.",
+ "type": "string"
+ },
+ "repo": {
+ "description": "The name of the repository.",
+ "type": "string"
+ },
+ "severity": {
+ "description": "Filter dependabot alerts by severity",
+ "enum": [
+ "low",
+ "medium",
+ "high",
+ "critical"
+ ],
+ "type": "string"
+ },
+ "state": {
+ "default": "open",
+ "description": "Filter dependabot alerts by state. Defaults to open",
+ "enum": [
+ "open",
+ "fixed",
+ "dismissed",
+ "auto_dismissed"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "list_dependabot_alerts"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_pull_requests.snap b/pkg/github/__toolsnaps__/list_pull_requests.snap
index b8369784d..fee7e2ff1 100644
--- a/pkg/github/__toolsnaps__/list_pull_requests.snap
+++ b/pkg/github/__toolsnaps__/list_pull_requests.snap
@@ -3,7 +3,7 @@
"title": "List pull requests",
"readOnlyHint": true
},
- "description": "List pull requests in a GitHub repository.",
+ "description": "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.",
"inputSchema": {
"properties": {
"base": {
diff --git a/pkg/github/actions.go b/pkg/github/actions.go
index 8c7b08a85..95b1ec7ba 100644
--- a/pkg/github/actions.go
+++ b/pkg/github/actions.go
@@ -37,12 +37,7 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc)
mcp.Required(),
mcp.Description(DescriptionRepositoryName),
),
- mcp.WithNumber("per_page",
- mcp.Description("The number of results per page (max 100)"),
- ),
- mcp.WithNumber("page",
- mcp.Description("The page number of the results to fetch"),
- ),
+ WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
@@ -55,11 +50,7 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc)
}
// Get optional pagination parameters
- perPage, err := OptionalIntParam(request, "per_page")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- page, err := OptionalIntParam(request, "page")
+ pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -71,8 +62,8 @@ func ListWorkflows(getClient GetClientFn, t translations.TranslationHelperFunc)
// Set up list options
opts := &github.ListOptions{
- PerPage: perPage,
- Page: page,
+ PerPage: pagination.perPage,
+ Page: pagination.page,
}
workflows, resp, err := client.Actions.ListWorkflows(ctx, owner, repo, opts)
@@ -157,12 +148,7 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun
mcp.Description("Returns workflow runs with the check run status"),
mcp.Enum("queued", "in_progress", "completed", "requested", "waiting"),
),
- mcp.WithNumber("per_page",
- mcp.Description("The number of results per page (max 100)"),
- ),
- mcp.WithNumber("page",
- mcp.Description("The page number of the results to fetch"),
- ),
+ WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
@@ -197,11 +183,7 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun
}
// Get optional pagination parameters
- perPage, err := OptionalIntParam(request, "per_page")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- page, err := OptionalIntParam(request, "page")
+ pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -218,8 +200,8 @@ func ListWorkflowRuns(getClient GetClientFn, t translations.TranslationHelperFun
Event: event,
Status: status,
ListOptions: github.ListOptions{
- PerPage: perPage,
- Page: page,
+ PerPage: pagination.perPage,
+ Page: pagination.page,
},
}
@@ -483,12 +465,7 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun
mcp.Description("Filters jobs by their completed_at timestamp"),
mcp.Enum("latest", "all"),
),
- mcp.WithNumber("per_page",
- mcp.Description("The number of results per page (max 100)"),
- ),
- mcp.WithNumber("page",
- mcp.Description("The page number of the results to fetch"),
- ),
+ WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
@@ -512,11 +489,7 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun
}
// Get optional pagination parameters
- perPage, err := OptionalIntParam(request, "per_page")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- page, err := OptionalIntParam(request, "page")
+ pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -530,8 +503,8 @@ func ListWorkflowJobs(getClient GetClientFn, t translations.TranslationHelperFun
opts := &github.ListWorkflowJobsOptions{
Filter: filter,
ListOptions: github.ListOptions{
- PerPage: perPage,
- Page: page,
+ PerPage: pagination.perPage,
+ Page: pagination.page,
},
}
@@ -1022,12 +995,7 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH
mcp.Required(),
mcp.Description("The unique identifier of the workflow run"),
),
- mcp.WithNumber("per_page",
- mcp.Description("The number of results per page (max 100)"),
- ),
- mcp.WithNumber("page",
- mcp.Description("The page number of the results to fetch"),
- ),
+ WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
@@ -1045,11 +1013,7 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH
runID := int64(runIDInt)
// Get optional pagination parameters
- perPage, err := OptionalIntParam(request, "per_page")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- page, err := OptionalIntParam(request, "page")
+ pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -1061,8 +1025,8 @@ func ListWorkflowRunArtifacts(getClient GetClientFn, t translations.TranslationH
// Set up list options
opts := &github.ListOptions{
- PerPage: perPage,
- Page: page,
+ PerPage: pagination.perPage,
+ Page: pagination.page,
}
artifacts, resp, err := client.Actions.ListWorkflowRunArtifacts(ctx, owner, repo, runID, opts)
diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go
index 1b904b9b1..f885ec5b9 100644
--- a/pkg/github/actions_test.go
+++ b/pkg/github/actions_test.go
@@ -23,7 +23,7 @@ func Test_ListWorkflows(t *testing.T) {
assert.NotEmpty(t, tool.Description)
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
- assert.Contains(t, tool.InputSchema.Properties, "per_page")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
@@ -393,7 +393,7 @@ func Test_ListWorkflowRunArtifacts(t *testing.T) {
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "run_id")
- assert.Contains(t, tool.InputSchema.Properties, "per_page")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "run_id"})
diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go
index bed2f4a39..9817fea7b 100644
--- a/pkg/github/context_tools.go
+++ b/pkg/github/context_tools.go
@@ -2,6 +2,7 @@ package github
import (
"context"
+ "time"
ghErrors "github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/translations"
@@ -9,17 +10,36 @@ import (
"github.com/mark3labs/mcp-go/server"
)
+// UserDetails contains additional fields about a GitHub user not already
+// present in MinimalUser. Used by get_me context tool but omitted from search_users.
+type UserDetails struct {
+ Name string `json:"name,omitempty"`
+ Company string `json:"company,omitempty"`
+ Blog string `json:"blog,omitempty"`
+ Location string `json:"location,omitempty"`
+ Email string `json:"email,omitempty"`
+ Hireable bool `json:"hireable,omitempty"`
+ Bio string `json:"bio,omitempty"`
+ TwitterUsername string `json:"twitter_username,omitempty"`
+ PublicRepos int `json:"public_repos"`
+ PublicGists int `json:"public_gists"`
+ Followers int `json:"followers"`
+ Following int `json:"following"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
+ PrivateGists int `json:"private_gists,omitempty"`
+ TotalPrivateRepos int64 `json:"total_private_repos,omitempty"`
+ OwnedPrivateRepos int64 `json:"owned_private_repos,omitempty"`
+}
+
// GetMe creates a tool to get details of the authenticated user.
func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
tool := mcp.NewTool("get_me",
- mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request includes \"me\", \"my\". The output will not change unless the user changes their profile, so only call this once.")),
+ mcp.WithDescription(t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_GET_ME_USER_TITLE", "Get my user profile"),
ReadOnlyHint: ToBoolPtr(true),
}),
- mcp.WithString("reason",
- mcp.Description("Optional: the reason for requesting the user information"),
- ),
)
type args struct{}
@@ -38,7 +58,34 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Too
), nil
}
- return MarshalledTextResult(user), nil
+ // Create minimal user representation instead of returning full user object
+ minimalUser := MinimalUser{
+ Login: user.GetLogin(),
+ ID: user.GetID(),
+ ProfileURL: user.GetHTMLURL(),
+ AvatarURL: user.GetAvatarURL(),
+ Details: &UserDetails{
+ Name: user.GetName(),
+ Company: user.GetCompany(),
+ Blog: user.GetBlog(),
+ Location: user.GetLocation(),
+ Email: user.GetEmail(),
+ Hireable: user.GetHireable(),
+ Bio: user.GetBio(),
+ TwitterUsername: user.GetTwitterUsername(),
+ PublicRepos: user.GetPublicRepos(),
+ PublicGists: user.GetPublicGists(),
+ Followers: user.GetFollowers(),
+ Following: user.GetFollowing(),
+ CreatedAt: user.GetCreatedAt().Time,
+ UpdatedAt: user.GetUpdatedAt().Time,
+ PrivateGists: user.GetPrivateGists(),
+ TotalPrivateRepos: user.GetTotalPrivateRepos(),
+ OwnedPrivateRepos: user.GetOwnedPrivateRepos(),
+ },
+ }
+
+ return MarshalledTextResult(minimalUser), nil
})
return tool, handler
diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go
index 0d9193976..03af4175d 100644
--- a/pkg/github/context_tools_test.go
+++ b/pkg/github/context_tools_test.go
@@ -26,15 +26,17 @@ func Test_GetMe(t *testing.T) {
// Setup mock user response
mockUser := &github.User{
- Login: github.Ptr("testuser"),
- Name: github.Ptr("Test User"),
- Email: github.Ptr("test@example.com"),
- Bio: github.Ptr("GitHub user for testing"),
- Company: github.Ptr("Test Company"),
- Location: github.Ptr("Test Location"),
- HTMLURL: github.Ptr("https://github.com/testuser"),
- CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)},
- Type: github.Ptr("User"),
+ Login: github.Ptr("testuser"),
+ Name: github.Ptr("Test User"),
+ Email: github.Ptr("test@example.com"),
+ Bio: github.Ptr("GitHub user for testing"),
+ Company: github.Ptr("Test Company"),
+ Location: github.Ptr("Test Location"),
+ HTMLURL: github.Ptr("https://github.com/testuser"),
+ CreatedAt: &github.Timestamp{Time: time.Now().Add(-365 * 24 * time.Hour)},
+ Type: github.Ptr("User"),
+ Hireable: github.Ptr(true),
+ TwitterUsername: github.Ptr("testuser_twitter"),
Plan: &github.Plan{
Name: github.Ptr("pro"),
},
@@ -117,17 +119,23 @@ func Test_GetMe(t *testing.T) {
}
// Unmarshal and verify the result
- var returnedUser github.User
+ var returnedUser MinimalUser
err = json.Unmarshal([]byte(textContent.Text), &returnedUser)
require.NoError(t, err)
+ // Verify minimal user details
+ assert.Equal(t, *tc.expectedUser.Login, returnedUser.Login)
+ assert.Equal(t, *tc.expectedUser.HTMLURL, returnedUser.ProfileURL)
+
// Verify user details
- assert.Equal(t, *tc.expectedUser.Login, *returnedUser.Login)
- assert.Equal(t, *tc.expectedUser.Name, *returnedUser.Name)
- assert.Equal(t, *tc.expectedUser.Email, *returnedUser.Email)
- assert.Equal(t, *tc.expectedUser.Bio, *returnedUser.Bio)
- assert.Equal(t, *tc.expectedUser.HTMLURL, *returnedUser.HTMLURL)
- assert.Equal(t, *tc.expectedUser.Type, *returnedUser.Type)
+ require.NotNil(t, returnedUser.Details)
+ assert.Equal(t, *tc.expectedUser.Name, returnedUser.Details.Name)
+ assert.Equal(t, *tc.expectedUser.Email, returnedUser.Details.Email)
+ assert.Equal(t, *tc.expectedUser.Bio, returnedUser.Details.Bio)
+ assert.Equal(t, *tc.expectedUser.Company, returnedUser.Details.Company)
+ assert.Equal(t, *tc.expectedUser.Location, returnedUser.Details.Location)
+ assert.Equal(t, *tc.expectedUser.Hireable, returnedUser.Details.Hireable)
+ assert.Equal(t, *tc.expectedUser.TwitterUsername, returnedUser.Details.TwitterUsername)
})
}
}
diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go
new file mode 100644
index 000000000..af21b83d1
--- /dev/null
+++ b/pkg/github/dependabot.go
@@ -0,0 +1,161 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v72/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+func GetDependabotAlert(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool(
+ "get_dependabot_alert",
+ mcp.WithDescription(t("TOOL_GET_DEPENDABOT_ALERT_DESCRIPTION", "Get details of a specific dependabot alert in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_DEPENDABOT_ALERT_USER_TITLE", "Get dependabot alert"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("The owner of the repository."),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("The name of the repository."),
+ ),
+ mcp.WithNumber("alertNumber",
+ mcp.Required(),
+ mcp.Description("The number of the alert."),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ alertNumber, err := RequiredInt(request, "alertNumber")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to get alert with number '%d'", alertNumber),
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get alert: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(alert)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal alert: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+func ListDependabotAlerts(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool(
+ "list_dependabot_alerts",
+ mcp.WithDescription(t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository.")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("The owner of the repository."),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("The name of the repository."),
+ ),
+ mcp.WithString("state",
+ mcp.Description("Filter dependabot alerts by state. Defaults to open"),
+ mcp.DefaultString("open"),
+ mcp.Enum("open", "fixed", "dismissed", "auto_dismissed"),
+ ),
+ mcp.WithString("severity",
+ mcp.Description("Filter dependabot alerts by severity"),
+ mcp.Enum("low", "medium", "high", "critical"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ state, err := OptionalParam[string](request, "state")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ severity, err := OptionalParam[string](request, "severity")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{
+ State: ToStringPtr(state),
+ Severity: ToStringPtr(severity),
+ })
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo),
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to list alerts: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(alerts)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal alerts: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go
new file mode 100644
index 000000000..f7c091981
--- /dev/null
+++ b/pkg/github/dependabot_test.go
@@ -0,0 +1,276 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/github/github-mcp-server/internal/toolsnaps"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v72/github"
+ "github.com/migueleliasweb/go-github-mock/src/mock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_GetDependabotAlert(t *testing.T) {
+ // Verify tool definition
+ mockClient := github.NewClient(nil)
+ tool, _ := GetDependabotAlert(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ // Validate tool schema
+ assert.Equal(t, "get_dependabot_alert", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Contains(t, tool.InputSchema.Properties, "alertNumber")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "alertNumber"})
+
+ // Setup mock alert for success case
+ mockAlert := &github.DependabotAlert{
+ Number: github.Ptr(42),
+ State: github.Ptr("open"),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/42"),
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedAlert *github.DependabotAlert
+ expectedErrMsg string
+ }{
+ {
+ name: "successful alert fetch",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatch(
+ mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber,
+ mockAlert,
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "alertNumber": float64(42),
+ },
+ expectError: false,
+ expectedAlert: mockAlert,
+ },
+ {
+ name: "alert fetch fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposDependabotAlertsByOwnerByRepoByAlertNumber,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "alertNumber": float64(9999),
+ },
+ expectError: true,
+ expectedErrMsg: "failed to get alert",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := GetDependabotAlert(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ // Create call request
+ request := createMCPRequest(tc.requestArgs)
+
+ // Call handler
+ result, err := handler(context.Background(), request)
+
+ // Verify results
+ if tc.expectError {
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ // Parse the result and get the text content if no error
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedAlert github.DependabotAlert
+ err = json.Unmarshal([]byte(textContent.Text), &returnedAlert)
+ assert.NoError(t, err)
+ assert.Equal(t, *tc.expectedAlert.Number, *returnedAlert.Number)
+ assert.Equal(t, *tc.expectedAlert.State, *returnedAlert.State)
+ assert.Equal(t, *tc.expectedAlert.HTMLURL, *returnedAlert.HTMLURL)
+ })
+ }
+}
+
+func Test_ListDependabotAlerts(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListDependabotAlerts(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "list_dependabot_alerts", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Contains(t, tool.InputSchema.Properties, "state")
+ assert.Contains(t, tool.InputSchema.Properties, "severity")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+ // Setup mock alerts for success case
+ criticalAlert := github.DependabotAlert{
+ Number: github.Ptr(1),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/1"),
+ State: github.Ptr("open"),
+ SecurityAdvisory: &github.DependabotSecurityAdvisory{
+ Severity: github.Ptr("critical"),
+ },
+ }
+ highSeverityAlert := github.DependabotAlert{
+ Number: github.Ptr(2),
+ HTMLURL: github.Ptr("https://github.com/owner/repo/security/dependabot/2"),
+ State: github.Ptr("fixed"),
+ SecurityAdvisory: &github.DependabotSecurityAdvisory{
+ Severity: github.Ptr("high"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedAlerts []*github.DependabotAlert
+ expectedErrMsg string
+ }{
+ {
+ name: "successful open alerts listing",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposDependabotAlertsByOwnerByRepo,
+ expectQueryParams(t, map[string]string{
+ "state": "open",
+ }).andThen(
+ mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "state": "open",
+ },
+ expectError: false,
+ expectedAlerts: []*github.DependabotAlert{&criticalAlert},
+ },
+ {
+ name: "successful severity filtered listing",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposDependabotAlertsByOwnerByRepo,
+ expectQueryParams(t, map[string]string{
+ "severity": "high",
+ }).andThen(
+ mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "severity": "high",
+ },
+ expectError: false,
+ expectedAlerts: []*github.DependabotAlert{&highSeverityAlert},
+ },
+ {
+ name: "successful all alerts listing",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposDependabotAlertsByOwnerByRepo,
+ expectQueryParams(t, map[string]string{}).andThen(
+ mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}),
+ ),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert},
+ },
+ {
+ name: "alerts listing fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposDependabotAlertsByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"message": "Unauthorized access"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to list alerts",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListDependabotAlerts(stubGetClientFn(client), translations.NullTranslationHelper)
+
+ request := createMCPRequest(tc.requestArgs)
+
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
+ return
+ }
+
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedAlerts []*github.DependabotAlert
+ err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts)
+ assert.NoError(t, err)
+ assert.Len(t, returnedAlerts, len(tc.expectedAlerts))
+ for i, alert := range returnedAlerts {
+ assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number)
+ assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL)
+ assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State)
+ if tc.expectedAlerts[i].SecurityAdvisory != nil && tc.expectedAlerts[i].SecurityAdvisory.Severity != nil &&
+ alert.SecurityAdvisory != nil && alert.SecurityAdvisory.Severity != nil {
+ assert.Equal(t, *tc.expectedAlerts[i].SecurityAdvisory.Severity, *alert.SecurityAdvisory.Severity)
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go
new file mode 100644
index 000000000..3e53a633b
--- /dev/null
+++ b/pkg/github/discussions.go
@@ -0,0 +1,383 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/go-viper/mapstructure/v2"
+ "github.com/google/go-github/v72/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+ "github.com/shurcooL/githubv4"
+)
+
+func ListDiscussions(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_discussions",
+ mcp.WithDescription(t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithString("category",
+ mcp.Description("Optional filter by discussion category ID. If provided, only discussions with this category are listed."),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ // Required params
+ owner, err := RequiredParam[string](request, "owner")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ repo, err := RequiredParam[string](request, "repo")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Optional params
+ category, err := OptionalParam[string](request, "category")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getGQLClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
+ }
+
+ // If category filter is specified, use it as the category ID for server-side filtering
+ var categoryID *githubv4.ID
+ if category != "" {
+ id := githubv4.ID(category)
+ categoryID = &id
+ }
+
+ // Now execute the discussions query
+ var discussions []*github.Discussion
+ if categoryID != nil {
+ // Query with category filter (server-side filtering)
+ var query struct {
+ Repository struct {
+ Discussions struct {
+ Nodes []struct {
+ Number githubv4.Int
+ Title githubv4.String
+ CreatedAt githubv4.DateTime
+ Category struct {
+ Name githubv4.String
+ } `graphql:"category"`
+ URL githubv4.String `graphql:"url"`
+ }
+ } `graphql:"discussions(first: 100, categoryId: $categoryId)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ "categoryId": *categoryID,
+ }
+ if err := client.Query(ctx, &query, vars); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Map nodes to GitHub Discussion objects
+ for _, n := range query.Repository.Discussions.Nodes {
+ di := &github.Discussion{
+ Number: github.Ptr(int(n.Number)),
+ Title: github.Ptr(string(n.Title)),
+ HTMLURL: github.Ptr(string(n.URL)),
+ CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time},
+ DiscussionCategory: &github.DiscussionCategory{
+ Name: github.Ptr(string(n.Category.Name)),
+ },
+ }
+ discussions = append(discussions, di)
+ }
+ } else {
+ // Query without category filter
+ var query struct {
+ Repository struct {
+ Discussions struct {
+ Nodes []struct {
+ Number githubv4.Int
+ Title githubv4.String
+ CreatedAt githubv4.DateTime
+ Category struct {
+ Name githubv4.String
+ } `graphql:"category"`
+ URL githubv4.String `graphql:"url"`
+ }
+ } `graphql:"discussions(first: 100)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String(owner),
+ "repo": githubv4.String(repo),
+ }
+ if err := client.Query(ctx, &query, vars); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Map nodes to GitHub Discussion objects
+ for _, n := range query.Repository.Discussions.Nodes {
+ di := &github.Discussion{
+ Number: github.Ptr(int(n.Number)),
+ Title: github.Ptr(string(n.Title)),
+ HTMLURL: github.Ptr(string(n.URL)),
+ CreatedAt: &github.Timestamp{Time: n.CreatedAt.Time},
+ DiscussionCategory: &github.DiscussionCategory{
+ Name: github.Ptr(string(n.Category.Name)),
+ },
+ }
+ discussions = append(discussions, di)
+ }
+ }
+
+ // Marshal and return
+ out, err := json.Marshal(discussions)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal discussions: %w", err)
+ }
+ return mcp.NewToolResultText(string(out)), nil
+ }
+}
+
+func GetDiscussion(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_discussion",
+ mcp.WithDescription(t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("discussionNumber",
+ mcp.Required(),
+ mcp.Description("Discussion Number"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ // Decode params
+ var params struct {
+ Owner string
+ Repo string
+ DiscussionNumber int32
+ }
+ if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ client, err := getGQLClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
+ }
+
+ var q struct {
+ Repository struct {
+ Discussion struct {
+ Number githubv4.Int
+ Body githubv4.String
+ CreatedAt githubv4.DateTime
+ URL githubv4.String `graphql:"url"`
+ Category struct {
+ Name githubv4.String
+ } `graphql:"category"`
+ } `graphql:"discussion(number: $discussionNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String(params.Owner),
+ "repo": githubv4.String(params.Repo),
+ "discussionNumber": githubv4.Int(params.DiscussionNumber),
+ }
+ if err := client.Query(ctx, &q, vars); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ d := q.Repository.Discussion
+ discussion := &github.Discussion{
+ Number: github.Ptr(int(d.Number)),
+ Body: github.Ptr(string(d.Body)),
+ HTMLURL: github.Ptr(string(d.URL)),
+ CreatedAt: &github.Timestamp{Time: d.CreatedAt.Time},
+ DiscussionCategory: &github.DiscussionCategory{
+ Name: github.Ptr(string(d.Category.Name)),
+ },
+ }
+ out, err := json.Marshal(discussion)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal discussion: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(out)), nil
+ }
+}
+
+func GetDiscussionComments(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_discussion_comments",
+ mcp.WithDescription(t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner", mcp.Required(), mcp.Description("Repository owner")),
+ mcp.WithString("repo", mcp.Required(), mcp.Description("Repository name")),
+ mcp.WithNumber("discussionNumber", mcp.Required(), mcp.Description("Discussion Number")),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ // Decode params
+ var params struct {
+ Owner string
+ Repo string
+ DiscussionNumber int32
+ }
+ if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getGQLClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
+ }
+
+ var q struct {
+ Repository struct {
+ Discussion struct {
+ Comments struct {
+ Nodes []struct {
+ Body githubv4.String
+ }
+ } `graphql:"comments(first:100)"`
+ } `graphql:"discussion(number: $discussionNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String(params.Owner),
+ "repo": githubv4.String(params.Repo),
+ "discussionNumber": githubv4.Int(params.DiscussionNumber),
+ }
+ if err := client.Query(ctx, &q, vars); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ var comments []*github.IssueComment
+ for _, c := range q.Repository.Discussion.Comments.Nodes {
+ comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))})
+ }
+
+ out, err := json.Marshal(comments)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal comments: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(out)), nil
+ }
+}
+
+func ListDiscussionCategories(getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_discussion_categories",
+ mcp.WithDescription(t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithNumber("first",
+ mcp.Description("Number of categories to return per page (min 1, max 100)"),
+ mcp.Min(1),
+ mcp.Max(100),
+ ),
+ mcp.WithNumber("last",
+ mcp.Description("Number of categories to return from the end (min 1, max 100)"),
+ mcp.Min(1),
+ mcp.Max(100),
+ ),
+ mcp.WithString("after",
+ mcp.Description("Cursor for pagination, use the 'after' field from the previous response"),
+ ),
+ mcp.WithString("before",
+ mcp.Description("Cursor for pagination, use the 'before' field from the previous response"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ // Decode params
+ var params struct {
+ Owner string
+ Repo string
+ First int32
+ Last int32
+ After string
+ Before string
+ }
+ if err := mapstructure.Decode(request.Params.Arguments, ¶ms); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // Validate pagination parameters
+ if params.First != 0 && params.Last != 0 {
+ return mcp.NewToolResultError("only one of 'first' or 'last' may be specified"), nil
+ }
+ if params.After != "" && params.Before != "" {
+ return mcp.NewToolResultError("only one of 'after' or 'before' may be specified"), nil
+ }
+ if params.After != "" && params.Last != 0 {
+ return mcp.NewToolResultError("'after' cannot be used with 'last'. Did you mean to use 'before' instead?"), nil
+ }
+ if params.Before != "" && params.First != 0 {
+ return mcp.NewToolResultError("'before' cannot be used with 'first'. Did you mean to use 'after' instead?"), nil
+ }
+
+ client, err := getGQLClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil
+ }
+ var q struct {
+ Repository struct {
+ DiscussionCategories struct {
+ Nodes []struct {
+ ID githubv4.ID
+ Name githubv4.String
+ }
+ } `graphql:"discussionCategories(first: 100)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String(params.Owner),
+ "repo": githubv4.String(params.Repo),
+ }
+ if err := client.Query(ctx, &q, vars); err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ var categories []map[string]string
+ for _, c := range q.Repository.DiscussionCategories.Nodes {
+ categories = append(categories, map[string]string{
+ "id": fmt.Sprint(c.ID),
+ "name": string(c.Name),
+ })
+ }
+ out, err := json.Marshal(categories)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal discussion categories: %w", err)
+ }
+ return mcp.NewToolResultText(string(out)), nil
+ }
+}
diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go
new file mode 100644
index 000000000..5132c6ce0
--- /dev/null
+++ b/pkg/github/discussions_test.go
@@ -0,0 +1,392 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/github/github-mcp-server/internal/githubv4mock"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v72/github"
+ "github.com/shurcooL/githubv4"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+var (
+ discussionsGeneral = []map[string]any{
+ {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}},
+ {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}},
+ }
+ discussionsAll = []map[string]any{
+ {"number": 1, "title": "Discussion 1 title", "createdAt": "2023-01-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/1", "category": map[string]any{"name": "General"}},
+ {"number": 2, "title": "Discussion 2 title", "createdAt": "2023-02-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/2", "category": map[string]any{"name": "Questions"}},
+ {"number": 3, "title": "Discussion 3 title", "createdAt": "2023-03-01T00:00:00Z", "url": "https://github.com/owner/repo/discussions/3", "category": map[string]any{"name": "General"}},
+ }
+ mockResponseListAll = githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussions": map[string]any{"nodes": discussionsAll},
+ },
+ })
+ mockResponseListGeneral = githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussions": map[string]any{"nodes": discussionsGeneral},
+ },
+ })
+ mockErrorRepoNotFound = githubv4mock.ErrorResponse("repository not found")
+)
+
+func Test_ListDiscussions(t *testing.T) {
+ mockClient := githubv4.NewClient(nil)
+ // Verify tool definition and schema
+ toolDef, _ := ListDiscussions(stubGetGQLClientFn(mockClient), translations.NullTranslationHelper)
+ assert.Equal(t, "list_discussions", toolDef.Name)
+ assert.NotEmpty(t, toolDef.Description)
+ assert.Contains(t, toolDef.InputSchema.Properties, "owner")
+ assert.Contains(t, toolDef.InputSchema.Properties, "repo")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo"})
+
+ // mock for the call to ListDiscussions without category filter
+ var qDiscussions struct {
+ Repository struct {
+ Discussions struct {
+ Nodes []struct {
+ Number githubv4.Int
+ Title githubv4.String
+ CreatedAt githubv4.DateTime
+ Category struct {
+ Name githubv4.String
+ } `graphql:"category"`
+ URL githubv4.String `graphql:"url"`
+ }
+ } `graphql:"discussions(first: 100)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ // mock for the call to get discussions with category filter
+ var qDiscussionsFiltered struct {
+ Repository struct {
+ Discussions struct {
+ Nodes []struct {
+ Number githubv4.Int
+ Title githubv4.String
+ CreatedAt githubv4.DateTime
+ Category struct {
+ Name githubv4.String
+ } `graphql:"category"`
+ URL githubv4.String `graphql:"url"`
+ }
+ } `graphql:"discussions(first: 100, categoryId: $categoryId)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+
+ varsListAll := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ }
+
+ varsRepoNotFound := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("nonexistent-repo"),
+ }
+
+ varsDiscussionsFiltered := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "categoryId": githubv4.ID("DIC_kwDOABC123"),
+ }
+
+ tests := []struct {
+ name string
+ reqParams map[string]interface{}
+ expectError bool
+ errContains string
+ expectedCount int
+ }{
+ {
+ name: "list all discussions without category filter",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ expectedCount: 3, // All discussions
+ },
+ {
+ name: "filter by category ID",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "category": "DIC_kwDOABC123",
+ },
+ expectError: false,
+ expectedCount: 2, // Only General discussions (matching the category ID)
+ },
+ {
+ name: "repository not found error",
+ reqParams: map[string]interface{}{
+ "owner": "owner",
+ "repo": "nonexistent-repo",
+ },
+ expectError: true,
+ errContains: "repository not found",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ var httpClient *http.Client
+
+ switch tc.name {
+ case "list all discussions without category filter":
+ // Simple case - no category filter
+ matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsListAll, mockResponseListAll)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "filter by category ID":
+ // Simple case - category filter using category ID directly
+ matcher := githubv4mock.NewQueryMatcher(qDiscussionsFiltered, varsDiscussionsFiltered, mockResponseListGeneral)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ case "repository not found error":
+ matcher := githubv4mock.NewQueryMatcher(qDiscussions, varsRepoNotFound, mockErrorRepoNotFound)
+ httpClient = githubv4mock.NewMockedHTTPClient(matcher)
+ }
+
+ gqlClient := githubv4.NewClient(httpClient)
+ _, handler := ListDiscussions(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
+
+ req := createMCPRequest(tc.reqParams)
+ res, err := handler(context.Background(), req)
+ text := getTextResult(t, res).Text
+
+ if tc.expectError {
+ require.True(t, res.IsError)
+ assert.Contains(t, text, tc.errContains)
+ return
+ }
+ require.NoError(t, err)
+
+ var returnedDiscussions []*github.Discussion
+ err = json.Unmarshal([]byte(text), &returnedDiscussions)
+ require.NoError(t, err)
+
+ assert.Len(t, returnedDiscussions, tc.expectedCount, "Expected %d discussions, got %d", tc.expectedCount, len(returnedDiscussions))
+
+ // Verify that all returned discussions have a category if filtered
+ if _, hasCategory := tc.reqParams["category"]; hasCategory {
+ for _, discussion := range returnedDiscussions {
+ require.NotNil(t, discussion.DiscussionCategory, "Discussion should have category")
+ assert.NotEmpty(t, *discussion.DiscussionCategory.Name, "Discussion should have category name")
+ }
+ }
+ })
+ }
+}
+
+func Test_GetDiscussion(t *testing.T) {
+ // Verify tool definition and schema
+ toolDef, _ := GetDiscussion(nil, translations.NullTranslationHelper)
+ assert.Equal(t, "get_discussion", toolDef.Name)
+ assert.NotEmpty(t, toolDef.Description)
+ assert.Contains(t, toolDef.InputSchema.Properties, "owner")
+ assert.Contains(t, toolDef.InputSchema.Properties, "repo")
+ assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"})
+
+ var q struct {
+ Repository struct {
+ Discussion struct {
+ Number githubv4.Int
+ Body githubv4.String
+ CreatedAt githubv4.DateTime
+ URL githubv4.String `graphql:"url"`
+ Category struct {
+ Name githubv4.String
+ } `graphql:"category"`
+ } `graphql:"discussion(number: $discussionNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "discussionNumber": githubv4.Int(1),
+ }
+ tests := []struct {
+ name string
+ response githubv4mock.GQLResponse
+ expectError bool
+ expected *github.Discussion
+ errContains string
+ }{
+ {
+ name: "successful retrieval",
+ response: githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{"discussion": map[string]any{
+ "number": 1,
+ "body": "This is a test discussion",
+ "url": "https://github.com/owner/repo/discussions/1",
+ "createdAt": "2025-04-25T12:00:00Z",
+ "category": map[string]any{"name": "General"},
+ }},
+ }),
+ expectError: false,
+ expected: &github.Discussion{
+ HTMLURL: github.Ptr("https://github.com/owner/repo/discussions/1"),
+ Number: github.Ptr(1),
+ Body: github.Ptr("This is a test discussion"),
+ CreatedAt: &github.Timestamp{Time: time.Date(2025, 4, 25, 12, 0, 0, 0, time.UTC)},
+ DiscussionCategory: &github.DiscussionCategory{
+ Name: github.Ptr("General"),
+ },
+ },
+ },
+ {
+ name: "discussion not found",
+ response: githubv4mock.ErrorResponse("discussion not found"),
+ expectError: true,
+ errContains: "discussion not found",
+ },
+ }
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ matcher := githubv4mock.NewQueryMatcher(q, vars, tc.response)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ gqlClient := githubv4.NewClient(httpClient)
+ _, handler := GetDiscussion(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
+
+ req := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo", "discussionNumber": int32(1)})
+ res, err := handler(context.Background(), req)
+ text := getTextResult(t, res).Text
+
+ if tc.expectError {
+ require.True(t, res.IsError)
+ assert.Contains(t, text, tc.errContains)
+ return
+ }
+
+ require.NoError(t, err)
+ var out github.Discussion
+ require.NoError(t, json.Unmarshal([]byte(text), &out))
+ assert.Equal(t, *tc.expected.HTMLURL, *out.HTMLURL)
+ assert.Equal(t, *tc.expected.Number, *out.Number)
+ assert.Equal(t, *tc.expected.Body, *out.Body)
+ // Check category label
+ assert.Equal(t, *tc.expected.DiscussionCategory.Name, *out.DiscussionCategory.Name)
+ })
+ }
+}
+
+func Test_GetDiscussionComments(t *testing.T) {
+ // Verify tool definition and schema
+ toolDef, _ := GetDiscussionComments(nil, translations.NullTranslationHelper)
+ assert.Equal(t, "get_discussion_comments", toolDef.Name)
+ assert.NotEmpty(t, toolDef.Description)
+ assert.Contains(t, toolDef.InputSchema.Properties, "owner")
+ assert.Contains(t, toolDef.InputSchema.Properties, "repo")
+ assert.Contains(t, toolDef.InputSchema.Properties, "discussionNumber")
+ assert.ElementsMatch(t, toolDef.InputSchema.Required, []string{"owner", "repo", "discussionNumber"})
+
+ var q struct {
+ Repository struct {
+ Discussion struct {
+ Comments struct {
+ Nodes []struct {
+ Body githubv4.String
+ }
+ } `graphql:"comments(first:100)"`
+ } `graphql:"discussion(number: $discussionNumber)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ "discussionNumber": githubv4.Int(1),
+ }
+ mockResponse := githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussion": map[string]any{
+ "comments": map[string]any{
+ "nodes": []map[string]any{
+ {"body": "This is the first comment"},
+ {"body": "This is the second comment"},
+ },
+ },
+ },
+ },
+ })
+ matcher := githubv4mock.NewQueryMatcher(q, vars, mockResponse)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ gqlClient := githubv4.NewClient(httpClient)
+ _, handler := GetDiscussionComments(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
+
+ request := createMCPRequest(map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "discussionNumber": int32(1),
+ })
+
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+
+ textContent := getTextResult(t, result)
+
+ var returnedComments []*github.IssueComment
+ err = json.Unmarshal([]byte(textContent.Text), &returnedComments)
+ require.NoError(t, err)
+ assert.Len(t, returnedComments, 2)
+ expectedBodies := []string{"This is the first comment", "This is the second comment"}
+ for i, comment := range returnedComments {
+ assert.Equal(t, expectedBodies[i], *comment.Body)
+ }
+}
+
+func Test_ListDiscussionCategories(t *testing.T) {
+ var q struct {
+ Repository struct {
+ DiscussionCategories struct {
+ Nodes []struct {
+ ID githubv4.ID
+ Name githubv4.String
+ }
+ } `graphql:"discussionCategories(first: 100)"`
+ } `graphql:"repository(owner: $owner, name: $repo)"`
+ }
+ vars := map[string]interface{}{
+ "owner": githubv4.String("owner"),
+ "repo": githubv4.String("repo"),
+ }
+ mockResp := githubv4mock.DataResponse(map[string]any{
+ "repository": map[string]any{
+ "discussionCategories": map[string]any{
+ "nodes": []map[string]any{
+ {"id": "123", "name": "CategoryOne"},
+ {"id": "456", "name": "CategoryTwo"},
+ },
+ },
+ },
+ })
+ matcher := githubv4mock.NewQueryMatcher(q, vars, mockResp)
+ httpClient := githubv4mock.NewMockedHTTPClient(matcher)
+ gqlClient := githubv4.NewClient(httpClient)
+
+ tool, handler := ListDiscussionCategories(stubGetGQLClientFn(gqlClient), translations.NullTranslationHelper)
+ assert.Equal(t, "list_discussion_categories", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+ request := createMCPRequest(map[string]interface{}{"owner": "owner", "repo": "repo"})
+ result, err := handler(context.Background(), request)
+ require.NoError(t, err)
+
+ text := getTextResult(t, result).Text
+ var categories []map[string]string
+ require.NoError(t, json.Unmarshal([]byte(text), &categories))
+ assert.Len(t, categories, 2)
+ assert.Equal(t, "123", categories[0]["id"])
+ assert.Equal(t, "CategoryOne", categories[0]["name"])
+ assert.Equal(t, "456", categories[1]["id"])
+ assert.Equal(t, "CategoryTwo", categories[1]["name"])
+}
diff --git a/pkg/github/issues.go b/pkg/github/issues.go
index 6121786d2..9d51aeb50 100644
--- a/pkg/github/issues.go
+++ b/pkg/github/issues.go
@@ -608,12 +608,7 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun
mcp.Required(),
mcp.Description("Issue number"),
),
- mcp.WithNumber("page",
- mcp.Description("Page number"),
- ),
- mcp.WithNumber("per_page",
- mcp.Description("Number of records per page"),
- ),
+ WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
@@ -628,19 +623,15 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- page, err := OptionalIntParamWithDefault(request, "page", 1)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- perPage, err := OptionalIntParamWithDefault(request, "per_page", 30)
+ pagination, err := OptionalPaginationParams(request)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
opts := &github.IssueListCommentsOptions{
ListOptions: github.ListOptions{
- Page: page,
- PerPage: perPage,
+ Page: pagination.page,
+ PerPage: pagination.perPage,
},
}
diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go
index 056fa7ed8..a6facbe2f 100644
--- a/pkg/github/issues_test.go
+++ b/pkg/github/issues_test.go
@@ -1087,7 +1087,7 @@ func Test_GetIssueComments(t *testing.T) {
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "issue_number")
assert.Contains(t, tool.InputSchema.Properties, "page")
- assert.Contains(t, tool.InputSchema.Properties, "per_page")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"})
// Setup mock comments for success case
@@ -1152,7 +1152,7 @@ func Test_GetIssueComments(t *testing.T) {
"repo": "repo",
"issue_number": float64(42),
"page": float64(2),
- "per_page": float64(10),
+ "perPage": float64(10),
},
expectError: false,
expectedComments: mockComments,
diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go
index bad822b13..32c7e850c 100644
--- a/pkg/github/pullrequests.go
+++ b/pkg/github/pullrequests.go
@@ -330,7 +330,7 @@ func UpdatePullRequest(getClient GetClientFn, t translations.TranslationHelperFu
// ListPullRequests creates a tool to list and filter repository pull requests.
func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFunc) (mcp.Tool, server.ToolHandlerFunc) {
return mcp.NewTool("list_pull_requests",
- mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository.")),
+ mcp.WithDescription(t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_LIST_PULL_REQUESTS_USER_TITLE", "List pull requests"),
ReadOnlyHint: ToBoolPtr(true),
@@ -396,7 +396,6 @@ func ListPullRequests(getClient GetClientFn, t translations.TranslationHelperFun
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
-
opts := &github.PullRequestListOptions{
State: state,
Head: head,
diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go
index 5b116745e..8a7a8af4a 100644
--- a/pkg/github/repositories.go
+++ b/pkg/github/repositories.go
@@ -97,7 +97,7 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too
// ListCommits creates a tool to get commits of a branch in a repository.
func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("list_commits",
- mcp.WithDescription(t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository")),
+ mcp.WithDescription(t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
Title: t("TOOL_LIST_COMMITS_USER_TITLE", "List commits"),
ReadOnlyHint: ToBoolPtr(true),
@@ -111,10 +111,10 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
mcp.Description("Repository name"),
),
mcp.WithString("sha",
- mcp.Description("SHA or Branch name"),
+ mcp.Description("Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA."),
),
mcp.WithString("author",
- mcp.Description("Author username or email address"),
+ mcp.Description("Author username or email address to filter commits by"),
),
WithPagination(),
),
@@ -139,13 +139,17 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
-
+ // Set default perPage to 30 if not provided
+ perPage := pagination.perPage
+ if perPage == 0 {
+ perPage = 30
+ }
opts := &github.CommitsListOptions{
SHA: sha,
Author: author,
ListOptions: github.ListOptions{
Page: pagination.page,
- PerPage: pagination.perPage,
+ PerPage: perPage,
},
}
@@ -284,7 +288,7 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF
mcp.Description("Branch to create/update the file in"),
),
mcp.WithString("sha",
- mcp.Description("SHA of file being replaced (for updates)"),
+ mcp.Description("Required if updating an existing file. The blob SHA of the file being replaced."),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -466,7 +470,7 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
mcp.Description("Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`"),
),
mcp.WithString("sha",
- mcp.Description("Accepts optional git sha, if sha is specified it will be used instead of ref"),
+ mcp.Description("Accepts optional commit SHA. If specified, it will be used instead of ref"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
diff --git a/pkg/github/search.go b/pkg/github/search.go
index 5106b84d8..a72b38bc6 100644
--- a/pkg/github/search.go
+++ b/pkg/github/search.go
@@ -155,11 +155,13 @@ func SearchCode(getClient GetClientFn, t translations.TranslationHelperFunc) (to
}
}
+// MinimalUser is the output type for user and organization search results.
type MinimalUser struct {
- Login string `json:"login"`
- ID int64 `json:"id,omitempty"`
- ProfileURL string `json:"profile_url,omitempty"`
- AvatarURL string `json:"avatar_url,omitempty"`
+ Login string `json:"login"`
+ ID int64 `json:"id,omitempty"`
+ ProfileURL string `json:"profile_url,omitempty"`
+ AvatarURL string `json:"avatar_url,omitempty"`
+ Details *UserDetails `json:"details,omitempty"` // Optional field for additional user details
}
type MinimalSearchUsersResult struct {
@@ -224,15 +226,11 @@ func userOrOrgHandler(accountType string, getClient GetClientFn) server.ToolHand
for _, user := range result.Users {
if user.Login != nil {
- mu := MinimalUser{Login: *user.Login}
- if user.ID != nil {
- mu.ID = *user.ID
- }
- if user.HTMLURL != nil {
- mu.ProfileURL = *user.HTMLURL
- }
- if user.AvatarURL != nil {
- mu.AvatarURL = *user.AvatarURL
+ mu := MinimalUser{
+ Login: user.GetLogin(),
+ ID: user.GetID(),
+ ProfileURL: user.GetHTMLURL(),
+ AvatarURL: user.GetAvatarURL(),
}
minimalUsers = append(minimalUsers, mu)
}
diff --git a/pkg/github/server.go b/pkg/github/server.go
index 85d078f1b..e7b831791 100644
--- a/pkg/github/server.go
+++ b/pkg/github/server.go
@@ -175,7 +175,9 @@ func OptionalStringArrayParam(r mcp.CallToolRequest, p string) ([]string, error)
}
// WithPagination returns a ToolOption that adds "page" and "perPage" parameters to the tool.
-// The "page" parameter is optional, min 1. The "perPage" parameter is optional, min 1, max 100.
+// The "page" parameter is optional, min 1.
+// The "perPage" parameter is optional, min 1, max 100. If unset, defaults to 30.
+// https://docs.github.com/en/rest/using-the-rest-api/using-pagination-in-the-rest-api
func WithPagination() mcp.ToolOption {
return func(tool *mcp.Tool) {
mcp.WithNumber("page",
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 76b31d477..a469b7678 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -103,6 +103,11 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(GetSecretScanningAlert(getClient, t)),
toolsets.NewServerTool(ListSecretScanningAlerts(getClient, t)),
)
+ dependabot := toolsets.NewToolset("dependabot", "Dependabot tools").
+ AddReadTools(
+ toolsets.NewServerTool(GetDependabotAlert(getClient, t)),
+ toolsets.NewServerTool(ListDependabotAlerts(getClient, t)),
+ )
notifications := toolsets.NewToolset("notifications", "GitHub Notifications related tools").
AddReadTools(
@@ -116,6 +121,14 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(ManageRepositoryNotificationSubscription(getClient, t)),
)
+ discussions := toolsets.NewToolset("discussions", "GitHub Discussions related tools").
+ AddReadTools(
+ toolsets.NewServerTool(ListDiscussions(getGQLClient, t)),
+ toolsets.NewServerTool(GetDiscussion(getGQLClient, t)),
+ toolsets.NewServerTool(GetDiscussionComments(getGQLClient, t)),
+ toolsets.NewServerTool(ListDiscussionCategories(getGQLClient, t)),
+ )
+
actions := toolsets.NewToolset("actions", "GitHub Actions workflows and CI/CD operations").
AddReadTools(
toolsets.NewServerTool(ListWorkflows(getClient, t)),
@@ -154,8 +167,10 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
tsg.AddToolset(actions)
tsg.AddToolset(codeSecurity)
tsg.AddToolset(secretProtection)
+ tsg.AddToolset(dependabot)
tsg.AddToolset(notifications)
tsg.AddToolset(experiments)
+ tsg.AddToolset(discussions)
return tsg
}
@@ -179,3 +194,12 @@ func InitDynamicToolset(s *server.MCPServer, tsg *toolsets.ToolsetGroup, t trans
func ToBoolPtr(b bool) *bool {
return &b
}
+
+// ToStringPtr converts a string to a *string pointer.
+// Returns nil if the string is empty.
+func ToStringPtr(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
diff --git a/script/get-discussions b/script/get-discussions
new file mode 100755
index 000000000..3e68abf24
--- /dev/null
+++ b/script/get-discussions
@@ -0,0 +1,5 @@
+#!/bin/bash
+
+# echo '{"jsonrpc":"2.0","id":3,"params":{"name":"list_discussions","arguments": {"owner": "github", "repo": "securitylab", "first": 10, "since": "2025-04-01T00:00:00Z"}},"method":"tools/call"}' | go run cmd/github-mcp-server/main.go stdio | jq .
+echo '{"jsonrpc":"2.0","id":3,"params":{"name":"list_discussions","arguments": {"owner": "github", "repo": "securitylab", "first": 10, "since": "2025-04-01T00:00:00Z", "sort": "CREATED_AT", "direction": "DESC"}},"method":"tools/call"}' | go run cmd/github-mcp-server/main.go stdio | jq .
+
diff --git a/script/lint b/script/lint
index 58884e3a0..e6ea9da89 100755
--- a/script/lint
+++ b/script/lint
@@ -7,7 +7,6 @@ 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
diff --git a/script/test b/script/test
new file mode 100755
index 000000000..7f0dd0c20
--- /dev/null
+++ b/script/test
@@ -0,0 +1,3 @@
+set -eu
+
+go test -race ./...
\ No newline at end of file
pFad - Phonifier reborn
Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy