Files
github--github-mcp-server/pkg/http/server.go
T
2026-07-13 12:37:57 +08:00

271 lines
8.7 KiB
Go

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
}
}