From f1813d73ace0a75aec252826305ed849c23474c8 Mon Sep 17 00:00:00 2001 From: Matt Metzger Date: Fri, 4 Apr 2025 16:19:23 -0400 Subject: [PATCH 1/2] Add support for retrieving GitHub Issue Comments Add new tool 'get_issue_comments' that allows fetching comments associated with GitHub issues. This complements the existing issue retrieval functionality and follows the same patterns as the pull request comments implementation. The implementation includes: - New getIssueComments function in pkg/github/issues.go - Tool registration in server.go - Comprehensive test coverage in issues_test.go --- README.md | 6 +++ cmd/mcpcurl/README.md | 1 + pkg/github/issues.go | 60 +++++++++++++++++++++ pkg/github/issues_test.go | 109 ++++++++++++++++++++++++++++++++++++++ pkg/github/server.go | 1 + 5 files changed, 177 insertions(+) diff --git a/README.md b/README.md index 56d16b60..8c24d474 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,12 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `repo`: Repository name (string, required) - `issue_number`: Issue number (number, required) +- **get_issue_comments** - Get comments for a GitHub issue + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `issue_number`: Issue number (number, required) + - **create_issue** - Create a new issue in a GitHub repository - `owner`: Repository owner (string, required) diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md index 06ce26ee..95e1339a 100644 --- a/cmd/mcpcurl/README.md +++ b/cmd/mcpcurl/README.md @@ -50,6 +50,7 @@ Available Commands: fork_repository Fork a GitHub repository to your account or specified organization get_file_contents Get the contents of a file or directory from a GitHub repository get_issue Get details of a specific issue in a GitHub repository. + get_issue_comments Get comments for a GitHub issue list_commits Get list of commits of a branch in a GitHub repository list_issues List issues in a GitHub repository with filtering options push_files Push multiple files to a GitHub repository in a single commit diff --git a/pkg/github/issues.go b/pkg/github/issues.go index df2f6f58..4aee8c3a 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -597,6 +597,66 @@ func updateIssue(client *github.Client, t translations.TranslationHelperFunc) (t } } +// getIssueComments creates a tool to get comments for a GitHub issue. +func getIssueComments(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_issue_comments", + mcp.WithDescription(t("TOOL_GET_ISSUE_COMMENTS_DESCRIPTION", "Get comments for a GitHub issue")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("Issue number"), + ), + ), + 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 + } + issueNumber, err := requiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.IssueListCommentsOptions{ + ListOptions: github.ListOptions{ + PerPage: 100, + }, + } + + comments, resp, err := client.Issues.ListComments(ctx, owner, repo, issueNumber, opts) + if err != nil { + return nil, fmt.Errorf("failed to get issue comments: %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 issue comments: %s", string(body))), nil + } + + r, err := json.Marshal(comments) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. // Returns the parsed time or an error if parsing fails. // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 5dab1631..8d4bf8d2 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -984,3 +984,112 @@ func Test_ParseISOTimestamp(t *testing.T) { }) } } + +func Test_GetIssueComments(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := getIssueComments(mockClient, translations.NullTranslationHelper) + + assert.Equal(t, "get_issue_comments", 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, "issue_number") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + + // Setup mock comments for success case + mockComments := []*github.IssueComment{ + { + ID: github.Ptr(int64(123)), + Body: github.Ptr("This is the first comment"), + User: &github.User{ + Login: github.Ptr("user1"), + }, + CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 24)}, + }, + { + ID: github.Ptr(int64(456)), + Body: github.Ptr("This is the second comment"), + User: &github.User{ + Login: github.Ptr("user2"), + }, + CreatedAt: &github.Timestamp{Time: time.Now().Add(-time.Hour)}, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedComments []*github.IssueComment + expectedErrMsg string + }{ + { + name: "successful comments retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, + mockComments, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedComments: mockComments, + }, + { + name: "issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get issue comments", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := getIssueComments(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) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedComments []*github.IssueComment + err = json.Unmarshal([]byte(textContent.Text), &returnedComments) + require.NoError(t, err) + assert.Equal(t, len(tc.expectedComments), len(returnedComments)) + if len(returnedComments) > 0 { + assert.Equal(t, *tc.expectedComments[0].Body, *returnedComments[0].Body) + assert.Equal(t, *tc.expectedComments[0].User.Login, *returnedComments[0].User.Login) + } + }) + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index 66dbfd1c..18e5da09 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -34,6 +34,7 @@ func NewServer(client *github.Client, readOnly bool, t translations.TranslationH s.AddTool(getIssue(client, t)) s.AddTool(searchIssues(client, t)) s.AddTool(listIssues(client, t)) + s.AddTool(getIssueComments(client, t)) if !readOnly { s.AddTool(createIssue(client, t)) s.AddTool(addIssueComment(client, t)) From 6c2a1594334279d10ff5a5bf525848923f70824d Mon Sep 17 00:00:00 2001 From: Matt Metzger Date: Sat, 5 Apr 2025 15:48:50 -0400 Subject: [PATCH 2/2] Support pagination for get_issue_comments --- pkg/github/issues.go | 17 ++++++++++++++++- pkg/github/issues_test.go | 25 +++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 4aee8c3a..d5aba2e7 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -613,6 +613,12 @@ func getIssueComments(client *github.Client, t translations.TranslationHelperFun mcp.Required(), mcp.Description("Issue number"), ), + mcp.WithNumber("page", + mcp.Description("Page number"), + ), + mcp.WithNumber("per_page", + mcp.Description("Number of records per page"), + ), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := requiredParam[string](request, "owner") @@ -627,10 +633,19 @@ func getIssueComments(client *github.Client, t translations.TranslationHelperFun if err != nil { return mcp.NewToolResultError(err.Error()), nil } + page, err := optionalIntParamWithDefault(request, "page", 1) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := optionalIntParamWithDefault(request, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } opts := &github.IssueListCommentsOptions{ ListOptions: github.ListOptions{ - PerPage: 100, + Page: page, + PerPage: perPage, }, } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 8d4bf8d2..0326b9be 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -995,6 +995,8 @@ func Test_GetIssueComments(t *testing.T) { assert.Contains(t, tool.InputSchema.Properties, "owner") assert.Contains(t, tool.InputSchema.Properties, "repo") assert.Contains(t, tool.InputSchema.Properties, "issue_number") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "per_page") assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) // Setup mock comments for success case @@ -1041,6 +1043,29 @@ func Test_GetIssueComments(t *testing.T) { expectError: false, expectedComments: mockComments, }, + { + name: "successful comments retrieval with pagination", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, + expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockComments), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + "page": float64(2), + "per_page": float64(10), + }, + expectError: false, + expectedComments: mockComments, + }, { name: "issue not found", mockedClient: mock.NewMockedHTTPClient(