Skip to content

Commit bdcbc06

Browse files
committed
add repo nav tool
1 parent eb0757c commit bdcbc06

File tree

5 files changed

+362
-0
lines changed

5 files changed

+362
-0
lines changed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,6 +867,13 @@ The following sets of tools are available (all are on by default):
867867
- `repo`: Repository name (string, required)
868868
- `tag`: Tag name (e.g., 'v1.0.0') (string, required)
869869

870+
- **get_repository_tree** - Get repository tree
871+
- `owner`: Repository owner (username or organization) (string, required)
872+
- `path_filter`: Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory) (string, optional)
873+
- `recursive`: Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false (boolean, optional)
874+
- `repo`: Repository name (string, required)
875+
- `tree_sha`: The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch (string, optional)
876+
870877
- **get_tag** - Get tag details
871878
- `owner`: Repository owner (string, required)
872879
- `repo`: Repository name (string, required)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"annotations": {
3+
"title": "Get repository tree",
4+
"readOnlyHint": true
5+
},
6+
"description": "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA",
7+
"inputSchema": {
8+
"properties": {
9+
"owner": {
10+
"description": "Repository owner (username or organization)",
11+
"type": "string"
12+
},
13+
"path_filter": {
14+
"description": "Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)",
15+
"type": "string"
16+
},
17+
"recursive": {
18+
"default": false,
19+
"description": "Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false",
20+
"type": "boolean"
21+
},
22+
"repo": {
23+
"description": "Repository name",
24+
"type": "string"
25+
},
26+
"tree_sha": {
27+
"description": "The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch",
28+
"type": "string"
29+
}
30+
},
31+
"required": [
32+
"owner",
33+
"repo"
34+
],
35+
"type": "object"
36+
},
37+
"name": "get_repository_tree"
38+
}

pkg/github/repositories.go

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -677,6 +677,147 @@ func GetFileContents(getClient GetClientFn, getRawClient raw.GetRawClientFn, t t
677677
}
678678
}
679679

680+
// GetRepositoryTree creates a tool to get the tree structure of a GitHub repository.
681+
func GetRepositoryTree(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
682+
return mcp.NewTool("get_repository_tree",
683+
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")),
684+
mcp.WithToolAnnotation(mcp.ToolAnnotation{
685+
Title: t("TOOL_GET_REPOSITORY_TREE_USER_TITLE", "Get repository tree"),
686+
ReadOnlyHint: ToBoolPtr(true),
687+
}),
688+
mcp.WithString("owner",
689+
mcp.Required(),
690+
mcp.Description("Repository owner (username or organization)"),
691+
),
692+
mcp.WithString("repo",
693+
mcp.Required(),
694+
mcp.Description("Repository name"),
695+
),
696+
mcp.WithString("tree_sha",
697+
mcp.Description("The SHA1 value or ref (branch or tag) name of the tree. Defaults to the repository's default branch"),
698+
),
699+
mcp.WithBoolean("recursive",
700+
mcp.Description("Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false"),
701+
mcp.DefaultBool(false),
702+
),
703+
mcp.WithString("path_filter",
704+
mcp.Description("Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory)"),
705+
),
706+
),
707+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
708+
owner, err := RequiredParam[string](request, "owner")
709+
if err != nil {
710+
return mcp.NewToolResultError(err.Error()), nil
711+
}
712+
repo, err := RequiredParam[string](request, "repo")
713+
if err != nil {
714+
return mcp.NewToolResultError(err.Error()), nil
715+
}
716+
treeSHA, err := OptionalParam[string](request, "tree_sha")
717+
if err != nil {
718+
return mcp.NewToolResultError(err.Error()), nil
719+
}
720+
recursive, err := OptionalBoolParamWithDefault(request, "recursive", false)
721+
if err != nil {
722+
return mcp.NewToolResultError(err.Error()), nil
723+
}
724+
pathFilter, err := OptionalParam[string](request, "path_filter")
725+
if err != nil {
726+
return mcp.NewToolResultError(err.Error()), nil
727+
}
728+
729+
client, err := getClient(ctx)
730+
if err != nil {
731+
return mcp.NewToolResultError("failed to get GitHub client"), nil
732+
}
733+
734+
// If no tree_sha is provided, use the repository's default branch
735+
if treeSHA == "" {
736+
repoInfo, _, err := client.Repositories.Get(ctx, owner, repo)
737+
if err != nil {
738+
return mcp.NewToolResultError(fmt.Sprintf("failed to get repository info: %s", err)), nil
739+
}
740+
treeSHA = *repoInfo.DefaultBranch
741+
}
742+
743+
// Get the tree using the GitHub Git Tree API
744+
tree, resp, err := client.Git.GetTree(ctx, owner, repo, treeSHA, recursive)
745+
if err != nil {
746+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
747+
"failed to get repository tree",
748+
resp,
749+
err,
750+
), nil
751+
}
752+
defer func() { _ = resp.Body.Close() }()
753+
754+
// Filter tree entries if path_filter is provided
755+
var filteredEntries []*github.TreeEntry
756+
if pathFilter != "" {
757+
for _, entry := range tree.Entries {
758+
if entry.Path != nil && strings.HasPrefix(*entry.Path, pathFilter) {
759+
filteredEntries = append(filteredEntries, entry)
760+
}
761+
}
762+
} else {
763+
filteredEntries = tree.Entries
764+
}
765+
766+
// Create a simplified response structure
767+
type TreeEntryResponse struct {
768+
Path string `json:"path"`
769+
Type string `json:"type"`
770+
Size *int `json:"size,omitempty"`
771+
Mode string `json:"mode"`
772+
SHA string `json:"sha"`
773+
URL string `json:"url"`
774+
}
775+
776+
type TreeResponse struct {
777+
SHA string `json:"sha"`
778+
Truncated bool `json:"truncated"`
779+
Tree []TreeEntryResponse `json:"tree"`
780+
TreeSHA string `json:"tree_sha"`
781+
Owner string `json:"owner"`
782+
Repo string `json:"repo"`
783+
Recursive bool `json:"recursive"`
784+
Count int `json:"count"`
785+
}
786+
787+
treeEntries := make([]TreeEntryResponse, len(filteredEntries))
788+
for i, entry := range filteredEntries {
789+
treeEntries[i] = TreeEntryResponse{
790+
Path: *entry.Path,
791+
Type: *entry.Type,
792+
Mode: *entry.Mode,
793+
SHA: *entry.SHA,
794+
URL: *entry.URL,
795+
}
796+
if entry.Size != nil {
797+
treeEntries[i].Size = entry.Size
798+
}
799+
}
800+
801+
response := TreeResponse{
802+
SHA: *tree.SHA,
803+
Truncated: *tree.Truncated,
804+
Tree: treeEntries,
805+
TreeSHA: treeSHA,
806+
Owner: owner,
807+
Repo: repo,
808+
Recursive: recursive,
809+
Count: len(filteredEntries),
810+
}
811+
812+
r, err := json.Marshal(response)
813+
if err != nil {
814+
return nil, fmt.Errorf("failed to marshal response: %w", err)
815+
}
816+
817+
return mcp.NewToolResultText(string(r)), nil
818+
}
819+
}
820+
680821
// ForkRepository creates a tool to fork a repository.
681822
func ForkRepository(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
682823
return mcp.NewTool("fork_repository",

pkg/github/repositories_test.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3192,3 +3192,178 @@ func Test_UnstarRepository(t *testing.T) {
31923192
})
31933193
}
31943194
}
3195+
3196+
func Test_GetRepositoryTree(t *testing.T) {
3197+
// Verify tool definition once
3198+
mockClient := github.NewClient(nil)
3199+
tool, _ := GetRepositoryTree(stubGetClientFn(mockClient), translations.NullTranslationHelper)
3200+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
3201+
3202+
assert.Equal(t, "get_repository_tree", tool.Name)
3203+
assert.NotEmpty(t, tool.Description)
3204+
assert.Contains(t, tool.InputSchema.Properties, "owner")
3205+
assert.Contains(t, tool.InputSchema.Properties, "repo")
3206+
assert.Contains(t, tool.InputSchema.Properties, "tree_sha")
3207+
assert.Contains(t, tool.InputSchema.Properties, "recursive")
3208+
assert.Contains(t, tool.InputSchema.Properties, "path_filter")
3209+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
3210+
3211+
// Setup mock data
3212+
mockRepo := &github.Repository{
3213+
DefaultBranch: github.Ptr("main"),
3214+
}
3215+
mockTree := &github.Tree{
3216+
SHA: github.Ptr("abc123"),
3217+
Truncated: github.Ptr(false),
3218+
Entries: []*github.TreeEntry{
3219+
{
3220+
Path: github.Ptr("README.md"),
3221+
Mode: github.Ptr("100644"),
3222+
Type: github.Ptr("blob"),
3223+
SHA: github.Ptr("file1sha"),
3224+
Size: github.Ptr(123),
3225+
URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file1sha"),
3226+
},
3227+
{
3228+
Path: github.Ptr("src/main.go"),
3229+
Mode: github.Ptr("100644"),
3230+
Type: github.Ptr("blob"),
3231+
SHA: github.Ptr("file2sha"),
3232+
Size: github.Ptr(456),
3233+
URL: github.Ptr("https://api.github.com/repos/owner/repo/git/blobs/file2sha"),
3234+
},
3235+
},
3236+
}
3237+
3238+
tests := []struct {
3239+
name string
3240+
mockedClient *http.Client
3241+
requestArgs map[string]interface{}
3242+
expectError bool
3243+
expectedErrMsg string
3244+
}{
3245+
{
3246+
name: "successfully get repository tree",
3247+
mockedClient: mock.NewMockedHTTPClient(
3248+
mock.WithRequestMatchHandler(
3249+
mock.GetReposByOwnerByRepo,
3250+
mockResponse(t, http.StatusOK, mockRepo),
3251+
),
3252+
mock.WithRequestMatchHandler(
3253+
mock.GetReposGitTreesByOwnerByRepoByTreeSha,
3254+
mockResponse(t, http.StatusOK, mockTree),
3255+
),
3256+
),
3257+
requestArgs: map[string]interface{}{
3258+
"owner": "owner",
3259+
"repo": "repo",
3260+
},
3261+
},
3262+
{
3263+
name: "successfully get repository tree with path filter",
3264+
mockedClient: mock.NewMockedHTTPClient(
3265+
mock.WithRequestMatchHandler(
3266+
mock.GetReposByOwnerByRepo,
3267+
mockResponse(t, http.StatusOK, mockRepo),
3268+
),
3269+
mock.WithRequestMatchHandler(
3270+
mock.GetReposGitTreesByOwnerByRepoByTreeSha,
3271+
mockResponse(t, http.StatusOK, mockTree),
3272+
),
3273+
),
3274+
requestArgs: map[string]interface{}{
3275+
"owner": "owner",
3276+
"repo": "repo",
3277+
"path_filter": "src/",
3278+
},
3279+
},
3280+
{
3281+
name: "repository not found",
3282+
mockedClient: mock.NewMockedHTTPClient(
3283+
mock.WithRequestMatchHandler(
3284+
mock.GetReposByOwnerByRepo,
3285+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
3286+
w.WriteHeader(http.StatusNotFound)
3287+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
3288+
}),
3289+
),
3290+
),
3291+
requestArgs: map[string]interface{}{
3292+
"owner": "owner",
3293+
"repo": "nonexistent",
3294+
},
3295+
expectError: true,
3296+
expectedErrMsg: "failed to get repository info",
3297+
},
3298+
{
3299+
name: "tree not found",
3300+
mockedClient: mock.NewMockedHTTPClient(
3301+
mock.WithRequestMatchHandler(
3302+
mock.GetReposByOwnerByRepo,
3303+
mockResponse(t, http.StatusOK, mockRepo),
3304+
),
3305+
mock.WithRequestMatchHandler(
3306+
mock.GetReposGitTreesByOwnerByRepoByTreeSha,
3307+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
3308+
w.WriteHeader(http.StatusNotFound)
3309+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
3310+
}),
3311+
),
3312+
),
3313+
requestArgs: map[string]interface{}{
3314+
"owner": "owner",
3315+
"repo": "repo",
3316+
},
3317+
expectError: true,
3318+
expectedErrMsg: "failed to get repository tree",
3319+
},
3320+
}
3321+
3322+
for _, tc := range tests {
3323+
t.Run(tc.name, func(t *testing.T) {
3324+
_, handler := GetRepositoryTree(stubGetClientFromHTTPFn(tc.mockedClient), translations.NullTranslationHelper)
3325+
3326+
// Create the tool request
3327+
request := createMCPRequest(tc.requestArgs)
3328+
3329+
result, err := handler(context.Background(), request)
3330+
3331+
if tc.expectError {
3332+
require.NoError(t, err)
3333+
require.True(t, result.IsError)
3334+
errorContent := getErrorResult(t, result)
3335+
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
3336+
} else {
3337+
require.NoError(t, err)
3338+
require.False(t, result.IsError)
3339+
3340+
// Parse the result and get the text content
3341+
textContent := getTextResult(t, result)
3342+
3343+
// Parse the JSON response
3344+
var treeResponse map[string]interface{}
3345+
err := json.Unmarshal([]byte(textContent.Text), &treeResponse)
3346+
require.NoError(t, err)
3347+
3348+
// Verify response structure
3349+
assert.Equal(t, "owner", treeResponse["owner"])
3350+
assert.Equal(t, "repo", treeResponse["repo"])
3351+
assert.Contains(t, treeResponse, "tree")
3352+
assert.Contains(t, treeResponse, "count")
3353+
assert.Contains(t, treeResponse, "sha")
3354+
assert.Contains(t, treeResponse, "truncated")
3355+
3356+
// Check filtering if path_filter was provided
3357+
if pathFilter, exists := tc.requestArgs["path_filter"]; exists {
3358+
tree := treeResponse["tree"].([]interface{})
3359+
for _, entry := range tree {
3360+
entryMap := entry.(map[string]interface{})
3361+
path := entryMap["path"].(string)
3362+
assert.True(t, strings.HasPrefix(path, pathFilter.(string)),
3363+
"Path %s should start with filter %s", path, pathFilter)
3364+
}
3365+
}
3366+
}
3367+
})
3368+
}
3369+
}

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetG
2525
AddReadTools(
2626
toolsets.NewServerTool(SearchRepositories(getClient, t)),
2727
toolsets.NewServerTool(GetFileContents(getClient, getRawClient, t)),
28+
toolsets.NewServerTool(GetRepositoryTree(getClient, t)),
2829
toolsets.NewServerTool(ListCommits(getClient, t)),
2930
toolsets.NewServerTool(SearchCode(getClient, t)),
3031
toolsets.NewServerTool(GetCommit(getClient, t)),

0 commit comments

Comments
 (0)