Files
github--github-mcp-server/pkg/http/server.go
Sam Morrow 8ec62491c6 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
2026-08-18 14:50:38 +02:00

303 lines
9.8 KiB
Go

package http
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/github/github-mcp-server/internal/requeststate"
ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/http/middleware"
"github.com/github/github-mcp-server/pkg/http/oauth"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/lockdown"
"github.com/github/github-mcp-server/pkg/observability"
"github.com/github/github-mcp-server/pkg/observability/metrics"
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/go-chi/chi/v5"
)
// MRTRStateKeyEnv is the environment variable used to configure HTTP request-state encryption.
const MRTRStateKeyEnv = "GITHUB_MCP_SERVER_MRTR_STATE_KEY"
type ServerConfig struct {
// Version of the server
Version string
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
Host string
// Port to listen on (default: 8082).
Port int
// ListenHost is the host the HTTP server binds to (e.g. "127.0.0.1").
// When empty, the server binds to all interfaces. Combined with Port.
ListenHost string
// BaseURL is the publicly accessible URL of this server for OAuth resource metadata.
// If not set, the server will derive the URL from incoming request headers.
BaseURL string
// ResourcePath is the externally visible base path for this server (e.g., "/mcp").
// This is used to restore the original path when a proxy strips a base path before forwarding.
ResourcePath string
// TrustProxyHeaders indicates whether X-Forwarded-Host and X-Forwarded-Proto
// should be honored when constructing OAuth resource metadata URLs. Only
// enable this when the server is deployed behind a trusted proxy that sets
// these headers. When BaseURL is set, it always wins and this setting has
// no effect.
TrustProxyHeaders bool
// ExportTranslations indicates if we should export translations
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions
ExportTranslations bool
// EnableCommandLogging indicates if we should log commands
EnableCommandLogging bool
// Path to the log file if not stderr
LogFilePath string
// Content window size
ContentWindowSize int
// LockdownMode indicates if we should enable lockdown mode
LockdownMode bool
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
RepoAccessCacheTTL *time.Duration
// ScopeChallenge indicates if we should return OAuth scope challenges, and if we should perform
// tool filtering based on token scopes.
ScopeChallenge bool
// ReadOnly indicates if we should only register read-only tools.
// When set via CLI flag, this acts as an upper bound — per-request headers
// cannot re-enable write tools.
ReadOnly bool
// EnabledToolsets is a list of toolsets to enable.
// When set via CLI flag, per-request headers can only narrow within these toolsets.
EnabledToolsets []string
// EnabledTools is a list of specific tools to enable (additive to toolsets).
EnabledTools []string
// ExcludeTools is a list of tool names to disable regardless of other settings.
// When set via CLI flag, per-request headers cannot re-include these tools.
ExcludeTools []string
// EnabledFeatures is a list of feature flags that are enabled.
EnabledFeatures []string
// InsidersMode expands to the curated set of feature flags enabled for insiders.
InsidersMode bool
// MRTRStateKey is a Base64-encoded 32-byte key used to protect multi-round-trip request state.
MRTRStateKey string
disableDeleteRepository bool
}
func RunHTTPServer(cfg ServerConfig) error {
// Create app context
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
t, dumpTranslations := translations.TranslationHelper()
var slogHandler slog.Handler
var logOutput io.Writer
if cfg.LogFilePath != "" {
file, err := os.OpenFile(cfg.LogFilePath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return fmt.Errorf("failed to open log file: %w", err)
}
logOutput = file
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelDebug})
} else {
logOutput = os.Stderr
slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo})
}
logger := slog.New(slogHandler)
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "lockdownEnabled", cfg.LockdownMode, "readOnly", cfg.ReadOnly, "insidersMode", cfg.InsidersMode)
stateSealer, err := configureRequestState(&cfg, logger)
if err != nil {
return err
}
apiHost, err := utils.NewAPIHost(cfg.Host)
if err != nil {
return fmt.Errorf("failed to parse API host: %w", err)
}
hostType, err := utils.ParseHostType(cfg.Host)
if err != nil {
return fmt.Errorf("failed to classify API host: %w", err)
}
repoAccessOpts := []lockdown.RepoAccessOption{
lockdown.WithLogger(logger.With("component", "lockdown")),
}
if cfg.RepoAccessCacheTTL != nil {
repoAccessOpts = append(repoAccessOpts, lockdown.WithTTL(*cfg.RepoAccessCacheTTL))
}
featureChecker := createHTTPFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode)
obs, err := observability.NewExporters(logger, metrics.NewNoopMetrics())
if err != nil {
return fmt.Errorf("failed to create observability exporters: %w", err)
}
deps := github.NewRequestDeps(
apiHost,
cfg.Version,
cfg.LockdownMode,
repoAccessOpts,
t,
cfg.ContentWindowSize,
featureChecker,
obs,
)
deps.StateSealer = stateSealer
// Initialize the global tool scope map
err = initGlobalToolScopeMap(t, hostType)
if err != nil {
return fmt.Errorf("failed to initialize tool scope map: %w", err)
}
// Register OAuth protected resource metadata endpoints
oauthCfg := &oauth.Config{
BaseURL: cfg.BaseURL,
ResourcePath: cfg.ResourcePath,
TrustProxyHeaders: cfg.TrustProxyHeaders,
}
serverOptions := []HandlerOption{}
if cfg.ScopeChallenge {
scopeFetcher := scopes.NewFetcher(apiHost, scopes.FetcherOptions{})
serverOptions = append(serverOptions, WithScopeFetcher(scopeFetcher))
}
r := chi.NewRouter()
handler := NewHTTPMcpHandler(ctx, &cfg, deps, t, logger, apiHost, append(serverOptions, WithFeatureChecker(featureChecker), WithOAuthConfig(oauthCfg))...)
oauthHandler, err := oauth.NewAuthHandler(oauthCfg, apiHost)
if err != nil {
return fmt.Errorf("failed to create OAuth handler: %w", err)
}
r.Group(func(r chi.Router) {
r.Use(middleware.SetCorsHeaders)
// Register Middleware First, needs to be before route registration
handler.RegisterMiddleware(r)
// Register MCP server routes
handler.RegisterRoutes(r)
})
logger.Info("MCP endpoints registered", "baseURL", cfg.BaseURL)
r.Group(func(r chi.Router) {
// Register OAuth protected resource metadata endpoints
oauthHandler.RegisterRoutes(r)
})
logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL)
addr := resolveListenAddress(cfg.ListenHost, cfg.Port)
httpSvr := http.Server{
Addr: addr,
Handler: r,
ReadHeaderTimeout: 60 * time.Second,
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
logger.Info("shutting down server")
if err := httpSvr.Shutdown(shutdownCtx); err != nil {
logger.Error("error during server shutdown", "error", err)
}
}()
if cfg.ExportTranslations {
// Once server is initialized, all translations are loaded
dumpTranslations()
}
logger.Info("HTTP server listening", "addr", addr)
if err := httpSvr.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("HTTP server error: %w", err)
}
logger.Info("server stopped gracefully")
return nil
}
// resolveListenAddress returns the address string passed to http.Server.
// When host is empty the server binds to all interfaces on the given port;
// otherwise host and port are joined into a single address.
func resolveListenAddress(host string, port int) string {
if host == "" {
return fmt.Sprintf(":%d", port)
}
return net.JoinHostPort(host, strconv.Itoa(port))
}
func configureRequestState(cfg *ServerConfig, logger *slog.Logger) (github.RequestStateSealer, error) {
if cfg.MRTRStateKey == "" {
cfg.disableDeleteRepository = true
logger.Warn("delete_repository disabled: request-state encryption key is not configured", "environmentVariable", MRTRStateKeyEnv)
return nil, nil
}
sealer, err := requeststate.New(cfg.MRTRStateKey)
if err != nil {
return nil, fmt.Errorf("invalid %s: %w", MRTRStateKeyEnv, err)
}
return sealer, nil
}
func initGlobalToolScopeMap(t translations.TranslationHelperFunc, hostType utils.HostType) error {
// Build inventory with all tools to extract scope information
inv, err := inventory.NewBuilder().
SetTools(github.AllTools(t, github.WithHost(hostType))).
Build()
if err != nil {
return fmt.Errorf("failed to build inventory for tool scope map: %w", err)
}
// Initialize the global scope map
scopes.SetToolScopeMapFromInventory(inv)
return nil
}
// createHTTPFeatureChecker creates a feature checker that resolves static CLI
// features plus per-request header features and insiders mode.
func createHTTPFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker {
return func(ctx context.Context, flag string) (bool, error) {
headerFeatures := ghcontext.GetHeaderFeatures(ctx)
features := make([]string, 0, len(enabledFeatures)+len(headerFeatures))
features = append(features, enabledFeatures...)
features = append(features, headerFeatures...)
effective := github.ResolveFeatureFlags(features, insidersMode || ghcontext.IsInsidersMode(ctx))
return effective[flag], nil
}
}