From ac896261ae84c8e4ec13706b183b2a2ae229f546 Mon Sep 17 00:00:00 2001 From: tonytrg Date: Wed, 22 Oct 2025 20:26:09 +0200 Subject: [PATCH 1/3] consolidating repos tools --- README.md | 80 +- pkg/github/__toolsnaps__/commit_read.snap | 58 + .../__toolsnaps__/create_or_update_file.snap | 49 - pkg/github/__toolsnaps__/delete_file.snap | 41 - pkg/github/__toolsnaps__/file_write.snap | 80 + pkg/github/__toolsnaps__/get_commit.snap | 46 - .../__toolsnaps__/get_release_by_tag.snap | 30 - pkg/github/__toolsnaps__/get_tag.snap | 30 - pkg/github/__toolsnaps__/list_commits.snap | 44 - pkg/github/__toolsnaps__/list_tags.snap | 36 - pkg/github/__toolsnaps__/push_files.snap | 58 - pkg/github/__toolsnaps__/release_read.snap | 49 + pkg/github/repositories.go | 1696 ++++++++--------- pkg/github/repositories_test.go | 1061 ++--------- pkg/github/tools.go | 15 +- 15 files changed, 1123 insertions(+), 2250 deletions(-) create mode 100644 pkg/github/__toolsnaps__/commit_read.snap delete mode 100644 pkg/github/__toolsnaps__/create_or_update_file.snap delete mode 100644 pkg/github/__toolsnaps__/delete_file.snap create mode 100644 pkg/github/__toolsnaps__/file_write.snap delete mode 100644 pkg/github/__toolsnaps__/get_commit.snap delete mode 100644 pkg/github/__toolsnaps__/get_release_by_tag.snap delete mode 100644 pkg/github/__toolsnaps__/get_tag.snap delete mode 100644 pkg/github/__toolsnaps__/list_commits.snap delete mode 100644 pkg/github/__toolsnaps__/list_tags.snap delete mode 100644 pkg/github/__toolsnaps__/push_files.snap create mode 100644 pkg/github/__toolsnaps__/release_read.snap diff --git a/README.md b/README.md index bdba0d146..2e189a3ce 100644 --- a/README.md +++ b/README.md @@ -983,21 +983,22 @@ Possible options: 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) @@ -1005,26 +1006,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) @@ -1032,52 +1029,19 @@ 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) +- **release_read** - Read operations for releases and tags + - `method`: The read operation to perform on releases/tags. (string, required) - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (for list_tags and list_releases methods) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (for list_tags and list_releases methods) (number, optional) - `repo`: Repository name (string, required) + - `tag`: Tag name (required for get_tag and get_release_by_tag methods) (string, optional) - **search_code** - Search code - `order`: Sort order for results (string, optional) 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_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap deleted file mode 100644 index 1c2ecc9a3..000000000 --- a/pkg/github/__toolsnaps__/get_commit.snap +++ /dev/null @@ -1,46 +0,0 @@ -{ - "annotations": { - "title": "Get commit details", - "readOnlyHint": true - }, - "description": "Get details for a commit from a GitHub repository", - "inputSchema": { - "properties": { - "include_diff": { - "default": true, - "description": "Whether to include file diffs and stats in the response. Default is true.", - "type": "boolean" - }, - "owner": { - "description": "Repository owner", - "type": "string" - }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, - "perPage": { - "description": "Results per page for pagination (min 1, max 100)", - "maximum": 100, - "minimum": 1, - "type": "number" - }, - "repo": { - "description": "Repository name", - "type": "string" - }, - "sha": { - "description": "Commit SHA, branch name, or tag name", - "type": "string" - } - }, - "required": [ - "owner", - "repo", - "sha" - ], - "type": "object" - }, - "name": "get_commit" -} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_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_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap deleted file mode 100644 index a802436c2..000000000 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ /dev/null @@ -1,44 +0,0 @@ -{ - "annotations": { - "title": "List commits", - "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).", - "inputSchema": { - "properties": { - "author": { - "description": "Author username or email address to filter commits by", - "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": "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA.", - "type": "string" - } - }, - "required": [ - "owner", - "repo" - ], - "type": "object" - }, - "name": "list_commits" -} \ 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__/release_read.snap b/pkg/github/__toolsnaps__/release_read.snap new file mode 100644 index 000000000..d81bbd1ba --- /dev/null +++ b/pkg/github/__toolsnaps__/release_read.snap @@ -0,0 +1,49 @@ +{ + "annotations": { + "title": "Read operations for releases and tags", + "readOnlyHint": true + }, + "description": "Read operations for releases and tags in a GitHub repository.\n\nAvailable methods:\n- list_tags: List all git tags in a repository.\n- get_tag: Get details about a specific git tag.\n- list_releases: List all releases in a repository.\n- get_latest_release: Get the latest release in a repository.\n- get_release_by_tag: Get a specific release by its tag name.\n", + "inputSchema": { + "properties": { + "method": { + "description": "The read operation to perform on releases/tags.", + "enum": [ + "list_tags", + "get_tag", + "list_releases", + "get_latest_release", + "get_release_by_tag" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1) (for list_tags and list_releases methods)", + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100) (for list_tags and list_releases methods)", + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "tag": { + "description": "Tag name (required for get_tag and get_release_by_tag methods)", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo" + ], + "type": "object" + }, + "name": "release_read" +} \ No newline at end of file diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 7ffc5fc0c..85585f186 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,169 +693,8 @@ 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() }() - - 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 + // Get the source branch SHA + var ref *github.Reference if fromBranch == "" { // Get default branch if from_branch not specified @@ -1044,255 +747,27 @@ 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) - } - - 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")), +// ReleaseRead creates a consolidated tool for release and tag read operations. +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 releases and tags in a GitHub repository. + +Available methods: +- list_tags: List all git tags in a repository. +- get_tag: Get details about a specific git tag. +- list_releases: List all releases in a repository. +- get_latest_release: Get the latest release in a repository. +- get_release_by_tag: Get a specific release by its tag name. +`)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_LIST_TAGS_USER_TITLE", "List tags"), + Title: t("TOOL_RELEASE_READ_USER_TITLE", "Read operations for releases and tags"), ReadOnlyHint: ToBoolPtr(true), }), - mcp.WithString("owner", + mcp.WithString("method", mcp.Required(), - mcp.Description("Repository owner"), + mcp.Description("The read operation to perform on releases/tags."), + mcp.Enum("list_tags", "get_tag", "list_releases", "get_latest_release", "get_release_by_tag"), ), - 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,226 +777,26 @@ 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_tag and get_release_by_tag methods)"), ), - ), - func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - repo, err := RequiredParam[string](request, "repo") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - tag, err := RequiredParam[string](request, "tag") - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - - client, err := getClient(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get GitHub client: %w", err) - } - - // First get the tag reference - ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/tags/"+tag) - if err != nil { - return 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 - } - 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) - } - - releases, resp, err := client.Repositories.ListReleases(ctx, owner, repo, opts) - if err != nil { - return nil, fmt.Errorf("failed to list releases: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to list 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() }() - - 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 - } -} - -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')"), + mcp.WithNumber("page", + mcp.Description("Page number for pagination (min 1) (for list_tags and list_releases methods)"), + ), + mcp.WithNumber("perPage", + mcp.Description("Results per page for pagination (min 1, max 100) (for list_tags and list_releases methods)"), ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { - owner, err := RequiredParam[string](request, "owner") + method, err := RequiredParam[string](request, "method") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - repo, err := RequiredParam[string](request, "repo") + + owner, err := RequiredParam[string](request, "owner") if err != nil { return mcp.NewToolResultError(err.Error()), nil } - tag, err := RequiredParam[string](request, "tag") + repo, err := RequiredParam[string](request, "repo") if err != nil { return mcp.NewToolResultError(err.Error()), nil } @@ -1531,31 +806,198 @@ func GetReleaseByTag(getClient GetClientFn, t translations.TranslationHelperFunc return nil, fmt.Errorf("failed to get GitHub client: %w", err) } - 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 + switch method { + case "list_tags": + return ListTagsMethod(ctx, client, owner, repo, request) + case "get_tag": + return GetTagMethod(ctx, client, owner, repo, request) + case "list_releases": + return ListReleasesMethod(ctx, client, owner, repo, request) + case "get_latest_release": + return GetLatestReleaseMethod(ctx, client, owner, repo) + case "get_release_by_tag": + return GetReleaseByTagMethod(ctx, client, owner, repo, request) + default: + return nil, fmt.Errorf("unknown method: %s", method) } - 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 - } +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 + } - r, err := json.Marshal(release) - 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 + 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 +} + +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 + } + + // 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 +} + +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 nil, fmt.Errorf("failed to list releases: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list 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 +} + +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 nil, fmt.Errorf("failed to get latest release: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to get 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 +} + +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 } // filterPaths filters the entries in a GitHub tree to find paths that @@ -1693,6 +1135,436 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner return &raw.ContentOpts{Ref: ref, SHA: sha}, nil } +// 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_FILE_WRITE_USER_TITLE", "Write operations (create, update, delete, push_files) on repository files"), + ReadOnlyHint: ToBoolPtr(false), + }), + 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("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + 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)"), + ), + ), + 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 + } + 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 + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + 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) + } + } +} + +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 + } + + // 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), + } + + // 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() }() + + 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 + } + + 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 +} + +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 + } + + // 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() }() + + 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 +} + +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 + } + + // 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) + } + + return mcp.NewToolResultText(string(r)), 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", diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 22014148d..02643bd0a 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,242 +1627,71 @@ 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_ReleaseRead(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := DeleteFile(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := ReleaseRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "delete_file", tool.Name) + 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, "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), - ), - ), - // 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), + mock.GetReposTagsByOwnerByRepo, + expectPath( + t, + "/repos/owner/repo/tags", + ).andThen( + mockResponse(t, http.StatusOK, mockTags), ), ), ), requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", + "method": "list_tags", + "owner": "owner", + "repo": "repo", }, expectError: false, expectedTags: mockTags, @@ -2028,8 +1708,9 @@ func Test_ListTags(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "owner": "owner", - "repo": "repo", + "method": "list_tags", + "owner": "owner", + "repo": "repo", }, expectError: true, expectedErrMsg: "failed to list tags", @@ -2040,7 +1721,7 @@ func Test_ListTags(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ListTags(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := ReleaseRead(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) @@ -2078,496 +1759,6 @@ func Test_ListTags(t *testing.T) { } } -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", - }, - expectError: false, // API errors return tool errors, not Go errors - expectedErrMsg: "failed to get release by tag: v999.0.0", - }, - { - name: "server error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesTagsByOwnerByRepoByTag, - 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", - "tag": "v1.0.0", - }, - expectError: false, // API errors return tool errors, not Go errors - expectedErrMsg: "failed to get release by tag: v1.0.0", - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - _, handler := GetReleaseByTag(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) - - if tc.expectedErrMsg != "" { - require.True(t, result.IsError) - errorContent := getErrorResult(t, result) - assert.Contains(t, errorContent.Text, tc.expectedErrMsg) - return - } - - require.False(t, result.IsError) - - textContent := getTextResult(t, result) - - var returnedRelease github.RepositoryRelease - err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) - 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) - } - }) - } -} func Test_filterPaths(t *testing.T) { tests := []struct { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 31138258a..1e3f29e08 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -158,29 +158,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(ReleaseRead(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)), From d5675c5341247bc59f015b5eababcf4992853cc8 Mon Sep 17 00:00:00 2001 From: tonytrg Date: Fri, 24 Oct 2025 11:13:51 +0200 Subject: [PATCH 2/3] housekeeping move stargazer tools --- docs/remote-server.md | 3 +- pkg/github/repositories.go | 228 --------------------- pkg/github/repositories_test.go | 326 ------------------------------ pkg/github/stargazers.go | 242 ++++++++++++++++++++++ pkg/github/stargazers_test.go | 343 ++++++++++++++++++++++++++++++++ 5 files changed, 586 insertions(+), 556 deletions(-) create mode 100644 pkg/github/stargazers.go create mode 100644 pkg/github/stargazers_test.go diff --git a/docs/remote-server.md b/docs/remote-server.md index 66c8be388..fa55168e5 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,8 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | |----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| default | Default GitHub MCP toolset (see [default toolset](../README.md#default-toolset)) | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | -| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%2Freadonly%22%7D) | +| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 85585f186..bd152749c 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1564,231 +1564,3 @@ func PushFilesMethod(ctx context.Context, client *github.Client, owner, repo, br return mcp.NewToolResultText(string(r)), 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")), - 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/repositories_test.go b/pkg/github/repositories_test.go index 02643bd0a..e34d60d59 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2102,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") + } + }) + } +} From 6fba3ac4d7b1f5af7f9e7e78d0e62957287b76a9 Mon Sep 17 00:00:00 2001 From: tonytrg Date: Fri, 24 Oct 2025 11:47:26 +0200 Subject: [PATCH 3/3] refactor --- README.md | 31 ++- docs/remote-server.md | 1 + pkg/github/__toolsnaps__/release_read.snap | 23 +- pkg/github/__toolsnaps__/tag_read.snap | 49 ++++ pkg/github/releases.go | 180 +++++++++++++++ pkg/github/releases_test.go | 253 +++++++++++++++++++++ pkg/github/repositories.go | 132 ++--------- pkg/github/repositories_test.go | 12 +- pkg/github/tools.go | 11 +- 9 files changed, 550 insertions(+), 142 deletions(-) create mode 100644 pkg/github/__toolsnaps__/tag_read.snap create mode 100644 pkg/github/releases.go create mode 100644 pkg/github/releases_test.go diff --git a/README.md b/README.md index a554c5b12..9c4b7e813 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,6 +963,20 @@ 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 @@ -1016,14 +1031,6 @@ Possible options: - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) -- **release_read** - Read operations for releases and tags - - `method`: The read operation to perform on releases/tags. (string, required) - - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (min 1) (for list_tags and list_releases methods) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (for list_tags and list_releases methods) (number, optional) - - `repo`: Repository name (string, required) - - `tag`: Tag name (required for get_tag and get_release_by_tag methods) (string, optional) - - **search_code** - Search code - `order`: Sort order for results (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -1039,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__/release_read.snap b/pkg/github/__toolsnaps__/release_read.snap index d81bbd1ba..7251156e4 100644 --- a/pkg/github/__toolsnaps__/release_read.snap +++ b/pkg/github/__toolsnaps__/release_read.snap @@ -1,19 +1,17 @@ { "annotations": { - "title": "Read operations for releases and tags", + "title": "Read operations for releases", "readOnlyHint": true }, - "description": "Read operations for releases and tags in a GitHub repository.\n\nAvailable methods:\n- list_tags: List all git tags in a repository.\n- get_tag: Get details about a specific git tag.\n- list_releases: List all releases in a repository.\n- get_latest_release: Get the latest release in a repository.\n- get_release_by_tag: Get a specific release by its tag name.\n", + "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": { "method": { - "description": "The read operation to perform on releases/tags.", + "description": "The read operation to perform on releases.", "enum": [ - "list_tags", - "get_tag", - "list_releases", - "get_latest_release", - "get_release_by_tag" + "list", + "get_latest", + "get_by_tag" ], "type": "string" }, @@ -22,11 +20,14 @@ "type": "string" }, "page": { - "description": "Page number for pagination (min 1) (for list_tags and list_releases methods)", + "description": "Page number for pagination (min 1)", + "minimum": 1, "type": "number" }, "perPage": { - "description": "Results per page for pagination (min 1, max 100) (for list_tags and list_releases methods)", + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, "type": "number" }, "repo": { @@ -34,7 +35,7 @@ "type": "string" }, "tag": { - "description": "Tag name (required for get_tag and get_release_by_tag methods)", + "description": "Tag name (required for get_by_tag method)", "type": "string" } }, diff --git a/pkg/github/__toolsnaps__/tag_read.snap b/pkg/github/__toolsnaps__/tag_read.snap new file mode 100644 index 000000000..b78dff42d --- /dev/null +++ b/pkg/github/__toolsnaps__/tag_read.snap @@ -0,0 +1,49 @@ +{ + "annotations": { + "title": "Read operations for git tags", + "readOnlyHint": true + }, + "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": { + "method": { + "description": "The read operation to perform on tags.", + "enum": [ + "list", + "get" + ], + "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" + }, + "tag": { + "description": "Tag name (required for get method)", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo" + ], + "type": "object" + }, + "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 bd152749c..1954cb273 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -747,26 +747,24 @@ func CreateBranch(getClient GetClientFn, t translations.TranslationHelperFunc) ( } } -// ReleaseRead creates a consolidated tool for release and tag read operations. -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 releases and tags in a GitHub repository. +// 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. Available methods: -- list_tags: List all git tags in a repository. -- get_tag: Get details about a specific git tag. -- list_releases: List all releases in a repository. -- get_latest_release: Get the latest release in a repository. -- get_release_by_tag: Get a specific release by its tag name. +- list: List all git tags in a repository. +- get: Get details about a specific git tag. `)), mcp.WithToolAnnotation(mcp.ToolAnnotation{ - Title: t("TOOL_RELEASE_READ_USER_TITLE", "Read operations for releases and tags"), + Title: t("TOOL_TAG_READ_USER_TITLE", "Read operations for git tags"), ReadOnlyHint: ToBoolPtr(true), }), mcp.WithString("method", mcp.Required(), - mcp.Description("The read operation to perform on releases/tags."), - mcp.Enum("list_tags", "get_tag", "list_releases", "get_latest_release", "get_release_by_tag"), + mcp.Enum("list", "get"), + mcp.Description("The read operation to perform on tags."), ), mcp.WithString("owner", mcp.Required(), @@ -777,14 +775,9 @@ Available methods: mcp.Description("Repository name"), ), mcp.WithString("tag", - mcp.Description("Tag name (required for get_tag and get_release_by_tag methods)"), - ), - mcp.WithNumber("page", - mcp.Description("Page number for pagination (min 1) (for list_tags and list_releases methods)"), - ), - mcp.WithNumber("perPage", - mcp.Description("Results per page for pagination (min 1, max 100) (for list_tags and list_releases methods)"), + mcp.Description("Tag name (required for get method)"), ), + WithPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { method, err := RequiredParam[string](request, "method") @@ -807,22 +800,17 @@ Available methods: } switch method { - case "list_tags": + case "list": return ListTagsMethod(ctx, client, owner, repo, request) - case "get_tag": + case "get": return GetTagMethod(ctx, client, owner, repo, request) - case "list_releases": - return ListReleasesMethod(ctx, client, owner, repo, request) - case "get_latest_release": - return GetLatestReleaseMethod(ctx, client, owner, repo) - case "get_release_by_tag": - return GetReleaseByTagMethod(ctx, client, owner, repo, request) default: - return nil, fmt.Errorf("unknown method: %s", method) + return mcp.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil } } } +// 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 { @@ -912,94 +900,6 @@ func GetTagMethod(ctx context.Context, client *github.Client, owner, repo string return mcp.NewToolResultText(string(r)), nil } -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 nil, fmt.Errorf("failed to list releases: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to list 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 -} - -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 nil, fmt.Errorf("failed to get latest release: %w", err) - } - defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode != http.StatusOK { - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) - } - return mcp.NewToolResultError(fmt.Sprintf("failed to get 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 -} - -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 -} - // filterPaths filters the entries in a GitHub tree to find paths that // match the given suffix. // maxResults limits the number of results returned to first maxResults entries, diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index e34d60d59..3b7fb4364 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1629,13 +1629,13 @@ func Test_ListBranches(t *testing.T) { // Test_DeleteFile is removed as the delete_file tool has been consolidated into file_write -func Test_ReleaseRead(t *testing.T) { +func Test_TagRead(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) - tool, _ := ReleaseRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) + tool, _ := TagRead(stubGetClientFn(mockClient), translations.NullTranslationHelper) require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "release_read", 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") @@ -1689,7 +1689,7 @@ func Test_ReleaseRead(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "method": "list_tags", + "method": "list", "owner": "owner", "repo": "repo", }, @@ -1708,7 +1708,7 @@ func Test_ReleaseRead(t *testing.T) { ), ), requestArgs: map[string]interface{}{ - "method": "list_tags", + "method": "list", "owner": "owner", "repo": "repo", }, @@ -1721,7 +1721,7 @@ func Test_ReleaseRead(t *testing.T) { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := github.NewClient(tc.mockedClient) - _, handler := ReleaseRead(stubGetClientFn(client), translations.NullTranslationHelper) + _, handler := TagRead(stubGetClientFn(client), translations.NullTranslationHelper) // Create call request request := createMCPRequest(tc.requestArgs) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 2a88c2775..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.", @@ -167,7 +171,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG toolsets.NewServerTool(CommitRead(getClient, t)), toolsets.NewServerTool(SearchCode(getClient, t)), toolsets.NewServerTool(ListBranches(getClient, t)), - toolsets.NewServerTool(ReleaseRead(getClient, t)), + toolsets.NewServerTool(TagRead(getClient, t)), ). AddWriteTools( toolsets.NewServerTool(FileWrite(getClient, t)), @@ -331,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 @@ -360,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