Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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**: `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
1 change: 1 addition & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
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
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
68 changes: 68 additions & 0 deletions internal/requeststate/sealer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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
}

// 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))
}
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
}
58 changes: 58 additions & 0 deletions internal/requeststate/sealer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
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 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
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
Loading
Loading