Skip to content

Commit 8ec6249

Browse files
Add confirmed repository deletion tool (#3076)
* 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 * refactor(inventory): generalize tool availability guards Gate protocol-restricted tools on required elicitation capabilities and enforce direct calls inside the registered handler so SDK result finalization remains intact. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * feat(http): protect MRTR request state Seal repository deletion targets for self-hosted HTTP with a stable AES-256-GCM key. Hide only delete_repository when no key is configured and expose an optional sealer interface for remote integrators. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(repos): expire deletion confirmations Bind sealed repository deletion state to the immutable repository ID and a ten-minute expiry. Re-check identity before deletion so replay cannot affect a recreated repository. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(http): preserve tool and scope restrictions Apply static allowlists before removing unavailable tools and fail closed on invalid configured tool names. Model independent OAuth requirements as conjunctive groups so repository deletion requires both delete_repo and repo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(repos): require protected confirmation state Give stdio a process-local request-state sealer and make deletion fail closed without one. Preserve legacy any-of OAuth behavior globally while documenting and enforcing delete_repository's conjunctive delete_repo and repo requirements. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(oauth): request repository deletion scope Include delete_repo in the supported OAuth scope set used by stdio login, HTTP protected-resource metadata, and tool filtering. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(oauth): require deletion scope opt-in Keep delete_repo in protected-resource discovery for step-up authorization while excluding it from the default stdio OAuth grant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * refactor(oauth): derive scope sets from catalog Generate protected-resource supported scopes and the lower-risk default OAuth grant from one canonical scope definition list. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * refactor(scopes): own OAuth scope catalog Move supported and default OAuth scope policy into pkg/scopes so protected-resource metadata and stdio grants derive from the scope domain package. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 * fix(scopes): require workflow scope opt-in Keep workflow and codespace in protected-resource discovery while excluding both from the default OAuth grant alongside delete_repo. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8 --------- Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
1 parent bfb59bb commit 8ec6249

32 files changed

Lines changed: 1697 additions & 48 deletions

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 (all required)**: `delete_repo`, `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)

cmd/github-mcp-server/generate_docs.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -221,13 +221,13 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
221221

222222
// OAuth scopes if present
223223
if len(tool.RequiredScopes) > 0 {
224-
// Scope filtering uses "any of" semantics (see scopes.HasRequiredScopes),
225-
// so when multiple required scopes are listed, render them as alternatives
226-
// rather than implying all are required.
227224
scopeList := "`" + strings.Join(tool.RequiredScopes, "`, `") + "`"
228-
if len(tool.RequiredScopes) > 1 {
225+
switch {
226+
case len(tool.RequiredScopeGroups) > 1:
227+
fmt.Fprintf(buf, " - **Required OAuth Scopes (all required)**: %s\n", scopeList)
228+
case len(tool.RequiredScopes) > 1:
229229
fmt.Fprintf(buf, " - **Required OAuth Scopes (any of)**: %s\n", scopeList)
230-
} else {
230+
default:
231231
fmt.Fprintf(buf, " - **Required OAuth Scopes**: %s\n", scopeList)
232232
}
233233

cmd/github-mcp-server/main.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,11 @@ var (
128128
}
129129

130130
// When no static token is provided, log in via OAuth using the given
131-
// client. The requested scopes default to the full supported set
132-
// (which filters out no tools); an explicit, narrower --oauth-scopes
133-
// both narrows the grant and hides tools needing other scopes.
131+
// client. The requested scopes default to the standard set; high-risk
132+
// scopes such as delete_repo require explicit --oauth-scopes opt-in.
133+
// The requested set also filters tools needing other scopes.
134134
if token == "" && !appAuthRequested {
135-
scopes := ghoauth.SupportedScopes
135+
scopes := ghoauth.DefaultScopes
136136
if viper.IsSet("oauth-scopes") {
137137
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil {
138138
return fmt.Errorf("failed to unmarshal oauth-scopes: %w", err)
@@ -217,6 +217,7 @@ var (
217217
EnabledFeatures: enabledFeatures,
218218
InsidersMode: viper.GetBool("insiders"),
219219
TrustProxyHeaders: viper.GetBool("trust-proxy-headers"),
220+
MRTRStateKey: os.Getenv(ghhttp.MRTRStateKeyEnv),
220221
}
221222

222223
return ghhttp.RunHTTPServer(httpConfig)

cmd/github-mcp-server/main_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ package main
33
import (
44
"os"
55
"path/filepath"
6+
"strings"
67
"testing"
78

9+
"github.com/github/github-mcp-server/pkg/inventory"
810
"github.com/google/jsonschema-go/jsonschema"
11+
"github.com/modelcontextprotocol/go-sdk/mcp"
912
"github.com/stretchr/testify/assert"
1013
"github.com/stretchr/testify/require"
1114
)
@@ -38,6 +41,40 @@ func TestGitHubAppFlagsAreStdioOnly(t *testing.T) {
3841
assert.Nil(t, httpCmd.Flags().Lookup("app-id"))
3942
}
4043

44+
func TestWriteToolDocScopeSemantics(t *testing.T) {
45+
tests := []struct {
46+
name string
47+
tool inventory.ServerTool
48+
want string
49+
}{
50+
{
51+
name: "legacy multi-scope tools use any-of",
52+
tool: inventory.ServerTool{
53+
Tool: mcp.Tool{Name: "legacy", Annotations: &mcp.ToolAnnotations{Title: "Legacy"}},
54+
RequiredScopes: []string{"repo", "read:org"},
55+
},
56+
want: "**Required OAuth Scopes (any of)**",
57+
},
58+
{
59+
name: "conjunctive scope groups use all-required",
60+
tool: inventory.ServerTool{
61+
Tool: mcp.Tool{Name: "conjunctive", Annotations: &mcp.ToolAnnotations{Title: "Conjunctive"}},
62+
RequiredScopes: []string{"delete_repo", "repo"},
63+
RequiredScopeGroups: [][]string{{"delete_repo"}, {"repo"}},
64+
},
65+
want: "**Required OAuth Scopes (all required)**",
66+
},
67+
}
68+
69+
for _, tt := range tests {
70+
t.Run(tt.name, func(t *testing.T) {
71+
var buf strings.Builder
72+
writeToolDoc(&buf, tt.tool)
73+
assert.Contains(t, buf.String(), tt.want)
74+
})
75+
}
76+
}
77+
4178
func TestSchemaTypeString(t *testing.T) {
4279
tests := []struct {
4380
name string

docs/streamable-http.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,27 @@ github-mcp-server http --scope-challenge
3232

3333
When `--scope-challenge` is enabled, requests with insufficient scopes receive a `403 Forbidden` response with a `WWW-Authenticate` header indicating the required scopes.
3434

35+
### Repository deletion and request-state encryption
36+
37+
The `delete_repository` tool uses multi-round-trip elicitation and carries its
38+
confirmed target through client-held request state. To expose this tool in HTTP
39+
mode, configure a stable 32-byte encryption key encoded with standard Base64:
40+
41+
```bash
42+
export GITHUB_MCP_SERVER_MRTR_STATE_KEY="$(openssl rand -base64 32 | tr -d '\n')"
43+
github-mcp-server http
44+
```
45+
46+
Use the same key on every replica that may handle a retry. Keep it secret and
47+
stable during deployments; changing it invalidates confirmations already in
48+
flight. If the variable is absent, `delete_repository` is not exposed by the
49+
HTTP server. If it is present but malformed, the server refuses to start.
50+
51+
This self-hosted key is independent of keys used by the hosted remote server.
52+
Integrators can provide their own request-state sealer through the exported
53+
`github.RequestStateSealer` interface and expose it from their tool dependencies
54+
through `github.RequestStateSealerProvider` without changing their key format.
55+
3556
### With OAuth Metadata Discovery
3657

3758
For use behind reverse proxies or with custom domains, expose OAuth metadata endpoints:

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

internal/ghmcp/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"time"
1414

1515
"github.com/github/github-mcp-server/internal/oauth"
16+
"github.com/github/github-mcp-server/internal/requeststate"
1617
"github.com/github/github-mcp-server/pkg/errors"
1718
"github.com/github/github-mcp-server/pkg/github"
1819
"github.com/github/github-mcp-server/pkg/http/transport"
@@ -169,6 +170,10 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
169170
featureChecker,
170171
obs,
171172
)
173+
deps.StateSealer, err = requeststate.NewRandom()
174+
if err != nil {
175+
return nil, fmt.Errorf("failed to configure request-state protection: %w", err)
176+
}
172177
// Build and register the tool/resource/prompt inventory
173178
inventoryBuilder := github.NewInventory(cfg.Translator, github.WithHost(hostType)).
174179
WithDeprecatedAliases(github.DeprecatedToolAliases).

internal/requeststate/sealer.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package requeststate
2+
3+
import (
4+
"context"
5+
"crypto/aes"
6+
"crypto/cipher"
7+
"crypto/rand"
8+
"encoding/base64"
9+
"errors"
10+
"fmt"
11+
)
12+
13+
const keySize = 32
14+
15+
// Sealer protects request state with AES-256-GCM.
16+
type Sealer struct {
17+
aead cipher.AEAD
18+
}
19+
20+
// NewRandom constructs a sealer with a process-local random key.
21+
func NewRandom() (*Sealer, error) {
22+
key := make([]byte, keySize)
23+
if _, err := rand.Read(key); err != nil {
24+
return nil, fmt.Errorf("generating key: %w", err)
25+
}
26+
return newFromKey(key)
27+
}
28+
29+
// New constructs a sealer from a standard Base64-encoded 32-byte key.
30+
func New(encodedKey string) (*Sealer, error) {
31+
key, err := base64.StdEncoding.DecodeString(encodedKey)
32+
if err != nil {
33+
return nil, fmt.Errorf("decoding key: %w", err)
34+
}
35+
if len(key) != keySize {
36+
return nil, fmt.Errorf("decoded key must be %d bytes, got %d", keySize, len(key))
37+
}
38+
return newFromKey(key)
39+
}
40+
41+
func newFromKey(key []byte) (*Sealer, error) {
42+
block, err := aes.NewCipher(key)
43+
if err != nil {
44+
return nil, fmt.Errorf("creating cipher: %w", err)
45+
}
46+
aead, err := cipher.NewGCM(block)
47+
if err != nil {
48+
return nil, fmt.Errorf("creating GCM: %w", err)
49+
}
50+
return &Sealer{aead: aead}, nil
51+
}
52+
53+
// Seal encrypts and authenticates plaintext into a URL-safe opaque token.
54+
func (s *Sealer) Seal(_ context.Context, plaintext []byte) (string, error) {
55+
nonce := make([]byte, s.aead.NonceSize())
56+
if _, err := rand.Read(nonce); err != nil {
57+
return "", fmt.Errorf("generating nonce: %w", err)
58+
}
59+
sealed := s.aead.Seal(nonce, nonce, plaintext, nil)
60+
return base64.RawURLEncoding.EncodeToString(sealed), nil
61+
}
62+
63+
// Open verifies and decrypts a token produced by Seal.
64+
func (s *Sealer) Open(token string) ([]byte, error) {
65+
if token == "" {
66+
return nil, errors.New("empty token")
67+
}
68+
sealed, err := base64.RawURLEncoding.DecodeString(token)
69+
if err != nil {
70+
return nil, fmt.Errorf("decoding token: %w", err)
71+
}
72+
nonceSize := s.aead.NonceSize()
73+
if len(sealed) < nonceSize {
74+
return nil, errors.New("token is too short")
75+
}
76+
plaintext, err := s.aead.Open(nil, sealed[:nonceSize], sealed[nonceSize:], nil)
77+
if err != nil {
78+
return nil, fmt.Errorf("opening token: %w", err)
79+
}
80+
return plaintext, nil
81+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package requeststate
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"strings"
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestSealer(t *testing.T) {
14+
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
15+
sealer, err := New(key)
16+
require.NoError(t, err)
17+
18+
t.Run("round trip", func(t *testing.T) {
19+
plaintext := []byte(`{"owner":"octo","repo":"repo"}`)
20+
token, err := sealer.Seal(context.Background(), plaintext)
21+
require.NoError(t, err)
22+
assert.NotContains(t, token, string(plaintext))
23+
24+
opened, err := sealer.Open(token)
25+
require.NoError(t, err)
26+
assert.Equal(t, plaintext, opened)
27+
})
28+
29+
t.Run("rejects tampering", func(t *testing.T) {
30+
token, err := sealer.Seal(context.Background(), []byte("state"))
31+
require.NoError(t, err)
32+
replacement := "A"
33+
if strings.HasSuffix(token, replacement) {
34+
replacement = "B"
35+
}
36+
37+
_, err = sealer.Open(token[:len(token)-1] + replacement)
38+
require.Error(t, err)
39+
})
40+
}
41+
42+
func TestNewRandom(t *testing.T) {
43+
sealer, err := NewRandom()
44+
require.NoError(t, err)
45+
token, err := sealer.Seal(context.Background(), []byte("state"))
46+
require.NoError(t, err)
47+
opened, err := sealer.Open(token)
48+
require.NoError(t, err)
49+
assert.Equal(t, []byte("state"), opened)
50+
}
51+
52+
func TestNew(t *testing.T) {
53+
tests := []struct {
54+
name string
55+
key string
56+
}{
57+
{name: "empty key"},
58+
{name: "invalid Base64", key: "not-base64"},
59+
{name: "wrong decoded length", key: base64.StdEncoding.EncodeToString([]byte("too short"))},
60+
}
61+
62+
for _, tt := range tests {
63+
t.Run(tt.name, func(t *testing.T) {
64+
_, err := New(tt.key)
65+
require.Error(t, err)
66+
})
67+
}
68+
}
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+
}

0 commit comments

Comments
 (0)