Repository: github/github-mcp-server

Issue:
Add ability to view "branches" for a repo
### Describe the feature or problem you’d like to solve

I ran into this today, I wanted to get a list of branches for one of the repos in my organization, but the server wasn't able to provide it. You had to already know what the branch was. Now, of course, I can use git to pull that information and look locally, but this feels like something that should be included.

### Proposed solution

Add the following functionality to the MCP Server:

```shell
curl -L \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer <TOKEN>" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/repos/OWNER/REPO/branches
```

### Additional context

n/a

Discussion:
@aryasoni98: Hi @cstroliadavis! I’d love to work on this issue. Could you please assign it to me?

Solution patch:
diff --git a/README.md b/README.md
index f85663a..00f49b6 100644
--- a/README.md
+++ b/README.md
@@ -311,6 +311,13 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description
   - `branch`: Branch name (string, optional)
   - `sha`: File SHA if updating (string, optional)
 
+- **list_branches** - List branches in a GitHub repository
+
+  - `owner`: Repository owner (string, required)
+  - `repo`: Repository name (string, required)
+  - `page`: Page number (number, optional)
+  - `perPage`: Results per page (number, optional)
+
 - **push_files** - Push multiple files in a single commit
 
   - `owner`: Repository owner (string, required)
diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go
index 56500ea..1d74dcf 100644
--- a/pkg/github/repositories.go
+++ b/pkg/github/repositories.go
@@ -150,6 +150,69 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t
 		}
 }
 
+// ListBranches creates a tool to list branches in a GitHub repository.
+func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
+	return mcp.NewTool("list_branches",
+			mcp.WithDescription(t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository")),
+			mcp.WithString("owner",
+				mcp.Required(),
+				mcp.Description("Repository owner"),
+			),
+			mcp.WithString("repo",
+				mcp.Required(),
+				mcp.Description("Repository name"),
+			),
+			WithPagination(),
+		),
+		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
+			}
+			pagination, err := OptionalPaginationParams(request)
+			if err != nil {
+				return mcp.NewToolResultError(err.Error()), nil
+			}
+
+			opts := &github.BranchListOptions{
+				ListOptions: github.ListOptions{
+					Page:    pagination.page,
+					PerPage: pagination.perPage,
+				},
+			}
+
+			client, err := getClient(ctx)
+			if err != nil {
+				return nil, fmt.Errorf("failed to get GitHub client: %w", err)
+			}
+
+			branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts)
+			if err != nil {
+				return nil, fmt.Errorf("failed to list branches: %w", err)
+			}
+			defer func() { _ = resp.Body.Close() }()
+
+			if resp.StatusCode != http.StatusOK {
+				body, err := io.ReadAll(resp.Body)
+				if err != nil {
+					return nil, fmt.Errorf("failed to read response body: %w", err)
+				}
+				return mcp.NewToolResultError(fmt.Sprintf("failed to list branches: %s", string(body))), nil
+			}
+
+			r, err := json.Marshal(branches)
+			if err != nil {
+				return nil, fmt.Errorf("failed to marshal response: %w", err)
+			}
+
+			return mcp.NewToolResultText(string(r)), nil
+		}
+}
+
 // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository.
 func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) {
 	return mcp.NewTool("create_or_update_file",
diff --git a/pkg/github/server.go b/pkg/github/server.go
index 490a810..f7eea97 100644
--- a/pkg/github/server.go
+++ b/pkg/github/server.go
@@ -70,6 +70,7 @@ func NewServer(getClient GetClientFn, version string, readOnly bool, t translati
 	s.AddTool(GetFileContents(getClient, t))
 	s.AddTool(GetCommit(getClient, t))
 	s.AddTool(ListCommits(getClient, t))
+	s.AddTool(ListBranches(getClient, t))
 	if !readOnly {
 		s.AddTool(CreateOrUpdateFile(getClient, t))
 		s.AddTool(CreateRepository(getClient, t))



Test patch:
diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go
index 20f96dd..2410a31 100644
--- a/pkg/github/repositories_test.go
+++ b/pkg/github/repositories_test.go
@@ -1423,3 +1423,113 @@ func Test_PushFiles(t *testing.T) {
 		})
 	}
 }
+
+func Test_ListBranches(t *testing.T) {
+	// Verify tool definition once
+	mockClient := github.NewClient(nil)
+	tool, _ := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+	assert.Equal(t, "list_branches", 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, "page")
+	assert.Contains(t, tool.InputSchema.Properties, "perPage")
+	assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"})
+
+	// Setup mock branches for success case
+	mockBranches := []*github.Branch{
+		{
+			Name:   github.Ptr("main"),
+			Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")},
+		},
+		{
+			Name:   github.Ptr("develop"),
+			Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")},
+		},
+	}
+
+	// Test cases
+	tests := []struct {
+		name          string
+		args          map[string]interface{}
+		mockResponses []mock.MockBackendOption
+		wantErr       bool
+		errContains   string
+	}{
+		{
+			name: "success",
+			args: map[string]interface{}{
+				"owner": "owner",
+				"repo":  "repo",
+				"page":  float64(2),
+			},
+			mockResponses: []mock.MockBackendOption{
+				mock.WithRequestMatch(
+					mock.GetReposBranchesByOwnerByRepo,
+					mockBranches,
+				),
+			},
+			wantErr: false,
+		},
+		{
+			name: "missing owner",
+			args: map[string]interface{}{
+				"repo": "repo",
+			},
+			mockResponses: []mock.MockBackendOption{},
+			wantErr:       false,
+			errContains:   "missing required parameter: owner",
+		},
+		{
+			name: "missing repo",
+			args: map[string]interface{}{
+				"owner": "owner",
+			},
+			mockResponses: []mock.MockBackendOption{},
+			wantErr:       false,
+			errContains:   "missing required parameter: repo",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Create mock client
+			mockClient := github.NewClient(mock.NewMockedHTTPClient(tt.mockResponses...))
+			_, handler := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper)
+
+			// Create request
+			request := createMCPRequest(tt.args)
+
+			// Call handler
+			result, err := handler(context.Background(), request)
+			if tt.wantErr {
+				require.Error(t, err)
+				if tt.errContains != "" {
+					assert.Contains(t, err.Error(), tt.errContains)
+				}
+				return
+			}
+
+			require.NoError(t, err)
+			require.NotNil(t, result)
+
+			if tt.errContains != "" {
+				textContent := getTextResult(t, result)
+				assert.Contains(t, textContent.Text, tt.errContains)
+				return
+			}
+
+			textContent := getTextResult(t, result)
+			require.NotEmpty(t, textContent.Text)
+
+			// Verify response
+			var branches []*github.Branch
+			err = json.Unmarshal([]byte(textContent.Text), &branches)
+			require.NoError(t, err)
+			assert.Len(t, branches, 2)
+			assert.Equal(t, "main", *branches[0].Name)
+			assert.Equal(t, "develop", *branches[1].Name)
+		})
+	}
+}