Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,11 @@ The following sets of tools are available:
- `path`: Path to the file to delete (string, required)
- `repo`: Repository name (string, required)

- **delete_repository** - Delete repository
- **Required OAuth Scopes**: `delete_repo`
- `owner`: Repository owner (username or organization) (string, required)
- `repo`: Repository name (string, required)

- **fork_repository** - Fork repository
- **Required OAuth Scopes**: `repo`
- `organization`: Organization to fork to (string, optional)
Expand Down
10 changes: 1 addition & 9 deletions internal/ghmcp/oauth.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,6 @@ type oauthAuthenticator interface {
// delayed response from an older prompt from affecting a newer flow.
const oauthElicitIDPrefix = "github_authorization:"

// protocolVersionNoServerElicitation is the first MCP protocol version that
// forbids server-initiated JSON-RPC requests (SEP-2322): from this version on
// the server may not send elicitation/create while serving a request and must
// instead return an InputRequests map from the tool call (multi round-trip
// requests). It mirrors the go-sdk's internal constant of the same value, which
// the SDK does not export.
const protocolVersionNoServerElicitation = "2026-07-28"

// serverMayInitiateElicitation reports whether the server is permitted to send
// elicitation requests to the client itself, which the spec allows only before
// protocol version 2026-07-28. A nil or un-negotiated session (only reached in
Expand All @@ -120,7 +112,7 @@ func serverMayInitiateElicitation(ss *mcp.ServerSession) bool {
return true
}
params := ss.InitializeParams()
return params == nil || params.ProtocolVersion < protocolVersionNoServerElicitation
return params == nil || params.ProtocolVersion < inventory.ProtocolVersionMultiRoundTrip
}

// createOAuthToolMiddleware returns tool-handler middleware that authorizes the
Expand Down
27 changes: 27 additions & 0 deletions pkg/github/__toolsnaps__/delete_repository.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"annotations": {
"destructiveHint": true,
"idempotentHint": false,
"readOnlyHint": false,
"title": "Delete repository"
},
"description": "Delete a GitHub repository after the user confirms the exact owner/repository name",
"inputSchema": {
"properties": {
"owner": {
"description": "Repository owner (username or organization)",
"type": "string"
},
"repo": {
"description": "Repository name",
"type": "string"
}
},
"required": [
"owner",
"repo"
],
"type": "object"
},
"name": "delete_repository"
}
1 change: 1 addition & 0 deletions pkg/github/helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const (
PostReposForksByOwnerByRepo = "POST /repos/{owner}/{repo}/forks"
GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription"
PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription"
DeleteReposByOwnerByRepo = "DELETE /repos/{owner}/{repo}"
DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription"
ListCollaborators = "GET /repos/{owner}/{repo}/collaborators"

Expand Down
113 changes: 113 additions & 0 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -704,6 +704,119 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool
)
}

const (
deleteRepositoryConfirmationID = "delete_repository_confirmation"
deleteRepositoryConfirmationField = "repository_name"
)

// DeleteRepository creates a tool that deletes a GitHub repository after the
// user confirms its full name through elicitation.
func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool {
tool := NewTool(
ToolsetMetadataRepos,
mcp.Tool{
Name: "delete_repository",
Description: t("TOOL_DELETE_REPOSITORY_DESCRIPTION", "Delete a GitHub repository after the user confirms the exact owner/repository name"),
Annotations: &mcp.ToolAnnotations{
Title: t("TOOL_DELETE_REPOSITORY_USER_TITLE", "Delete repository"),
ReadOnlyHint: false,
DestructiveHint: github.Ptr(true),
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
"owner": {
Type: "string",
Description: "Repository owner (username or organization)",
},
"repo": {
Type: "string",
Description: "Repository name",
},
},
Required: []string{"owner", "repo"},
},
},
[]scopes.Scope{scopes.DeleteRepo},
func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
owner, err := RequiredParam[string](args, "owner")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
repo, err := RequiredParam[string](args, "repo")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}

fullName := owner + "/" + repo
var responses mcp.InputResponseMap
if req != nil && req.Params != nil {
responses = req.Params.InputResponses
}
response, ok := responses[deleteRepositoryConfirmationID]
if !ok {
return &mcp.CallToolResult{
InputRequests: mcp.InputRequestMap{
deleteRepositoryConfirmationID: &mcp.ElicitParams{
Mode: "form",
Message: fmt.Sprintf("Type %q to confirm permanent deletion of this repository.", fullName),
RequestedSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{
deleteRepositoryConfirmationField: {
Type: "string",
Title: "Repository name",
Description: fmt.Sprintf("Enter %s exactly to confirm deletion", fullName),
},
},
Required: []string{deleteRepositoryConfirmationField},
},
},
},
}, nil, nil
}

confirmation, ok := response.(*mcp.ElicitResult)
if !ok {
return utils.NewToolResultError("Repository deletion confirmation was invalid. The repository was not deleted."), nil, nil
}
if confirmation.Action != "accept" {
return utils.NewToolResultError("Repository deletion was not confirmed. The repository was not deleted."), nil, nil
}
confirmedName, ok := confirmation.Content[deleteRepositoryConfirmationField].(string)
if !ok || confirmedName != fullName {
return utils.NewToolResultError(fmt.Sprintf("Repository name confirmation did not match %q. The repository was not deleted.", fullName)), nil, nil
}

client, err := deps.GetClient(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
resp, err := client.Repositories.Delete(ctx, owner, repo)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
fmt.Sprintf("failed to delete repository: %s", fullName),
resp,
err,
), nil, nil
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode != http.StatusNoContent {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to delete repository", resp, body), nil, nil
}

return utils.NewToolResultText(fmt.Sprintf("Repository %s was deleted.", fullName)), nil, nil
},
)
tool.MinimumProtocolVersion = inventory.ProtocolVersionMultiRoundTrip
return tool
}

// FetchRepoIsPrivate returns whether a repository is private. It is a thin
// wrapper around the GitHub Repositories.Get endpoint provided as a shared
// helper for IFC label computation across tools.
Expand Down
167 changes: 167 additions & 0 deletions pkg/github/repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import (

"github.com/github/github-mcp-server/internal/githubv4mock"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/raw"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/go-github/v89/github"
Expand Down Expand Up @@ -2984,6 +2986,171 @@ func Test_PushFiles(t *testing.T) {
}
}

func Test_DeleteRepository(t *testing.T) {
serverTool := DeleteRepository(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))

schema, ok := tool.InputSchema.(*jsonschema.Schema)
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
assert.Equal(t, "delete_repository", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.ElementsMatch(t, []string{"owner", "repo"}, schema.Required)
require.NotNil(t, tool.Annotations)
require.NotNil(t, tool.Annotations.DestructiveHint)
assert.True(t, *tool.Annotations.DestructiveHint)
assert.Equal(t, inventory.ProtocolVersionMultiRoundTrip, serverTool.MinimumProtocolVersion)
assert.Equal(t, []string{string(scopes.DeleteRepo)}, serverTool.RequiredScopes)

t.Run("requests exact repository name through elicitation", func(t *testing.T) {
result := invokeDeleteRepository(t, serverTool, NewMockedHTTPClient(), nil)

require.False(t, result.IsError)
require.Len(t, result.InputRequests, 1)
inputRequest, ok := result.InputRequests[deleteRepositoryConfirmationID].(*mcp.ElicitParams)
require.True(t, ok)
assert.Equal(t, "form", inputRequest.Mode)
assert.Contains(t, inputRequest.Message, `"owner/repo"`)

requestedSchema, ok := inputRequest.RequestedSchema.(*jsonschema.Schema)
require.True(t, ok)
assert.ElementsMatch(t, []string{deleteRepositoryConfirmationField}, requestedSchema.Required)
assert.Contains(t, requestedSchema.Properties, deleteRepositoryConfirmationField)
})

t.Run("deletes after exact confirmation", func(t *testing.T) {
client := NewMockedHTTPClient(
WithRequestMatchHandler(
DeleteReposByOwnerByRepo,
mockResponse(t, http.StatusNoContent, nil),
),
)
result := invokeDeleteRepository(t, serverTool, client, &mcp.ElicitResult{
Action: "accept",
Content: map[string]any{
deleteRepositoryConfirmationField: "owner/repo",
},
})

require.False(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, "owner/repo was deleted")
})

t.Run("completes multi-round-trip elicitation before deleting", func(t *testing.T) {
Comment thread
SamMorrowDrums marked this conversation as resolved.
httpClient := NewMockedHTTPClient(
WithRequestMatchHandler(
DeleteReposByOwnerByRepo,
mockResponse(t, http.StatusNoContent, nil),
),
)
deps := BaseDeps{Client: mustNewGHClient(t, httpClient)}

inv, err := inventory.NewBuilder().
SetTools([]inventory.ServerTool{serverTool}).
WithToolsets([]string{"all"}).
Build()
require.NoError(t, err)

server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil)
server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) {
return next(ContextWithDeps(ctx, deps), method, request)
}
})
inv.RegisterTools(context.Background(), server, deps)

serverTransport, clientTransport := mcp.NewInMemoryTransports()
serverSession, err := server.Connect(context.Background(), serverTransport, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = serverSession.Close() })

client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, &mcp.ClientOptions{
ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) {
return &mcp.ElicitResult{
Action: "accept",
Content: map[string]any{
deleteRepositoryConfirmationField: "owner/repo",
},
}, nil
},
})
clientSession, err := client.Connect(context.Background(), clientTransport, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = clientSession.Close() })

result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{
Name: "delete_repository",
Arguments: map[string]any{
"owner": "owner",
"repo": "repo",
},
})
require.NoError(t, err)
require.False(t, result.IsError)
assert.Contains(t, getTextResult(t, result).Text, "owner/repo was deleted")
})

t.Run("refuses mismatched confirmation", func(t *testing.T) {
result := invokeDeleteRepository(t, serverTool, NewMockedHTTPClient(), &mcp.ElicitResult{
Action: "accept",
Content: map[string]any{
deleteRepositoryConfirmationField: "owner/another-repo",
},
})

require.True(t, result.IsError)
assert.Contains(t, getErrorResult(t, result).Text, "did not match")
})

t.Run("refuses declined confirmation", func(t *testing.T) {
result := invokeDeleteRepository(t, serverTool, NewMockedHTTPClient(), &mcp.ElicitResult{
Action: "decline",
})

require.True(t, result.IsError)
assert.Contains(t, getErrorResult(t, result).Text, "was not confirmed")
})

t.Run("returns GitHub API errors", func(t *testing.T) {
client := NewMockedHTTPClient(
WithRequestMatchHandler(
DeleteReposByOwnerByRepo,
mockResponse(t, http.StatusForbidden, map[string]any{"message": "Requires admin permissions"}),
),
)
result := invokeDeleteRepository(t, serverTool, client, &mcp.ElicitResult{
Action: "accept",
Content: map[string]any{
deleteRepositoryConfirmationField: "owner/repo",
},
})

require.True(t, result.IsError)
assert.Contains(t, getErrorResult(t, result).Text, "failed to delete repository")
})
}

func invokeDeleteRepository(t *testing.T, tool inventory.ServerTool, httpClient *http.Client, confirmation *mcp.ElicitResult) *mcp.CallToolResult {
t.Helper()

deps := BaseDeps{Client: mustNewGHClient(t, httpClient)}
handler := tool.Handler(deps)
request := createMCPRequest(map[string]any{
"owner": "owner",
"repo": "repo",
})
if confirmation != nil {
request.Params.InputResponses = mcp.InputResponseMap{
deleteRepositoryConfirmationID: confirmation,
}
}

result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)
require.NotNil(t, result)
return result
}

func Test_ListBranches(t *testing.T) {
// Verify tool definition once
serverTool := ListBranches(translations.NullTranslationHelper)
Expand Down
1 change: 1 addition & 0 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent
GetReleaseByTag(t),
CreateOrUpdateFile(t),
CreateRepository(t),
DeleteRepository(t),
ForkRepository(t),
CreateBranch(t),
PushFiles(t),
Expand Down
Loading
Loading