From fe15e4ee80f1a25a2597c2e371aae2f465cf97b0 Mon Sep 17 00:00:00 2001 From: Eran Cohen Date: Thu, 24 Apr 2025 16:23:25 +0300 Subject: [PATCH 1/3] feat: Add support for git tag operations Add git tag functionality including: - List repository tags - Get tag details - Support for tag-based content access This enables basic read-only tag management through the MCP server API. --- pkg/github/repositories.go | 144 ++++++++++++++++++ pkg/github/repositories_test.go | 254 ++++++++++++++++++++++++++++++++ pkg/github/tools.go | 2 + 3 files changed, 400 insertions(+) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 7c1bc23e..beaab7c8 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -796,3 +796,147 @@ func PushFiles(getClient GetClientFn, t translations.TranslationHelperFunc) (too 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")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_LIST_TAGS_USER_TITLE", "List tags"), + ReadOnlyHint: 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) + } + + tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list tags: %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 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: 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"), + ), + ), + 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 nil, fmt.Errorf("failed to get tag reference: %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 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 nil, fmt.Errorf("failed to get tag object: %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 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 + } +} diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 5b8129fe..7fe58fc8 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1528,3 +1528,257 @@ func Test_ListBranches(t *testing.T) { }) } } + +func Test_ListTags(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListTags(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + 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("abc123"), + 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("def456"), + 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.WithRequestMatch( + mock.GetReposTagsByOwnerByRepo, + mockTags, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedTags: mockTags, + }, + { + name: "list tags fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposTagsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "failed to list tags", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := ListTags(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.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) + + // Parse and verify the result + var returnedTags []*github.RepositoryTag + err = json.Unmarshal([]byte(textContent.Text), &returnedTags) + require.NoError(t, err) + + // Verify each tag + require.Equal(t, len(tc.expectedTags), len(returnedTags)) + for i, expectedTag := range tc.expectedTags { + assert.Equal(t, *expectedTag.Name, *returnedTags[i].Name) + assert.Equal(t, *expectedTag.Commit.SHA, *returnedTags[i].Commit.SHA) + } + }) + } +} + +func Test_GetTag(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetTag(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + 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("tag123"), + }, + } + + mockTagObj := &github.Tag{ + SHA: github.Ptr("tag123"), + 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.WithRequestMatch( + mock.GetReposGitRefByOwnerByRepoByRef, + mockTagRef, + ), + mock.WithRequestMatch( + mock.GetReposGitTagsByOwnerByRepoByTagSha, + 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.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) + + // 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) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 1a4a3b4d..3776a129 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -27,6 +27,8 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, 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)), ). AddWriteTools( toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)), From 76c6f6db3c320c65d90942fac0222b1fd94871f3 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 29 Apr 2025 16:39:19 +0200 Subject: [PATCH 2/3] Test path params for tag tools --- pkg/github/helper_test.go | 29 ++++++++++++++++++++------- pkg/github/repositories_test.go | 35 +++++++++++++++++++++++---------- 2 files changed, 47 insertions(+), 17 deletions(-) diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 40fc0b94..f241d334 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -10,6 +10,15 @@ import ( "github.com/stretchr/testify/require" ) +// expectPath is a helper function to create a partial mock that expects a +// request with the given path, with the ability to chain a response handler. +func expectPath(t *testing.T, expectedPath string) *partialMock { + return &partialMock{ + t: t, + expectedPath: expectedPath, + } +} + // expectQueryParams is a helper function to create a partial mock that expects a // request with the given query parameters, with the ability to chain a response handler. func expectQueryParams(t *testing.T, expectedQueryParams map[string]string) *partialMock { @@ -29,7 +38,9 @@ func expectRequestBody(t *testing.T, expectedRequestBody any) *partialMock { } type partialMock struct { - t *testing.T + t *testing.T + + expectedPath string expectedQueryParams map[string]string expectedRequestBody any } @@ -37,12 +48,8 @@ type partialMock struct { func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc { p.t.Helper() return func(w http.ResponseWriter, r *http.Request) { - if p.expectedRequestBody != nil { - var unmarshaledRequestBody any - err := json.NewDecoder(r.Body).Decode(&unmarshaledRequestBody) - require.NoError(p.t, err) - - require.Equal(p.t, p.expectedRequestBody, unmarshaledRequestBody) + if p.expectedPath != "" { + require.Equal(p.t, p.expectedPath, r.URL.Path) } if p.expectedQueryParams != nil { @@ -52,6 +59,14 @@ func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc } } + if p.expectedRequestBody != nil { + var unmarshaledRequestBody any + err := json.NewDecoder(r.Body).Decode(&unmarshaledRequestBody) + require.NoError(p.t, err) + + require.Equal(p.t, p.expectedRequestBody, unmarshaledRequestBody) + } + responseHandler(w, r) } } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 7fe58fc8..59d19fc4 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1545,7 +1545,7 @@ func Test_ListTags(t *testing.T) { { Name: github.Ptr("v1.0.0"), Commit: &github.Commit{ - SHA: github.Ptr("abc123"), + 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"), @@ -1554,7 +1554,7 @@ func Test_ListTags(t *testing.T) { { Name: github.Ptr("v0.9.0"), Commit: &github.Commit{ - SHA: github.Ptr("def456"), + 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"), @@ -1573,9 +1573,14 @@ func Test_ListTags(t *testing.T) { { name: "successful tags list", mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( + mock.WithRequestMatchHandler( mock.GetReposTagsByOwnerByRepo, - mockTags, + expectPath( + t, + "/repos/owner/repo/tags", + ).andThen( + mockResponse(t, http.StatusOK, mockTags), + ), ), ), requestArgs: map[string]interface{}{ @@ -1659,12 +1664,12 @@ func Test_GetTag(t *testing.T) { mockTagRef := &github.Reference{ Ref: github.Ptr("refs/tags/v1.0.0"), Object: &github.GitObject{ - SHA: github.Ptr("tag123"), + SHA: github.Ptr("v1.0.0-tag-sha"), }, } mockTagObj := &github.Tag{ - SHA: github.Ptr("tag123"), + SHA: github.Ptr("v1.0.0-tag-sha"), Tag: github.Ptr("v1.0.0"), Message: github.Ptr("Release v1.0.0"), Object: &github.GitObject{ @@ -1684,13 +1689,23 @@ func Test_GetTag(t *testing.T) { { name: "successful tag retrieval", mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( + mock.WithRequestMatchHandler( mock.GetReposGitRefByOwnerByRepoByRef, - mockTagRef, + expectPath( + t, + "/repos/owner/repo/git/ref/tags/v1.0.0", + ).andThen( + mockResponse(t, http.StatusOK, mockTagRef), + ), ), - mock.WithRequestMatch( + mock.WithRequestMatchHandler( mock.GetReposGitTagsByOwnerByRepoByTagSha, - mockTagObj, + expectPath( + t, + "/repos/owner/repo/git/tags/v1.0.0-tag-sha", + ).andThen( + mockResponse(t, http.StatusOK, mockTagObj), + ), ), ), requestArgs: map[string]interface{}{ From 859621733cbff2a73dc550b469445d7000ce8018 Mon Sep 17 00:00:00 2001 From: William Martin Date: Wed, 30 Apr 2025 13:17:38 +0200 Subject: [PATCH 3/3] Add e2e test for tags --- e2e/README.md | 4 +- e2e/e2e_test.go | 136 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) diff --git a/e2e/README.md b/e2e/README.md index 21b65bfa..bb93b32c 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -81,4 +81,6 @@ FAIL The current test suite is intentionally very limited in scope. This is because the maintenance costs on e2e tests tend to increase significantly over time. To read about some challenges with GitHub integration tests, see [go-github integration tests README](https://github.com/google/go-github/blob/5b75aa86dba5cf4af2923afa0938774f37fa0a67/test/README.md). We will expand this suite circumspectly! -Currently, visibility into failures is not particularly good. +The tests are quite repetitive and verbose. This is intentional as we want to see them develop more before committing to abstractions. + +Currently, visibility into failures is not particularly good. We're hoping that we can pull apart the mcp-go client and have it hook into streams representing stdio without requiring an exec. This way we can get breakpoints in the debugger easily. diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 757dd5c2..5da6379c 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -206,3 +206,139 @@ func TestToolsets(t *testing.T) { require.True(t, toolsContains("list_branches"), "expected to find 'list_branches' tool") require.False(t, toolsContains("get_pull_request"), "expected not to find 'get_pull_request' tool") } + +func TestTags(t *testing.T) { + mcpClient := setupMCPClient(t) + + ctx := context.Background() + + // First, who am I + getMeRequest := mcp.CallToolRequest{} + getMeRequest.Params.Name = "get_me" + + t.Log("Getting current user...") + resp, err := mcpClient.CallTool(ctx, getMeRequest) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + require.False(t, resp.IsError, "expected result not to be an error") + require.Len(t, resp.Content, 1, "expected content to have one item") + + textContent, ok := resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetMeText struct { + Login string `json:"login"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetMeText) + require.NoError(t, err, "expected to unmarshal text content successfully") + + currentOwner := trimmedGetMeText.Login + + // Then create a repository with a README (via autoInit) + repoName := fmt.Sprintf("github-mcp-server-e2e-%s-%d", t.Name(), time.Now().UnixMilli()) + createRepoRequest := mcp.CallToolRequest{} + createRepoRequest.Params.Name = "create_repository" + createRepoRequest.Params.Arguments = map[string]any{ + "name": repoName, + "private": true, + "autoInit": true, + } + + t.Logf("Creating repository %s/%s...", currentOwner, repoName) + _, err = mcpClient.CallTool(ctx, createRepoRequest) + require.NoError(t, err, "expected to call 'get_me' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Cleanup the repository after the test + t.Cleanup(func() { + // MCP Server doesn't support deletions, but we can use the GitHub Client + ghClient := github.NewClient(nil).WithAuthToken(getE2EToken(t)) + t.Logf("Deleting repository %s/%s...", currentOwner, repoName) + _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName) + require.NoError(t, err, "expected to delete repository successfully") + }) + + // Then create a tag + // MCP Server doesn't support tag creation, but we can use the GitHub Client + ghClient := github.NewClient(nil).WithAuthToken(getE2EToken(t)) + t.Logf("Creating tag %s/%s:%s...", currentOwner, repoName, "v0.0.1") + ref, _, err := ghClient.Git.GetRef(context.Background(), currentOwner, repoName, "refs/heads/main") + require.NoError(t, err, "expected to get ref successfully") + + tagObj, _, err := ghClient.Git.CreateTag(context.Background(), currentOwner, repoName, &github.Tag{ + Tag: github.Ptr("v0.0.1"), + Message: github.Ptr("v0.0.1"), + Object: &github.GitObject{ + SHA: ref.Object.SHA, + Type: github.Ptr("commit"), + }, + }) + require.NoError(t, err, "expected to create tag object successfully") + + _, _, err = ghClient.Git.CreateRef(context.Background(), currentOwner, repoName, &github.Reference{ + Ref: github.Ptr("refs/tags/v0.0.1"), + Object: &github.GitObject{ + SHA: tagObj.SHA, + }, + }) + require.NoError(t, err, "expected to create tag ref successfully") + + // List the tags + listTagsRequest := mcp.CallToolRequest{} + listTagsRequest.Params.Name = "list_tags" + listTagsRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + } + + t.Logf("Listing tags for %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, listTagsRequest) + require.NoError(t, err, "expected to call 'list_tags' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + require.False(t, resp.IsError, "expected result not to be an error") + require.Len(t, resp.Content, 1, "expected content to have one item") + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedTags []struct { + Name string `json:"name"` + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedTags) + require.NoError(t, err, "expected to unmarshal text content successfully") + + require.Len(t, trimmedTags, 1, "expected to find one tag") + require.Equal(t, "v0.0.1", trimmedTags[0].Name, "expected tag name to match") + require.Equal(t, *ref.Object.SHA, trimmedTags[0].Commit.SHA, "expected tag SHA to match") + + // And fetch an individual tag + getTagRequest := mcp.CallToolRequest{} + getTagRequest.Params.Name = "get_tag" + getTagRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "tag": "v0.0.1", + } + + t.Logf("Getting tag %s/%s:%s...", currentOwner, repoName, "v0.0.1") + resp, err = mcpClient.CallTool(ctx, getTagRequest) + require.NoError(t, err, "expected to call 'get_tag' tool successfully") + require.False(t, resp.IsError, "expected result not to be an error") + + var trimmedTag []struct { // don't understand why this is an array + Name string `json:"name"` + Commit struct { + SHA string `json:"sha"` + } `json:"commit"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedTag) + require.NoError(t, err, "expected to unmarshal text content successfully") + require.Len(t, trimmedTag, 1, "expected to find one tag") + require.Equal(t, "v0.0.1", trimmedTag[0].Name, "expected tag name to match") + require.Equal(t, *ref.Object.SHA, trimmedTag[0].Commit.SHA, "expected tag SHA to match") +}