Skip to content

Commit 0e7debc

Browse files
Implement list repositories
1 parent 01aefd3 commit 0e7debc

File tree

3 files changed

+306
-0
lines changed

3 files changed

+306
-0
lines changed

pkg/github/organizations.go

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
9+
"github.com/github/github-mcp-server/pkg/translations"
10+
"github.com/google/go-github/v69/github"
11+
"github.com/mark3labs/mcp-go/mcp"
12+
"github.com/mark3labs/mcp-go/server"
13+
)
14+
15+
// ListCommits creates a tool to get commits of a branch in a repository.
16+
func ListRepositories(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
17+
return mcp.NewTool("list_repositories",
18+
mcp.WithDescription(t("TOOL_LIST_REPOSITORIES_DESCRIPTION", "Get list of repositories in a GitHub organization")),
19+
mcp.WithString("org",
20+
mcp.Required(),
21+
mcp.Description("Organization name"),
22+
),
23+
mcp.WithString("type",
24+
mcp.Description("Type of repositories to list. Possible values are: all, public, private, forks, sources, member. Default is 'all'."),
25+
),
26+
mcp.WithString("sort",
27+
mcp.Description("How to sort the repository list. Can be one of created, updated, pushed, full_name. Default is 'created'"),
28+
),
29+
mcp.WithString("direction",
30+
mcp.Description("Direction in which to sort repositories. Can be one of asc or desc. Default when using full_name: asc; otherwise desc."),
31+
),
32+
WithPagination(),
33+
),
34+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
35+
org, err := requiredParam[string](request, "org")
36+
if err != nil {
37+
return mcp.NewToolResultError(err.Error()), nil
38+
}
39+
pagination, err := OptionalPaginationParams(request)
40+
if err != nil {
41+
return mcp.NewToolResultError(err.Error()), nil
42+
}
43+
44+
opts := &github.RepositoryListByOrgOptions{
45+
ListOptions: github.ListOptions{
46+
Page: pagination.page,
47+
PerPage: pagination.perPage,
48+
},
49+
}
50+
51+
repo_type, err := OptionalParam[string](request, "type")
52+
if err != nil {
53+
return mcp.NewToolResultError(err.Error()), nil
54+
}
55+
if repo_type != "" {
56+
opts.Type = repo_type
57+
}
58+
sort, err := OptionalParam[string](request, "sort")
59+
if err != nil {
60+
return mcp.NewToolResultError(err.Error()), nil
61+
}
62+
if sort != "" {
63+
opts.Sort = sort
64+
}
65+
direction, err := OptionalParam[string](request, "direction")
66+
if err != nil {
67+
return mcp.NewToolResultError(err.Error()), nil
68+
}
69+
if direction != "" {
70+
opts.Direction = direction
71+
}
72+
73+
repos, resp, err := client.Repositories.ListByOrg(ctx, org, opts)
74+
if err != nil {
75+
return nil, fmt.Errorf("failed to list repositories: %w", err)
76+
}
77+
defer func() { _ = resp.Body.Close() }()
78+
79+
if resp.StatusCode != 200 {
80+
body, err := io.ReadAll(resp.Body)
81+
if err != nil {
82+
return nil, fmt.Errorf("failed to read response body: %w", err)
83+
}
84+
return mcp.NewToolResultError(fmt.Sprintf("failed to list repositories: %s", string(body))), nil
85+
}
86+
87+
r, err := json.Marshal(repos)
88+
if err != nil {
89+
return nil, fmt.Errorf("failed to marshal response: %w", err)
90+
}
91+
92+
return mcp.NewToolResultText(string(r)), nil
93+
}
94+
}

pkg/github/organizations_test.go

+209
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
package github
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"testing"
8+
9+
"github.com/github/github-mcp-server/pkg/translations"
10+
"github.com/google/go-github/v69/github"
11+
"github.com/migueleliasweb/go-github-mock/src/mock"
12+
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
14+
)
15+
16+
func Test_ListRepositories(t *testing.T) {
17+
// Verify tool definition once
18+
mockClient := github.NewClient(nil)
19+
tool, _ := ListRepositories(mockClient, translations.NullTranslationHelper)
20+
21+
assert.Equal(t, "list_repositories", tool.Name)
22+
assert.NotEmpty(t, tool.Description)
23+
assert.Contains(t, tool.InputSchema.Properties, "org")
24+
assert.Contains(t, tool.InputSchema.Properties, "type")
25+
assert.Contains(t, tool.InputSchema.Properties, "sort")
26+
assert.Contains(t, tool.InputSchema.Properties, "direction")
27+
assert.Contains(t, tool.InputSchema.Properties, "perPage")
28+
assert.Contains(t, tool.InputSchema.Properties, "page")
29+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"org"})
30+
31+
// Setup mock repos for success case
32+
mockRepos := []*github.Repository{
33+
{
34+
ID: github.Ptr(int64(1001)),
35+
Name: github.Ptr("repo1"),
36+
FullName: github.Ptr("testorg/repo1"),
37+
Description: github.Ptr("Test repo 1"),
38+
HTMLURL: github.Ptr("https://github.com/testorg/repo1"),
39+
Private: github.Ptr(false),
40+
Fork: github.Ptr(false),
41+
},
42+
{
43+
ID: github.Ptr(int64(1002)),
44+
Name: github.Ptr("repo2"),
45+
FullName: github.Ptr("testorg/repo2"),
46+
Description: github.Ptr("Test repo 2"),
47+
HTMLURL: github.Ptr("https://github.com/testorg/repo2"),
48+
Private: github.Ptr(true),
49+
Fork: github.Ptr(false),
50+
},
51+
}
52+
53+
tests := []struct {
54+
name string
55+
mockedClient *http.Client
56+
requestArgs map[string]interface{}
57+
expectError bool
58+
expectedRepos []*github.Repository
59+
expectedErrMsg string
60+
}{
61+
{
62+
name: "successful repositories listing",
63+
mockedClient: mock.NewMockedHTTPClient(
64+
mock.WithRequestMatchHandler(
65+
mock.GetOrgsReposByOrg,
66+
expectQueryParams(t, map[string]string{
67+
"type": "all",
68+
"sort": "created",
69+
"direction": "desc",
70+
"per_page": "30",
71+
"page": "1",
72+
}).andThen(
73+
mockResponse(t, http.StatusOK, mockRepos),
74+
),
75+
),
76+
),
77+
requestArgs: map[string]interface{}{
78+
"org": "testorg",
79+
"type": "all",
80+
"sort": "created",
81+
"direction": "desc",
82+
"perPage": float64(30),
83+
"page": float64(1),
84+
},
85+
expectError: false,
86+
expectedRepos: mockRepos,
87+
},
88+
{
89+
name: "successful repos listing with defaults",
90+
mockedClient: mock.NewMockedHTTPClient(
91+
mock.WithRequestMatchHandler(
92+
mock.GetOrgsReposByOrg,
93+
expectQueryParams(t, map[string]string{
94+
"per_page": "30",
95+
"page": "1",
96+
}).andThen(
97+
mockResponse(t, http.StatusOK, mockRepos),
98+
),
99+
),
100+
),
101+
requestArgs: map[string]interface{}{
102+
"org": "testorg",
103+
// Using defaults for other parameters
104+
},
105+
expectError: false,
106+
expectedRepos: mockRepos,
107+
},
108+
{
109+
name: "custom pagination and filtering",
110+
mockedClient: mock.NewMockedHTTPClient(
111+
mock.WithRequestMatchHandler(
112+
mock.GetOrgsReposByOrg,
113+
expectQueryParams(t, map[string]string{
114+
"type": "public",
115+
"sort": "updated",
116+
"direction": "asc",
117+
"per_page": "10",
118+
"page": "2",
119+
}).andThen(
120+
mockResponse(t, http.StatusOK, mockRepos),
121+
),
122+
),
123+
),
124+
requestArgs: map[string]interface{}{
125+
"org": "testorg",
126+
"type": "public",
127+
"sort": "updated",
128+
"direction": "asc",
129+
"perPage": float64(10),
130+
"page": float64(2),
131+
},
132+
expectError: false,
133+
expectedRepos: mockRepos,
134+
},
135+
{
136+
name: "API error response",
137+
mockedClient: mock.NewMockedHTTPClient(
138+
mock.WithRequestMatchHandler(
139+
mock.GetOrgsReposByOrg,
140+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
141+
w.WriteHeader(http.StatusNotFound)
142+
_, _ = w.Write([]byte(`{"message": "Not Found"}`))
143+
}),
144+
),
145+
),
146+
requestArgs: map[string]interface{}{
147+
"org": "nonexistentorg",
148+
},
149+
expectError: true,
150+
expectedErrMsg: "failed to list repositories",
151+
},
152+
{
153+
name: "rate limit exceeded",
154+
mockedClient: mock.NewMockedHTTPClient(
155+
mock.WithRequestMatchHandler(
156+
mock.GetOrgsReposByOrg,
157+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
158+
w.WriteHeader(http.StatusForbidden)
159+
_, _ = w.Write([]byte(`{"message": "API rate limit exceeded"}`))
160+
}),
161+
),
162+
),
163+
requestArgs: map[string]interface{}{
164+
"org": "testorg",
165+
},
166+
expectError: true,
167+
expectedErrMsg: "failed to list repositories",
168+
},
169+
}
170+
171+
for _, tc := range tests {
172+
t.Run(tc.name, func(t *testing.T) {
173+
// Setup client with mock
174+
client := github.NewClient(tc.mockedClient)
175+
_, handler := ListRepositories(client, translations.NullTranslationHelper)
176+
177+
// Create call request
178+
request := createMCPRequest(tc.requestArgs)
179+
180+
// Call handler
181+
result, err := handler(context.Background(), request)
182+
183+
// Verify results
184+
if tc.expectError {
185+
require.Error(t, err)
186+
assert.Contains(t, err.Error(), tc.expectedErrMsg)
187+
return
188+
}
189+
190+
require.NoError(t, err)
191+
192+
// Parse the result and get the text content if no error
193+
textContent := getTextResult(t, result)
194+
195+
// Unmarshal and verify the result
196+
var returnedRepos []*github.Repository
197+
err = json.Unmarshal([]byte(textContent.Text), &returnedRepos)
198+
require.NoError(t, err)
199+
assert.Len(t, returnedRepos, len(tc.expectedRepos))
200+
for i, repo := range returnedRepos {
201+
assert.Equal(t, *tc.expectedRepos[i].ID, *repo.ID)
202+
assert.Equal(t, *tc.expectedRepos[i].Name, *repo.Name)
203+
assert.Equal(t, *tc.expectedRepos[i].FullName, *repo.FullName)
204+
assert.Equal(t, *tc.expectedRepos[i].Private, *repo.Private)
205+
assert.Equal(t, *tc.expectedRepos[i].HTMLURL, *repo.HTMLURL)
206+
}
207+
})
208+
}
209+
}

pkg/github/server.go

+3
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ func NewServer(getClient GetClientFn, version string, readOnly bool, t translati
7979
s.AddTool(PushFiles(getClient, t))
8080
}
8181

82+
// Add GitHub tools - Organizations
83+
s.AddTool(ListRepositories(client, t))
84+
8285
// Add GitHub tools - Search
8386
s.AddTool(SearchCode(getClient, t))
8487
s.AddTool(SearchUsers(getClient, t))

0 commit comments

Comments
 (0)