diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index b6637191..489681e9 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -4,6 +4,7 @@ package e2e_test import ( "context" + "encoding/base64" "encoding/json" "fmt" "os" @@ -369,3 +370,405 @@ func TestTags(t *testing.T) { 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") } + +func TestFileDeletion(t *testing.T) { + t.Parallel() + + 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 := gogithub.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") + }) + + // Create a branch on which to create a new commit + createBranchRequest := mcp.CallToolRequest{} + createBranchRequest.Params.Name = "create_branch" + createBranchRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "branch": "test-branch", + "from_branch": "main", + } + + t.Logf("Creating branch in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, createBranchRequest) + require.NoError(t, err, "expected to call 'create_branch' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Create a commit with a new file + commitRequest := mcp.CallToolRequest{} + commitRequest.Params.Name = "create_or_update_file" + commitRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-file.txt", + "content": fmt.Sprintf("Created by e2e test %s", t.Name()), + "message": "Add test file", + "branch": "test-branch", + } + + t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, commitRequest) + require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedCommitText struct { + SHA string `json:"sha"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedCommitText) + require.NoError(t, err, "expected to unmarshal text content successfully") + + // Check the file exists + getFileContentsRequest := mcp.CallToolRequest{} + getFileContentsRequest.Params.Name = "get_file_contents" + getFileContentsRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-file.txt", + "branch": "test-branch", + } + + t.Logf("Getting file contents in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, getFileContentsRequest) + require.NoError(t, err, "expected to call 'get_file_contents' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetFileText struct { + Content string `json:"content"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetFileText) + require.NoError(t, err, "expected to unmarshal text content successfully") + b, err := base64.StdEncoding.DecodeString(trimmedGetFileText.Content) + require.NoError(t, err, "expected to decode base64 content successfully") + require.Equal(t, fmt.Sprintf("Created by e2e test %s", t.Name()), string(b), "expected file content to match") + + // Delete the file + deleteFileRequest := mcp.CallToolRequest{} + deleteFileRequest.Params.Name = "delete_file" + deleteFileRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-file.txt", + "message": "Delete test file", + "branch": "test-branch", + } + + t.Logf("Deleting file in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, deleteFileRequest) + require.NoError(t, err, "expected to call 'delete_file' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // See that there is a commit that removes the file + listCommitsRequest := mcp.CallToolRequest{} + listCommitsRequest.Params.Name = "list_commits" + listCommitsRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "sha": "test-branch", // can be SHA or branch, which is an unfortunate API design + } + + t.Logf("Listing commits in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, listCommitsRequest) + require.NoError(t, err, "expected to call 'list_commits' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedListCommitsText []struct { + SHA string `json:"sha"` + Commit struct { + Message string `json:"message"` + } + Files []struct { + Filename string `json:"filename"` + Deletions int `json:"deletions"` + } + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedListCommitsText) + require.NoError(t, err, "expected to unmarshal text content successfully") + require.GreaterOrEqual(t, len(trimmedListCommitsText), 1, "expected to find at least one commit") + + deletionCommit := trimmedListCommitsText[0] + require.Equal(t, "Delete test file", deletionCommit.Commit.Message, "expected commit message to match") + + // Now get the commit so we can look at the file changes because list_commits doesn't include them + getCommitRequest := mcp.CallToolRequest{} + getCommitRequest.Params.Name = "get_commit" + getCommitRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "sha": deletionCommit.SHA, + } + + t.Logf("Getting commit %s/%s:%s...", currentOwner, repoName, deletionCommit.SHA) + resp, err = mcpClient.CallTool(ctx, getCommitRequest) + require.NoError(t, err, "expected to call 'get_commit' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetCommitText struct { + Files []struct { + Filename string `json:"filename"` + Deletions int `json:"deletions"` + } + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetCommitText) + require.NoError(t, err, "expected to unmarshal text content successfully") + require.Len(t, trimmedGetCommitText.Files, 1, "expected to find one file change") + require.Equal(t, "test-file.txt", trimmedGetCommitText.Files[0].Filename, "expected filename to match") + require.Equal(t, 1, trimmedGetCommitText.Files[0].Deletions, "expected one deletion") +} + +func TestDirectoryDeletion(t *testing.T) { + t.Parallel() + + 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 := gogithub.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") + }) + + // Create a branch on which to create a new commit + createBranchRequest := mcp.CallToolRequest{} + createBranchRequest.Params.Name = "create_branch" + createBranchRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "branch": "test-branch", + "from_branch": "main", + } + + t.Logf("Creating branch in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, createBranchRequest) + require.NoError(t, err, "expected to call 'create_branch' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // Create a commit with a new file + commitRequest := mcp.CallToolRequest{} + commitRequest.Params.Name = "create_or_update_file" + commitRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-dir/test-file.txt", + "content": fmt.Sprintf("Created by e2e test %s", t.Name()), + "message": "Add test file", + "branch": "test-branch", + } + + t.Logf("Creating commit with new file in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, commitRequest) + require.NoError(t, err, "expected to call 'create_or_update_file' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedCommitText struct { + SHA string `json:"sha"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedCommitText) + require.NoError(t, err, "expected to unmarshal text content successfully") + + // Check the file exists + getFileContentsRequest := mcp.CallToolRequest{} + getFileContentsRequest.Params.Name = "get_file_contents" + getFileContentsRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-dir/test-file.txt", + "branch": "test-branch", + } + + t.Logf("Getting file contents in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, getFileContentsRequest) + require.NoError(t, err, "expected to call 'get_file_contents' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetFileText struct { + Content string `json:"content"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetFileText) + require.NoError(t, err, "expected to unmarshal text content successfully") + b, err := base64.StdEncoding.DecodeString(trimmedGetFileText.Content) + require.NoError(t, err, "expected to decode base64 content successfully") + require.Equal(t, fmt.Sprintf("Created by e2e test %s", t.Name()), string(b), "expected file content to match") + + // Delete the directory containing the file + deleteFileRequest := mcp.CallToolRequest{} + deleteFileRequest.Params.Name = "delete_file" + deleteFileRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "path": "test-dir", + "message": "Delete test directory", + "branch": "test-branch", + } + + t.Logf("Deleting directory in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, deleteFileRequest) + require.NoError(t, err, "expected to call 'delete_file' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + // See that there is a commit that removes the directory + listCommitsRequest := mcp.CallToolRequest{} + listCommitsRequest.Params.Name = "list_commits" + listCommitsRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "sha": "test-branch", // can be SHA or branch, which is an unfortunate API design + } + + t.Logf("Listing commits in %s/%s...", currentOwner, repoName) + resp, err = mcpClient.CallTool(ctx, listCommitsRequest) + require.NoError(t, err, "expected to call 'list_commits' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedListCommitsText []struct { + SHA string `json:"sha"` + Commit struct { + Message string `json:"message"` + } + Files []struct { + Filename string `json:"filename"` + Deletions int `json:"deletions"` + } `json:"files"` + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedListCommitsText) + require.NoError(t, err, "expected to unmarshal text content successfully") + require.GreaterOrEqual(t, len(trimmedListCommitsText), 1, "expected to find at least one commit") + + deletionCommit := trimmedListCommitsText[0] + require.Equal(t, "Delete test directory", deletionCommit.Commit.Message, "expected commit message to match") + + // Now get the commit so we can look at the file changes because list_commits doesn't include them + getCommitRequest := mcp.CallToolRequest{} + getCommitRequest.Params.Name = "get_commit" + getCommitRequest.Params.Arguments = map[string]any{ + "owner": currentOwner, + "repo": repoName, + "sha": deletionCommit.SHA, + } + + t.Logf("Getting commit %s/%s:%s...", currentOwner, repoName, deletionCommit.SHA) + resp, err = mcpClient.CallTool(ctx, getCommitRequest) + require.NoError(t, err, "expected to call 'get_commit' tool successfully") + require.False(t, resp.IsError, fmt.Sprintf("expected result not to be an error: %+v", resp)) + + textContent, ok = resp.Content[0].(mcp.TextContent) + require.True(t, ok, "expected content to be of type TextContent") + + var trimmedGetCommitText struct { + Files []struct { + Filename string `json:"filename"` + Deletions int `json:"deletions"` + } + } + err = json.Unmarshal([]byte(textContent.Text), &trimmedGetCommitText) + require.NoError(t, err, "expected to unmarshal text content successfully") + require.Len(t, trimmedGetCommitText.Files, 1, "expected to find one file change") + require.Equal(t, "test-dir/test-file.txt", trimmedGetCommitText.Files[0].Filename, "expected filename to match") + require.Equal(t, 1, trimmedGetCommitText.Files[0].Deletions, "expected one deletion") +} diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index fa69de55..4403e2a1 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -287,7 +287,7 @@ func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperF return mcp.NewToolResultError(err.Error()), nil } - // Convert content to base64 + // json.Marshal encodes byte arrays with base64, which is required for the API. contentBytes := []byte(content) // Create the file options @@ -556,6 +556,166 @@ 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")), + mcp.WithToolAnnotation(mcp.ToolAnnotation{ + Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"), + ReadOnlyHint: toBoolPtr(false), + DestructiveHint: toBoolPtr(true), + }), + 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 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"), + ), + ), + 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 + } + 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 + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Get the reference for the branch + ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) + if err != nil { + return nil, fmt.Errorf("failed to get branch reference: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + // Get the commit object that the branch points to + baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) + if err != nil { + return nil, fmt.Errorf("failed to get base commit: %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 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 nil, fmt.Errorf("failed to create tree: %w", err) + } + 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 nil, fmt.Errorf("failed to create commit: %w", err) + } + 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 nil, fmt.Errorf("failed to update 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 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", diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 59d19fc4..6bb97da5 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1529,6 +1529,183 @@ func Test_ListBranches(t *testing.T) { } } +func Test_DeleteFile(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := DeleteFile(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "delete_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, "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"}) + + // Setup mock objects for Git Data API + mockRef := &github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{ + SHA: github.Ptr("abc123"), + }, + } + + mockCommit := &github.Commit{ + SHA: github.Ptr("abc123"), + Tree: &github.Tree{ + SHA: github.Ptr("def456"), + }, + } + + 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: "successful file deletion using Git Data API", + 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) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 0d809978..faef86ce 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -36,6 +36,7 @@ func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, toolsets.NewServerTool(ForkRepository(getClient, t)), toolsets.NewServerTool(CreateBranch(getClient, t)), toolsets.NewServerTool(PushFiles(getClient, t)), + toolsets.NewServerTool(DeleteFile(getClient, t)), ) issues := toolsets.NewToolset("issues", "GitHub Issues related tools"). AddReadTools(