Skip to content

Commit 2dce589

Browse files
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
1 parent c285c6a commit 2dce589

10 files changed

Lines changed: 240 additions & 73 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1309,7 +1309,7 @@ The following sets of tools are available:
13091309
- `repo`: Repository name (string, required)
13101310

13111311
- **delete_repository** - Delete repository
1312-
- **Required OAuth Scopes (any of)**: `delete_repo`, `repo`
1312+
- **Required OAuth Scopes (all required)**: `delete_repo`, `repo`
13131313
- `owner`: Repository owner (username or organization) (string, required)
13141314
- `repo`: Repository name (string, required)
13151315

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_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

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: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,15 @@ type Sealer struct {
1717
aead cipher.AEAD
1818
}
1919

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+
2029
// New constructs a sealer from a standard Base64-encoded 32-byte key.
2130
func New(encodedKey string) (*Sealer, error) {
2231
key, err := base64.StdEncoding.DecodeString(encodedKey)
@@ -26,6 +35,10 @@ func New(encodedKey string) (*Sealer, error) {
2635
if len(key) != keySize {
2736
return nil, fmt.Errorf("decoded key must be %d bytes, got %d", keySize, len(key))
2837
}
38+
return newFromKey(key)
39+
}
40+
41+
func newFromKey(key []byte) (*Sealer, error) {
2942
block, err := aes.NewCipher(key)
3043
if err != nil {
3144
return nil, fmt.Errorf("creating cipher: %w", err)

internal/requeststate/sealer_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ func TestSealer(t *testing.T) {
3939
})
4040
}
4141

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+
4252
func TestNew(t *testing.T) {
4353
tests := []struct {
4454
name string

pkg/github/dependencies.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,6 @@ func NewTool[In, Out any](
245245
})
246246
st.RequiredScopes = scopes.ToStringSlice(requiredScopes...)
247247
st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...)
248-
st.RequiredScopeGroups = scopes.ExpandScopeGroups(requiredScopes...)
249248
return st
250249
}
251250

@@ -269,7 +268,6 @@ func NewToolFromHandler(
269268
})
270269
st.RequiredScopes = scopes.ToStringSlice(requiredScopes...)
271270
st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...)
272-
st.RequiredScopeGroups = scopes.ExpandScopeGroups(requiredScopes...)
273271
return st
274272
}
275273

pkg/github/repositories.go

Lines changed: 49 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -760,36 +760,36 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool
760760

761761
fullName := owner + "/" + repo
762762
sealer := requestStateSealerFromDeps(deps)
763-
var deletionState *deleteRepositoryState
763+
if sealer == nil {
764+
return utils.NewToolResultError("Repository deletion is unavailable because request-state protection is not configured."), nil, nil
765+
}
766+
var deletionState deleteRepositoryState
764767
var responses mcp.InputResponseMap
765768
if req != nil && req.Params != nil {
766769
responses = req.Params.InputResponses
767770
}
768771
response, ok := responses[deleteRepositoryConfirmationID]
769772
if !ok {
770-
var requestState string
771-
if sealer != nil {
772-
client, err := deps.GetClient(ctx)
773-
if err != nil {
774-
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
775-
}
776-
repositoryID, result := repositoryIDForDeletion(ctx, client, owner, repo)
777-
if result != nil {
778-
return result, nil, nil
779-
}
780-
state, err := json.Marshal(deleteRepositoryState{
781-
Owner: owner,
782-
Repo: repo,
783-
RepositoryID: repositoryID,
784-
ExpiresAt: time.Now().Add(deleteRepositoryConfirmationTTL).Unix(),
785-
})
786-
if err != nil {
787-
return nil, nil, fmt.Errorf("failed to marshal repository deletion state: %w", err)
788-
}
789-
requestState, err = sealer.Seal(ctx, state)
790-
if err != nil {
791-
return nil, nil, fmt.Errorf("failed to seal repository deletion state: %w", err)
792-
}
773+
client, err := deps.GetClient(ctx)
774+
if err != nil {
775+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
776+
}
777+
repositoryID, result := repositoryIDForDeletion(ctx, client, owner, repo)
778+
if result != nil {
779+
return result, nil, nil
780+
}
781+
state, err := json.Marshal(deleteRepositoryState{
782+
Owner: owner,
783+
Repo: repo,
784+
RepositoryID: repositoryID,
785+
ExpiresAt: time.Now().Add(deleteRepositoryConfirmationTTL).Unix(),
786+
})
787+
if err != nil {
788+
return nil, nil, fmt.Errorf("failed to marshal repository deletion state: %w", err)
789+
}
790+
requestState, err := sealer.Seal(ctx, state)
791+
if err != nil {
792+
return nil, nil, fmt.Errorf("failed to seal repository deletion state: %w", err)
793793
}
794794
return &mcp.CallToolResult{
795795
InputRequests: mcp.InputRequestMap{
@@ -813,28 +813,24 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool
813813
}, nil, nil
814814
}
815815

816-
if sealer != nil {
817-
if req.Params.RequestState == "" {
818-
return utils.NewToolResultError("Repository deletion confirmation state was missing. The repository was not deleted."), nil, nil
819-
}
820-
stateJSON, err := sealer.Open(req.Params.RequestState)
821-
if err != nil {
822-
return utils.NewToolResultError("Repository deletion confirmation state was invalid. The repository was not deleted."), nil, nil
823-
}
824-
var state deleteRepositoryState
825-
if err := json.Unmarshal(stateJSON, &state); err != nil {
826-
return utils.NewToolResultError("Repository deletion confirmation state was invalid. The repository was not deleted."), nil, nil
827-
}
828-
if state.Owner != owner || state.Repo != repo {
829-
return utils.NewToolResultError("Repository deletion target changed after confirmation was requested. The repository was not deleted."), nil, nil
830-
}
831-
if state.ExpiresAt <= time.Now().Unix() {
832-
return utils.NewToolResultError("Repository deletion confirmation expired. The repository was not deleted."), nil, nil
833-
}
834-
if state.RepositoryID == 0 {
835-
return utils.NewToolResultError("Repository deletion confirmation state was invalid. The repository was not deleted."), nil, nil
836-
}
837-
deletionState = &state
816+
if req.Params.RequestState == "" {
817+
return utils.NewToolResultError("Repository deletion confirmation state was missing. The repository was not deleted."), nil, nil
818+
}
819+
stateJSON, err := sealer.Open(req.Params.RequestState)
820+
if err != nil {
821+
return utils.NewToolResultError("Repository deletion confirmation state was invalid. The repository was not deleted."), nil, nil
822+
}
823+
if err := json.Unmarshal(stateJSON, &deletionState); err != nil {
824+
return utils.NewToolResultError("Repository deletion confirmation state was invalid. The repository was not deleted."), nil, nil
825+
}
826+
if deletionState.Owner != owner || deletionState.Repo != repo {
827+
return utils.NewToolResultError("Repository deletion target changed after confirmation was requested. The repository was not deleted."), nil, nil
828+
}
829+
if deletionState.ExpiresAt <= time.Now().Unix() {
830+
return utils.NewToolResultError("Repository deletion confirmation expired. The repository was not deleted."), nil, nil
831+
}
832+
if deletionState.RepositoryID == 0 {
833+
return utils.NewToolResultError("Repository deletion confirmation state was invalid. The repository was not deleted."), nil, nil
838834
}
839835

840836
confirmation, ok := response.(*mcp.ElicitResult)
@@ -853,14 +849,12 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool
853849
if err != nil {
854850
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
855851
}
856-
if deletionState != nil {
857-
currentRepositoryID, result := repositoryIDForDeletion(ctx, client, owner, repo)
858-
if result != nil {
859-
return result, nil, nil
860-
}
861-
if currentRepositoryID != deletionState.RepositoryID {
862-
return utils.NewToolResultError("Repository identity changed after confirmation was requested. The repository was not deleted."), nil, nil
863-
}
852+
currentRepositoryID, result := repositoryIDForDeletion(ctx, client, owner, repo)
853+
if result != nil {
854+
return result, nil, nil
855+
}
856+
if currentRepositoryID != deletionState.RepositoryID {
857+
return utils.NewToolResultError("Repository identity changed after confirmation was requested. The repository was not deleted."), nil, nil
864858
}
865859
resp, err := client.Repositories.Delete(ctx, owner, repo)
866860
if err != nil {
@@ -885,6 +879,7 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool
885879
)
886880
tool.MinimumProtocolVersion = inventory.ProtocolVersionMultiRoundTrip
887881
tool.RequiredElicitationMode = inventory.ElicitationModeForm
882+
tool.RequiredScopeGroups = scopes.ExpandScopeGroups(scopes.DeleteRepo, scopes.Repo)
888883
return tool
889884
}
890885

0 commit comments

Comments
 (0)