Skip to content

Commit bba6d9e

Browse files
committed
feat(repositories): add delete_branch tool
The repositories toolset exposes create_branch and list_branches but provides no way to delete a branch. This adds a delete_branch tool that fills that gap, following the existing create_branch implementation. As a guardrail, the tool fetches the branch first and refuses to delete protected branches, returning a clear error to the model instead of attempting the deletion. It requires owner, repo, and branch, is scoped to `repo`, and is annotated as a non-read-only operation. Tests cover successful deletion, protected-branch refusal, branch not found, and deletion failure. Closes #1476
1 parent 8cd03c0 commit bba6d9e

6 files changed

Lines changed: 256 additions & 12 deletions

File tree

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1265,6 +1265,12 @@ The following sets of tools are available:
12651265
- `organization`: Organization to create the repository in (omit to create in your personal account) (string, optional)
12661266
- `private`: Whether the repository should be private. Defaults to true (private) when omitted. (boolean, optional)
12671267

1268+
- **delete_branch** - Delete branch
1269+
- **Required OAuth Scopes**: `repo`
1270+
- `branch`: Name of the branch to delete (string, required)
1271+
- `owner`: Repository owner (string, required)
1272+
- `repo`: Repository name (string, required)
1273+
12681274
- **delete_file** - Delete file
12691275
- **Required OAuth Scopes**: `repo`
12701276
- `branch`: Branch to delete the file from (string, required)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"annotations": {
3+
"title": "Delete branch"
4+
},
5+
"description": "Delete a branch from a GitHub repository. Protected branches cannot be deleted.",
6+
"inputSchema": {
7+
"properties": {
8+
"branch": {
9+
"description": "Name of the branch to delete",
10+
"type": "string"
11+
},
12+
"owner": {
13+
"description": "Repository owner",
14+
"type": "string"
15+
},
16+
"repo": {
17+
"description": "Repository name",
18+
"type": "string"
19+
}
20+
},
21+
"required": [
22+
"owner",
23+
"repo",
24+
"branch"
25+
],
26+
"type": "object"
27+
},
28+
"name": "delete_branch"
29+
}

pkg/github/helper_test.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,24 +30,26 @@ const (
3030
DeleteUserStarredByOwnerByRepo = "DELETE /user/starred/{owner}/{repo}"
3131

3232
// Repository endpoints
33-
GetReposByOwnerByRepo = "GET /repos/{owner}/{repo}"
34-
GetReposBranchesByOwnerByRepo = "GET /repos/{owner}/{repo}/branches"
35-
GetReposTagsByOwnerByRepo = "GET /repos/{owner}/{repo}/tags"
36-
GetReposCommitsByOwnerByRepo = "GET /repos/{owner}/{repo}/commits"
37-
GetReposCommitsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}"
38-
GetReposContentsByOwnerByRepoByPath = "GET /repos/{owner}/{repo}/contents/{path}"
39-
PutReposContentsByOwnerByRepoByPath = "PUT /repos/{owner}/{repo}/contents/{path}"
40-
PostReposForksByOwnerByRepo = "POST /repos/{owner}/{repo}/forks"
41-
GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription"
42-
PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription"
43-
DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription"
44-
ListCollaborators = "GET /repos/{owner}/{repo}/collaborators"
33+
GetReposByOwnerByRepo = "GET /repos/{owner}/{repo}"
34+
GetReposBranchesByOwnerByRepo = "GET /repos/{owner}/{repo}/branches"
35+
GetReposBranchesByOwnerByRepoByBranch = "GET /repos/{owner}/{repo}/branches/{branch}"
36+
GetReposTagsByOwnerByRepo = "GET /repos/{owner}/{repo}/tags"
37+
GetReposCommitsByOwnerByRepo = "GET /repos/{owner}/{repo}/commits"
38+
GetReposCommitsByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/commits/{ref}"
39+
GetReposContentsByOwnerByRepoByPath = "GET /repos/{owner}/{repo}/contents/{path}"
40+
PutReposContentsByOwnerByRepoByPath = "PUT /repos/{owner}/{repo}/contents/{path}"
41+
PostReposForksByOwnerByRepo = "POST /repos/{owner}/{repo}/forks"
42+
GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription"
43+
PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription"
44+
DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription"
45+
ListCollaborators = "GET /repos/{owner}/{repo}/collaborators"
4546

4647
// Git endpoints
4748
GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}"
4849
GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref:.*}"
4950
PostReposGitRefsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/refs"
5051
PatchReposGitRefsByOwnerByRepoByRef = "PATCH /repos/{owner}/{repo}/git/refs/{ref:.*}"
52+
DeleteReposGitRefsByOwnerByRepoByRef = "DELETE /repos/{owner}/{repo}/git/refs/{ref:.*}"
5153
GetReposGitCommitsByOwnerByRepoByCommitSHA = "GET /repos/{owner}/{repo}/git/commits/{commit_sha}"
5254
PostReposGitCommitsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/commits"
5355
GetReposGitTagsByOwnerByRepoByTagSHA = "GET /repos/{owner}/{repo}/git/tags/{tag_sha}"

pkg/github/repositories.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,87 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool {
12921292
)
12931293
}
12941294

1295+
// DeleteBranch creates a tool to delete a branch in a GitHub repository.
1296+
func DeleteBranch(t translations.TranslationHelperFunc) inventory.ServerTool {
1297+
return NewTool(
1298+
ToolsetMetadataRepos,
1299+
mcp.Tool{
1300+
Name: "delete_branch",
1301+
Description: t("TOOL_DELETE_BRANCH_DESCRIPTION", "Delete a branch from a GitHub repository. Protected branches cannot be deleted."),
1302+
Annotations: &mcp.ToolAnnotations{
1303+
Title: t("TOOL_DELETE_BRANCH_USER_TITLE", "Delete branch"),
1304+
ReadOnlyHint: false,
1305+
},
1306+
InputSchema: &jsonschema.Schema{
1307+
Type: "object",
1308+
Properties: map[string]*jsonschema.Schema{
1309+
"owner": {
1310+
Type: "string",
1311+
Description: "Repository owner",
1312+
},
1313+
"repo": {
1314+
Type: "string",
1315+
Description: "Repository name",
1316+
},
1317+
"branch": {
1318+
Type: "string",
1319+
Description: "Name of the branch to delete",
1320+
},
1321+
},
1322+
Required: []string{"owner", "repo", "branch"},
1323+
},
1324+
},
1325+
[]scopes.Scope{scopes.Repo},
1326+
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
1327+
owner, err := RequiredParam[string](args, "owner")
1328+
if err != nil {
1329+
return utils.NewToolResultError(err.Error()), nil, nil
1330+
}
1331+
repo, err := RequiredParam[string](args, "repo")
1332+
if err != nil {
1333+
return utils.NewToolResultError(err.Error()), nil, nil
1334+
}
1335+
branch, err := RequiredParam[string](args, "branch")
1336+
if err != nil {
1337+
return utils.NewToolResultError(err.Error()), nil, nil
1338+
}
1339+
1340+
client, err := deps.GetClient(ctx)
1341+
if err != nil {
1342+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
1343+
}
1344+
1345+
// Guardrail: refuse to delete protected branches.
1346+
branchInfo, resp, err := client.Repositories.GetBranch(ctx, owner, repo, branch, 1)
1347+
if err != nil {
1348+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
1349+
"failed to get branch",
1350+
resp,
1351+
err,
1352+
), nil, nil
1353+
}
1354+
defer func() { _ = resp.Body.Close() }()
1355+
1356+
if branchInfo.GetProtected() {
1357+
return utils.NewToolResultError(fmt.Sprintf("branch %q is protected and cannot be deleted", branch)), nil, nil
1358+
}
1359+
1360+
// Delete the branch reference.
1361+
resp, err = client.Git.DeleteRef(ctx, owner, repo, "refs/heads/"+branch)
1362+
if err != nil {
1363+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
1364+
"failed to delete branch",
1365+
resp,
1366+
err,
1367+
), nil, nil
1368+
}
1369+
defer func() { _ = resp.Body.Close() }()
1370+
1371+
return utils.NewToolResultText(fmt.Sprintf("Successfully deleted branch %q", branch)), nil, nil
1372+
},
1373+
)
1374+
}
1375+
12951376
// PushFiles creates a tool to push multiple files in a single commit to a GitHub repository.
12961377
func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool {
12971378
return NewTool(

pkg/github/repositories_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,6 +1022,131 @@ func Test_CreateBranch(t *testing.T) {
10221022
}
10231023
}
10241024

1025+
func Test_DeleteBranch(t *testing.T) {
1026+
// Verify tool definition once
1027+
serverTool := DeleteBranch(translations.NullTranslationHelper)
1028+
tool := serverTool.Tool
1029+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
1030+
1031+
schema, ok := tool.InputSchema.(*jsonschema.Schema)
1032+
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
1033+
1034+
assert.Equal(t, "delete_branch", tool.Name)
1035+
assert.NotEmpty(t, tool.Description)
1036+
assert.False(t, tool.Annotations.ReadOnlyHint, "delete_branch must not be read-only")
1037+
assert.Contains(t, schema.Properties, "owner")
1038+
assert.Contains(t, schema.Properties, "repo")
1039+
assert.Contains(t, schema.Properties, "branch")
1040+
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "branch"})
1041+
1042+
unprotectedBranch := &github.Branch{
1043+
Name: github.Ptr("feature"),
1044+
Protected: github.Ptr(false),
1045+
}
1046+
protectedBranch := &github.Branch{
1047+
Name: github.Ptr("main"),
1048+
Protected: github.Ptr(true),
1049+
}
1050+
1051+
tests := []struct {
1052+
name string
1053+
mockedClient *http.Client
1054+
requestArgs map[string]any
1055+
expectError bool
1056+
expectedErrMsg string
1057+
expectedSuccess string
1058+
}{
1059+
{
1060+
name: "successful branch deletion",
1061+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1062+
GetReposBranchesByOwnerByRepoByBranch: mockResponse(t, http.StatusOK, unprotectedBranch),
1063+
DeleteReposGitRefsByOwnerByRepoByRef: mockResponse(t, http.StatusNoContent, nil),
1064+
}),
1065+
requestArgs: map[string]any{
1066+
"owner": "owner",
1067+
"repo": "repo",
1068+
"branch": "feature",
1069+
},
1070+
expectError: false,
1071+
expectedSuccess: `Successfully deleted branch "feature"`,
1072+
},
1073+
{
1074+
name: "refuses to delete protected branch",
1075+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1076+
GetReposBranchesByOwnerByRepoByBranch: mockResponse(t, http.StatusOK, protectedBranch),
1077+
}),
1078+
requestArgs: map[string]any{
1079+
"owner": "owner",
1080+
"repo": "repo",
1081+
"branch": "main",
1082+
},
1083+
expectError: true,
1084+
expectedErrMsg: "protected and cannot be deleted",
1085+
},
1086+
{
1087+
name: "branch not found",
1088+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1089+
GetReposBranchesByOwnerByRepoByBranch: func(w http.ResponseWriter, _ *http.Request) {
1090+
w.WriteHeader(http.StatusNotFound)
1091+
_, _ = w.Write([]byte(`{"message": "Branch not found"}`))
1092+
},
1093+
}),
1094+
requestArgs: map[string]any{
1095+
"owner": "owner",
1096+
"repo": "repo",
1097+
"branch": "nonexistent",
1098+
},
1099+
expectError: true,
1100+
expectedErrMsg: "failed to get branch",
1101+
},
1102+
{
1103+
name: "fail to delete reference",
1104+
mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
1105+
GetReposBranchesByOwnerByRepoByBranch: mockResponse(t, http.StatusOK, unprotectedBranch),
1106+
DeleteReposGitRefsByOwnerByRepoByRef: func(w http.ResponseWriter, _ *http.Request) {
1107+
w.WriteHeader(http.StatusUnprocessableEntity)
1108+
_, _ = w.Write([]byte(`{"message": "Reference does not exist"}`))
1109+
},
1110+
}),
1111+
requestArgs: map[string]any{
1112+
"owner": "owner",
1113+
"repo": "repo",
1114+
"branch": "feature",
1115+
},
1116+
expectError: true,
1117+
expectedErrMsg: "failed to delete branch",
1118+
},
1119+
}
1120+
1121+
for _, tc := range tests {
1122+
t.Run(tc.name, func(t *testing.T) {
1123+
client := mustNewGHClient(t, tc.mockedClient)
1124+
deps := BaseDeps{
1125+
Client: client,
1126+
}
1127+
handler := serverTool.Handler(deps)
1128+
1129+
request := createMCPRequest(tc.requestArgs)
1130+
1131+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1132+
1133+
if tc.expectError {
1134+
require.NoError(t, err)
1135+
require.True(t, result.IsError)
1136+
errorContent := getErrorResult(t, result)
1137+
assert.Contains(t, errorContent.Text, tc.expectedErrMsg)
1138+
return
1139+
}
1140+
1141+
require.NoError(t, err)
1142+
require.False(t, result.IsError)
1143+
1144+
textContent := getTextResult(t, result)
1145+
assert.Contains(t, textContent.Text, tc.expectedSuccess)
1146+
})
1147+
}
1148+
}
1149+
10251150
func Test_GetCommit(t *testing.T) {
10261151
// Verify tool definition once
10271152
serverTool := GetCommit(translations.NullTranslationHelper)

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
197197
CreateRepository(t),
198198
ForkRepository(t),
199199
CreateBranch(t),
200+
DeleteBranch(t),
200201
PushFiles(t),
201202
DeleteFile(t),
202203
ListStarredRepositories(t),

0 commit comments

Comments
 (0)