Add Repository Tree Navigation Tool (#1164)

* add repo nav tool

* comment responses

* move into git.go

* fix documentation

* Update pkg/github/git.go

Co-authored-by: Adam Holt <omgitsads@github.com>

* Fix undefined variable error in GetRepositoryTree

* Update git.go to use github.com/google/go-github/v77 for consistency with main branch

---------

Co-authored-by: Adam Holt <omgitsads@github.com>
This commit is contained in:
Natalie Dunbar
2025-11-10 10:29:13 -05:00
committed by GitHub
parent b68bec003d
commit e26cf427ef
9 changed files with 400 additions and 0 deletions
+14
View File
@@ -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 |
@@ -630,6 +631,19 @@ The following sets of tools are available:
<details>
<summary>Git</summary>
- **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)
</details>
<details>
<summary>Issues</summary>
- **add_issue_comment** - Add comment to issue
BIN
View File
Binary file not shown.
+1
View File
@@ -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) |
+1
View File
@@ -3,6 +3,7 @@ module github.com/github/github-mcp-server
go 1.24.0
require (
github.com/google/go-github/v76 v76.0.0
github.com/google/go-github/v77 v77.0.0
github.com/josephburnett/jd v1.9.2
github.com/mark3labs/mcp-go v0.36.0
+2
View File
@@ -26,6 +26,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30=
github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M=
github.com/google/go-github/v76 v76.0.0 h1:MCa9VQn+VG5GG7Y7BAkBvSRUN3o+QpaEOuZwFPJmdFA=
github.com/google/go-github/v76 v76.0.0/go.mod h1:38+d/8pYDO4fBLYfBhXF5EKO0wA3UkXBjfmQapFsNCQ=
github.com/google/go-github/v77 v77.0.0 h1:9DsKKbZqil5y/4Z9mNpZDQnpli6PJbqipSuuNdcbjwI=
github.com/google/go-github/v77 v77.0.0/go.mod h1:c8VmGXRUmaZUqbctUcGEDWYnMrtzZfJhDSylEf1wfmA=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
@@ -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"
}
+160
View File
@@ -0,0 +1,160 @@
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/v77/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, repoResp, err := client.Repositories.Get(ctx, owner, repo)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get repository info",
repoResp,
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
}
}
+175
View File
@@ -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)
}
}
}
})
}
}
+9
View File
@@ -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)),
@@ -353,6 +361,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)