diff --git a/README.md b/README.md
index 1e6bb4b1a..67efa643a 100644
--- a/README.md
+++ b/README.md
@@ -400,6 +400,7 @@ The following sets of tools are available:
| `discussions` | GitHub Discussions related tools |
| `experiments` | Experimental features that are not considered stable yet |
| `gists` | GitHub Gist related tools |
+| `git` | GitHub Git API related tools for low-level Git operations |
| `issues` | GitHub Issues related tools |
| `labels` | GitHub Labels related tools |
| `notifications` | GitHub Notifications related tools |
@@ -627,6 +628,19 @@ The following sets of tools are available:
+Git
+
+- **get_repository_tree** - Get repository tree
+ - `owner`: Repository owner (username or organization) (string, required)
+ - `path_filter`: Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory) (string, optional)
+ - `recursive`: Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false (boolean, optional)
+ - `repo`: Repository name (string, required)
+ - `tree_sha`: The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch (string, optional)
+
+
+
+
+
Issues
- **add_issue_comment** - Add comment to issue
diff --git a/docs/remote-server.md b/docs/remote-server.md
index fa55168e5..ce506893e 100644
--- a/docs/remote-server.md
+++ b/docs/remote-server.md
@@ -26,6 +26,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to
| Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) |
| Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) |
| Gists | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) |
+| Git | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) |
| Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) |
| Labels | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) |
| Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) |
diff --git a/pkg/github/__toolsnaps__/get_repository_tree.snap b/pkg/github/__toolsnaps__/get_repository_tree.snap
new file mode 100644
index 000000000..0645bf241
--- /dev/null
+++ b/pkg/github/__toolsnaps__/get_repository_tree.snap
@@ -0,0 +1,38 @@
+{
+ "annotations": {
+ "title": "Get repository tree",
+ "readOnlyHint": true
+ },
+ "description": "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA",
+ "inputSchema": {
+ "properties": {
+ "owner": {
+ "description": "Repository owner (username or organization)",
+ "type": "string"
+ },
+ "path_filter": {
+ "description": "Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)",
+ "type": "string"
+ },
+ "recursive": {
+ "default": false,
+ "description": "Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false",
+ "type": "boolean"
+ },
+ "repo": {
+ "description": "Repository name",
+ "type": "string"
+ },
+ "tree_sha": {
+ "description": "The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch",
+ "type": "string"
+ }
+ },
+ "required": [
+ "owner",
+ "repo"
+ ],
+ "type": "object"
+ },
+ "name": "get_repository_tree"
+}
\ No newline at end of file
diff --git a/pkg/github/git.go b/pkg/github/git.go
new file mode 100644
index 000000000..44a240e04
--- /dev/null
+++ b/pkg/github/git.go
@@ -0,0 +1,156 @@
+package github
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ ghErrors "github.com/github/github-mcp-server/pkg/errors"
+ "github.com/github/github-mcp-server/pkg/translations"
+ "github.com/google/go-github/v76/github"
+ "github.com/mark3labs/mcp-go/mcp"
+ "github.com/mark3labs/mcp-go/server"
+)
+
+// TreeEntryResponse represents a single entry in a Git tree.
+type TreeEntryResponse struct {
+ Path string `json:"path"`
+ Type string `json:"type"`
+ Size *int `json:"size,omitempty"`
+ Mode string `json:"mode"`
+ SHA string `json:"sha"`
+ URL string `json:"url"`
+}
+
+// TreeResponse represents the response structure for a Git tree.
+type TreeResponse struct {
+ SHA string `json:"sha"`
+ Truncated bool `json:"truncated"`
+ Tree []TreeEntryResponse `json:"tree"`
+ TreeSHA string `json:"tree_sha"`
+ Owner string `json:"owner"`
+ Repo string `json:"repo"`
+ Recursive bool `json:"recursive"`
+ Count int `json:"count"`
+}
+
+// GetRepositoryTree creates a tool to get the tree structure of a GitHub repository.
+func GetRepositoryTree(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+ return mcp.NewTool("get_repository_tree",
+ mcp.WithDescription(t("TOOL_GET_REPOSITORY_TREE_DESCRIPTION", "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA")),
+ mcp.WithToolAnnotation(mcp.ToolAnnotation{
+ Title: t("TOOL_GET_REPOSITORY_TREE_USER_TITLE", "Get repository tree"),
+ ReadOnlyHint: 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("tree_sha",
+ mcp.Description("The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch"),
+ ),
+ mcp.WithBoolean("recursive",
+ mcp.Description("Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false"),
+ mcp.DefaultBool(false),
+ ),
+ mcp.WithString("path_filter",
+ mcp.Description("Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)"),
+ ),
+ ),
+ 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
+ }
+ treeSHA, err := OptionalParam[string](request, "tree_sha")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ recursive, err := OptionalBoolParamWithDefault(request, "recursive", false)
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+ pathFilter, err := OptionalParam[string](request, "path_filter")
+ if err != nil {
+ return mcp.NewToolResultError(err.Error()), nil
+ }
+
+ client, err := getClient(ctx)
+ if err != nil {
+ return mcp.NewToolResultError("failed to get GitHub client"), nil
+ }
+
+ // If no tree_sha is provided, use the repository's default branch
+ if treeSHA == "" {
+ repoInfo, _, err := client.Repositories.Get(ctx, owner, repo)
+ if err != nil {
+ return mcp.NewToolResultError(fmt.Sprintf("failed to get repository info: %s", err)), nil
+ }
+ treeSHA = *repoInfo.DefaultBranch
+ }
+
+ // Get the tree using the GitHub Git Tree API
+ tree, resp, err := client.Git.GetTree(ctx, owner, repo, treeSHA, recursive)
+ if err != nil {
+ return ghErrors.NewGitHubAPIErrorResponse(ctx,
+ "failed to get repository tree",
+ resp,
+ err,
+ ), nil
+ }
+ defer func() { _ = resp.Body.Close() }()
+
+ // Filter tree entries if path_filter is provided
+ var filteredEntries []*github.TreeEntry
+ if pathFilter != "" {
+ for _, entry := range tree.Entries {
+ if strings.HasPrefix(entry.GetPath(), pathFilter) {
+ filteredEntries = append(filteredEntries, entry)
+ }
+ }
+ } else {
+ filteredEntries = tree.Entries
+ }
+
+ treeEntries := make([]TreeEntryResponse, len(filteredEntries))
+ for i, entry := range filteredEntries {
+ treeEntries[i] = TreeEntryResponse{
+ Path: entry.GetPath(),
+ Type: entry.GetType(),
+ Mode: entry.GetMode(),
+ SHA: entry.GetSHA(),
+ URL: entry.GetURL(),
+ }
+ if entry.Size != nil {
+ treeEntries[i].Size = entry.Size
+ }
+ }
+
+ response := TreeResponse{
+ SHA: *tree.SHA,
+ Truncated: *tree.Truncated,
+ Tree: treeEntries,
+ TreeSHA: treeSHA,
+ Owner: owner,
+ Repo: repo,
+ Recursive: recursive,
+ Count: len(filteredEntries),
+ }
+
+ r, err := json.Marshal(response)
+ 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 8baca434e..84b000951 100644
--- a/pkg/github/repositories_test.go
+++ b/pkg/github/repositories_test.go
@@ -3237,3 +3237,178 @@ func Test_UnstarRepository(t *testing.T) {
})
}
}
+
+func Test_GetRepositoryTree(t *testing.T) {
+ // Verify tool definition once
+ mockClient := github.NewClient(nil)
+ tool, _ := GetRepositoryTree(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+ require.NoError(t, toolsnaps.Test(tool.Name, tool))
+
+ assert.Equal(t, "get_repository_tree", 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, "tree_sha")
+ assert.Contains(t, tool.InputSchema.Properties, "recursive")
+ assert.Contains(t, tool.InputSchema.Properties, "path_filter")
+ assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+ // Setup mock data
+ mockRepo := &github.Repository{
+ DefaultBranch: github.Ptr("main"),
+ }
+ mockTree := &github.Tree{
+ SHA: github.Ptr("abc123"),
+ Truncated: github.Ptr(false),
+ Entries: []*github.TreeEntry{
+ {
+ Path: github.Ptr("README.md"),
+ Mode: github.Ptr("100644"),
+ Type: github.Ptr("blob"),
+ SHA: github.Ptr("file1sha"),
+ Size: github.Ptr(123),
+ URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file1sha"),
+ },
+ {
+ Path: github.Ptr("src/main.go"),
+ Mode: github.Ptr("100644"),
+ Type: github.Ptr("blob"),
+ SHA: github.Ptr("file2sha"),
+ Size: github.Ptr(456),
+ URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file2sha"),
+ },
+ },
+ }
+
+ tests := []struct {
+ name string
+ mockedClient *http.Client
+ requestArgs map[string]interface{}
+ expectError bool
+ expectedErrMsg string
+ }{
+ {
+ name: "successfully get repository tree",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposByOwnerByRepo,
+ mockResponse(t, http.StatusOK, mockRepo),
+ ),
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitTreesByOwnerByRepoByTreeSha,
+ mockResponse(t, http.StatusOK, mockTree),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ },
+ {
+ name: "successfully get repository tree with path filter",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposByOwnerByRepo,
+ mockResponse(t, http.StatusOK, mockRepo),
+ ),
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitTreesByOwnerByRepoByTreeSha,
+ mockResponse(t, http.StatusOK, mockTree),
+ ),
+ ),
+ requestArgs: map[string]interface{}{
+ "owner": "owner",
+ "repo": "repo",
+ "path_filter": "src/",
+ },
+ },
+ {
+ name: "repository not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposByOwnerByRepo,
+ 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": "nonexistent",
+ },
+ expectError: true,
+ expectedErrMsg: "failed to get repository info",
+ },
+ {
+ name: "tree not found",
+ mockedClient: mock.NewMockedHTTPClient(
+ mock.WithRequestMatchHandler(
+ mock.GetReposByOwnerByRepo,
+ mockResponse(t, http.StatusOK, mockRepo),
+ ),
+ mock.WithRequestMatchHandler(
+ mock.GetReposGitTreesByOwnerByRepoByTreeSha,
+ 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 repository tree",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ _, handler := GetRepositoryTree(stubGetClientFromHTTPFn(tc.mockedClient), translations.NullTranslationHelper)
+
+ // Create the tool request
+ request := createMCPRequest(tc.requestArgs)
+
+ result, err := handler(context.Background(), request)
+
+ if tc.expectError {
+ require.NoError(t, err)
+ require.True(t, result.IsError)
+ errorContent := getErrorResult(t, result)
+ assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
+ } else {
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ // Parse the result and get the text content
+ textContent := getTextResult(t, result)
+
+ // Parse the JSON response
+ var treeResponse map[string]interface{}
+ err := json.Unmarshal([]byte(textContent.Text), &treeResponse)
+ require.NoError(t, err)
+
+ // Verify response structure
+ assert.Equal(t, "owner", treeResponse["owner"])
+ assert.Equal(t, "repo", treeResponse["repo"])
+ assert.Contains(t, treeResponse, "tree")
+ assert.Contains(t, treeResponse, "count")
+ assert.Contains(t, treeResponse, "sha")
+ assert.Contains(t, treeResponse, "truncated")
+
+ // Check filtering if path_filter was provided
+ if pathFilter, exists := tc.requestArgs["path_filter"]; exists {
+ tree := treeResponse["tree"].([]interface{})
+ for _, entry := range tree {
+ entryMap := entry.(map[string]interface{})
+ path := entryMap["path"].(string)
+ assert.True(t, strings.HasPrefix(path, pathFilter.(string)),
+ "Path %s should start with filter %s", path, pathFilter)
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/pkg/github/tools.go b/pkg/github/tools.go
index f32c2ee42..0f2c059a9 100644
--- a/pkg/github/tools.go
+++ b/pkg/github/tools.go
@@ -39,6 +39,10 @@ var (
ID: "repos",
Description: "GitHub Repository related tools",
}
+ ToolsetMetadataGit = ToolsetMetadata{
+ ID: "git",
+ Description: "GitHub Git API related tools for low-level Git operations",
+ }
ToolsetMetadataIssues = ToolsetMetadata{
ID: "issues",
Description: "GitHub Issues related tools",
@@ -189,6 +193,10 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
toolsets.NewServerResourceTemplate(GetRepositoryResourceTagContent(getClient, getRawClient, t)),
toolsets.NewServerResourceTemplate(GetRepositoryResourcePrContent(getClient, getRawClient, t)),
)
+ git := toolsets.NewToolset(ToolsetMetadataGit.ID, ToolsetMetadataGit.Description).
+ AddReadTools(
+ toolsets.NewServerTool(GetRepositoryTree(getClient, t)),
+ )
issues := toolsets.NewToolset(ToolsetMetadataIssues.ID, ToolsetMetadataIssues.Description).
AddReadTools(
toolsets.NewServerTool(IssueRead(getClient, getGQLClient, t)),
@@ -352,6 +360,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
// Add toolsets to the group
tsg.AddToolset(contextTools)
tsg.AddToolset(repos)
+ tsg.AddToolset(git)
tsg.AddToolset(issues)
tsg.AddToolset(orgs)
tsg.AddToolset(users)