Skip to content

Commit 01aefd3

Browse files
authored
Add ability to view branches for a repo #141 (#205)
* Add ability to view branches for a repo #141 * fix: update ListBranches test to use InputSchema and correct translation helper * fix: update ListBranches test to use InputSchema and correct translation helper * fix: update ListBranches test to handle errors in tool result * fix: replace deprecated github.String with github.Ptr * docs: add list_branches tool documentation to README
1 parent 3c18a34 commit 01aefd3

File tree

4 files changed

+181
-0
lines changed

4 files changed

+181
-0
lines changed

README.md

+7
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,13 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
311311
- `branch`: Branch name (string, optional)
312312
- `sha`: File SHA if updating (string, optional)
313313

314+
- **list_branches** - List branches in a GitHub repository
315+
316+
- `owner`: Repository owner (string, required)
317+
- `repo`: Repository name (string, required)
318+
- `page`: Page number (number, optional)
319+
- `perPage`: Results per page (number, optional)
320+
314321
- **push_files** - Push multiple files in a single commit
315322

316323
- `owner`: Repository owner (string, required)

pkg/github/repositories.go

+63
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,69 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
150150
}
151151
}
152152

153+
// ListBranches creates a tool to list branches in a GitHub repository.
154+
func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
155+
return mcp.NewTool("list_branches",
156+
mcp.WithDescription(t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository")),
157+
mcp.WithString("owner",
158+
mcp.Required(),
159+
mcp.Description("Repository owner"),
160+
),
161+
mcp.WithString("repo",
162+
mcp.Required(),
163+
mcp.Description("Repository name"),
164+
),
165+
WithPagination(),
166+
),
167+
func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
168+
owner, err := requiredParam[string](request, "owner")
169+
if err != nil {
170+
return mcp.NewToolResultError(err.Error()), nil
171+
}
172+
repo, err := requiredParam[string](request, "repo")
173+
if err != nil {
174+
return mcp.NewToolResultError(err.Error()), nil
175+
}
176+
pagination, err := OptionalPaginationParams(request)
177+
if err != nil {
178+
return mcp.NewToolResultError(err.Error()), nil
179+
}
180+
181+
opts := &github.BranchListOptions{
182+
ListOptions: github.ListOptions{
183+
Page: pagination.page,
184+
PerPage: pagination.perPage,
185+
},
186+
}
187+
188+
client, err := getClient(ctx)
189+
if err != nil {
190+
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
191+
}
192+
193+
branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts)
194+
if err != nil {
195+
return nil, fmt.Errorf("failed to list branches: %w", err)
196+
}
197+
defer func() { _ = resp.Body.Close() }()
198+
199+
if resp.StatusCode != http.StatusOK {
200+
body, err := io.ReadAll(resp.Body)
201+
if err != nil {
202+
return nil, fmt.Errorf("failed to read response body: %w", err)
203+
}
204+
return mcp.NewToolResultError(fmt.Sprintf("failed to list branches: %s", string(body))), nil
205+
}
206+
207+
r, err := json.Marshal(branches)
208+
if err != nil {
209+
return nil, fmt.Errorf("failed to marshal response: %w", err)
210+
}
211+
212+
return mcp.NewToolResultText(string(r)), nil
213+
}
214+
}
215+
153216
// CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository.
154217
func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
155218
return mcp.NewTool("create_or_update_file",

pkg/github/repositories_test.go

+110
Original file line numberDiff line numberDiff line change
@@ -1423,3 +1423,113 @@ func Test_PushFiles(t *testing.T) {
14231423
})
14241424
}
14251425
}
1426+
1427+
func Test_ListBranches(t *testing.T) {
1428+
// Verify tool definition once
1429+
mockClient := github.NewClient(nil)
1430+
tool, _ := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper)
1431+
1432+
assert.Equal(t, "list_branches", tool.Name)
1433+
assert.NotEmpty(t, tool.Description)
1434+
assert.Contains(t, tool.InputSchema.Properties, "owner")
1435+
assert.Contains(t, tool.InputSchema.Properties, "repo")
1436+
assert.Contains(t, tool.InputSchema.Properties, "page")
1437+
assert.Contains(t, tool.InputSchema.Properties, "perPage")
1438+
assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
1439+
1440+
// Setup mock branches for success case
1441+
mockBranches := []*github.Branch{
1442+
{
1443+
Name: github.Ptr("main"),
1444+
Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")},
1445+
},
1446+
{
1447+
Name: github.Ptr("develop"),
1448+
Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")},
1449+
},
1450+
}
1451+
1452+
// Test cases
1453+
tests := []struct {
1454+
name string
1455+
args map[string]interface{}
1456+
mockResponses []mock.MockBackendOption
1457+
wantErr bool
1458+
errContains string
1459+
}{
1460+
{
1461+
name: "success",
1462+
args: map[string]interface{}{
1463+
"owner": "owner",
1464+
"repo": "repo",
1465+
"page": float64(2),
1466+
},
1467+
mockResponses: []mock.MockBackendOption{
1468+
mock.WithRequestMatch(
1469+
mock.GetReposBranchesByOwnerByRepo,
1470+
mockBranches,
1471+
),
1472+
},
1473+
wantErr: false,
1474+
},
1475+
{
1476+
name: "missing owner",
1477+
args: map[string]interface{}{
1478+
"repo": "repo",
1479+
},
1480+
mockResponses: []mock.MockBackendOption{},
1481+
wantErr: false,
1482+
errContains: "missing required parameter: owner",
1483+
},
1484+
{
1485+
name: "missing repo",
1486+
args: map[string]interface{}{
1487+
"owner": "owner",
1488+
},
1489+
mockResponses: []mock.MockBackendOption{},
1490+
wantErr: false,
1491+
errContains: "missing required parameter: repo",
1492+
},
1493+
}
1494+
1495+
for _, tt := range tests {
1496+
t.Run(tt.name, func(t *testing.T) {
1497+
// Create mock client
1498+
mockClient := github.NewClient(mock.NewMockedHTTPClient(tt.mockResponses...))
1499+
_, handler := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper)
1500+
1501+
// Create request
1502+
request := createMCPRequest(tt.args)
1503+
1504+
// Call handler
1505+
result, err := handler(context.Background(), request)
1506+
if tt.wantErr {
1507+
require.Error(t, err)
1508+
if tt.errContains != "" {
1509+
assert.Contains(t, err.Error(), tt.errContains)
1510+
}
1511+
return
1512+
}
1513+
1514+
require.NoError(t, err)
1515+
require.NotNil(t, result)
1516+
1517+
if tt.errContains != "" {
1518+
textContent := getTextResult(t, result)
1519+
assert.Contains(t, textContent.Text, tt.errContains)
1520+
return
1521+
}
1522+
1523+
textContent := getTextResult(t, result)
1524+
require.NotEmpty(t, textContent.Text)
1525+
1526+
// Verify response
1527+
var branches []*github.Branch
1528+
err = json.Unmarshal([]byte(textContent.Text), &branches)
1529+
require.NoError(t, err)
1530+
assert.Len(t, branches, 2)
1531+
assert.Equal(t, "main", *branches[0].Name)
1532+
assert.Equal(t, "develop", *branches[1].Name)
1533+
})
1534+
}
1535+
}

pkg/github/server.go

+1
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ func NewServer(getClient GetClientFn, version string, readOnly bool, t translati
7070
s.AddTool(GetFileContents(getClient, t))
7171
s.AddTool(GetCommit(getClient, t))
7272
s.AddTool(ListCommits(getClient, t))
73+
s.AddTool(ListBranches(getClient, t))
7374
if !readOnly {
7475
s.AddTool(CreateOrUpdateFile(getClient, t))
7576
s.AddTool(CreateRepository(getClient, t))

0 commit comments

Comments
 (0)