Files
github--github-mcp-server/cmd/github-mcp-server/main.go
Sam Morrow 5e0be03d18
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Build and Test Go Project / build (macos-latest) (push) Has been cancelled
Build and Test Go Project / build (ubuntu-latest) (push) Has been cancelled
Build and Test Go Project / build (windows-latest) (push) Has been cancelled
feat: add OAuth 2.1 authentication for stdio mode
Add PKCE and device flow OAuth support for stdio mode, enabling
browser-based authentication as an alternative to PATs.

Flow priority (security-ordered):
1. PKCE + browser auto-open (native)
2. PKCE + URL elicitation (Docker with bound port)
3. Device flow fallback (more phishable, last resort)

Key changes:
- internal/oauth: self-contained OAuth manager with PKCE and device flow
- internal/buildinfo: build-time OAuth credential injection via ldflags
- BearerAuthTransport: added TokenProvider for dynamic token resolution
- OAuth middleware intercepts tools/call to trigger lazy authentication
- Scope-based tool filtering using existing SupportedScopes
- PAT remains optional when OAuth credentials are configured

Security:
- PKCE S256 prevents code interception
- State parameter prevents CSRF
- Callback binds to 127.0.0.1 only
- URL elicitation for sensitive URLs (never exposed to LLM)
- Tokens stored in memory only, never persisted to disk
- ReadHeaderTimeout prevents Slowloris on callback server
- html/template auto-escaping prevents XSS in callback pages

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-09 13:14:21 +01:00

271 lines
11 KiB
Go

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")
// Resolve OAuth credentials: explicit config > build-time > none
oauthClientID, oauthClientSecret := resolveOAuthCredentials()
if token == "" && oauthClientID == "" {
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set and no OAuth credentials available")
}
// 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,
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
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,
}
// Configure OAuth if credentials are available and no PAT is set.
// PAT takes priority — if both are configured, PAT is used directly.
if token == "" && oauthClientID != "" {
oauthScopes := getOAuthScopes()
oauthCfg := oauth.GetGitHubOAuthConfig(
oauthClientID,
oauthClientSecret,
oauthScopes,
viper.GetString("host"),
viper.GetInt("oauth-callback-port"),
)
stdioServerConfig.OAuthManager = oauth.NewManager(oauthCfg, nil)
stdioServerConfig.OAuthScopes = oauthScopes
}
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 {
ttl := viper.GetDuration("repo-access-cache-ttl")
httpConfig := ghhttp.ServerConfig{
Version: version,
Host: viper.GetString("host"),
Port: viper.GetInt("port"),
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"),
}
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("dynamic-toolsets", false, "Enable dynamic toolsets")
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)")
// HTTP-specific flags
httpCmd.Flags().Int("port", 8082, "HTTP server port")
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")
// OAuth flags (stdio only)
stdioCmd.Flags().String("oauth-client-id", "", "OAuth client ID for browser-based authentication")
stdioCmd.Flags().String("oauth-client-secret", "", "OAuth client secret")
stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Explicit OAuth scopes to request (overrides automatic computation)")
stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed port for OAuth callback server (0 for random, required for Docker with -p)")
// 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("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
_ = 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("port", httpCmd.Flags().Lookup("port"))
_ = 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("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"))
// 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)
}
// resolveOAuthCredentials returns OAuth client credentials from the best
// available source. Priority: explicit config > build-time baked > none.
func resolveOAuthCredentials() (clientID, clientSecret string) {
clientID = viper.GetString("oauth-client-id")
clientSecret = viper.GetString("oauth-client-secret")
if clientID != "" {
return clientID, clientSecret
}
if buildinfo.OAuthClientID != "" {
return buildinfo.OAuthClientID, buildinfo.OAuthClientSecret
}
return "", ""
}
// getOAuthScopes returns the OAuth scopes to request. Uses explicit override
// if provided, otherwise falls back to the canonical SupportedScopes list
// which covers all tools the server may expose.
func getOAuthScopes() []string {
if viper.IsSet("oauth-scopes") {
var scopes []string
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err == nil && len(scopes) > 0 {
return scopes
}
}
// Use the canonical list maintained alongside the HTTP OAuth metadata.
// This requests all scopes any tool might need. The consent screen shows
// the user exactly what is being requested, and scope-based tool filtering
// hides tools the granted token cannot satisfy.
return ghoauth.SupportedScopes
}