package http import ( "context" "fmt" "io" "log/slog" "net" "net/http" "os" "os/signal" "strconv" "syscall" "time" 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" ) 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 } 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) apiHost, err := utils.NewAPIHost(cfg.Host) if err != nil { return fmt.Errorf("failed to parse 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, ) // Initialize the global tool scope map err = initGlobalToolScopeMap(t) 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 initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { // Build inventory with all tools to extract scope information inv, err := inventory.NewBuilder(). SetTools(github.AllTools(t)). 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 } }