package main import ( "errors" "fmt" "os" "strings" "time" "github.com/github/github-mcp-server/internal/buildinfo" "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/github" ghhttp "github.com/github/github-mcp-server/pkg/http" ghoauth "github.com/github/github-mcp-server/pkg/http/oauth" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" ) // These variables are set by the build process using ldflags. var version = "version" var commit = "commit" var date = "date" var ( rootCmd = &cobra.Command{ Use: "server", Short: "GitHub MCP Server", Long: `A GitHub MCP server that handles various tools and resources.`, Version: fmt.Sprintf("Version: %s\nCommit: %s\nBuild Date: %s", version, commit, date), } stdioCmd = &cobra.Command{ Use: "stdio", Short: "Start stdio server", Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`, RunE: func(_ *cobra.Command, _ []string) error { token := viper.GetString("personal_access_token") oauthClientID := viper.GetString("oauth-client-id") oauthClientSecret := viper.GetString("oauth-client-secret") // Fall back to the build-time baked-in client (official releases) when none is // configured explicitly. The baked-in app is registered on github.com, so it is // only applied to the default host; GHES/ghe.com users must bring their own // --oauth-client-id. Recognizing the host via NormalizeHost means an explicit // GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps // zero-config login working. The secret tracks the id, so an explicitly provided // id with no secret never picks up the baked-in secret. if oauthClientID == "" && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" { oauthClientID = buildinfo.OAuthClientID oauthClientSecret = buildinfo.OAuthClientSecret } if token == "" && oauthClientID == "" { return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth") } // If you're wondering why we're not using viper.GetStringSlice("toolsets"), // it's because viper doesn't handle comma-separated values correctly for env // vars when using GetStringSlice. // https://github.com/spf13/viper/issues/380 // // Additionally, viper.UnmarshalKey returns an empty slice even when the flag // is not set, but we need nil to indicate "use defaults". So we check IsSet first. var enabledToolsets []string if viper.IsSet("toolsets") { if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { return fmt.Errorf("failed to unmarshal toolsets: %w", err) } } // else: enabledToolsets stays nil, meaning "use defaults" // Parse tools (similar to toolsets) var enabledTools []string if viper.IsSet("tools") { if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { return fmt.Errorf("failed to unmarshal tools: %w", err) } } // Parse excluded tools (similar to tools) var excludeTools []string if viper.IsSet("exclude_tools") { if err := viper.UnmarshalKey("exclude_tools", &excludeTools); err != nil { return fmt.Errorf("failed to unmarshal exclude-tools: %w", err) } } // Parse enabled features (similar to toolsets) var enabledFeatures []string if viper.IsSet("features") { if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { return fmt.Errorf("failed to unmarshal features: %w", err) } } ttl := viper.GetDuration("repo-access-cache-ttl") stdioServerConfig := ghmcp.StdioServerConfig{ Version: version, Host: viper.GetString("host"), Token: token, EnabledToolsets: enabledToolsets, EnabledTools: enabledTools, EnabledFeatures: enabledFeatures, ReadOnly: viper.GetBool("read-only"), ExportTranslations: viper.GetBool("export-translations"), EnableCommandLogging: viper.GetBool("enable-command-logging"), LogFilePath: viper.GetString("log-file"), ContentWindowSize: viper.GetInt("content-window-size"), LockdownMode: viper.GetBool("lockdown-mode"), InsidersMode: viper.GetBool("insiders"), ExcludeTools: excludeTools, RepoAccessCacheTTL: &ttl, } // When no static token is provided, log in via OAuth using the given // client. The requested scopes default to the full supported set // (which filters out no tools); an explicit, narrower --oauth-scopes // both narrows the grant and hides tools needing other scopes. if token == "" { scopes := ghoauth.SupportedScopes if viper.IsSet("oauth-scopes") { if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil { return fmt.Errorf("failed to unmarshal oauth-scopes: %w", err) } } oauthConfig := oauth.NewGitHubConfig( oauthClientID, oauthClientSecret, scopes, viper.GetString("host"), viper.GetInt("oauth-callback-port"), ) stdioServerConfig.OAuthManager = oauth.NewManager(oauthConfig, nil) stdioServerConfig.OAuthScopes = scopes } return ghmcp.RunStdioServer(stdioServerConfig) }, } httpCmd = &cobra.Command{ Use: "http", Short: "Start HTTP server", Long: `Start an HTTP server that listens for MCP requests over HTTP.`, RunE: func(_ *cobra.Command, _ []string) error { // Parse toolsets (same approach as stdio — see comment there) var enabledToolsets []string if viper.IsSet("toolsets") { if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { return fmt.Errorf("failed to unmarshal toolsets: %w", err) } } var enabledTools []string if viper.IsSet("tools") { if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { return fmt.Errorf("failed to unmarshal tools: %w", err) } } var excludeTools []string if viper.IsSet("exclude_tools") { if err := viper.UnmarshalKey("exclude_tools", &excludeTools); err != nil { return fmt.Errorf("failed to unmarshal exclude-tools: %w", err) } } var enabledFeatures []string if viper.IsSet("features") { if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { return fmt.Errorf("failed to unmarshal features: %w", err) } } ttl := viper.GetDuration("repo-access-cache-ttl") httpConfig := ghhttp.ServerConfig{ Version: version, Host: viper.GetString("host"), Port: viper.GetInt("port"), ListenHost: viper.GetString("listen-host"), BaseURL: viper.GetString("base-url"), ResourcePath: viper.GetString("base-path"), ExportTranslations: viper.GetBool("export-translations"), EnableCommandLogging: viper.GetBool("enable-command-logging"), LogFilePath: viper.GetString("log-file"), ContentWindowSize: viper.GetInt("content-window-size"), LockdownMode: viper.GetBool("lockdown-mode"), RepoAccessCacheTTL: &ttl, ScopeChallenge: viper.GetBool("scope-challenge"), ReadOnly: viper.GetBool("read-only"), EnabledToolsets: enabledToolsets, EnabledTools: enabledTools, ExcludeTools: excludeTools, EnabledFeatures: enabledFeatures, InsidersMode: viper.GetBool("insiders"), TrustProxyHeaders: viper.GetBool("trust-proxy-headers"), } return ghhttp.RunHTTPServer(httpConfig) }, } ) func init() { cobra.OnInitialize(initConfig) rootCmd.SetGlobalNormalizationFunc(wordSepNormalizeFunc) rootCmd.SetVersionTemplate("{{.Short}}\n{{.Version}}\n") // Add global flags that will be shared by all commands rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp()) rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable") rootCmd.PersistentFlags().StringSlice("exclude-tools", nil, "Comma-separated list of tool names to disable regardless of other settings") rootCmd.PersistentFlags().StringSlice("features", nil, "Comma-separated list of feature flags to enable") rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations") rootCmd.PersistentFlags().String("log-file", "", "Path to log file") rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file") rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file") rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)") rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode") rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") // stdio-specific OAuth flags. Provide --oauth-client-id (instead of a token) // to log in via the browser-based OAuth flow on first use. Works for both // OAuth Apps and GitHub Apps. stdioCmd.Flags().String("oauth-client-id", "", "OAuth App or GitHub App client ID, enabling interactive OAuth login when no token is set") stdioCmd.Flags().String("oauth-client-secret", "", "OAuth client secret, if the app requires one (it is a public, non-confidential credential for distributed clients)") stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set") stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed local port for the OAuth callback server. Defaults to a random port; set a fixed port when mapping it through Docker") // HTTP-specific flags httpCmd.Flags().Int("port", 8082, "HTTP server port") httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.") httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)") httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)") httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses") httpCmd.Flags().Bool("trust-proxy-headers", false, "Honor X-Forwarded-Host and X-Forwarded-Proto when constructing OAuth resource metadata URLs. Only enable when the server is deployed behind a trusted proxy that sets these headers. Ignored when --base-url is set.") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) _ = viper.BindPFlag("exclude_tools", rootCmd.PersistentFlags().Lookup("exclude-tools")) _ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features")) _ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only")) _ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file")) _ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging")) _ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations")) _ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host")) _ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size")) _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) _ = viper.BindPFlag("oauth-client-id", stdioCmd.Flags().Lookup("oauth-client-id")) _ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret")) _ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes")) _ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port")) _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) _ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host")) _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) _ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path")) _ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge")) _ = viper.BindPFlag("trust-proxy-headers", httpCmd.Flags().Lookup("trust-proxy-headers")) // Add subcommands rootCmd.AddCommand(stdioCmd) rootCmd.AddCommand(httpCmd) } func initConfig() { // Initialize Viper configuration viper.SetEnvPrefix("github") viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) viper.AutomaticEnv() } func main() { if err := rootCmd.Execute(); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } } func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName { from := []string{"_"} to := "-" for _, sep := range from { name = strings.ReplaceAll(name, sep, to) } return pflag.NormalizedName(name) }