diff --git a/README.md b/README.md
index 1e37bc0e1..02f6902c3 100644
--- a/README.md
+++ b/README.md
@@ -406,6 +406,7 @@ The following sets of tools are available:
| `orgs` | GitHub Organization related tools |
| `projects` | GitHub Projects related tools |
| `pull_requests` | GitHub Pull Request related tools |
+| `releases` | GitHub Releases related tools |
| `repos` | GitHub Repository related tools |
| `secret_protection` | Secret protection related tools, such as GitHub Secret Scanning |
| `security_advisories` | Security advisories related tools |
@@ -962,23 +963,38 @@ Possible options:
+Releases
+
+- **release_read** - Read operations for releases
+ - `method`: The read operation to perform on releases. (string, required)
+ - `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `repo`: Repository name (string, required)
+ - `tag`: Tag name (required for get_by_tag method) (string, optional)
+
+
+
+
+
Repositories
+- **commit_read** - Read commits
+ - `author`: For 'list' method: Author username or email address to filter commits by (string, optional)
+ - `include_diff`: For 'get' method: Whether to include file diffs and stats in the response. Default is true. (boolean, optional)
+ - `method`: Method to use: 'get' for getting a single commit, 'list' for listing commits (string, required)
+ - `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `repo`: Repository name (string, required)
+ - `sha`: For 'get': Commit SHA, branch name, or tag name (required). For 'list': Commit SHA, branch or tag name to list commits of (optional). (string, optional)
+
- **create_branch** - Create branch
- `branch`: Name for new branch (string, required)
- `from_branch`: Source branch (defaults to repo default) (string, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
-- **create_or_update_file** - Create or update file
- - `branch`: Branch to create/update the file in (string, required)
- - `content`: Content of the file (string, required)
- - `message`: Commit message (string, required)
- - `owner`: Repository owner (username or organization) (string, required)
- - `path`: Path where to create/update the file (string, required)
- - `repo`: Repository name (string, required)
- - `sha`: Required if updating an existing file. The blob SHA of the file being replaced. (string, optional)
-
- **create_repository** - Create repository
- `autoInit`: Initialize with README (boolean, optional)
- `description`: Repository description (string, optional)
@@ -986,26 +1002,22 @@ Possible options:
- `organization`: Organization to create the repository in (omit to create in your personal account) (string, optional)
- `private`: Whether repo should be private (boolean, optional)
-- **delete_file** - Delete file
- - `branch`: Branch to delete the file from (string, required)
+- **file_write** - Write operations (create, update, delete, push_files) on repository files
+ - `branch`: Branch to perform the operation on (string, required)
+ - `content`: Content of the file (required for create and update methods) (string, optional)
+ - `files`: Array of file objects to push (required for push_files method), each object with path (string) and content (string) (object[], optional)
- `message`: Commit message (string, required)
+ - `method`: The write operation to perform on repository files. (string, required)
- `owner`: Repository owner (username or organization) (string, required)
- - `path`: Path to the file to delete (string, required)
+ - `path`: Path to the file (required for create, update, delete methods) (string, optional)
- `repo`: Repository name (string, required)
+ - `sha`: Blob SHA of the file being replaced (required for update method) (string, optional)
- **fork_repository** - Fork repository
- `organization`: Organization to fork to (string, optional)
- `owner`: Repository owner (string, required)
- `repo`: Repository name (string, required)
-- **get_commit** - Get commit details
- - `include_diff`: Whether to include file diffs and stats in the response. Default is true. (boolean, optional)
- - `owner`: Repository owner (string, required)
- - `page`: Page number for pagination (min 1) (number, optional)
- - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- - `repo`: Repository name (string, required)
- - `sha`: Commit SHA, branch name, or tag name (string, required)
-
- **get_file_contents** - Get file or directory contents
- `owner`: Repository owner (username or organization) (string, required)
- `path`: Path to file/directory (directories must end with a slash '/') (string, optional)
@@ -1013,53 +1025,12 @@ Possible options:
- `repo`: Repository name (string, required)
- `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional)
-- **get_latest_release** - Get latest release
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
-
-- **get_release_by_tag** - Get a release by tag name
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `tag`: Tag name (e.g., 'v1.0.0') (string, required)
-
-- **get_tag** - Get tag details
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
- - `tag`: Tag name (string, required)
-
- **list_branches** - List branches
- `owner`: Repository owner (string, required)
- `page`: Page number for pagination (min 1) (number, optional)
- `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- `repo`: Repository name (string, required)
-- **list_commits** - List commits
- - `author`: Author username or email address to filter commits by (string, optional)
- - `owner`: Repository owner (string, required)
- - `page`: Page number for pagination (min 1) (number, optional)
- - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- - `repo`: Repository name (string, required)
- - `sha`: Commit SHA, branch 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_releases** - List releases
- - `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)
-
-- **list_tags** - List tags
- - `owner`: Repository owner (string, required)
- - `page`: Page number for pagination (min 1) (number, optional)
- - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
- - `repo`: Repository name (string, required)
-
-- **push_files** - Push files to repository
- - `branch`: Branch to push to (string, required)
- - `files`: Array of file objects to push, each object with path (string) and content (string) (object[], required)
- - `message`: Commit message (string, required)
- - `owner`: Repository owner (string, required)
- - `repo`: Repository name (string, required)
-
- **search_code** - Search code
- `order`: Sort order for results (string, optional)
- `page`: Page number for pagination (min 1) (number, optional)
@@ -1075,6 +1046,14 @@ Possible options:
- `query`: Repository search query. Examples: 'machine learning in:name stars:>1000 language:python', 'topic:react', 'user:facebook'. Supports advanced search syntax for precise filtering. (string, required)
- `sort`: Sort repositories by field, defaults to best match (string, optional)
+- **tag_read** - Read operations for git tags
+ - `method`: The read operation to perform on tags. (string, required)
+ - `owner`: Repository owner (string, required)
+ - `page`: Page number for pagination (min 1) (number, optional)
+ - `perPage`: Results per page for pagination (min 1, max 100) (number, optional)
+ - `repo`: Repository name (string, required)
+ - `tag`: Tag name (required for get method) (string, optional)
+
diff --git a/docs/remote-server.md b/docs/remote-server.md
index fa55168e5..9726469c0 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -32,6 +32,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
| Organizations | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) |
| Projects | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) |
| Pull Requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) |
+| Releases | GitHub Releases related tools | https://api.githubcopilot.com/mcp/x/releases | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-releases&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Freleases%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/releases/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-releases&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Freleases%2Freadonly%22%7D) |
| Repositories | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) |
| Secret Protection | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) |
| Security Advisories | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) |
diff --git a/pkg/github/__toolsnaps__/commit_read.snap b/pkg/github/__toolsnaps__/commit_read.snap
new file mode 100644
index 000000000..5a8ef0196
--- /dev/null
+++ b/pkg/github/__toolsnaps__/commit_read.snap
@@ -0,0 +1,58 @@
+{
+ "annotations": {
+ "title": "Read commits",
+ "readOnlyHint": true
+ },
+ "description": "Read commit data from a GitHub repository. Supports getting a single commit or listing commits.",
+ "inputSchema": {
+ "properties": {
+ "author": {
+ "description": "For 'list' method: Author username or email address to filter commits by",
+ "type": "string"
+ },
+ "include_diff": {
+ "default": true,
+ "description": "For 'get' method: Whether to include file diffs and stats in the response. Default is true.",
+ "type": "boolean"
+ },
+ "method": {
+ "description": "Method to use: 'get' for getting a single commit, 'list' for listing commits",
+ "enum": [
+ "get",
+ "list"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner",
+ "type": "string"
+ },
+ "page": {
+ "description": "Page number for pagination (min 1)",
+ "minimum": 1,
+ "type": "number"
+ },
+ "perPage": {
+ "description": "Results per page for pagination (min 1, max 100)",
+ "maximum": 100,
+ "minimum": 1,
+ "type": "number"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "sha": {
+ "description": "For 'get': Commit SHA, branch name, or tag name (required). For 'list': Commit SHA, branch or tag name to list commits of (optional).",
+ "type": "string"
+ }
+ },
+ "required": [
+ "method",
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "commit_read"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap
deleted file mode 100644
index 61adef72c..000000000
--- a/pkg/github/__toolsnaps__/create_or_update_file.snap
+++ /dev/null
@@ -1,49 +0,0 @@
-{
- "annotations": {
- "title": "Create or update file",
- "readOnlyHint": false
- },
- "description": "Create or update a single file in a GitHub repository. If updating, you must provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.",
- "inputSchema": {
- "properties": {
- "branch": {
- "description": "Branch to create/update the file in",
- "type": "string"
- },
- "content": {
- "description": "Content of the file",
- "type": "string"
- },
- "message": {
- "description": "Commit message",
- "type": "string"
- },
- "owner": {
- "description": "Repository owner (username or organization)",
- "type": "string"
- },
- "path": {
- "description": "Path where to create/update the file",
- "type": "string"
- },
- "repo": {
- "description": "Repository name",
- "type": "string"
- },
- "sha": {
- "description": "Required if updating an existing file. The blob SHA of the file being replaced.",
- "type": "string"
- }
- },
- "required": [
- "owner",
- "repo",
- "path",
- "content",
- "message",
- "branch"
- ],
- "type": "object"
- },
- "name": "create_or_update_file"
-}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/delete_file.snap b/pkg/github/__toolsnaps__/delete_file.snap
deleted file mode 100644
index 2588ea5c5..000000000
--- a/pkg/github/__toolsnaps__/delete_file.snap
+++ /dev/null
@@ -1,41 +0,0 @@
-{
- "annotations": {
- "title": "Delete file",
- "readOnlyHint": false,
- "destructiveHint": true
- },
- "description": "Delete a file from a GitHub repository",
- "inputSchema": {
- "properties": {
- "branch": {
- "description": "Branch to delete the file from",
- "type": "string"
- },
- "message": {
- "description": "Commit message",
- "type": "string"
- },
- "owner": {
- "description": "Repository owner (username or organization)",
- "type": "string"
- },
- "path": {
- "description": "Path to the file to delete",
- "type": "string"
- },
- "repo": {
- "description": "Repository name",
- "type": "string"
- }
- },
- "required": [
- "owner",
- "repo",
- "path",
- "message",
- "branch"
- ],
- "type": "object"
- },
- "name": "delete_file"
-}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/file_write.snap b/pkg/github/__toolsnaps__/file_write.snap
new file mode 100644
index 000000000..7e3b93b3f
--- /dev/null
+++ b/pkg/github/__toolsnaps__/file_write.snap
@@ -0,0 +1,80 @@
+{
+ "annotations": {
+ "title": "Write operations (create, update, delete, push_files) on repository files",
+ "readOnlyHint": false
+ },
+ "description": "Write operations (create, update, delete, push_files) on repository files.\n\nAvailable methods:\n- create: Create a new file in a repository.\n- update: Update an existing file in a repository. Requires the SHA of the file being replaced.\n- delete: Delete a file from a repository.\n- push_files: Push multiple files to a repository in a single commit.\n",
+ "inputSchema": {
+ "properties": {
+ "branch": {
+ "description": "Branch to perform the operation on",
+ "type": "string"
+ },
+ "content": {
+ "description": "Content of the file (required for create and update methods)",
+ "type": "string"
+ },
+ "files": {
+ "description": "Array of file objects to push (required for push_files method), each object with path (string) and content (string)",
+ "items": {
+ "additionalProperties": false,
+ "properties": {
+ "content": {
+ "description": "file content",
+ "type": "string"
+ },
+ "path": {
+ "description": "path to the file",
+ "type": "string"
+ }
+ },
+ "required": [
+ "path",
+ "content"
+ ],
+ "type": "object"
+ },
+ "type": "array"
+ },
+ "message": {
+ "description": "Commit message",
+ "type": "string"
+ },
+ "method": {
+ "description": "The write operation to perform on repository files.",
+ "enum": [
+ "create",
+ "update",
+ "delete",
+ "push_files"
+ ],
+ "type": "string"
+ },
+ "owner": {
+ "description": "Repository owner (username or organization)",
+ "type": "string"
+ },
+ "path": {
+ "description": "Path to the file (required for create, update, delete methods)",
+ "type": "string"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "sha": {
+ "description": "Blob SHA of the file being replaced (required for update method)",
+ "type": "string"
+ }
+ },
+ "required": [
+ "method",
+ "owner",
+ "repo",
+ "branch",
+ "message"
+ ],
+ "type": "object"
+ },
+ "name": "file_write"
+}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_release_by_tag.snap b/pkg/github/__toolsnaps__/get_release_by_tag.snap
deleted file mode 100644
index c96d3c30a..000000000
--- a/pkg/github/__toolsnaps__/get_release_by_tag.snap
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- "annotations": {
- "title": "Get a release by tag name",
- "readOnlyHint": true
- },
- "description": "Get a specific release by its tag name in a GitHub repository",
- "inputSchema": {
- "properties": {
- "owner": {
- "description": "Repository owner",
- "type": "string"
- },
- "repo": {
- "description": "Repository name",
- "type": "string"
- },
- "tag": {
- "description": "Tag name (e.g., 'v1.0.0')",
- "type": "string"
- }
- },
- "required": [
- "owner",
- "repo",
- "tag"
- ],
- "type": "object"
- },
- "name": "get_release_by_tag"
-}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_tag.snap b/pkg/github/__toolsnaps__/get_tag.snap
deleted file mode 100644
index 42089f872..000000000
--- a/pkg/github/__toolsnaps__/get_tag.snap
+++ /dev/null
@@ -1,30 +0,0 @@
-{
- "annotations": {
- "title": "Get tag details",
- "readOnlyHint": true
- },
- "description": "Get details about a specific git tag in a GitHub repository",
- "inputSchema": {
- "properties": {
- "owner": {
- "description": "Repository owner",
- "type": "string"
- },
- "repo": {
- "description": "Repository name",
- "type": "string"
- },
- "tag": {
- "description": "Tag name",
- "type": "string"
- }
- },
- "required": [
- "owner",
- "repo",
- "tag"
- ],
- "type": "object"
- },
- "name": "get_tag"
-}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_tags.snap b/pkg/github/__toolsnaps__/list_tags.snap
deleted file mode 100644
index fcb9853fd..000000000
--- a/pkg/github/__toolsnaps__/list_tags.snap
+++ /dev/null
@@ -1,36 +0,0 @@
-{
- "annotations": {
- "title": "List tags",
- "readOnlyHint": true
- },
- "description": "List git tags in a GitHub repository",
- "inputSchema": {
- "properties": {
- "owner": {
- "description": "Repository owner",
- "type": "string"
- },
- "page": {
- "description": "Page number for pagination (min 1)",
- "minimum": 1,
- "type": "number"
- },
- "perPage": {
- "description": "Results per page for pagination (min 1, max 100)",
- "maximum": 100,
- "minimum": 1,
- "type": "number"
- },
- "repo": {
- "description": "Repository name",
- "type": "string"
- }
- },
- "required": [
- "owner",
- "repo"
- ],
- "type": "object"
- },
- "name": "list_tags"
-}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/push_files.snap b/pkg/github/__toolsnaps__/push_files.snap
deleted file mode 100644
index 3ade75eeb..000000000
--- a/pkg/github/__toolsnaps__/push_files.snap
+++ /dev/null
@@ -1,58 +0,0 @@
-{
- "annotations": {
- "title": "Push files to repository",
- "readOnlyHint": false
- },
- "description": "Push multiple files to a GitHub repository in a single commit",
- "inputSchema": {
- "properties": {
- "branch": {
- "description": "Branch to push to",
- "type": "string"
- },
- "files": {
- "description": "Array of file objects to push, each object with path (string) and content (string)",
- "items": {
- "additionalProperties": false,
- "properties": {
- "content": {
- "description": "file content",
- "type": "string"
- },
- "path": {
- "description": "path to the file",
- "type": "string"
- }
- },
- "required": [
- "path",
- "content"
- ],
- "type": "object"
- },
- "type": "array"
- },
- "message": {
- "description": "Commit message",
- "type": "string"
- },
- "owner": {
- "description": "Repository owner",
- "type": "string"
- },
- "repo": {
- "description": "Repository name",
- "type": "string"
- }
- },
- "required": [
- "owner",
- "repo",
- "branch",
- "files",
- "message"
- ],
- "type": "object"
- },
- "name": "push_files"
-}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/release_read.snap
similarity index 55%
rename from pkg/github/__toolsnaps__/list_commits.snap
rename to pkg/github/__toolsnaps__/release_read.snap
index a802436c2..7251156e4 100644
--- a/pkg/github/__toolsnaps__/list_commits.snap
+++ b/pkg/github/__toolsnaps__/release_read.snap
@@ -1,13 +1,18 @@
{
"annotations": {
- "title": "List commits",
+ "title": "Read operations for releases",
"readOnlyHint": true
},
- "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).",
+ "description": "Read operations for GitHub releases in a repository.\n\nAvailable methods:\n- list: List all releases in a repository.\n- get_latest: Get the latest release in a repository.\n- get_by_tag: Get a specific release by its tag name.\n",
"inputSchema": {
"properties": {
- "author": {
- "description": "Author username or email address to filter commits by",
+ "method": {
+ "description": "The read operation to perform on releases.",
+ "enum": [
+ "list",
+ "get_latest",
+ "get_by_tag"
+ ],
"type": "string"
},
"owner": {
@@ -29,16 +34,17 @@
"description": "Repository name",
"type": "string"
},
- "sha": {
- "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.",
+ "tag": {
+ "description": "Tag name (required for get_by_tag method)",
"type": "string"
}
},
"required": [
+ "method",
"owner",
"repo"
],
"type": "object"
},
- "name": "list_commits"
+ "name": "release_read"
}
\ No newline at end of file
diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/tag_read.snap
similarity index 57%
rename from pkg/github/__toolsnaps__/get_commit.snap
rename to pkg/github/__toolsnaps__/tag_read.snap
index 1c2ecc9a3..b78dff42d 100644
--- a/pkg/github/__toolsnaps__/get_commit.snap
+++ b/pkg/github/__toolsnaps__/tag_read.snap
@@ -1,15 +1,18 @@
{
"annotations": {
- "title": "Get commit details",
+ "title": "Read operations for git tags",
"readOnlyHint": true
},
- "description": "Get details for a commit from a GitHub repository",
+ "description": "Read operations for Git tags in a repository.\n\nAvailable methods:\n- list: List all git tags in a repository.\n- get: Get details about a specific git tag.\n",
"inputSchema": {
"properties": {
- "include_diff": {
- "default": true,
- "description": "Whether to include file diffs and stats in the response. Default is true.",
- "type": "boolean"
+ "method": {
+ "description": "The read operation to perform on tags.",
+ "enum": [
+ "list",
+ "get"
+ ],
+ "type": "string"
},
"owner": {
"description": "Repository owner",
@@ -30,17 +33,17 @@
"description": "Repository name",
"type": "string"
},
- "sha": {
- "description": "Commit SHA, branch name, or tag name",
+ "tag": {
+ "description": "Tag name (required for get method)",
"type": "string"
}
},
"required": [
+ "method",
"owner",
- "repo",
- "sha"
+ "repo"
],
"type": "object"
},
- "name": "get_commit"
+ "name": "tag_read"
}
\ No newline at end of file
diff --git a/pkg/github/releases.go b/pkg/github/releases.go
new file mode 100644
index 000000000..e1c7635ed
--- /dev/null
+++ b/pkg/github/releases.go
@@ -0,0 +1,180 @@
+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/v74/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+// ReleaseRead creates a tool for reading GitHub releases in a repository.
+// Supports multiple methods: list, get_latest, and get_by_tag.
+func ReleaseRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("release_read",
+ mcp.WithDescription(t("TOOL_RELEASE_READ_DESCRIPTION", `Read operations for GitHub releases in a repository.
+
+Available methods:
+- list: List all releases in a repository.
+- get_latest: Get the latest release in a repository.
+- get_by_tag: Get a specific release by its tag name.
+`)),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_RELEASE_READ_USER_TITLE", "Read operations for releases"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("method",
+ mcp.Required(),
+ mcp.Enum("list", "get_latest", "get_by_tag"),
+ mcp.Description("The read operation to perform on releases."),
+ ),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ mcp.WithString("tag",
+ mcp.Description("Tag name (required for get_by_tag method)"),
+ ),
+ WithPagination(),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ method, err := RequiredParam[string](request, "method")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ 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
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ switch method {
+ case "list":
+ return ListReleasesMethod(ctx, client, owner, repo, request)
+ case "get_latest":
+ return GetLatestReleaseMethod(ctx, client, owner, repo)
+ case "get_by_tag":
+ return GetReleaseByTagMethod(ctx, client, owner, repo, request)
+ default:
+ return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil
+ }
+ }
+}
+
+// ListReleasesMethod handles the "list" method for ReleaseRead
+func ListReleasesMethod(ctx context.Context, client *github.Client, owner, repo string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ opts := &github.ListOptions{
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
+ }
+
+ releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list releases",
+ 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 releases: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(releases)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+// GetLatestReleaseMethod handles the "get_latest" method for ReleaseRead
+func GetLatestReleaseMethod(ctx context.Context, client *github.Client, owner, repo string) (*mcp.CallToolResult, error) {
+ release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get latest release",
+ 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 latest release: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(release)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+// GetReleaseByTagMethod handles the "get_by_tag" method for ReleaseRead
+func GetReleaseByTagMethod(ctx context.Context, client *github.Client, owner, repo string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ tag, err := RequiredParam[string](request, "tag")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to get release by tag: %s", tag),
+ 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 release by tag: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(release)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
diff --git a/pkg/github/releases_test.go b/pkg/github/releases_test.go
new file mode 100644
index 000000000..5920b2364
--- /dev/null
+++ b/pkg/github/releases_test.go
@@ -0,0 +1,253 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/github/github-mcp-server/internal/toolsnaps"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v74/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/migueleliasweb/go-github-mock/src/mock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_ReleaseRead(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ReleaseRead(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "release_read", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "method")
+ assert.Contains(t, tool.InputSchema.Properties, "owner")
+ assert.Contains(t, tool.InputSchema.Properties, "repo")
+ assert.Contains(t, tool.InputSchema.Properties, "tag")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo"})
+
+ // Setup mock releases
+ createdAt := time.Now().Add(-30 * 24 * time.Hour)
+ publishedAt := time.Now().Add(-29 * 24 * time.Hour)
+
+ mockReleases := []*github.RepositoryRelease{
+ {
+ ID: github.Ptr(int64(1)),
+ TagName: github.Ptr("v1.0.0"),
+ Name: github.Ptr("Version 1.0.0"),
+ Body: github.Ptr("First stable release"),
+ Draft: github.Ptr(false),
+ Prerelease: github.Ptr(false),
+ CreatedAt: &github.Timestamp{Time: createdAt},
+ PublishedAt: &github.Timestamp{Time: publishedAt},
+ HTMLURL: github.Ptr("https://github.com/owner/repo/releases/tag/v1.0.0"),
+ },
+ {
+ ID: github.Ptr(int64(2)),
+ TagName: github.Ptr("v0.9.0"),
+ Name: github.Ptr("Version 0.9.0 Beta"),
+ Body: github.Ptr("Beta release"),
+ Draft: github.Ptr(false),
+ Prerelease: github.Ptr(true),
+ CreatedAt: &github.Timestamp{Time: createdAt.Add(-7 * 24 * time.Hour)},
+ PublishedAt: &github.Timestamp{Time: publishedAt.Add(-7 * 24 * time.Hour)},
+ HTMLURL: github.Ptr("https://github.com/owner/repo/releases/tag/v0.9.0"),
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ validateResult func(t *testing.T, result *mcp.CallToolResult)
+ }{
+ {
+ name: "list releases successfully",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposReleasesByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(mock.MustMarshal(mockReleases))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "method": "list",
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ validateResult: func(t *testing.T, result *mcp.CallToolResult) {
+ textContent := getTextResult(t, result)
+ var releases []*github.RepositoryRelease
+ err := json.Unmarshal([]byte(textContent.Text), &releases)
+ require.NoError(t, err)
+ assert.Len(t, releases, 2)
+ assert.Equal(t, "v1.0.0", *releases[0].TagName)
+ assert.Equal(t, "v0.9.0", *releases[1].TagName)
+ },
+ },
+ {
+ name: "list releases fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposReleasesByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "method": "list",
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to list releases",
+ },
+ {
+ name: "get latest release successfully",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposReleasesLatestByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(mock.MustMarshal(mockReleases[0]))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "method": "get_latest",
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: false,
+ validateResult: func(t *testing.T, result *mcp.CallToolResult) {
+ textContent := getTextResult(t, result)
+ var release github.RepositoryRelease
+ err := json.Unmarshal([]byte(textContent.Text), &release)
+ require.NoError(t, err)
+ assert.Equal(t, "v1.0.0", *release.TagName)
+ assert.Equal(t, "Version 1.0.0", *release.Name)
+ },
+ },
+ {
+ name: "get latest release fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposReleasesLatestByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "method": "get_latest",
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to get latest release",
+ },
+ {
+ name: "get release by tag successfully",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposReleasesTagsByOwnerByRepoByTag,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(mock.MustMarshal(mockReleases[1]))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "method": "get_by_tag",
+ "owner": "owner",
+ "repo": "repo",
+ "tag": "v0.9.0",
+ },
+ expectError: false,
+ validateResult: func(t *testing.T, result *mcp.CallToolResult) {
+ textContent := getTextResult(t, result)
+ var release github.RepositoryRelease
+ err := json.Unmarshal([]byte(textContent.Text), &release)
+ require.NoError(t, err)
+ assert.Equal(t, "v0.9.0", *release.TagName)
+ assert.Equal(t, "Version 0.9.0 Beta", *release.Name)
+ assert.True(t, *release.Prerelease)
+ },
+ },
+ {
+ name: "get release by tag fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposReleasesTagsByOwnerByRepoByTag,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "method": "get_by_tag",
+ "owner": "owner",
+ "repo": "repo",
+ "tag": "nonexistent",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to get release by tag",
+ },
+ {
+ name: "get release by tag missing tag parameter",
+ mockedClient: mock.NewMockedHTTPClient(),
+ requestArgs: map[string]interface{}{
+ "method": "get_by_tag",
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "tag",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ReleaseRead(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)
+
+ if tc.validateResult != nil {
+ tc.validateResult(t, result)
+ }
+ })
+ }
+}
diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go
index 7ffc5fc0c..1954cb273 100644
--- a/pkg/github/repositories.go
+++ b/pkg/github/repositories.go
@@ -18,13 +18,20 @@ import (
"github.com/mark3labs/mcp-go/server"
)
-func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("get_commit",
- mcp.WithDescription(t("TOOL_GET_COMMITS_DESCRIPTION", "Get details for a commit from a GitHub repository")),
+// CommitRead creates a consolidated tool for reading commit data from a GitHub repository.
+// Supports multiple methods: get (get commit details) and list (list commits).
+func CommitRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("commit_read",
+ mcp.WithDescription(t("TOOL_COMMIT_READ_DESCRIPTION", "Read commit data from a GitHub repository. Supports getting a single commit or listing commits.")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_GET_COMMITS_USER_TITLE", "Get commit details"),
+ Title: t("TOOL_COMMIT_READ_USER_TITLE", "Read commits"),
ReadOnlyHint: ToBoolPtr(true),
}),
+ mcp.WithString("method",
+ mcp.Required(),
+ mcp.Enum("get", "list"),
+ mcp.Description("Method to use: 'get' for getting a single commit, 'list' for listing commits"),
+ ),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -34,170 +41,155 @@ func GetCommit(getClient GetClientFn, t translations.TranslationHelperFunc) (too
mcp.Description("Repository name"),
),
mcp.WithString("sha",
- mcp.Required(),
- mcp.Description("Commit SHA, branch name, or tag name"),
+ mcp.Description("For 'get': Commit SHA, branch name, or tag name (required). For 'list': Commit SHA, branch or tag name to list commits of (optional)."),
),
mcp.WithBoolean("include_diff",
- mcp.Description("Whether to include file diffs and stats in the response. Default is true."),
+ mcp.Description("For 'get' method: Whether to include file diffs and stats in the response. Default is true."),
mcp.DefaultBool(true),
),
+ mcp.WithString("author",
+ mcp.Description("For 'list' method: Author username or email address to filter commits by"),
+ ),
WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](request, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- repo, err := RequiredParam[string](request, "repo")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- sha, err := RequiredParam[string](request, "sha")
+ method, err := RequiredParam[string](request, "method")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- includeDiff, err := OptionalBoolParamWithDefault(request, "include_diff", true)
+
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- pagination, err := OptionalPaginationParams(request)
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- opts := &github.ListOptions{
- Page: pagination.Page,
- PerPage: pagination.PerPage,
- }
-
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- commit, resp, err := client.Repositories.GetCommit(ctx, owner, repo, sha, opts)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- fmt.Sprintf("failed to get commit: %s", sha),
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
- if resp.StatusCode != 200 {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to get commit: %s", string(body))), nil
+ switch method {
+ case "get":
+ return GetCommitMethod(ctx, client, owner, repo, request)
+ case "list":
+ return ListCommitsMethod(ctx, client, owner, repo, request)
+ default:
+ return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil
}
+ }
+}
- // Convert to minimal commit
- minimalCommit := convertToMinimalCommit(commit, includeDiff)
+// GetCommitMethod handles the "get" method for CommitRead
+func GetCommitMethod(ctx context.Context, client *github.Client, owner, repo string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ sha, err := RequiredParam[string](request, "sha")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ includeDiff, err := OptionalBoolParamWithDefault(request, "include_diff", true)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- r, err := json.Marshal(minimalCommit)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
+ opts := &github.ListOptions{
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
+ }
- return mcp.NewToolResultText(string(r)), nil
+ commit, resp, err := client.Repositories.GetCommit(ctx, owner, repo, sha, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to get commit: %s", sha),
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != 200 {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
}
-}
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get commit: %s", string(body))), nil
+ }
-// 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. 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),
- }),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("Repository owner"),
- ),
- mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
- ),
- mcp.WithString("sha",
- 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 to filter commits by"),
- ),
- WithPagination(),
- ),
- func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](request, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- repo, err := RequiredParam[string](request, "repo")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- sha, err := OptionalParam[string](request, "sha")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- author, err := OptionalParam[string](request, "author")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- pagination, err := OptionalPaginationParams(request)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- // 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: perPage,
- },
- }
+ // Convert to minimal commit
+ minimalCommit := convertToMinimalCommit(commit, includeDiff)
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
- commits, resp, err := client.Repositories.ListCommits(ctx, owner, repo, opts)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- fmt.Sprintf("failed to list commits: %s", sha),
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
+ r, err := json.Marshal(minimalCommit)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
- if resp.StatusCode != 200 {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to list commits: %s", string(body))), nil
- }
+ return mcp.NewToolResultText(string(r)), nil
+}
- // Convert to minimal commits
- minimalCommits := make([]MinimalCommit, len(commits))
- for i, commit := range commits {
- minimalCommits[i] = convertToMinimalCommit(commit, false)
- }
+// ListCommitsMethod handles the "list" method for CommitRead
+func ListCommitsMethod(ctx context.Context, client *github.Client, owner, repo string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ sha, err := OptionalParam[string](request, "sha")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ author, err := OptionalParam[string](request, "author")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ // 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: perPage,
+ },
+ }
- r, err := json.Marshal(minimalCommits)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
+ commits, resp, err := client.Repositories.ListCommits(ctx, owner, repo, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to list commits: %s", sha),
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
- return mcp.NewToolResultText(string(r)), nil
+ if resp.StatusCode != 200 {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
}
+ return mcp.NewToolResultError(fmt.Sprintf("failed to list commits: %s", string(body))), nil
+ }
+
+ // Convert to minimal commits
+ minimalCommits := make([]MinimalCommit, len(commits))
+ for i, commit := range commits {
+ minimalCommits[i] = convertToMinimalCommit(commit, false)
+ }
+
+ r, err := json.Marshal(minimalCommits)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
}
// ListBranches creates a tool to list branches in a GitHub repository.
@@ -277,119 +269,6 @@ func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (
}
}
-// CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository.
-func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("create_or_update_file",
- mcp.WithDescription(t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository. If updating, you must provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"),
- ReadOnlyHint: ToBoolPtr(false),
- }),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("Repository owner (username or organization)"),
- ),
- mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
- ),
- mcp.WithString("path",
- mcp.Required(),
- mcp.Description("Path where to create/update the file"),
- ),
- mcp.WithString("content",
- mcp.Required(),
- mcp.Description("Content of the file"),
- ),
- mcp.WithString("message",
- mcp.Required(),
- mcp.Description("Commit message"),
- ),
- mcp.WithString("branch",
- mcp.Required(),
- mcp.Description("Branch to create/update the file in"),
- ),
- mcp.WithString("sha",
- 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) {
- owner, err := RequiredParam[string](request, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- repo, err := RequiredParam[string](request, "repo")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- path, err := RequiredParam[string](request, "path")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- content, err := RequiredParam[string](request, "content")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- message, err := RequiredParam[string](request, "message")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- branch, err := RequiredParam[string](request, "branch")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- // json.Marshal encodes byte arrays with base64, which is required for the API.
- contentBytes := []byte(content)
-
- // Create the file options
- opts := &github.RepositoryContentFileOptions{
- Message: github.Ptr(message),
- Content: contentBytes,
- Branch: github.Ptr(branch),
- }
-
- // If SHA is provided, set it (for updates)
- sha, err := OptionalParam[string](request, "sha")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- if sha != "" {
- opts.SHA = github.Ptr(sha)
- }
-
- // Create or update the file
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
- fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to create/update file",
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != 200 && resp.StatusCode != 201 {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to create/update file: %s", string(body))), nil
- }
-
- r, err := json.Marshal(fileContent)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
- }
-}
-
// CreateRepository creates a tool to create a new GitHub repository.
func CreateRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
return mcp.NewTool("create_repository",
@@ -767,39 +646,28 @@ func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc)
}
}
-// DeleteFile creates a tool to delete a file in a GitHub repository.
-// This tool uses a more roundabout way of deleting a file than just using the client.Repositories.DeleteFile.
-// This is because REST file deletion endpoint (and client.Repositories.DeleteFile) don't add commit signing to the deletion commit,
-// unlike how the endpoint backing the create_or_update_files tool does. This appears to be a quirk of the API.
-// The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app,
-// both of which suit an LLM well.
-func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("delete_file",
- mcp.WithDescription(t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository")),
+// CreateBranch creates a tool to create a new branch.
+func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("create_branch",
+ mcp.WithDescription(t("TOOL_CREATE_BRANCH_DESCRIPTION", "Create a new branch in a GitHub repository")),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"),
- ReadOnlyHint: ToBoolPtr(false),
- DestructiveHint: ToBoolPtr(true),
+ Title: t("TOOL_CREATE_BRANCH_USER_TITLE", "Create branch"),
+ ReadOnlyHint: ToBoolPtr(false),
}),
mcp.WithString("owner",
mcp.Required(),
- mcp.Description("Repository owner (username or organization)"),
+ mcp.Description("Repository owner"),
),
mcp.WithString("repo",
mcp.Required(),
mcp.Description("Repository name"),
),
- mcp.WithString("path",
- mcp.Required(),
- mcp.Description("Path to the file to delete"),
- ),
- mcp.WithString("message",
- mcp.Required(),
- mcp.Description("Commit message"),
- ),
mcp.WithString("branch",
mcp.Required(),
- mcp.Description("Branch to delete the file from"),
+ mcp.Description("Name for new branch"),
+ ),
+ mcp.WithString("from_branch",
+ mcp.Description("Source branch (defaults to repo default)"),
),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
@@ -811,15 +679,11 @@ func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (to
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- path, err := RequiredParam[string](request, "path")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- message, err := RequiredParam[string](request, "message")
+ branch, err := RequiredParam[string](request, "branch")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- branch, err := RequiredParam[string](request, "branch")
+ fromBranch, err := OptionalParam[string](request, "from_branch")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
@@ -829,173 +693,12 @@ func DeleteFile(getClient GetClientFn, t translations.TranslationHelperFunc) (to
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- // Get the reference for the branch
- ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch)
- if err != nil {
- return nil, fmt.Errorf("failed to get branch reference: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
-
- // Get the commit object that the branch points to
- baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to get base commit",
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
+ // Get the source branch SHA
+ var ref *github.Reference
- if resp.StatusCode != http.StatusOK {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to get commit: %s", string(body))), nil
- }
-
- // Create a tree entry for the file deletion by setting SHA to nil
- treeEntries := []*github.TreeEntry{
- {
- Path: github.Ptr(path),
- Mode: github.Ptr("100644"), // Regular file mode
- Type: github.Ptr("blob"),
- SHA: nil, // Setting SHA to nil deletes the file
- },
- }
-
- // Create a new tree with the deletion
- newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, treeEntries)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to create tree",
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusCreated {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to create tree: %s", string(body))), nil
- }
-
- // Create a new commit with the new tree
- commit := &github.Commit{
- Message: github.Ptr(message),
- Tree: newTree,
- Parents: []*github.Commit{{SHA: baseCommit.SHA}},
- }
- newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to create commit",
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
-
- if resp.StatusCode != http.StatusCreated {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to create commit: %s", string(body))), nil
- }
-
- // Update the branch reference to point to the new commit
- ref.Object.SHA = newCommit.SHA
- _, resp, err = client.Git.UpdateRef(ctx, owner, repo, ref, false)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to update reference",
- 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 update reference: %s", string(body))), nil
- }
-
- // Create a response similar to what the DeleteFile API would return
- response := map[string]interface{}{
- "commit": newCommit,
- "content": nil,
- }
-
- r, err := json.Marshal(response)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
- }
-}
-
-// CreateBranch creates a tool to create a new branch.
-func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("create_branch",
- mcp.WithDescription(t("TOOL_CREATE_BRANCH_DESCRIPTION", "Create a new branch in a GitHub repository")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_CREATE_BRANCH_USER_TITLE", "Create branch"),
- ReadOnlyHint: ToBoolPtr(false),
- }),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("Repository owner"),
- ),
- mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
- ),
- mcp.WithString("branch",
- mcp.Required(),
- mcp.Description("Name for new branch"),
- ),
- mcp.WithString("from_branch",
- mcp.Description("Source branch (defaults to repo default)"),
- ),
- ),
- 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
- }
- branch, err := RequiredParam[string](request, "branch")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- fromBranch, err := OptionalParam[string](request, "from_branch")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
-
- // Get the source branch SHA
- var ref *github.Reference
-
- if fromBranch == "" {
- // Get default branch if from_branch not specified
- repository, resp, err := client.Repositories.Get(ctx, owner, repo)
+ if fromBranch == "" {
+ // Get default branch if from_branch not specified
+ repository, resp, err := client.Repositories.Get(ctx, owner, repo)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get repository",
@@ -1044,255 +747,25 @@ func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) (
}
}
-// PushFiles creates a tool to push multiple files in a single commit to a GitHub repository.
-func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("push_files",
- mcp.WithDescription(t("TOOL_PUSH_FILES_DESCRIPTION", "Push multiple files to a GitHub repository in a single commit")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_PUSH_FILES_USER_TITLE", "Push files to repository"),
- ReadOnlyHint: ToBoolPtr(false),
- }),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("Repository owner"),
- ),
- mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
- ),
- mcp.WithString("branch",
- mcp.Required(),
- mcp.Description("Branch to push to"),
- ),
- mcp.WithArray("files",
- mcp.Required(),
- mcp.Items(
- map[string]interface{}{
- "type": "object",
- "additionalProperties": false,
- "required": []string{"path", "content"},
- "properties": map[string]interface{}{
- "path": map[string]interface{}{
- "type": "string",
- "description": "path to the file",
- },
- "content": map[string]interface{}{
- "type": "string",
- "description": "file content",
- },
- },
- }),
- mcp.Description("Array of file objects to push, each object with path (string) and content (string)"),
- ),
- mcp.WithString("message",
- mcp.Required(),
- mcp.Description("Commit message"),
- ),
- ),
- 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
- }
- branch, err := RequiredParam[string](request, "branch")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- message, err := RequiredParam[string](request, "message")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- // Parse files parameter - this should be an array of objects with path and content
- filesObj, ok := request.GetArguments()["files"].([]interface{})
- if !ok {
- return mcp.NewToolResultError("files parameter must be an array of objects with path and content"), nil
- }
-
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
-
- // Get the reference for the branch
- ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to get branch reference",
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
-
- // Get the commit object that the branch points to
- baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to get base commit",
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
-
- // Create tree entries for all files
- var entries []*github.TreeEntry
-
- for _, file := range filesObj {
- fileMap, ok := file.(map[string]interface{})
- if !ok {
- return mcp.NewToolResultError("each file must be an object with path and content"), nil
- }
-
- path, ok := fileMap["path"].(string)
- if !ok || path == "" {
- return mcp.NewToolResultError("each file must have a path"), nil
- }
-
- content, ok := fileMap["content"].(string)
- if !ok {
- return mcp.NewToolResultError("each file must have content"), nil
- }
-
- // Create a tree entry for the file
- entries = append(entries, &github.TreeEntry{
- Path: github.Ptr(path),
- Mode: github.Ptr("100644"), // Regular file mode
- Type: github.Ptr("blob"),
- Content: github.Ptr(content),
- })
- }
-
- // Create a new tree with the file entries
- newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to create tree",
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
-
- // Create a new commit
- commit := &github.Commit{
- Message: github.Ptr(message),
- Tree: newTree,
- Parents: []*github.Commit{{SHA: baseCommit.SHA}},
- }
- newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to create commit",
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
-
- // Update the reference to point to the new commit
- ref.Object.SHA = newCommit.SHA
- updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, ref, false)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to update reference",
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
-
- r, err := json.Marshal(updatedRef)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
+// TagRead creates a tool for reading Git tags in a repository.
+// Supports multiple methods: list and get.
+func TagRead(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("tag_read",
+ mcp.WithDescription(t("TOOL_TAG_READ_DESCRIPTION", `Read operations for Git tags in a repository.
- return mcp.NewToolResultText(string(r)), nil
- }
-}
-
-// ListTags creates a tool to list tags in a GitHub repository.
-func ListTags(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("list_tags",
- mcp.WithDescription(t("TOOL_LIST_TAGS_DESCRIPTION", "List git tags in a GitHub repository")),
+Available methods:
+- list: List all git tags in a repository.
+- get: Get details about a specific git tag.
+`)),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_LIST_TAGS_USER_TITLE", "List tags"),
+ Title: t("TOOL_TAG_READ_USER_TITLE", "Read operations for git tags"),
ReadOnlyHint: ToBoolPtr(true),
}),
- mcp.WithString("owner",
+ mcp.WithString("method",
mcp.Required(),
- mcp.Description("Repository owner"),
+ mcp.Enum("list", "get"),
+ mcp.Description("The read operation to perform on tags."),
),
- mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
- ),
- WithPagination(),
- ),
- func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](request, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- repo, err := RequiredParam[string](request, "repo")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- pagination, err := OptionalPaginationParams(request)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- opts := &github.ListOptions{
- Page: pagination.Page,
- PerPage: pagination.PerPage,
- }
-
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
-
- tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to list tags",
- 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 tags: %s", string(body))), nil
- }
-
- r, err := json.Marshal(tags)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
- }
-}
-
-// GetTag creates a tool to get details about a specific tag in a GitHub repository.
-func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("get_tag",
- mcp.WithDescription(t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_GET_TAG_USER_TITLE", "Get tag details"),
- ReadOnlyHint: ToBoolPtr(true),
- }),
mcp.WithString("owner",
mcp.Required(),
mcp.Description("Repository owner"),
@@ -1302,95 +775,16 @@ func GetTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool m
mcp.Description("Repository name"),
),
mcp.WithString("tag",
- mcp.Required(),
- mcp.Description("Tag name"),
+ mcp.Description("Tag name (required for get method)"),
),
+ WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](request, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- repo, err := RequiredParam[string](request, "repo")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- tag, err := RequiredParam[string](request, "tag")
+ method, err := RequiredParam[string](request, "method")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
-
- // First get the tag reference
- ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+tag)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to get tag reference",
- 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 tag reference: %s", string(body))), nil
- }
-
- // Then get the tag object
- tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- "failed to get tag object",
- 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 tag object: %s", string(body))), nil
- }
-
- r, err := json.Marshal(tagObj)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
- }
-}
-
-// ListReleases creates a tool to list releases in a GitHub repository.
-func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("list_releases",
- mcp.WithDescription(t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"),
- ReadOnlyHint: ToBoolPtr(true),
- }),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("Repository owner"),
- ),
- mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
- ),
- WithPagination(),
- ),
- func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
@@ -1399,163 +793,111 @@ func ListReleases(getClient GetClientFn, t translations.TranslationHelperFunc) (
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- pagination, err := OptionalPaginationParams(request)
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
-
- opts := &github.ListOptions{
- Page: pagination.Page,
- PerPage: pagination.PerPage,
- }
client, err := getClient(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts)
- if err != nil {
- return nil, fmt.Errorf("failed to list releases: %w", err)
+ switch method {
+ case "list":
+ return ListTagsMethod(ctx, client, owner, repo, request)
+ case "get":
+ return GetTagMethod(ctx, client, owner, repo, request)
+ default:
+ return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), 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 releases: %s", string(body))), nil
- }
-
- r, err := json.Marshal(releases)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
-
- return mcp.NewToolResultText(string(r)), nil
}
}
-// GetLatestRelease creates a tool to get the latest release in a GitHub repository.
-func GetLatestRelease(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("get_latest_release",
- mcp.WithDescription(t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"),
- ReadOnlyHint: ToBoolPtr(true),
- }),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("Repository owner"),
- ),
- mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
- ),
- ),
- func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- 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
- }
-
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
-
- release, resp, err := client.Repositories.GetLatestRelease(ctx, owner, repo)
- if err != nil {
- return nil, fmt.Errorf("failed to get latest release: %w", err)
- }
- defer func() { _ = resp.Body.Close() }()
+// ListTagsMethod handles listing tags for a repository
+func ListTagsMethod(ctx context.Context, client *github.Client, owner, repo string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- 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 latest release: %s", string(body))), nil
- }
+ opts := &github.ListOptions{
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
+ }
- r, err := json.Marshal(release)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
+ tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to list tags",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
- return mcp.NewToolResultText(string(r)), nil
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
}
-}
+ return mcp.NewToolResultError(fmt.Sprintf("failed to list tags: %s", string(body))), nil
+ }
-func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("get_release_by_tag",
- mcp.WithDescription(t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"),
- ReadOnlyHint: ToBoolPtr(true),
- }),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("Repository owner"),
- ),
- mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
- ),
- mcp.WithString("tag",
- mcp.Required(),
- mcp.Description("Tag name (e.g., 'v1.0.0')"),
- ),
- ),
- func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- owner, err := RequiredParam[string](request, "owner")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- repo, err := RequiredParam[string](request, "repo")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
- tag, err := RequiredParam[string](request, "tag")
- if err != nil {
- return mcp.NewToolResultError(err.Error()), nil
- }
+ r, err := json.Marshal(tags)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
+ return mcp.NewToolResultText(string(r)), nil
+}
- release, resp, err := client.Repositories.GetReleaseByTag(ctx, owner, repo, tag)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- fmt.Sprintf("failed to get release by tag: %s", tag),
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
+func GetTagMethod(ctx context.Context, client *github.Client, owner, repo string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ tag, err := RequiredParam[string](request, "tag")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- 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 release by tag: %s", string(body))), nil
- }
+ // First get the tag reference
+ ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+tag)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get tag reference",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
- r, err := json.Marshal(release)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal response: %w", err)
- }
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get tag reference: %s", string(body))), nil
+ }
- return mcp.NewToolResultText(string(r)), nil
+ // Then get the tag object
+ tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get tag object",
+ 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 tag object: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(tagObj)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
}
// filterPaths filters the entries in a GitHub tree to find paths that
@@ -1693,56 +1035,92 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner
return &raw.ContentOpts{Ref: ref, SHA: sha}, nil
}
-// ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user.
-func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("list_starred_repositories",
- mcp.WithDescription(t("TOOL_LIST_STARRED_REPOSITORIES_DESCRIPTION", "List starred repositories")),
+// FileWrite creates a consolidated tool for file write operations (create, update, delete, push_files).
+func FileWrite(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("file_write",
+ mcp.WithDescription(t("TOOL_FILE_WRITE_DESCRIPTION", `Write operations (create, update, delete, push_files) on repository files.
+
+Available methods:
+- create: Create a new file in a repository.
+- update: Update an existing file in a repository. Requires the SHA of the file being replaced.
+- delete: Delete a file from a repository.
+- push_files: Push multiple files to a repository in a single commit.
+`)),
mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_LIST_STARRED_REPOSITORIES_USER_TITLE", "List starred repositories"),
- ReadOnlyHint: ToBoolPtr(true),
+ Title: t("TOOL_FILE_WRITE_USER_TITLE", "Write operations (create, update, delete, push_files) on repository files"),
+ ReadOnlyHint: ToBoolPtr(false),
}),
- mcp.WithString("username",
- mcp.Description("Username to list starred repositories for. Defaults to the authenticated user."),
+ mcp.WithString("method",
+ mcp.Required(),
+ mcp.Description("The write operation to perform on repository files."),
+ mcp.Enum("create", "update", "delete", "push_files"),
+ ),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner (username or organization)"),
),
- mcp.WithString("sort",
- mcp.Description("How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to)."),
- mcp.Enum("created", "updated"),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
),
- mcp.WithString("direction",
- mcp.Description("The direction to sort the results by."),
- mcp.Enum("asc", "desc"),
+ mcp.WithString("branch",
+ mcp.Required(),
+ mcp.Description("Branch to perform the operation on"),
+ ),
+ mcp.WithString("message",
+ mcp.Required(),
+ mcp.Description("Commit message"),
+ ),
+ mcp.WithString("path",
+ mcp.Description("Path to the file (required for create, update, delete methods)"),
+ ),
+ mcp.WithString("content",
+ mcp.Description("Content of the file (required for create and update methods)"),
+ ),
+ mcp.WithString("sha",
+ mcp.Description("Blob SHA of the file being replaced (required for update method)"),
+ ),
+ mcp.WithArray("files",
+ mcp.Items(
+ map[string]interface{}{
+ "type": "object",
+ "additionalProperties": false,
+ "required": []string{"path", "content"},
+ "properties": map[string]interface{}{
+ "path": map[string]interface{}{
+ "type": "string",
+ "description": "path to the file",
+ },
+ "content": map[string]interface{}{
+ "type": "string",
+ "description": "file content",
+ },
+ },
+ }),
+ mcp.Description("Array of file objects to push (required for push_files method), each object with path (string) and content (string)"),
),
- WithPagination(),
),
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- username, err := OptionalParam[string](request, "username")
+ method, err := RequiredParam[string](request, "method")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- sort, err := OptionalParam[string](request, "sort")
+
+ owner, err := RequiredParam[string](request, "owner")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- direction, err := OptionalParam[string](request, "direction")
+ repo, err := RequiredParam[string](request, "repo")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
- pagination, err := OptionalPaginationParams(request)
+ branch, err := RequiredParam[string](request, "branch")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
-
- opts := &github.ActivityListStarredOptions{
- ListOptions: github.ListOptions{
- Page: pagination.Page,
- PerPage: pagination.PerPage,
- },
- }
- if sort != "" {
- opts.Sort = sort
- }
- if direction != "" {
- opts.Direction = direction
+ message, err := RequiredParam[string](request, "message")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
}
client, err := getClient(ctx)
@@ -1750,173 +1128,339 @@ func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHe
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
- var repos []*github.StarredRepository
- var resp *github.Response
- if username == "" {
- // List starred repositories for the authenticated user
- repos, resp, err = client.Activity.ListStarred(ctx, "", opts)
- } else {
- // List starred repositories for a specific user
- repos, resp, err = client.Activity.ListStarred(ctx, username, opts)
+ switch method {
+ case "create":
+ return CreateFile(ctx, client, owner, repo, branch, message, request)
+ case "update":
+ return UpdateFile(ctx, client, owner, repo, branch, message, request)
+ case "delete":
+ return DeleteFileMethod(ctx, client, owner, repo, branch, message, request)
+ case "push_files":
+ return PushFilesMethod(ctx, client, owner, repo, branch, message, request)
+ default:
+ return nil, fmt.Errorf("unknown method: %s", method)
}
+ }
+}
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- fmt.Sprintf("failed to list starred repositories for user '%s'", username),
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
+func CreateFile(ctx context.Context, client *github.Client, owner, repo, branch, message string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ path, err := RequiredParam[string](request, "path")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ content, err := RequiredParam[string](request, "content")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- if resp.StatusCode != 200 {
- body, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, fmt.Errorf("failed to read response body: %w", err)
- }
- return mcp.NewToolResultError(fmt.Sprintf("failed to list starred repositories: %s", string(body))), nil
- }
-
- // Convert to minimal format
- minimalRepos := make([]MinimalRepository, 0, len(repos))
- for _, starredRepo := range repos {
- repo := starredRepo.Repository
- minimalRepo := MinimalRepository{
- ID: repo.GetID(),
- Name: repo.GetName(),
- FullName: repo.GetFullName(),
- Description: repo.GetDescription(),
- HTMLURL: repo.GetHTMLURL(),
- Language: repo.GetLanguage(),
- Stars: repo.GetStargazersCount(),
- Forks: repo.GetForksCount(),
- OpenIssues: repo.GetOpenIssuesCount(),
- Private: repo.GetPrivate(),
- Fork: repo.GetFork(),
- Archived: repo.GetArchived(),
- DefaultBranch: repo.GetDefaultBranch(),
- }
+ // json.Marshal encodes byte arrays with base64, which is required for the API.
+ contentBytes := []byte(content)
- if repo.UpdatedAt != nil {
- minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z")
- }
+ // Create the file options
+ opts := &github.RepositoryContentFileOptions{
+ Message: github.Ptr(message),
+ Content: contentBytes,
+ Branch: github.Ptr(branch),
+ }
- minimalRepos = append(minimalRepos, minimalRepo)
- }
+ // Create the file
+ fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create file",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
- r, err := json.Marshal(minimalRepos)
- if err != nil {
- return nil, fmt.Errorf("failed to marshal starred repositories: %w", err)
- }
+ if resp.StatusCode != 200 && resp.StatusCode != 201 {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to create file: %s", string(body))), nil
+ }
- return mcp.NewToolResultText(string(r)), nil
+ r, err := json.Marshal(fileContent)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+}
+
+func UpdateFile(ctx context.Context, client *github.Client, owner, repo, branch, message string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ path, err := RequiredParam[string](request, "path")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ content, err := RequiredParam[string](request, "content")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ sha, err := RequiredParam[string](request, "sha")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ // json.Marshal encodes byte arrays with base64, which is required for the API.
+ contentBytes := []byte(content)
+
+ // Create the file options
+ opts := &github.RepositoryContentFileOptions{
+ Message: github.Ptr(message),
+ Content: contentBytes,
+ Branch: github.Ptr(branch),
+ SHA: github.Ptr(sha),
+ }
+
+ // Update the file
+ fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to update file",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != 200 && resp.StatusCode != 201 {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
}
+ return mcp.NewToolResultError(fmt.Sprintf("failed to update file: %s", string(body))), nil
+ }
+
+ r, err := json.Marshal(fileContent)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
}
-// StarRepository creates a tool to star a repository.
-func StarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("star_repository",
- mcp.WithDescription(t("TOOL_STAR_REPOSITORY_DESCRIPTION", "Star a GitHub repository")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_STAR_REPOSITORY_USER_TITLE", "Star repository"),
- ReadOnlyHint: ToBoolPtr(false),
- }),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("Repository owner"),
- ),
- mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
- ),
- ),
- func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- 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
- }
+func DeleteFileMethod(ctx context.Context, client *github.Client, owner, repo, branch, message string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ path, err := RequiredParam[string](request, "path")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
+ // Get the reference for the branch
+ ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get branch reference: %w", err)
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Get the commit object that the branch points to
+ baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get base commit",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
- resp, err := client.Activity.Star(ctx, owner, repo)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- fmt.Sprintf("failed to star 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 get commit: %s", string(body))), nil
+ }
- if resp.StatusCode != 204 {
- 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 star repository: %s", string(body))), nil
- }
+ // Create a tree entry for the file deletion by setting SHA to nil
+ treeEntries := []*github.TreeEntry{
+ {
+ Path: github.Ptr(path),
+ Mode: github.Ptr("100644"), // Regular file mode
+ Type: github.Ptr("blob"),
+ SHA: nil, // Setting SHA to nil deletes the file
+ },
+ }
+
+ // Create a new tree with the deletion
+ newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, treeEntries)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create tree",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusCreated {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to create tree: %s", string(body))), nil
+ }
+
+ // Create a new commit with the new tree
+ commit := &github.Commit{
+ Message: github.Ptr(message),
+ Tree: newTree,
+ Parents: []*github.Commit{{SHA: baseCommit.SHA}},
+ }
+ newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create commit",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != http.StatusCreated {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to create commit: %s", string(body))), nil
+ }
+
+ // Update the branch reference to point to the new commit
+ ref.Object.SHA = newCommit.SHA
+ _, resp, err = client.Git.UpdateRef(ctx, owner, repo, ref, false)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to update reference",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
- return mcp.NewToolResultText(fmt.Sprintf("Successfully starred repository %s/%s", owner, repo)), nil
+ if resp.StatusCode != http.StatusOK {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
}
+ return mcp.NewToolResultError(fmt.Sprintf("failed to update reference: %s", string(body))), nil
+ }
+
+ // Create a response similar to what the DeleteFile API would return
+ response := map[string]interface{}{
+ "commit": newCommit,
+ "content": nil,
+ }
+
+ r, err := json.Marshal(response)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
}
-// UnstarRepository creates a tool to unstar a repository.
-func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
- return mcp.NewTool("unstar_repository",
- mcp.WithDescription(t("TOOL_UNSTAR_REPOSITORY_DESCRIPTION", "Unstar a GitHub repository")),
- mcp.WithToolAnnotation(mcp.ToolAnnotation{
- Title: t("TOOL_UNSTAR_REPOSITORY_USER_TITLE", "Unstar repository"),
- ReadOnlyHint: ToBoolPtr(false),
- }),
- mcp.WithString("owner",
- mcp.Required(),
- mcp.Description("Repository owner"),
- ),
- mcp.WithString("repo",
- mcp.Required(),
- mcp.Description("Repository name"),
- ),
- ),
- func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
- 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
- }
+func PushFilesMethod(ctx context.Context, client *github.Client, owner, repo, branch, message string, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ // Parse files parameter - this should be an array of objects with path and content
+ filesObj, ok := request.GetArguments()["files"].([]interface{})
+ if !ok {
+ return mcp.NewToolResultError("files parameter must be an array of objects with path and content"), nil
+ }
- client, err := getClient(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get GitHub client: %w", err)
- }
+ // Get the reference for the branch
+ ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get branch reference",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Get the commit object that the branch points to
+ baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get base commit",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
- resp, err := client.Activity.Unstar(ctx, owner, repo)
- if err != nil {
- return ghErrors.NewGitHubAPIErrorResponse(ctx,
- fmt.Sprintf("failed to unstar repository %s/%s", owner, repo),
- resp,
- err,
- ), nil
- }
- defer func() { _ = resp.Body.Close() }()
+ // Create tree entries for all files
+ var entries []*github.TreeEntry
- if resp.StatusCode != 204 {
- 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 unstar repository: %s", string(body))), nil
- }
+ for _, file := range filesObj {
+ fileMap, ok := file.(map[string]interface{})
+ if !ok {
+ return mcp.NewToolResultError("each file must be an object with path and content"), nil
+ }
+
+ path, ok := fileMap["path"].(string)
+ if !ok || path == "" {
+ return mcp.NewToolResultError("each file must have a path"), nil
+ }
- return mcp.NewToolResultText(fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo)), nil
+ content, ok := fileMap["content"].(string)
+ if !ok {
+ return mcp.NewToolResultError("each file must have content"), nil
}
+
+ // Create a tree entry for the file
+ entries = append(entries, &github.TreeEntry{
+ Path: github.Ptr(path),
+ Mode: github.Ptr("100644"), // Regular file mode
+ Type: github.Ptr("blob"),
+ Content: github.Ptr(content),
+ })
+ }
+
+ // Create a new tree with the file entries
+ newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create tree",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Create a new commit
+ commit := &github.Commit{
+ Message: github.Ptr(message),
+ Tree: newTree,
+ Parents: []*github.Commit{{SHA: baseCommit.SHA}},
+ }
+ newCommit, resp, err := client.Git.CreateCommit(ctx, owner, repo, commit, nil)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to create commit",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Update the reference to point to the new commit
+ ref.Object.SHA = newCommit.SHA
+ updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, ref, false)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to update reference",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ r, err := json.Marshal(updatedRef)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
}
diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go
index 22014148d..3b7fb4364 100644
--- a/pkg/github/repositories_test.go
+++ b/pkg/github/repositories_test.go
@@ -624,18 +624,19 @@ func Test_CreateBranch(t *testing.T) {
}
}
-func Test_GetCommit(t *testing.T) {
+func Test_CommitRead(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
- tool, _ := GetCommit(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ tool, _ := CommitRead(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
- assert.Equal(t, "get_commit", tool.Name)
+ assert.Equal(t, "commit_read", tool.Name)
assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "method")
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "sha")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "sha"})
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo"})
mockCommit := &github.RepositoryCommit{
SHA: github.Ptr("abc123def456"),
@@ -677,7 +678,7 @@ func Test_GetCommit(t *testing.T) {
expectedErrMsg string
}{
{
- name: "successful commit fetch",
+ name: "get method - successful commit fetch",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposCommitsByOwnerByRepoByRef,
@@ -685,15 +686,16 @@ func Test_GetCommit(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "sha": "abc123def456",
+ "method": "get",
+ "owner": "owner",
+ "repo": "repo",
+ "sha": "abc123def456",
},
expectError: false,
expectedCommit: mockCommit,
},
{
- name: "commit fetch fails",
+ name: "get method - commit fetch fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposCommitsByOwnerByRepoByRef,
@@ -704,9 +706,10 @@ func Test_GetCommit(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "sha": "nonexistent-sha",
+ "method": "get",
+ "owner": "owner",
+ "repo": "repo",
+ "sha": "nonexistent-sha",
},
expectError: true,
expectedErrMsg: "failed to get commit",
@@ -717,7 +720,7 @@ func Test_GetCommit(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
- _, handler := GetCommit(stubGetClientFn(client), translations.NullTranslationHelper)
+ _, handler := CommitRead(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
@@ -753,21 +756,21 @@ func Test_GetCommit(t *testing.T) {
}
}
-func Test_ListCommits(t *testing.T) {
- // Verify tool definition once
+// Test for list method is covered in a separate test suite
+func Test_CommitRead_List(t *testing.T) {
+ // Verify tool definition
mockClient := github.NewClient(nil)
- tool, _ := ListCommits(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
+ tool, _ := CommitRead(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- assert.Equal(t, "list_commits", tool.Name)
+ assert.Equal(t, "commit_read", tool.Name)
assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "method")
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "sha")
assert.Contains(t, tool.InputSchema.Properties, "author")
assert.Contains(t, tool.InputSchema.Properties, "page")
assert.Contains(t, tool.InputSchema.Properties, "perPage")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
// Setup mock commits for success case
mockCommits := []*github.RepositoryCommit{
@@ -853,7 +856,7 @@ func Test_ListCommits(t *testing.T) {
expectedErrMsg string
}{
{
- name: "successful commits fetch with default params",
+ name: "list method - successful commits fetch with default params",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatch(
mock.GetReposCommitsByOwnerByRepo,
@@ -861,14 +864,15 @@ func Test_ListCommits(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
+ "method": "list",
+ "owner": "owner",
+ "repo": "repo",
},
expectError: false,
expectedCommits: mockCommits,
},
{
- name: "successful commits fetch with branch",
+ name: "list method - successful commits fetch with branch",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposCommitsByOwnerByRepo,
@@ -883,6 +887,7 @@ func Test_ListCommits(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
+ "method": "list",
"owner": "owner",
"repo": "repo",
"sha": "main",
@@ -892,7 +897,7 @@ func Test_ListCommits(t *testing.T) {
expectedCommits: mockCommits,
},
{
- name: "successful commits fetch with pagination",
+ name: "list method - successful commits fetch with pagination",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposCommitsByOwnerByRepo,
@@ -905,6 +910,7 @@ func Test_ListCommits(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
+ "method": "list",
"owner": "owner",
"repo": "repo",
"page": float64(2),
@@ -914,7 +920,7 @@ func Test_ListCommits(t *testing.T) {
expectedCommits: mockCommits,
},
{
- name: "commits fetch fails",
+ name: "list method - commits fetch fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
mock.GetReposCommitsByOwnerByRepo,
@@ -925,8 +931,9 @@ func Test_ListCommits(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "nonexistent-repo",
+ "method": "list",
+ "owner": "owner",
+ "repo": "nonexistent-repo",
},
expectError: true,
expectedErrMsg: "failed to list commits",
@@ -937,7 +944,7 @@ func Test_ListCommits(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
- _, handler := ListCommits(stubGetClientFn(client), translations.NullTranslationHelper)
+ _, handler := CommitRead(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
@@ -975,7 +982,7 @@ func Test_ListCommits(t *testing.T) {
assert.Equal(t, tc.expectedCommits[i].Author.GetLogin(), commit.Author.Login)
}
- // Files and stats are never included in list_commits
+ // Files and stats are never included in list method
assert.Nil(t, commit.Files)
assert.Nil(t, commit.Stats)
}
@@ -983,173 +990,6 @@ func Test_ListCommits(t *testing.T) {
}
}
-func Test_CreateOrUpdateFile(t *testing.T) {
- // Verify tool definition once
- mockClient := github.NewClient(nil)
- tool, _ := CreateOrUpdateFile(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "create_or_update_file", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "repo")
- assert.Contains(t, tool.InputSchema.Properties, "path")
- assert.Contains(t, tool.InputSchema.Properties, "content")
- assert.Contains(t, tool.InputSchema.Properties, "message")
- assert.Contains(t, tool.InputSchema.Properties, "branch")
- assert.Contains(t, tool.InputSchema.Properties, "sha")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "content", "message", "branch"})
-
- // Setup mock file content response
- mockFileResponse := &github.RepositoryContentResponse{
- Content: &github.RepositoryContent{
- Name: github.Ptr("example.md"),
- Path: github.Ptr("docs/example.md"),
- SHA: github.Ptr("abc123def456"),
- Size: github.Ptr(42),
- HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/docs/example.md"),
- DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/docs/example.md"),
- },
- Commit: github.Commit{
- SHA: github.Ptr("def456abc789"),
- Message: github.Ptr("Add example file"),
- Author: &github.CommitAuthor{
- Name: github.Ptr("Test User"),
- Email: github.Ptr("test@example.com"),
- Date: &github.Timestamp{Time: time.Now()},
- },
- HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"),
- },
- }
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedContent *github.RepositoryContentResponse
- expectedErrMsg string
- }{
- {
- name: "successful file creation",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PutReposContentsByOwnerByRepoByPath,
- expectRequestBody(t, map[string]interface{}{
- "message": "Add example file",
- "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content
- "branch": "main",
- }).andThen(
- mockResponse(t, http.StatusOK, mockFileResponse),
- ),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "path": "docs/example.md",
- "content": "# Example\n\nThis is an example file.",
- "message": "Add example file",
- "branch": "main",
- },
- expectError: false,
- expectedContent: mockFileResponse,
- },
- {
- name: "successful file update with SHA",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PutReposContentsByOwnerByRepoByPath,
- expectRequestBody(t, map[string]interface{}{
- "message": "Update example file",
- "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content
- "branch": "main",
- "sha": "abc123def456",
- }).andThen(
- mockResponse(t, http.StatusOK, mockFileResponse),
- ),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "path": "docs/example.md",
- "content": "# Updated Example\n\nThis file has been updated.",
- "message": "Update example file",
- "branch": "main",
- "sha": "abc123def456",
- },
- expectError: false,
- expectedContent: mockFileResponse,
- },
- {
- name: "file creation fails",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PutReposContentsByOwnerByRepoByPath,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusUnprocessableEntity)
- _, _ = w.Write([]byte(`{"message": "Invalid request"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "path": "docs/example.md",
- "content": "#Invalid Content",
- "message": "Invalid request",
- "branch": "nonexistent-branch",
- },
- expectError: true,
- expectedErrMsg: "failed to create/update file",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- // Setup client with mock
- client := github.NewClient(tc.mockedClient)
- _, handler := CreateOrUpdateFile(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 returnedContent github.RepositoryContentResponse
- err = json.Unmarshal([]byte(textContent.Text), &returnedContent)
- require.NoError(t, err)
-
- // Verify content
- assert.Equal(t, *tc.expectedContent.Content.Name, *returnedContent.Content.Name)
- assert.Equal(t, *tc.expectedContent.Content.Path, *returnedContent.Content.Path)
- assert.Equal(t, *tc.expectedContent.Content.SHA, *returnedContent.Content.SHA)
-
- // Verify commit
- assert.Equal(t, *tc.expectedContent.Commit.SHA, *returnedContent.Commit.SHA)
- assert.Equal(t, *tc.expectedContent.Commit.Message, *returnedContent.Commit.Message)
- })
- }
-}
-
func Test_CreateRepository(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
@@ -1324,20 +1164,24 @@ func Test_CreateRepository(t *testing.T) {
}
}
-func Test_PushFiles(t *testing.T) {
+func Test_FileWrite(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
- tool, _ := PushFiles(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ tool, _ := FileWrite(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
- assert.Equal(t, "push_files", tool.Name)
+ assert.Equal(t, "file_write", tool.Name)
assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "method")
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
assert.Contains(t, tool.InputSchema.Properties, "branch")
- assert.Contains(t, tool.InputSchema.Properties, "files")
assert.Contains(t, tool.InputSchema.Properties, "message")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "branch", "files", "message"})
+ assert.Contains(t, tool.InputSchema.Properties, "path")
+ assert.Contains(t, tool.InputSchema.Properties, "content")
+ assert.Contains(t, tool.InputSchema.Properties, "sha")
+ assert.Contains(t, tool.InputSchema.Properties, "files")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo", "branch", "message"})
// Setup mock objects
mockRef := &github.Reference{
@@ -1441,9 +1285,11 @@ func Test_PushFiles(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "branch": "main",
+ "method": "push_files",
+ "owner": "owner",
+ "repo": "repo",
+ "branch": "main",
+ "message": "Update multiple files",
"files": []interface{}{
map[string]interface{}{
"path": "README.md",
@@ -1454,7 +1300,6 @@ func Test_PushFiles(t *testing.T) {
"content": "# Example\n\nThis is an example file.",
},
},
- "message": "Update multiple files",
},
expectError: false,
expectedRef: mockUpdatedRef,
@@ -1465,11 +1310,12 @@ func Test_PushFiles(t *testing.T) {
// No requests expected
),
requestArgs: map[string]interface{}{
+ "method": "push_files",
"owner": "owner",
"repo": "repo",
"branch": "main",
- "files": "invalid-files-parameter", // Not an array
"message": "Update multiple files",
+ "files": "invalid-files-parameter", // Not an array
},
expectError: false, // This returns a tool error, not a Go error
expectedErrMsg: "files parameter must be an array",
@@ -1489,15 +1335,16 @@ func Test_PushFiles(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "branch": "main",
+ "method": "push_files",
+ "owner": "owner",
+ "repo": "repo",
+ "branch": "main",
+ "message": "Update file",
"files": []interface{}{
map[string]interface{}{
"content": "# Missing path",
},
},
- "message": "Update file",
},
expectError: false, // This returns a tool error, not a Go error
expectedErrMsg: "each file must have a path",
@@ -1517,16 +1364,17 @@ func Test_PushFiles(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "branch": "main",
+ "method": "push_files",
+ "owner": "owner",
+ "repo": "repo",
+ "branch": "main",
+ "message": "Update file",
"files": []interface{}{
map[string]interface{}{
"path": "README.md",
// Missing content
},
},
- "message": "Update file",
},
expectError: false, // This returns a tool error, not a Go error
expectedErrMsg: "each file must have content",
@@ -1540,16 +1388,17 @@ func Test_PushFiles(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "branch": "non-existent-branch",
+ "method": "push_files",
+ "owner": "owner",
+ "repo": "repo",
+ "branch": "non-existent-branch",
+ "message": "Update file",
"files": []interface{}{
map[string]interface{}{
"path": "README.md",
"content": "# README",
},
},
- "message": "Update file",
},
expectError: true,
expectedErrMsg: "failed to get branch reference",
@@ -1569,16 +1418,17 @@ func Test_PushFiles(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "branch": "main",
+ "method": "push_files",
+ "owner": "owner",
+ "repo": "repo",
+ "branch": "main",
+ "message": "Update file",
"files": []interface{}{
map[string]interface{}{
"path": "README.md",
"content": "# README",
},
},
- "message": "Update file",
},
expectError: true,
expectedErrMsg: "failed to get base commit",
@@ -1603,16 +1453,17 @@ func Test_PushFiles(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "branch": "main",
+ "method": "push_files",
+ "owner": "owner",
+ "repo": "repo",
+ "branch": "main",
+ "message": "Update file",
"files": []interface{}{
map[string]interface{}{
"path": "README.md",
"content": "# README",
},
},
- "message": "Update file",
},
expectError: true,
expectedErrMsg: "failed to create tree",
@@ -1623,7 +1474,7 @@ func Test_PushFiles(t *testing.T) {
t.Run(tc.name, func(t *testing.T) {
// Setup client with mock
client := github.NewClient(tc.mockedClient)
- _, handler := PushFiles(stubGetClientFn(client), translations.NullTranslationHelper)
+ _, handler := FileWrite(stubGetClientFn(client), translations.NullTranslationHelper)
// Create call request
request := createMCPRequest(tc.requestArgs)
@@ -1776,737 +1627,80 @@ func Test_ListBranches(t *testing.T) {
}
}
-func Test_DeleteFile(t *testing.T) {
+// Test_DeleteFile is removed as the delete_file tool has been consolidated into file_write
+
+func Test_TagRead(t *testing.T) {
// Verify tool definition once
mockClient := github.NewClient(nil)
- tool, _ := DeleteFile(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ tool, _ := TagRead(stubGetClientFn(mockClient), translations.NullTranslationHelper)
require.NoError(t, toolsnaps.Test(tool.Name, tool))
- assert.Equal(t, "delete_file", tool.Name)
+ assert.Equal(t, "tag_read", tool.Name)
assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "method")
assert.Contains(t, tool.InputSchema.Properties, "owner")
assert.Contains(t, tool.InputSchema.Properties, "repo")
- assert.Contains(t, tool.InputSchema.Properties, "path")
- assert.Contains(t, tool.InputSchema.Properties, "message")
- assert.Contains(t, tool.InputSchema.Properties, "branch")
- // SHA is no longer required since we're using Git Data API
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "path", "message", "branch"})
+ assert.Contains(t, tool.InputSchema.Properties, "tag")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"method", "owner", "repo"})
- // Setup mock objects for Git Data API
- mockRef := &github.Reference{
- Ref: github.Ptr("refs/heads/main"),
- Object: &github.GitObject{
- SHA: github.Ptr("abc123"),
+ // Setup mock tags for success case
+ mockTags := []*github.RepositoryTag{
+ {
+ Name: github.Ptr("v1.0.0"),
+ Commit: &github.Commit{
+ SHA: github.Ptr("v1.0.0-tag-sha"),
+ URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abc123"),
+ },
+ ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v1.0.0"),
+ TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v1.0.0"),
},
- }
-
- mockCommit := &github.Commit{
- SHA: github.Ptr("abc123"),
- Tree: &github.Tree{
- SHA: github.Ptr("def456"),
+ {
+ Name: github.Ptr("v0.9.0"),
+ Commit: &github.Commit{
+ SHA: github.Ptr("v0.9.0-tag-sha"),
+ URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/def456"),
+ },
+ ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v0.9.0"),
+ TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v0.9.0"),
},
}
- mockTree := &github.Tree{
- SHA: github.Ptr("ghi789"),
- }
-
- mockNewCommit := &github.Commit{
- SHA: github.Ptr("jkl012"),
- Message: github.Ptr("Delete example file"),
- HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"),
- }
-
tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedCommitSHA string
- expectedErrMsg string
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedTags []*github.RepositoryTag
+ expectedErrMsg string
}{
{
- name: "successful file deletion using Git Data API",
+ name: "successful tags list",
mockedClient: mock.NewMockedHTTPClient(
- // Get branch reference
- mock.WithRequestMatch(
- mock.GetReposGitRefByOwnerByRepoByRef,
- mockRef,
- ),
- // Get commit
- mock.WithRequestMatch(
- mock.GetReposGitCommitsByOwnerByRepoByCommitSha,
- mockCommit,
- ),
- // Create tree
mock.WithRequestMatchHandler(
- mock.PostReposGitTreesByOwnerByRepo,
- expectRequestBody(t, map[string]interface{}{
- "base_tree": "def456",
- "tree": []interface{}{
- map[string]interface{}{
- "path": "docs/example.md",
- "mode": "100644",
- "type": "blob",
- "sha": nil,
- },
- },
- }).andThen(
- mockResponse(t, http.StatusCreated, mockTree),
+ mock.GetReposTagsByOwnerByRepo,
+ expectPath(
+ t,
+ "/repos/owner/repo/tags",
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockTags),
),
),
- // Create commit
- mock.WithRequestMatchHandler(
- mock.PostReposGitCommitsByOwnerByRepo,
- expectRequestBody(t, map[string]interface{}{
- "message": "Delete example file",
- "tree": "ghi789",
- "parents": []interface{}{"abc123"},
- }).andThen(
- mockResponse(t, http.StatusCreated, mockNewCommit),
- ),
- ),
- // Update reference
- mock.WithRequestMatchHandler(
- mock.PatchReposGitRefsByOwnerByRepoByRef,
- expectRequestBody(t, map[string]interface{}{
- "sha": "jkl012",
- "force": false,
- }).andThen(
- mockResponse(t, http.StatusOK, &github.Reference{
- Ref: github.Ptr("refs/heads/main"),
- Object: &github.GitObject{
- SHA: github.Ptr("jkl012"),
- },
- }),
- ),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "path": "docs/example.md",
- "message": "Delete example file",
- "branch": "main",
- },
- expectError: false,
- expectedCommitSHA: "jkl012",
- },
- {
- name: "file deletion fails - branch not found",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetReposGitRefByOwnerByRepoByRef,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- _, _ = w.Write([]byte(`{"message": "Reference not found"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "path": "docs/nonexistent.md",
- "message": "Delete nonexistent file",
- "branch": "nonexistent-branch",
- },
- expectError: true,
- expectedErrMsg: "failed to get branch reference",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- // Setup client with mock
- client := github.NewClient(tc.mockedClient)
- _, handler := DeleteFile(stubGetClientFn(client), translations.NullTranslationHelper)
-
- // Create call request
- request := createMCPRequest(tc.requestArgs)
-
- // Call handler
- result, err := handler(context.Background(), request)
-
- // Verify results
- if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
- return
- }
-
- require.NoError(t, err)
-
- // Parse the result and get the text content if no error
- textContent := getTextResult(t, result)
-
- // Unmarshal and verify the result
- var response map[string]interface{}
- err = json.Unmarshal([]byte(textContent.Text), &response)
- require.NoError(t, err)
-
- // Verify the response contains the expected commit
- commit, ok := response["commit"].(map[string]interface{})
- require.True(t, ok)
- commitSHA, ok := commit["sha"].(string)
- require.True(t, ok)
- assert.Equal(t, tc.expectedCommitSHA, commitSHA)
- })
- }
-}
-
-func Test_ListTags(t *testing.T) {
- // Verify tool definition once
- mockClient := github.NewClient(nil)
- tool, _ := ListTags(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "list_tags", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "repo")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
-
- // Setup mock tags for success case
- mockTags := []*github.RepositoryTag{
- {
- Name: github.Ptr("v1.0.0"),
- Commit: &github.Commit{
- SHA: github.Ptr("v1.0.0-tag-sha"),
- URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abc123"),
- },
- ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v1.0.0"),
- TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v1.0.0"),
- },
- {
- Name: github.Ptr("v0.9.0"),
- Commit: &github.Commit{
- SHA: github.Ptr("v0.9.0-tag-sha"),
- URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/def456"),
- },
- ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v0.9.0"),
- TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v0.9.0"),
- },
- }
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedTags []*github.RepositoryTag
- expectedErrMsg string
- }{
- {
- name: "successful tags list",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetReposTagsByOwnerByRepo,
- expectPath(
- t,
- "/repos/owner/repo/tags",
- ).andThen(
- mockResponse(t, http.StatusOK, mockTags),
- ),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- },
- expectError: false,
- expectedTags: mockTags,
- },
- {
- name: "list tags fails",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetReposTagsByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusInternalServerError)
- _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- },
- expectError: true,
- expectedErrMsg: "failed to list tags",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- // Setup client with mock
- client := github.NewClient(tc.mockedClient)
- _, handler := ListTags(stubGetClientFn(client), translations.NullTranslationHelper)
-
- // Create call request
- request := createMCPRequest(tc.requestArgs)
-
- // Call handler
- result, err := handler(context.Background(), request)
-
- // Verify results
- if tc.expectError {
- require.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)
-
- // Parse and verify the result
- var returnedTags []*github.RepositoryTag
- err = json.Unmarshal([]byte(textContent.Text), &returnedTags)
- require.NoError(t, err)
-
- // Verify each tag
- require.Equal(t, len(tc.expectedTags), len(returnedTags))
- for i, expectedTag := range tc.expectedTags {
- assert.Equal(t, *expectedTag.Name, *returnedTags[i].Name)
- assert.Equal(t, *expectedTag.Commit.SHA, *returnedTags[i].Commit.SHA)
- }
- })
- }
-}
-
-func Test_GetTag(t *testing.T) {
- // Verify tool definition once
- mockClient := github.NewClient(nil)
- tool, _ := GetTag(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "get_tag", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "repo")
- assert.Contains(t, tool.InputSchema.Properties, "tag")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"})
-
- mockTagRef := &github.Reference{
- Ref: github.Ptr("refs/tags/v1.0.0"),
- Object: &github.GitObject{
- SHA: github.Ptr("v1.0.0-tag-sha"),
- },
- }
-
- mockTagObj := &github.Tag{
- SHA: github.Ptr("v1.0.0-tag-sha"),
- Tag: github.Ptr("v1.0.0"),
- Message: github.Ptr("Release v1.0.0"),
- Object: &github.GitObject{
- Type: github.Ptr("commit"),
- SHA: github.Ptr("abc123"),
- },
- }
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedTag *github.Tag
- expectedErrMsg string
- }{
- {
- name: "successful tag retrieval",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetReposGitRefByOwnerByRepoByRef,
- expectPath(
- t,
- "/repos/owner/repo/git/ref/tags/v1.0.0",
- ).andThen(
- mockResponse(t, http.StatusOK, mockTagRef),
- ),
- ),
- mock.WithRequestMatchHandler(
- mock.GetReposGitTagsByOwnerByRepoByTagSha,
- expectPath(
- t,
- "/repos/owner/repo/git/tags/v1.0.0-tag-sha",
- ).andThen(
- mockResponse(t, http.StatusOK, mockTagObj),
- ),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "tag": "v1.0.0",
- },
- expectError: false,
- expectedTag: mockTagObj,
- },
- {
- name: "tag reference not found",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetReposGitRefByOwnerByRepoByRef,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- _, _ = w.Write([]byte(`{"message": "Reference does not exist"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "tag": "v1.0.0",
- },
- expectError: true,
- expectedErrMsg: "failed to get tag reference",
- },
- {
- name: "tag object not found",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposGitRefByOwnerByRepoByRef,
- mockTagRef,
- ),
- mock.WithRequestMatchHandler(
- mock.GetReposGitTagsByOwnerByRepoByTagSha,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- _, _ = w.Write([]byte(`{"message": "Tag object does not exist"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "tag": "v1.0.0",
- },
- expectError: true,
- expectedErrMsg: "failed to get tag object",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- // Setup client with mock
- client := github.NewClient(tc.mockedClient)
- _, handler := GetTag(stubGetClientFn(client), translations.NullTranslationHelper)
-
- // Create call request
- request := createMCPRequest(tc.requestArgs)
-
- // Call handler
- result, err := handler(context.Background(), request)
-
- // Verify results
- if tc.expectError {
- require.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)
-
- // Parse and verify the result
- var returnedTag github.Tag
- err = json.Unmarshal([]byte(textContent.Text), &returnedTag)
- require.NoError(t, err)
-
- assert.Equal(t, *tc.expectedTag.SHA, *returnedTag.SHA)
- assert.Equal(t, *tc.expectedTag.Tag, *returnedTag.Tag)
- assert.Equal(t, *tc.expectedTag.Message, *returnedTag.Message)
- assert.Equal(t, *tc.expectedTag.Object.Type, *returnedTag.Object.Type)
- assert.Equal(t, *tc.expectedTag.Object.SHA, *returnedTag.Object.SHA)
- })
- }
-}
-
-func Test_ListReleases(t *testing.T) {
- mockClient := github.NewClient(nil)
- tool, _ := ListReleases(stubGetClientFn(mockClient), translations.NullTranslationHelper)
-
- assert.Equal(t, "list_releases", 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"})
-
- mockReleases := []*github.RepositoryRelease{
- {
- ID: github.Ptr(int64(1)),
- TagName: github.Ptr("v1.0.0"),
- Name: github.Ptr("First Release"),
- },
- {
- ID: github.Ptr(int64(2)),
- TagName: github.Ptr("v0.9.0"),
- Name: github.Ptr("Beta Release"),
- },
- }
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedResult []*github.RepositoryRelease
- expectedErrMsg string
- }{
- {
- name: "successful releases list",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposReleasesByOwnerByRepo,
- mockReleases,
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- },
- expectError: false,
- expectedResult: mockReleases,
- },
- {
- name: "releases list fails",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetReposReleasesByOwnerByRepo,
- 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",
- },
- expectError: true,
- expectedErrMsg: "failed to list releases",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- client := github.NewClient(tc.mockedClient)
- _, handler := ListReleases(stubGetClientFn(client), translations.NullTranslationHelper)
- request := createMCPRequest(tc.requestArgs)
- result, err := handler(context.Background(), request)
-
- if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
- return
- }
-
- require.NoError(t, err)
- textContent := getTextResult(t, result)
- var returnedReleases []*github.RepositoryRelease
- err = json.Unmarshal([]byte(textContent.Text), &returnedReleases)
- require.NoError(t, err)
- assert.Len(t, returnedReleases, len(tc.expectedResult))
- for i, rel := range returnedReleases {
- assert.Equal(t, *tc.expectedResult[i].TagName, *rel.TagName)
- }
- })
- }
-}
-func Test_GetLatestRelease(t *testing.T) {
- mockClient := github.NewClient(nil)
- tool, _ := GetLatestRelease(stubGetClientFn(mockClient), translations.NullTranslationHelper)
-
- assert.Equal(t, "get_latest_release", 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"})
-
- mockRelease := &github.RepositoryRelease{
- ID: github.Ptr(int64(1)),
- TagName: github.Ptr("v1.0.0"),
- Name: github.Ptr("First Release"),
- }
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedResult *github.RepositoryRelease
- expectedErrMsg string
- }{
- {
- name: "successful latest release fetch",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposReleasesLatestByOwnerByRepo,
- mockRelease,
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- },
- expectError: false,
- expectedResult: mockRelease,
- },
- {
- name: "latest release fetch fails",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetReposReleasesLatestByOwnerByRepo,
- 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",
- },
- expectError: true,
- expectedErrMsg: "failed to get latest release",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- client := github.NewClient(tc.mockedClient)
- _, handler := GetLatestRelease(stubGetClientFn(client), translations.NullTranslationHelper)
- request := createMCPRequest(tc.requestArgs)
- result, err := handler(context.Background(), request)
-
- if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
- return
- }
-
- require.NoError(t, err)
- textContent := getTextResult(t, result)
- var returnedRelease github.RepositoryRelease
- err = json.Unmarshal([]byte(textContent.Text), &returnedRelease)
- require.NoError(t, err)
- assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName)
- })
- }
-}
-
-func Test_GetReleaseByTag(t *testing.T) {
- mockClient := github.NewClient(nil)
- tool, _ := GetReleaseByTag(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "get_release_by_tag", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "owner")
- assert.Contains(t, tool.InputSchema.Properties, "repo")
- assert.Contains(t, tool.InputSchema.Properties, "tag")
- assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "tag"})
-
- mockRelease := &github.RepositoryRelease{
- ID: github.Ptr(int64(1)),
- TagName: github.Ptr("v1.0.0"),
- Name: github.Ptr("Release v1.0.0"),
- Body: github.Ptr("This is the first stable release."),
- Assets: []*github.ReleaseAsset{
- {
- ID: github.Ptr(int64(1)),
- Name: github.Ptr("release-v1.0.0.tar.gz"),
- },
- },
- }
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedResult *github.RepositoryRelease
- expectedErrMsg string
- }{
- {
- name: "successful release by tag fetch",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatch(
- mock.GetReposReleasesTagsByOwnerByRepoByTag,
- mockRelease,
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "tag": "v1.0.0",
- },
- expectError: false,
- expectedResult: mockRelease,
- },
- {
- name: "missing owner parameter",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]interface{}{
- "repo": "repo",
- "tag": "v1.0.0",
- },
- expectError: false, // Returns tool error, not Go error
- expectedErrMsg: "missing required parameter: owner",
- },
- {
- name: "missing repo parameter",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "tag": "v1.0.0",
- },
- expectError: false, // Returns tool error, not Go error
- expectedErrMsg: "missing required parameter: repo",
- },
- {
- name: "missing tag parameter",
- mockedClient: mock.NewMockedHTTPClient(),
- requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- },
- expectError: false, // Returns tool error, not Go error
- expectedErrMsg: "missing required parameter: tag",
- },
- {
- name: "release by tag not found",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetReposReleasesTagsByOwnerByRepoByTag,
- 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",
- "tag": "v999.0.0",
+ "method": "list",
+ "owner": "owner",
+ "repo": "repo",
},
- expectError: false, // API errors return tool errors, not Go errors
- expectedErrMsg: "failed to get release by tag: v999.0.0",
+ expectError: false,
+ expectedTags: mockTags,
},
{
- name: "server error",
+ name: "list tags fails",
mockedClient: mock.NewMockedHTTPClient(
mock.WithRequestMatchHandler(
- mock.GetReposReleasesTagsByOwnerByRepoByTag,
+ mock.GetReposTagsByOwnerByRepo,
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"message": "Internal Server Error"}`))
@@ -2514,61 +1708,58 @@ func Test_GetReleaseByTag(t *testing.T) {
),
),
requestArgs: map[string]interface{}{
- "owner": "owner",
- "repo": "repo",
- "tag": "v1.0.0",
+ "method": "list",
+ "owner": "owner",
+ "repo": "repo",
},
- expectError: false, // API errors return tool errors, not Go errors
- expectedErrMsg: "failed to get release by tag: v1.0.0",
+ expectError: true,
+ expectedErrMsg: "failed to list tags",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
client := github.NewClient(tc.mockedClient)
- _, handler := GetReleaseByTag(stubGetClientFn(client), translations.NullTranslationHelper)
+ _, handler := TagRead(stubGetClientFn(client), translations.NullTranslationHelper)
+ // Create call request
request := createMCPRequest(tc.requestArgs)
+ // Call handler
result, err := handler(context.Background(), request)
+ // Verify results
if tc.expectError {
- require.Error(t, err)
- assert.Contains(t, err.Error(), tc.expectedErrMsg)
- return
- }
-
- require.NoError(t, err)
-
- if tc.expectedErrMsg != "" {
+ require.NoError(t, err)
require.True(t, result.IsError)
errorContent := getErrorResult(t, result)
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
return
}
+ require.NoError(t, err)
require.False(t, result.IsError)
+ // Parse the result and get the text content if no error
textContent := getTextResult(t, result)
- var returnedRelease github.RepositoryRelease
- err = json.Unmarshal([]byte(textContent.Text), &returnedRelease)
+ // Parse and verify the result
+ var returnedTags []*github.RepositoryTag
+ err = json.Unmarshal([]byte(textContent.Text), &returnedTags)
require.NoError(t, err)
- assert.Equal(t, *tc.expectedResult.ID, *returnedRelease.ID)
- assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName)
- assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name)
- if tc.expectedResult.Body != nil {
- assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body)
- }
- if len(tc.expectedResult.Assets) > 0 {
- require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets))
- assert.Equal(t, *tc.expectedResult.Assets[0].Name, *returnedRelease.Assets[0].Name)
+ // Verify each tag
+ require.Equal(t, len(tc.expectedTags), len(returnedTags))
+ for i, expectedTag := range tc.expectedTags {
+ assert.Equal(t, *expectedTag.Name, *returnedTags[i].Name)
+ assert.Equal(t, *expectedTag.Commit.SHA, *returnedTags[i].Commit.SHA)
}
})
}
}
+
func Test_filterPaths(t *testing.T) {
tests := []struct {
name string
@@ -2911,329 +2102,3 @@ func Test_resolveGitReference(t *testing.T) {
})
}
}
-
-func Test_ListStarredRepositories(t *testing.T) {
- // Verify tool definition once
- mockClient := github.NewClient(nil)
- tool, _ := ListStarredRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "list_starred_repositories", tool.Name)
- assert.NotEmpty(t, tool.Description)
- assert.Contains(t, tool.InputSchema.Properties, "username")
- assert.Contains(t, tool.InputSchema.Properties, "sort")
- assert.Contains(t, tool.InputSchema.Properties, "direction")
- assert.Contains(t, tool.InputSchema.Properties, "page")
- assert.Contains(t, tool.InputSchema.Properties, "perPage")
- assert.Empty(t, tool.InputSchema.Required) // All parameters are optional
-
- // Setup mock starred repositories
- starredAt := time.Now().Add(-24 * time.Hour)
- updatedAt := time.Now().Add(-2 * time.Hour)
- mockStarredRepos := []*github.StarredRepository{
- {
- StarredAt: &github.Timestamp{Time: starredAt},
- Repository: &github.Repository{
- ID: github.Ptr(int64(12345)),
- Name: github.Ptr("awesome-repo"),
- FullName: github.Ptr("owner/awesome-repo"),
- Description: github.Ptr("An awesome repository"),
- HTMLURL: github.Ptr("https://github.com/owner/awesome-repo"),
- Language: github.Ptr("Go"),
- StargazersCount: github.Ptr(100),
- ForksCount: github.Ptr(25),
- OpenIssuesCount: github.Ptr(5),
- UpdatedAt: &github.Timestamp{Time: updatedAt},
- Private: github.Ptr(false),
- Fork: github.Ptr(false),
- Archived: github.Ptr(false),
- DefaultBranch: github.Ptr("main"),
- },
- },
- {
- StarredAt: &github.Timestamp{Time: starredAt.Add(-12 * time.Hour)},
- Repository: &github.Repository{
- ID: github.Ptr(int64(67890)),
- Name: github.Ptr("cool-project"),
- FullName: github.Ptr("user/cool-project"),
- Description: github.Ptr("A very cool project"),
- HTMLURL: github.Ptr("https://github.com/user/cool-project"),
- Language: github.Ptr("Python"),
- StargazersCount: github.Ptr(500),
- ForksCount: github.Ptr(75),
- OpenIssuesCount: github.Ptr(10),
- UpdatedAt: &github.Timestamp{Time: updatedAt.Add(-1 * time.Hour)},
- Private: github.Ptr(false),
- Fork: github.Ptr(true),
- Archived: github.Ptr(false),
- DefaultBranch: github.Ptr("master"),
- },
- },
- }
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedErrMsg string
- expectedCount int
- }{
- {
- name: "successful list for authenticated user",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetUserStarred,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(mock.MustMarshal(mockStarredRepos))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{},
- expectError: false,
- expectedCount: 2,
- },
- {
- name: "successful list for specific user",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetUsersStarredByUsername,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusOK)
- _, _ = w.Write(mock.MustMarshal(mockStarredRepos))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "username": "testuser",
- },
- expectError: false,
- expectedCount: 2,
- },
- {
- name: "list fails",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.GetUserStarred,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- _, _ = w.Write([]byte(`{"message": "Not Found"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{},
- expectError: true,
- expectedErrMsg: "failed to list starred repositories",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- // Setup client with mock
- client := github.NewClient(tc.mockedClient)
- _, handler := ListStarredRepositories(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.NotNil(t, result)
- textResult, ok := result.Content[0].(mcp.TextContent)
- require.True(t, ok, "Expected text content")
- assert.Contains(t, textResult.Text, tc.expectedErrMsg)
- } else {
- require.NoError(t, err)
- require.NotNil(t, result)
-
- // Parse the result and get the text content
- textContent := getTextResult(t, result)
-
- // Unmarshal and verify the result
- var returnedRepos []MinimalRepository
- err = json.Unmarshal([]byte(textContent.Text), &returnedRepos)
- require.NoError(t, err)
-
- assert.Len(t, returnedRepos, tc.expectedCount)
- if tc.expectedCount > 0 {
- assert.Equal(t, "awesome-repo", returnedRepos[0].Name)
- assert.Equal(t, "owner/awesome-repo", returnedRepos[0].FullName)
- }
- }
- })
- }
-}
-
-func Test_StarRepository(t *testing.T) {
- // Verify tool definition once
- mockClient := github.NewClient(nil)
- tool, _ := StarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "star_repository", 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"})
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedErrMsg string
- }{
- {
- name: "successful star",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PutUserStarredByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNoContent)
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "testowner",
- "repo": "testrepo",
- },
- expectError: false,
- },
- {
- name: "star fails",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.PutUserStarredByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- _, _ = w.Write([]byte(`{"message": "Not Found"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "testowner",
- "repo": "nonexistent",
- },
- expectError: true,
- expectedErrMsg: "failed to star repository",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- // Setup client with mock
- client := github.NewClient(tc.mockedClient)
- _, handler := StarRepository(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.NotNil(t, result)
- textResult, ok := result.Content[0].(mcp.TextContent)
- require.True(t, ok, "Expected text content")
- assert.Contains(t, textResult.Text, tc.expectedErrMsg)
- } else {
- require.NoError(t, err)
- require.NotNil(t, result)
-
- // Parse the result and get the text content
- textContent := getTextResult(t, result)
- assert.Contains(t, textContent.Text, "Successfully starred repository")
- }
- })
- }
-}
-
-func Test_UnstarRepository(t *testing.T) {
- // Verify tool definition once
- mockClient := github.NewClient(nil)
- tool, _ := UnstarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper)
- require.NoError(t, toolsnaps.Test(tool.Name, tool))
-
- assert.Equal(t, "unstar_repository", 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"})
-
- tests := []struct {
- name string
- mockedClient *http.Client
- requestArgs map[string]interface{}
- expectError bool
- expectedErrMsg string
- }{
- {
- name: "successful unstar",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.DeleteUserStarredByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNoContent)
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "testowner",
- "repo": "testrepo",
- },
- expectError: false,
- },
- {
- name: "unstar fails",
- mockedClient: mock.NewMockedHTTPClient(
- mock.WithRequestMatchHandler(
- mock.DeleteUserStarredByOwnerByRepo,
- http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
- w.WriteHeader(http.StatusNotFound)
- _, _ = w.Write([]byte(`{"message": "Not Found"}`))
- }),
- ),
- ),
- requestArgs: map[string]interface{}{
- "owner": "testowner",
- "repo": "nonexistent",
- },
- expectError: true,
- expectedErrMsg: "failed to unstar repository",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- // Setup client with mock
- client := github.NewClient(tc.mockedClient)
- _, handler := UnstarRepository(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.NotNil(t, result)
- textResult, ok := result.Content[0].(mcp.TextContent)
- require.True(t, ok, "Expected text content")
- assert.Contains(t, textResult.Text, tc.expectedErrMsg)
- } else {
- require.NoError(t, err)
- require.NotNil(t, result)
-
- // Parse the result and get the text content
- textContent := getTextResult(t, result)
- assert.Contains(t, textContent.Text, "Successfully unstarred repository")
- }
- })
- }
-}
diff --git a/pkg/github/stargazers.go b/pkg/github/stargazers.go
new file mode 100644
index 000000000..f552e6fd4
--- /dev/null
+++ b/pkg/github/stargazers.go
@@ -0,0 +1,242 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v74/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+// ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user.
+func ListStarredRepositories(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("list_starred_repositories",
+ mcp.WithDescription(t("TOOL_LIST_STARRED_REPOSITORIES_DESCRIPTION", "List starred repositories")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_LIST_STARRED_REPOSITORIES_USER_TITLE", "List starred repositories"),
+ ReadOnlyHint: ToBoolPtr(true),
+ }),
+ mcp.WithString("username",
+ mcp.Description("Username to list starred repositories for. Defaults to the authenticated user."),
+ ),
+ mcp.WithString("sort",
+ mcp.Description("How to sort the results. Can be either 'created' (when the repository was starred) or 'updated' (when the repository was last pushed to)."),
+ mcp.Enum("created", "updated"),
+ ),
+ mcp.WithString("direction",
+ mcp.Description("The direction to sort the results by."),
+ mcp.Enum("asc", "desc"),
+ ),
+ WithPagination(),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ username, err := OptionalParam[string](request, "username")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ sort, err := OptionalParam[string](request, "sort")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ direction, err := OptionalParam[string](request, "direction")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ pagination, err := OptionalPaginationParams(request)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ opts := &github.ActivityListStarredOptions{
+ ListOptions: github.ListOptions{
+ Page: pagination.Page,
+ PerPage: pagination.PerPage,
+ },
+ }
+ if sort != "" {
+ opts.Sort = sort
+ }
+ if direction != "" {
+ opts.Direction = direction
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ var repos []*github.StarredRepository
+ var resp *github.Response
+ if username == "" {
+ // List starred repositories for the authenticated user
+ repos, resp, err = client.Activity.ListStarred(ctx, "", opts)
+ } else {
+ // List starred repositories for a specific user
+ repos, resp, err = client.Activity.ListStarred(ctx, username, opts)
+ }
+
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to list starred repositories for user '%s'", username),
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != 200 {
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+ return mcp.NewToolResultError(fmt.Sprintf("failed to list starred repositories: %s", string(body))), nil
+ }
+
+ // Convert to minimal format
+ minimalRepos := make([]MinimalRepository, 0, len(repos))
+ for _, starredRepo := range repos {
+ repo := starredRepo.Repository
+ minimalRepo := MinimalRepository{
+ ID: repo.GetID(),
+ Name: repo.GetName(),
+ FullName: repo.GetFullName(),
+ Description: repo.GetDescription(),
+ HTMLURL: repo.GetHTMLURL(),
+ Language: repo.GetLanguage(),
+ Stars: repo.GetStargazersCount(),
+ Forks: repo.GetForksCount(),
+ OpenIssues: repo.GetOpenIssuesCount(),
+ Private: repo.GetPrivate(),
+ Fork: repo.GetFork(),
+ Archived: repo.GetArchived(),
+ DefaultBranch: repo.GetDefaultBranch(),
+ }
+
+ if repo.UpdatedAt != nil {
+ minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z")
+ }
+
+ minimalRepos = append(minimalRepos, minimalRepo)
+ }
+
+ r, err := json.Marshal(minimalRepos)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal starred repositories: %w", err)
+ }
+
+ return mcp.NewToolResultText(string(r)), nil
+ }
+}
+
+// StarRepository creates a tool to star a repository.
+func StarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("star_repository",
+ mcp.WithDescription(t("TOOL_STAR_REPOSITORY_DESCRIPTION", "Star a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_STAR_REPOSITORY_USER_TITLE", "Star repository"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ 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
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ resp, err := client.Activity.Star(ctx, owner, repo)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to star repository %s/%s", owner, repo),
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != 204 {
+ 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 star repository: %s", string(body))), nil
+ }
+
+ return mcp.NewToolResultText(fmt.Sprintf("Successfully starred repository %s/%s", owner, repo)), nil
+ }
+}
+
+// UnstarRepository creates a tool to unstar a repository.
+func UnstarRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("unstar_repository",
+ mcp.WithDescription(t("TOOL_UNSTAR_REPOSITORY_DESCRIPTION", "Unstar a GitHub repository")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_UNSTAR_REPOSITORY_USER_TITLE", "Unstar repository"),
+ ReadOnlyHint: ToBoolPtr(false),
+ }),
+ mcp.WithString("owner",
+ mcp.Required(),
+ mcp.Description("Repository owner"),
+ ),
+ mcp.WithString("repo",
+ mcp.Required(),
+ mcp.Description("Repository name"),
+ ),
+ ),
+ func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
+ 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
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+ }
+
+ resp, err := client.Activity.Unstar(ctx, owner, repo)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ fmt.Sprintf("failed to unstar repository %s/%s", owner, repo),
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ if resp.StatusCode != 204 {
+ 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 unstar repository: %s", string(body))), nil
+ }
+
+ return mcp.NewToolResultText(fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo)), nil
+ }
+}
diff --git a/pkg/github/stargazers_test.go b/pkg/github/stargazers_test.go
new file mode 100644
index 000000000..67c2217cd
--- /dev/null
+++ b/pkg/github/stargazers_test.go
@@ -0,0 +1,343 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/github/github-mcp-server/internal/toolsnaps"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v74/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/migueleliasweb/go-github-mock/src/mock"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func Test_ListStarredRepositories(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := ListStarredRepositories(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "list_starred_repositories", tool.Name)
+ assert.NotEmpty(t, tool.Description)
+ assert.Contains(t, tool.InputSchema.Properties, "username")
+ assert.Contains(t, tool.InputSchema.Properties, "sort")
+ assert.Contains(t, tool.InputSchema.Properties, "direction")
+ assert.Contains(t, tool.InputSchema.Properties, "page")
+ assert.Contains(t, tool.InputSchema.Properties, "perPage")
+ assert.Empty(t, tool.InputSchema.Required) // All parameters are optional
+
+ // Setup mock starred repositories
+ starredAt := time.Now().Add(-24 * time.Hour)
+ updatedAt := time.Now().Add(-2 * time.Hour)
+ mockStarredRepos := []*github.StarredRepository{
+ {
+ StarredAt: &github.Timestamp{Time: starredAt},
+ Repository: &github.Repository{
+ ID: github.Ptr(int64(12345)),
+ Name: github.Ptr("awesome-repo"),
+ FullName: github.Ptr("owner/awesome-repo"),
+ Description: github.Ptr("An awesome repository"),
+ HTMLURL: github.Ptr("https://github.com/owner/awesome-repo"),
+ Language: github.Ptr("Go"),
+ StargazersCount: github.Ptr(100),
+ ForksCount: github.Ptr(25),
+ OpenIssuesCount: github.Ptr(5),
+ UpdatedAt: &github.Timestamp{Time: updatedAt},
+ Private: github.Ptr(false),
+ Fork: github.Ptr(false),
+ Archived: github.Ptr(false),
+ DefaultBranch: github.Ptr("main"),
+ },
+ },
+ {
+ StarredAt: &github.Timestamp{Time: starredAt.Add(-12 * time.Hour)},
+ Repository: &github.Repository{
+ ID: github.Ptr(int64(67890)),
+ Name: github.Ptr("cool-project"),
+ FullName: github.Ptr("user/cool-project"),
+ Description: github.Ptr("A very cool project"),
+ HTMLURL: github.Ptr("https://github.com/user/cool-project"),
+ Language: github.Ptr("Python"),
+ StargazersCount: github.Ptr(500),
+ ForksCount: github.Ptr(75),
+ OpenIssuesCount: github.Ptr(10),
+ UpdatedAt: &github.Timestamp{Time: updatedAt.Add(-1 * time.Hour)},
+ Private: github.Ptr(false),
+ Fork: github.Ptr(true),
+ Archived: github.Ptr(false),
+ DefaultBranch: github.Ptr("master"),
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ expectedCount int
+ }{
+ {
+ name: "successful list for authenticated user",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetUserStarred,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(mock.MustMarshal(mockStarredRepos))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{},
+ expectError: false,
+ expectedCount: 2,
+ },
+ {
+ name: "successful list for specific user",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetUsersStarredByUsername,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write(mock.MustMarshal(mockStarredRepos))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "username": "testuser",
+ },
+ expectError: false,
+ expectedCount: 2,
+ },
+ {
+ name: "list fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetUserStarred,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{},
+ expectError: true,
+ expectedErrMsg: "failed to list starred repositories",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := ListStarredRepositories(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.NotNil(t, result)
+ textResult, ok := result.Content[0].(mcp.TextContent)
+ require.True(t, ok, "Expected text content")
+ assert.Contains(t, textResult.Text, tc.expectedErrMsg)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ // Parse the result and get the text content
+ textContent := getTextResult(t, result)
+
+ // Unmarshal and verify the result
+ var returnedRepos []MinimalRepository
+ err = json.Unmarshal([]byte(textContent.Text), &returnedRepos)
+ require.NoError(t, err)
+
+ assert.Len(t, returnedRepos, tc.expectedCount)
+ if tc.expectedCount > 0 {
+ assert.Equal(t, "awesome-repo", returnedRepos[0].Name)
+ assert.Equal(t, "owner/awesome-repo", returnedRepos[0].FullName)
+ }
+ }
+ })
+ }
+}
+
+func Test_StarRepository(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := StarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "star_repository", 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"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful star",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PutUserStarredByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ "repo": "testrepo",
+ },
+ expectError: false,
+ },
+ {
+ name: "star fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.PutUserStarredByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ "repo": "nonexistent",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to star repository",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := StarRepository(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.NotNil(t, result)
+ textResult, ok := result.Content[0].(mcp.TextContent)
+ require.True(t, ok, "Expected text content")
+ assert.Contains(t, textResult.Text, tc.expectedErrMsg)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ // Parse the result and get the text content
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, "Successfully starred repository")
+ }
+ })
+ }
+}
+
+func Test_UnstarRepository(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := UnstarRepository(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "unstar_repository", 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"})
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successful unstar",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.DeleteUserStarredByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ "repo": "testrepo",
+ },
+ expectError: false,
+ },
+ {
+ name: "unstar fails",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.DeleteUserStarredByOwnerByRepo,
+ http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"message": "Not Found"}`))
+ }),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "testowner",
+ "repo": "nonexistent",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to unstar repository",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ // Setup client with mock
+ client := github.NewClient(tc.mockedClient)
+ _, handler := UnstarRepository(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.NotNil(t, result)
+ textResult, ok := result.Content[0].(mcp.TextContent)
+ require.True(t, ok, "Expected text content")
+ assert.Contains(t, textResult.Text, tc.expectedErrMsg)
+ } else {
+ require.NoError(t, err)
+ require.NotNil(t, result)
+
+ // Parse the result and get the text content
+ textContent := getTextResult(t, result)
+ assert.Contains(t, textContent.Text, "Successfully unstarred repository")
+ }
+ })
+ }
+}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index 659286e02..315c01093 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -99,6 +99,10 @@ var (
ID: "stargazers",
Description: "GitHub Stargazers related tools",
}
+ ToolsetMetadataReleases = ToolsetMetadata{
+ ID: "releases",
+ Description: "GitHub Releases related tools",
+ }
ToolsetMetadataDynamic = ToolsetMetadata{
ID: "dynamic",
Description: "Discover GitHub MCP tools that can help achieve tasks by enabling additional sets of tools, you can control the enablement of any toolset to access its tools when this toolset is enabled.",
@@ -158,29 +162,22 @@ func GetDefaultToolsetIDs() []string {
func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc, contentWindowSize int) *toolsets.ToolsetGroup {
tsg := toolsets.NewToolsetGroup(readOnly)
- // Define all available features with their default state (disabled)
+ // Define all available features with their default state (disabled)
// Create toolsets
repos := toolsets.NewToolset(ToolsetMetadataRepos.ID, ToolsetMetadataRepos.Description).
AddReadTools(
toolsets.NewServerTool(SearchRepositories(getClient, t)),
toolsets.NewServerTool(GetFileContents(getClient, getRawClient, t)),
- toolsets.NewServerTool(ListCommits(getClient, t)),
+ toolsets.NewServerTool(CommitRead(getClient, t)),
toolsets.NewServerTool(SearchCode(getClient, t)),
- toolsets.NewServerTool(GetCommit(getClient, t)),
toolsets.NewServerTool(ListBranches(getClient, t)),
- toolsets.NewServerTool(ListTags(getClient, t)),
- toolsets.NewServerTool(GetTag(getClient, t)),
- toolsets.NewServerTool(ListReleases(getClient, t)),
- toolsets.NewServerTool(GetLatestRelease(getClient, t)),
- toolsets.NewServerTool(GetReleaseByTag(getClient, t)),
+ toolsets.NewServerTool(TagRead(getClient, t)),
).
AddWriteTools(
- toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)),
+ toolsets.NewServerTool(FileWrite(getClient, t)),
toolsets.NewServerTool(CreateRepository(getClient, t)),
toolsets.NewServerTool(ForkRepository(getClient, t)),
toolsets.NewServerTool(CreateBranch(getClient, t)),
- toolsets.NewServerTool(PushFiles(getClient, t)),
- toolsets.NewServerTool(DeleteFile(getClient, t)),
).
AddResourceTemplates(
toolsets.NewServerResourceTemplate(GetRepositoryResourceContent(getClient, getRawClient, t)),
@@ -338,6 +335,10 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerTool(StarRepository(getClient, t)),
toolsets.NewServerTool(UnstarRepository(getClient, t)),
)
+ releases := toolsets.NewToolset(ToolsetMetadataReleases.ID, ToolsetMetadataReleases.Description).
+ AddReadTools(
+ toolsets.NewServerTool(ReleaseRead(getClient, t)),
+ )
labels := toolsets.NewToolset(ToolsetLabels.ID, ToolsetLabels.Description).
AddReadTools(
// get
@@ -367,6 +368,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
tsg.AddToolset(securityAdvisories)
tsg.AddToolset(projects)
tsg.AddToolset(stargazers)
+ tsg.AddToolset(releases)
tsg.AddToolset(labels)
return tsg