Skip to content

Commit 98aef1f

Browse files
feat(repos): add confirmed repository deletion
Add a destructive delete_repository tool that requires an exact owner/repo confirmation through multi-round-trip elicitation. Gate the tool to MCP protocol 2026-07-28 and newer across local and remote transports. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
1 parent 0ea1f77 commit 98aef1f

14 files changed

Lines changed: 614 additions & 10 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1308,6 +1308,11 @@ The following sets of tools are available:
13081308
- `path`: Path to the file to delete (string, required)
13091309
- `repo`: Repository name (string, required)
13101310

1311+
- **delete_repository** - Delete repository
1312+
- **Required OAuth Scopes**: `delete_repo`
1313+
- `owner`: Repository owner (username or organization) (string, required)
1314+
- `repo`: Repository name (string, required)
1315+
13111316
- **fork_repository** - Fork repository
13121317
- **Required OAuth Scopes**: `repo`
13131318
- `organization`: Organization to fork to (string, optional)

internal/ghmcp/oauth.go

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,6 @@ type oauthAuthenticator interface {
103103
// delayed response from an older prompt from affecting a newer flow.
104104
const oauthElicitIDPrefix = "github_authorization:"
105105

106-
// protocolVersionNoServerElicitation is the first MCP protocol version that
107-
// forbids server-initiated JSON-RPC requests (SEP-2322): from this version on
108-
// the server may not send elicitation/create while serving a request and must
109-
// instead return an InputRequests map from the tool call (multi round-trip
110-
// requests). It mirrors the go-sdk's internal constant of the same value, which
111-
// the SDK does not export.
112-
const protocolVersionNoServerElicitation = "2026-07-28"
113-
114106
// serverMayInitiateElicitation reports whether the server is permitted to send
115107
// elicitation requests to the client itself, which the spec allows only before
116108
// protocol version 2026-07-28. A nil or un-negotiated session (only reached in
@@ -120,7 +112,7 @@ func serverMayInitiateElicitation(ss *mcp.ServerSession) bool {
120112
return true
121113
}
122114
params := ss.InitializeParams()
123-
return params == nil || params.ProtocolVersion < protocolVersionNoServerElicitation
115+
return params == nil || params.ProtocolVersion < inventory.ProtocolVersionMultiRoundTrip
124116
}
125117

126118
// createOAuthToolMiddleware returns tool-handler middleware that authorizes the
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"annotations": {
3+
"destructiveHint": true,
4+
"idempotentHint": false,
5+
"readOnlyHint": false,
6+
"title": "Delete repository"
7+
},
8+
"description": "Delete a GitHub repository after the user confirms the exact owner/repository name",
9+
"inputSchema": {
10+
"properties": {
11+
"owner": {
12+
"description": "Repository owner (username or organization)",
13+
"type": "string"
14+
},
15+
"repo": {
16+
"description": "Repository name",
17+
"type": "string"
18+
}
19+
},
20+
"required": [
21+
"owner",
22+
"repo"
23+
],
24+
"type": "object"
25+
},
26+
"name": "delete_repository"
27+
}

pkg/github/helper_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const (
4040
PostReposForksByOwnerByRepo = "POST /repos/{owner}/{repo}/forks"
4141
GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription"
4242
PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription"
43+
DeleteReposByOwnerByRepo = "DELETE /repos/{owner}/{repo}"
4344
DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription"
4445
ListCollaborators = "GET /repos/{owner}/{repo}/collaborators"
4546

pkg/github/repositories.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,119 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool
704704
)
705705
}
706706

707+
const (
708+
deleteRepositoryConfirmationID = "delete_repository_confirmation"
709+
deleteRepositoryConfirmationField = "repository_name"
710+
)
711+
712+
// DeleteRepository creates a tool that deletes a GitHub repository after the
713+
// user confirms its full name through elicitation.
714+
func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool {
715+
tool := NewTool(
716+
ToolsetMetadataRepos,
717+
mcp.Tool{
718+
Name: "delete_repository",
719+
Description: t("TOOL_DELETE_REPOSITORY_DESCRIPTION", "Delete a GitHub repository after the user confirms the exact owner/repository name"),
720+
Annotations: &mcp.ToolAnnotations{
721+
Title: t("TOOL_DELETE_REPOSITORY_USER_TITLE", "Delete repository"),
722+
ReadOnlyHint: false,
723+
DestructiveHint: github.Ptr(true),
724+
},
725+
InputSchema: &jsonschema.Schema{
726+
Type: "object",
727+
Properties: map[string]*jsonschema.Schema{
728+
"owner": {
729+
Type: "string",
730+
Description: "Repository owner (username or organization)",
731+
},
732+
"repo": {
733+
Type: "string",
734+
Description: "Repository name",
735+
},
736+
},
737+
Required: []string{"owner", "repo"},
738+
},
739+
},
740+
[]scopes.Scope{scopes.DeleteRepo},
741+
func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
742+
owner, err := RequiredParam[string](args, "owner")
743+
if err != nil {
744+
return utils.NewToolResultError(err.Error()), nil, nil
745+
}
746+
repo, err := RequiredParam[string](args, "repo")
747+
if err != nil {
748+
return utils.NewToolResultError(err.Error()), nil, nil
749+
}
750+
751+
fullName := owner + "/" + repo
752+
var responses mcp.InputResponseMap
753+
if req != nil && req.Params != nil {
754+
responses = req.Params.InputResponses
755+
}
756+
response, ok := responses[deleteRepositoryConfirmationID]
757+
if !ok {
758+
return &mcp.CallToolResult{
759+
InputRequests: mcp.InputRequestMap{
760+
deleteRepositoryConfirmationID: &mcp.ElicitParams{
761+
Mode: "form",
762+
Message: fmt.Sprintf("Type %q to confirm permanent deletion of this repository.", fullName),
763+
RequestedSchema: &jsonschema.Schema{
764+
Type: "object",
765+
Properties: map[string]*jsonschema.Schema{
766+
deleteRepositoryConfirmationField: {
767+
Type: "string",
768+
Title: "Repository name",
769+
Description: fmt.Sprintf("Enter %s exactly to confirm deletion", fullName),
770+
},
771+
},
772+
Required: []string{deleteRepositoryConfirmationField},
773+
},
774+
},
775+
},
776+
}, nil, nil
777+
}
778+
779+
confirmation, ok := response.(*mcp.ElicitResult)
780+
if !ok {
781+
return utils.NewToolResultError("Repository deletion confirmation was invalid. The repository was not deleted."), nil, nil
782+
}
783+
if confirmation.Action != "accept" {
784+
return utils.NewToolResultError("Repository deletion was not confirmed. The repository was not deleted."), nil, nil
785+
}
786+
confirmedName, ok := confirmation.Content[deleteRepositoryConfirmationField].(string)
787+
if !ok || confirmedName != fullName {
788+
return utils.NewToolResultError(fmt.Sprintf("Repository name confirmation did not match %q. The repository was not deleted.", fullName)), nil, nil
789+
}
790+
791+
client, err := deps.GetClient(ctx)
792+
if err != nil {
793+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
794+
}
795+
resp, err := client.Repositories.Delete(ctx, owner, repo)
796+
if err != nil {
797+
return ghErrors.NewGitHubAPIErrorResponse(ctx,
798+
fmt.Sprintf("failed to delete repository: %s", fullName),
799+
resp,
800+
err,
801+
), nil, nil
802+
}
803+
defer func() { _ = resp.Body.Close() }()
804+
805+
if resp.StatusCode != http.StatusNoContent {
806+
body, err := io.ReadAll(resp.Body)
807+
if err != nil {
808+
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
809+
}
810+
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to delete repository", resp, body), nil, nil
811+
}
812+
813+
return utils.NewToolResultText(fmt.Sprintf("Repository %s was deleted.", fullName)), nil, nil
814+
},
815+
)
816+
tool.MinimumProtocolVersion = inventory.ProtocolVersionMultiRoundTrip
817+
return tool
818+
}
819+
707820
// FetchRepoIsPrivate returns whether a repository is private. It is a thin
708821
// wrapper around the GitHub Repositories.Get endpoint provided as a shared
709822
// helper for IFC label computation across tools.

pkg/github/repositories_test.go

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ import (
1212

1313
"github.com/github/github-mcp-server/internal/githubv4mock"
1414
"github.com/github/github-mcp-server/internal/toolsnaps"
15+
"github.com/github/github-mcp-server/pkg/inventory"
1516
"github.com/github/github-mcp-server/pkg/raw"
17+
"github.com/github/github-mcp-server/pkg/scopes"
1618
"github.com/github/github-mcp-server/pkg/translations"
1719
"github.com/github/github-mcp-server/pkg/utils"
1820
"github.com/google/go-github/v89/github"
@@ -2984,6 +2986,171 @@ func Test_PushFiles(t *testing.T) {
29842986
}
29852987
}
29862988

2989+
func Test_DeleteRepository(t *testing.T) {
2990+
serverTool := DeleteRepository(translations.NullTranslationHelper)
2991+
tool := serverTool.Tool
2992+
require.NoError(t, toolsnaps.Test(tool.Name, tool))
2993+
2994+
schema, ok := tool.InputSchema.(*jsonschema.Schema)
2995+
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
2996+
assert.Equal(t, "delete_repository", tool.Name)
2997+
assert.NotEmpty(t, tool.Description)
2998+
assert.ElementsMatch(t, []string{"owner", "repo"}, schema.Required)
2999+
require.NotNil(t, tool.Annotations)
3000+
require.NotNil(t, tool.Annotations.DestructiveHint)
3001+
assert.True(t, *tool.Annotations.DestructiveHint)
3002+
assert.Equal(t, inventory.ProtocolVersionMultiRoundTrip, serverTool.MinimumProtocolVersion)
3003+
assert.Equal(t, []string{string(scopes.DeleteRepo)}, serverTool.RequiredScopes)
3004+
3005+
t.Run("requests exact repository name through elicitation", func(t *testing.T) {
3006+
result := invokeDeleteRepository(t, serverTool, NewMockedHTTPClient(), nil)
3007+
3008+
require.False(t, result.IsError)
3009+
require.Len(t, result.InputRequests, 1)
3010+
inputRequest, ok := result.InputRequests[deleteRepositoryConfirmationID].(*mcp.ElicitParams)
3011+
require.True(t, ok)
3012+
assert.Equal(t, "form", inputRequest.Mode)
3013+
assert.Contains(t, inputRequest.Message, `"owner/repo"`)
3014+
3015+
requestedSchema, ok := inputRequest.RequestedSchema.(*jsonschema.Schema)
3016+
require.True(t, ok)
3017+
assert.ElementsMatch(t, []string{deleteRepositoryConfirmationField}, requestedSchema.Required)
3018+
assert.Contains(t, requestedSchema.Properties, deleteRepositoryConfirmationField)
3019+
})
3020+
3021+
t.Run("deletes after exact confirmation", func(t *testing.T) {
3022+
client := NewMockedHTTPClient(
3023+
WithRequestMatchHandler(
3024+
DeleteReposByOwnerByRepo,
3025+
mockResponse(t, http.StatusNoContent, nil),
3026+
),
3027+
)
3028+
result := invokeDeleteRepository(t, serverTool, client, &mcp.ElicitResult{
3029+
Action: "accept",
3030+
Content: map[string]any{
3031+
deleteRepositoryConfirmationField: "owner/repo",
3032+
},
3033+
})
3034+
3035+
require.False(t, result.IsError)
3036+
assert.Contains(t, getTextResult(t, result).Text, "owner/repo was deleted")
3037+
})
3038+
3039+
t.Run("completes multi-round-trip elicitation before deleting", func(t *testing.T) {
3040+
httpClient := NewMockedHTTPClient(
3041+
WithRequestMatchHandler(
3042+
DeleteReposByOwnerByRepo,
3043+
mockResponse(t, http.StatusNoContent, nil),
3044+
),
3045+
)
3046+
deps := BaseDeps{Client: mustNewGHClient(t, httpClient)}
3047+
3048+
inv, err := inventory.NewBuilder().
3049+
SetTools([]inventory.ServerTool{serverTool}).
3050+
WithToolsets([]string{"all"}).
3051+
Build()
3052+
require.NoError(t, err)
3053+
3054+
server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil)
3055+
server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
3056+
return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) {
3057+
return next(ContextWithDeps(ctx, deps), method, request)
3058+
}
3059+
})
3060+
inv.RegisterTools(context.Background(), server, deps)
3061+
3062+
serverTransport, clientTransport := mcp.NewInMemoryTransports()
3063+
serverSession, err := server.Connect(context.Background(), serverTransport, nil)
3064+
require.NoError(t, err)
3065+
t.Cleanup(func() { _ = serverSession.Close() })
3066+
3067+
client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, &mcp.ClientOptions{
3068+
ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
3069+
return &mcp.ElicitResult{
3070+
Action: "accept",
3071+
Content: map[string]any{
3072+
deleteRepositoryConfirmationField: "owner/repo",
3073+
},
3074+
}, nil
3075+
},
3076+
})
3077+
clientSession, err := client.Connect(context.Background(), clientTransport, nil)
3078+
require.NoError(t, err)
3079+
t.Cleanup(func() { _ = clientSession.Close() })
3080+
3081+
result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{
3082+
Name: "delete_repository",
3083+
Arguments: map[string]any{
3084+
"owner": "owner",
3085+
"repo": "repo",
3086+
},
3087+
})
3088+
require.NoError(t, err)
3089+
require.False(t, result.IsError)
3090+
assert.Contains(t, getTextResult(t, result).Text, "owner/repo was deleted")
3091+
})
3092+
3093+
t.Run("refuses mismatched confirmation", func(t *testing.T) {
3094+
result := invokeDeleteRepository(t, serverTool, NewMockedHTTPClient(), &mcp.ElicitResult{
3095+
Action: "accept",
3096+
Content: map[string]any{
3097+
deleteRepositoryConfirmationField: "owner/another-repo",
3098+
},
3099+
})
3100+
3101+
require.True(t, result.IsError)
3102+
assert.Contains(t, getErrorResult(t, result).Text, "did not match")
3103+
})
3104+
3105+
t.Run("refuses declined confirmation", func(t *testing.T) {
3106+
result := invokeDeleteRepository(t, serverTool, NewMockedHTTPClient(), &mcp.ElicitResult{
3107+
Action: "decline",
3108+
})
3109+
3110+
require.True(t, result.IsError)
3111+
assert.Contains(t, getErrorResult(t, result).Text, "was not confirmed")
3112+
})
3113+
3114+
t.Run("returns GitHub API errors", func(t *testing.T) {
3115+
client := NewMockedHTTPClient(
3116+
WithRequestMatchHandler(
3117+
DeleteReposByOwnerByRepo,
3118+
mockResponse(t, http.StatusForbidden, map[string]any{"message": "Requires admin permissions"}),
3119+
),
3120+
)
3121+
result := invokeDeleteRepository(t, serverTool, client, &mcp.ElicitResult{
3122+
Action: "accept",
3123+
Content: map[string]any{
3124+
deleteRepositoryConfirmationField: "owner/repo",
3125+
},
3126+
})
3127+
3128+
require.True(t, result.IsError)
3129+
assert.Contains(t, getErrorResult(t, result).Text, "failed to delete repository")
3130+
})
3131+
}
3132+
3133+
func invokeDeleteRepository(t *testing.T, tool inventory.ServerTool, httpClient *http.Client, confirmation *mcp.ElicitResult) *mcp.CallToolResult {
3134+
t.Helper()
3135+
3136+
deps := BaseDeps{Client: mustNewGHClient(t, httpClient)}
3137+
handler := tool.Handler(deps)
3138+
request := createMCPRequest(map[string]any{
3139+
"owner": "owner",
3140+
"repo": "repo",
3141+
})
3142+
if confirmation != nil {
3143+
request.Params.InputResponses = mcp.InputResponseMap{
3144+
deleteRepositoryConfirmationID: confirmation,
3145+
}
3146+
}
3147+
3148+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
3149+
require.NoError(t, err)
3150+
require.NotNil(t, result)
3151+
return result
3152+
}
3153+
29873154
func Test_ListBranches(t *testing.T) {
29883155
// Verify tool definition once
29893156
serverTool := ListBranches(translations.NullTranslationHelper)

pkg/github/tools.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent
232232
GetReleaseByTag(t),
233233
CreateOrUpdateFile(t),
234234
CreateRepository(t),
235+
DeleteRepository(t),
235236
ForkRepository(t),
236237
CreateBranch(t),
237238
PushFiles(t),

0 commit comments

Comments
 (0)