Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
98aef1f
feat(repos): add confirmed repository deletion
SamMorrowDrums Aug 14, 2026
c34055e
refactor(inventory): generalize tool availability guards
SamMorrowDrums Aug 17, 2026
14aea52
feat(http): protect MRTR request state
SamMorrowDrums Aug 17, 2026
676a5b2
fix(repos): expire deletion confirmations
SamMorrowDrums Aug 17, 2026
8eba6fd
Merge remote-tracking branch 'origin/main' into sammorrowdrums-add-de…
SamMorrowDrums Aug 17, 2026
c285c6a
fix(http): preserve tool and scope restrictions
SamMorrowDrums Aug 17, 2026
2dce589
fix(repos): require protected confirmation state
SamMorrowDrums Aug 18, 2026
cceb5cf
fix(oauth): request repository deletion scope
SamMorrowDrums Aug 18, 2026
ec7bd6f
fix(oauth): require deletion scope opt-in
SamMorrowDrums Aug 18, 2026
f3a32d6
refactor(oauth): derive scope sets from catalog
SamMorrowDrums Aug 18, 2026
1dd5f33
refactor(scopes): own OAuth scope catalog
SamMorrowDrums Aug 18, 2026
213d53a
fix(scopes): require workflow scope opt-in
SamMorrowDrums Aug 18, 2026
60f2768
Merge remote-tracking branch 'origin/main' into sammorrowdrums-add-de…
SamMorrowDrums Aug 18, 2026
345459d
Merge remote-tracking branch 'origin/main' into sammorrowdrums-add-de…
SamMorrowDrums Aug 18, 2026
560b0ea
Merge remote-tracking branch 'origin/main' into sammorrowdrums-add-de…
SamMorrowDrums Aug 18, 2026
5eac1f0
Merge remote-tracking branch 'origin/main' into sammorrowdrums-add-de…
SamMorrowDrums Aug 18, 2026
5ea9a0e
Merge remote-tracking branch 'origin/main' into sammorrowdrums-add-de…
SamMorrowDrums Aug 18, 2026
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 (all required)**: `delete_repo`, `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: 5 additions & 5 deletions cmd/github-mcp-server/generate_docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,13 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {

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

Expand Down
9 changes: 5 additions & 4 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,11 +128,11 @@ var (
}

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

return ghhttp.RunHTTPServer(httpConfig)
Expand Down
37 changes: 37 additions & 0 deletions cmd/github-mcp-server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/github/github-mcp-server/pkg/inventory"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
Expand Down Expand Up @@ -38,6 +41,40 @@ func TestGitHubAppFlagsAreStdioOnly(t *testing.T) {
assert.Nil(t, httpCmd.Flags().Lookup("app-id"))
}

func TestWriteToolDocScopeSemantics(t *testing.T) {
tests := []struct {
name string
tool inventory.ServerTool
want string
}{
{
name: "legacy multi-scope tools use any-of",
tool: inventory.ServerTool{
Tool: mcp.Tool{Name: "legacy", Annotations: &mcp.ToolAnnotations{Title: "Legacy"}},
RequiredScopes: []string{"repo", "read:org"},
},
want: "**Required OAuth Scopes (any of)**",
},
{
name: "conjunctive scope groups use all-required",
tool: inventory.ServerTool{
Tool: mcp.Tool{Name: "conjunctive", Annotations: &mcp.ToolAnnotations{Title: "Conjunctive"}},
RequiredScopes: []string{"delete_repo", "repo"},
RequiredScopeGroups: [][]string{{"delete_repo"}, {"repo"}},
},
want: "**Required OAuth Scopes (all required)**",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf strings.Builder
writeToolDoc(&buf, tt.tool)
assert.Contains(t, buf.String(), tt.want)
})
}
}

func TestSchemaTypeString(t *testing.T) {
tests := []struct {
name string
Expand Down
21 changes: 21 additions & 0 deletions docs/streamable-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,27 @@ github-mcp-server http --scope-challenge

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

### Repository deletion and request-state encryption

The `delete_repository` tool uses multi-round-trip elicitation and carries its
confirmed target through client-held request state. To expose this tool in HTTP
mode, configure a stable 32-byte encryption key encoded with standard Base64:

```bash
export GITHUB_MCP_SERVER_MRTR_STATE_KEY="$(openssl rand -base64 32 | tr -d '\n')"
github-mcp-server http
```

Use the same key on every replica that may handle a retry. Keep it secret and
stable during deployments; changing it invalidates confirmations already in
flight. If the variable is absent, `delete_repository` is not exposed by the
HTTP server. If it is present but malformed, the server refuses to start.

This self-hosted key is independent of keys used by the hosted remote server.
Integrators can provide their own request-state sealer through the exported
`github.RequestStateSealer` interface and expose it from their tool dependencies
through `github.RequestStateSealerProvider` without changing their key format.

### With OAuth Metadata Discovery

For use behind reverse proxies or with custom domains, expose OAuth metadata endpoints:
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
5 changes: 5 additions & 0 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

"github.com/github/github-mcp-server/internal/oauth"
"github.com/github/github-mcp-server/internal/requeststate"
"github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/http/transport"
Expand Down Expand Up @@ -169,6 +170,10 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
featureChecker,
obs,
)
deps.StateSealer, err = requeststate.NewRandom()
if err != nil {
return nil, fmt.Errorf("failed to configure request-state protection: %w", err)
}
// Build and register the tool/resource/prompt inventory
inventoryBuilder := github.NewInventory(cfg.Translator, github.WithHost(hostType)).
WithDeprecatedAliases(github.DeprecatedToolAliases).
Expand Down
81 changes: 81 additions & 0 deletions internal/requeststate/sealer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package requeststate

import (
"context"
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
)

const keySize = 32

// Sealer protects request state with AES-256-GCM.
type Sealer struct {
aead cipher.AEAD
}

// NewRandom constructs a sealer with a process-local random key.
func NewRandom() (*Sealer, error) {
key := make([]byte, keySize)
if _, err := rand.Read(key); err != nil {
return nil, fmt.Errorf("generating key: %w", err)
}
return newFromKey(key)
}

// New constructs a sealer from a standard Base64-encoded 32-byte key.
func New(encodedKey string) (*Sealer, error) {
key, err := base64.StdEncoding.DecodeString(encodedKey)
if err != nil {
return nil, fmt.Errorf("decoding key: %w", err)
}
if len(key) != keySize {
return nil, fmt.Errorf("decoded key must be %d bytes, got %d", keySize, len(key))
}
return newFromKey(key)
}

func newFromKey(key []byte) (*Sealer, error) {
block, err := aes.NewCipher(key)
if err != nil {
return nil, fmt.Errorf("creating cipher: %w", err)
}
aead, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("creating GCM: %w", err)
}
return &Sealer{aead: aead}, nil
}

// Seal encrypts and authenticates plaintext into a URL-safe opaque token.
func (s *Sealer) Seal(_ context.Context, plaintext []byte) (string, error) {
nonce := make([]byte, s.aead.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", fmt.Errorf("generating nonce: %w", err)
}
sealed := s.aead.Seal(nonce, nonce, plaintext, nil)
return base64.RawURLEncoding.EncodeToString(sealed), nil
}

// Open verifies and decrypts a token produced by Seal.
func (s *Sealer) Open(token string) ([]byte, error) {
if token == "" {
return nil, errors.New("empty token")
}
sealed, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return nil, fmt.Errorf("decoding token: %w", err)
}
nonceSize := s.aead.NonceSize()
if len(sealed) < nonceSize {
return nil, errors.New("token is too short")
}
plaintext, err := s.aead.Open(nil, sealed[:nonceSize], sealed[nonceSize:], nil)
if err != nil {
return nil, fmt.Errorf("opening token: %w", err)
}
return plaintext, nil
}
68 changes: 68 additions & 0 deletions internal/requeststate/sealer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package requeststate

import (
"context"
"encoding/base64"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestSealer(t *testing.T) {
key := base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef"))
sealer, err := New(key)
require.NoError(t, err)

t.Run("round trip", func(t *testing.T) {
plaintext := []byte(`{"owner":"octo","repo":"repo"}`)
token, err := sealer.Seal(context.Background(), plaintext)
require.NoError(t, err)
assert.NotContains(t, token, string(plaintext))

opened, err := sealer.Open(token)
require.NoError(t, err)
assert.Equal(t, plaintext, opened)
})

t.Run("rejects tampering", func(t *testing.T) {
token, err := sealer.Seal(context.Background(), []byte("state"))
require.NoError(t, err)
replacement := "A"
if strings.HasSuffix(token, replacement) {
replacement = "B"
}

_, err = sealer.Open(token[:len(token)-1] + replacement)
require.Error(t, err)
})
}

func TestNewRandom(t *testing.T) {
sealer, err := NewRandom()
require.NoError(t, err)
token, err := sealer.Seal(context.Background(), []byte("state"))
require.NoError(t, err)
opened, err := sealer.Open(token)
require.NoError(t, err)
assert.Equal(t, []byte("state"), opened)
}

func TestNew(t *testing.T) {
tests := []struct {
name string
key string
}{
{name: "empty key"},
{name: "invalid Base64", key: "not-base64"},
{name: "wrong decoded length", key: base64.StdEncoding.EncodeToString([]byte("too short"))},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := New(tt.key)
require.Error(t, err)
})
}
}
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"
}
12 changes: 12 additions & 0 deletions pkg/github/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ type BaseDeps struct {

// Observability exporters (includes logger)
Obsv observability.Exporters

// StateSealer protects state sent through multi-round-trip requests.
StateSealer RequestStateSealer
}

// Compile-time assertion to verify that BaseDeps implements the ToolDependencies interface.
Expand Down Expand Up @@ -199,6 +202,9 @@ func (d BaseDeps) Metrics(ctx context.Context) metrics.Metrics {
return d.Obsv.Metrics(ctx)
}

// GetRequestStateSealer implements RequestStateSealerProvider.
func (d BaseDeps) GetRequestStateSealer() RequestStateSealer { return d.StateSealer }

// IsFeatureEnabled checks if a feature flag is enabled.
// Returns false if the feature checker is nil, flag name is empty, or an error occurs.
// This allows tools to conditionally change behavior based on feature flags.
Expand Down Expand Up @@ -279,6 +285,9 @@ type RequestDeps struct {

// Observability exporters (includes logger)
obsv observability.Exporters

// StateSealer protects state sent through multi-round-trip requests.
StateSealer RequestStateSealer
}

// NewRequestDeps creates a RequestDeps with the provided clients and configuration.
Expand Down Expand Up @@ -334,6 +343,9 @@ func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) {
return restClient, nil
}

// GetRequestStateSealer implements RequestStateSealerProvider.
func (d *RequestDeps) GetRequestStateSealer() RequestStateSealer { return d.StateSealer }

// GetGQLClient implements ToolDependencies.
func (d *RequestDeps) GetGQLClient(ctx context.Context) (*githubv4.Client, error) {
// extract the token from the context
Expand Down
Loading
Loading