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)),