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 (any of)**: `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
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"
}
14 changes: 14 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 @@ -239,6 +245,7 @@ func NewTool[In, Out any](
})
st.RequiredScopes = scopes.ToStringSlice(requiredScopes...)
st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...)
st.RequiredScopeGroups = scopes.ExpandScopeGroups(requiredScopes...)
return st
}

Expand All @@ -262,6 +269,7 @@ func NewToolFromHandler(
})
st.RequiredScopes = scopes.ToStringSlice(requiredScopes...)
st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...)
st.RequiredScopeGroups = scopes.ExpandScopeGroups(requiredScopes...)
return st
}

Expand All @@ -279,6 +287,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 +345,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