From 6044f36d3c731d20f730eafc2c58620c29580d77 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 19 Jan 2026 11:16:26 +0100 Subject: [PATCH 1/7] feat: add OAuth 2.1 authentication for stdio mode Implements zero-config OAuth authentication that automatically triggers when no token is provided. Supports both PKCE flow (browser-based) and device flow (for Docker/headless environments). Key features: - PKCE flow with automatic browser opening for native environments - Device flow fallback for Docker containers - Session elicitation for showing auth URLs to users - Cancel support via elicitation - Dynamic OAuth scope computation based on enabled tools - GHEC tenant support - Styled callback pages with Primer CSS Configuration: - GITHUB_OAUTH_CLIENT_ID: Required for OAuth - GITHUB_OAUTH_CLIENT_SECRET: Recommended - GITHUB_OAUTH_CALLBACK_PORT: Fixed port for Docker with -p flag Also includes: - NewStandardBuilder for consistent inventory building - NewSliceFeatureChecker for shared feature flag checking - XSS-safe HTML templates with auto-escaping --- README.md | 31 +++ cmd/github-mcp-server/list_scopes.go | 31 +-- cmd/github-mcp-server/main.go | 132 ++++++++++- docs/oauth-authentication.md | 144 ++++++++++++ go.mod | 2 +- internal/ghmcp/server.go | 214 +++++++++++++---- internal/oauth/manager.go | 298 ++++++++++++++++++++++++ internal/oauth/oauth.go | 258 ++++++++++++++++++++ internal/oauth/oauth_test.go | 194 +++++++++++++++ internal/oauth/templates/error.html | 26 +++ internal/oauth/templates/success.html | 26 +++ pkg/github/inventory.go | 25 ++ pkg/inventory/filters.go | 12 + third-party-licenses.darwin.md | 1 + third-party-licenses.linux.md | 1 + third-party-licenses.windows.md | 1 + third-party/golang.org/x/oauth2/LICENSE | 27 +++ 17 files changed, 1356 insertions(+), 67 deletions(-) create mode 100644 docs/oauth-authentication.md create mode 100644 internal/oauth/manager.go create mode 100644 internal/oauth/oauth.go create mode 100644 internal/oauth/oauth_test.go create mode 100644 internal/oauth/templates/error.html create mode 100644 internal/oauth/templates/success.html create mode 100644 third-party/golang.org/x/oauth2/LICENSE diff --git a/README.md b/README.md index 64b68a37a..32b6b09af 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,37 @@ To keep your GitHub PAT secure and reusable across different MCP hosts: +### OAuth Authentication (stdio mode) + +For stdio mode, you can use OAuth 2.1 instead of a Personal Access Token. The server automatically selects the appropriate flow: + +| Environment | Flow | Setup | +|-------------|------|-------| +| Native binary | PKCE (browser auto-opens) | Just set `GITHUB_OAUTH_CLIENT_ID` | +| Docker | Device flow (enter code at github.com/login/device) | Just set `GITHUB_OAUTH_CLIENT_ID` | +| Docker with port | PKCE (browser auto-opens) | Set `GITHUB_OAUTH_CALLBACK_PORT` and bind port | + +**Example MCP configuration (Docker with device flow):** +```json +{ + "mcpServers": { + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", + "-e", "GITHUB_OAUTH_CLIENT_ID", + "-e", "GITHUB_OAUTH_CLIENT_SECRET", + "ghcr.io/github/github-mcp-server"], + "env": { + "GITHUB_OAUTH_CLIENT_ID": "your_client_id", + "GITHUB_OAUTH_CLIENT_SECRET": "your_client_secret" + } + } + } +} +``` + +See [docs/oauth-authentication.md](docs/oauth-authentication.md) for full setup instructions, including how to create a GitHub OAuth App. + ### GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com) The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set diff --git a/cmd/github-mcp-server/list_scopes.go b/cmd/github-mcp-server/list_scopes.go index d8b8bf392..6a061219c 100644 --- a/cmd/github-mcp-server/list_scopes.go +++ b/cmd/github-mcp-server/list_scopes.go @@ -101,27 +101,28 @@ func runListScopes() error { } } + // Get 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) + } + } + readOnly := viper.GetBool("read-only") outputFormat := viper.GetString("list-scopes-output") // Create translation helper t, _ := translations.TranslationHelper() - // Build inventory using the same logic as the stdio server - inventoryBuilder := github.NewInventory(t). - WithReadOnly(readOnly) - - // Configure toolsets (same as stdio) - if enabledToolsets != nil { - inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets) - } - - // Configure specific tools - if len(enabledTools) > 0 { - inventoryBuilder = inventoryBuilder.WithTools(enabledTools) - } - - inv, err := inventoryBuilder.Build() + // Build inventory using the shared builder for consistency + inv, err := github.NewStandardBuilder(github.InventoryConfig{ + Translator: t, + ReadOnly: readOnly, + Toolsets: enabledToolsets, + Tools: enabledTools, + EnabledFeatures: enabledFeatures, + }).Build() if err != nil { return fmt.Errorf("failed to build inventory: %w", err) } diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index af59ee0e6..33287461c 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -1,14 +1,18 @@ package main import ( - "errors" + "context" "fmt" "os" + "sort" "strings" "time" "github.com/github/github-mcp-server/internal/ghmcp" + "github.com/github/github-mcp-server/internal/oauth" "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -32,11 +36,6 @@ var ( 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") - if token == "" { - return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set") - } - // 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. @@ -68,11 +67,54 @@ var ( } } + token := viper.GetString("personal_access_token") + var oauthMgr *oauth.Manager + var oauthScopes []string + var prebuiltInventory *inventory.Inventory + + // If no token provided, setup OAuth manager if configured + if token == "" { + oauthClientID := viper.GetString("oauth_client_id") + if oauthClientID != "" { + // Get translation helper for inventory building + t, _ := translations.TranslationHelper() + + // Compute OAuth scopes and get inventory (avoids double building) + scopesResult := getOAuthScopes(enabledToolsets, enabledTools, enabledFeatures, t) + oauthScopes = scopesResult.scopes + prebuiltInventory = scopesResult.inventory + + // Create OAuth manager for lazy authentication + oauthCfg := oauth.GetGitHubOAuthConfig( + oauthClientID, + viper.GetString("oauth_client_secret"), + oauthScopes, + viper.GetString("host"), + viper.GetInt("oauth_callback_port"), + ) + oauthMgr = oauth.NewManager(oauthCfg) + fmt.Fprintf(os.Stderr, "OAuth configured - will prompt for authentication when needed\n") + } else { + fmt.Fprintf(os.Stderr, "Warning: No authentication configured\n") + fmt.Fprintf(os.Stderr, " - Set GITHUB_PERSONAL_ACCESS_TOKEN, or\n") + fmt.Fprintf(os.Stderr, " - Configure OAuth with --oauth-client-id\n") + fmt.Fprintf(os.Stderr, "Tools will prompt for authentication when called\n") + } + } + + // Extract token from OAuth manager if available + if oauthMgr != nil && token == "" { + token = oauthMgr.GetAccessToken() + } + ttl := viper.GetDuration("repo-access-cache-ttl") stdioServerConfig := ghmcp.StdioServerConfig{ Version: version, Host: viper.GetString("host"), Token: token, + OAuthManager: oauthMgr, + OAuthScopes: oauthScopes, + PrebuiltInventory: prebuiltInventory, EnabledToolsets: enabledToolsets, EnabledTools: enabledTools, EnabledFeatures: enabledFeatures, @@ -112,6 +154,12 @@ func init() { rootCmd.PersistentFlags().Bool("insider-mode", false, "Enable insider features") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") + // OAuth flags (stdio mode only) + rootCmd.PersistentFlags().String("oauth-client-id", "", "GitHub OAuth app client ID (enables interactive OAuth flow if token not set)") + rootCmd.PersistentFlags().String("oauth-client-secret", "", "GitHub OAuth app client secret (recommended)") + rootCmd.PersistentFlags().StringSlice("oauth-scopes", nil, "OAuth scopes to request (comma-separated)") + rootCmd.PersistentFlags().Int("oauth-callback-port", 0, "Fixed port for OAuth callback (0 for random, required for Docker with -p flag)") + // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) @@ -126,6 +174,10 @@ func init() { _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("insider-mode", rootCmd.PersistentFlags().Lookup("insider-mode")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) + _ = viper.BindPFlag("oauth_client_id", rootCmd.PersistentFlags().Lookup("oauth-client-id")) + _ = viper.BindPFlag("oauth_client_secret", rootCmd.PersistentFlags().Lookup("oauth-client-secret")) + _ = viper.BindPFlag("oauth_scopes", rootCmd.PersistentFlags().Lookup("oauth-scopes")) + _ = viper.BindPFlag("oauth_callback_port", rootCmd.PersistentFlags().Lookup("oauth-callback-port")) // Add subcommands rootCmd.AddCommand(stdioCmd) @@ -154,3 +206,71 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName { } return pflag.NormalizedName(name) } + +// oauthScopesResult holds the result of OAuth scope computation +type oauthScopesResult struct { + scopes []string + inventory *inventory.Inventory // reused inventory to avoid double building +} + +// getOAuthScopes returns the OAuth scopes to request based on enabled tools +// Also returns the built inventory to avoid building it twice +// Uses custom scopes if explicitly provided, otherwise computes required scopes +// from the tools that will be enabled based on user configuration +func getOAuthScopes(enabledToolsets, enabledTools, enabledFeatures []string, t translations.TranslationHelperFunc) oauthScopesResult { + // Allow explicit override via --oauth-scopes flag + var scopeList []string + if viper.IsSet("oauth_scopes") { + if err := viper.UnmarshalKey("oauth_scopes", &scopeList); err == nil && len(scopeList) > 0 { + // When scopes are explicit, don't build inventory (will be built in server) + return oauthScopesResult{scopes: scopeList} + } + } + + // Build inventory with the same configuration that will be used at runtime + // This allows us to determine which tools will actually be available + // and avoids building the inventory twice + inventoryBuilder := github.NewStandardBuilder(github.InventoryConfig{ + Translator: t, + ReadOnly: viper.GetBool("read-only"), + Toolsets: enabledToolsets, + Tools: enabledTools, + EnabledFeatures: enabledFeatures, + }) + + inv, err := inventoryBuilder.Build() + if err != nil { + // Inventory build only fails if invalid tool names are passed via --tools + // In that case, return empty scopes - the error will surface when server starts + return oauthScopesResult{scopes: nil} + } + + // Collect all required scopes from available tools + // This is the canonical source of OAuth scopes for the enabled tools + requiredScopes := collectRequiredScopes(inv) + return oauthScopesResult{scopes: requiredScopes, inventory: inv} +} + +// collectRequiredScopes collects all unique required scopes from available tools +// Returns a sorted, deduplicated list of OAuth scopes needed for the enabled tools +func collectRequiredScopes(inv *inventory.Inventory) []string { + scopeSet := make(map[string]bool) + + // Get available tools (respects filters like read-only, toolsets, etc.) + for _, tool := range inv.AvailableTools(context.Background()) { + for _, scope := range tool.RequiredScopes { + if scope != "" { + scopeSet[scope] = true + } + } + } + + // Convert to sorted slice for deterministic output + scopes := make([]string, 0, len(scopeSet)) + for scope := range scopeSet { + scopes = append(scopes, scope) + } + sort.Strings(scopes) + + return scopes +} diff --git a/docs/oauth-authentication.md b/docs/oauth-authentication.md new file mode 100644 index 000000000..186d81727 --- /dev/null +++ b/docs/oauth-authentication.md @@ -0,0 +1,144 @@ +# OAuth Authentication + +The GitHub MCP Server supports OAuth authentication for stdio mode, enabling interactive authentication when no Personal Access Token (PAT) is configured. + +## Overview + +OAuth authentication allows users to authenticate with GitHub through their browser without pre-configuring a token. This is useful for: + +- **Interactive sessions** where users want to authenticate on-demand +- **Docker deployments** where tokens shouldn't be baked into images +- **Multi-user scenarios** where each user authenticates individually + +## Configuration + +### Required Environment Variables + +| Variable | Description | Required | +|----------|-------------|----------| +| `GITHUB_OAUTH_CLIENT_ID` | OAuth app client ID | Yes | +| `GITHUB_OAUTH_CLIENT_SECRET` | OAuth app client secret | Recommended | + +### Optional Flags + +| Flag | Environment Variable | Description | +|------|---------------------|-------------| +| `--oauth-callback-port` | `GITHUB_OAUTH_CALLBACK_PORT` | Fixed port for OAuth callback (required for Docker with `-p` flag) | +| `--oauth-scopes` | `GITHUB_OAUTH_SCOPES` | Custom OAuth scopes (comma-separated) | + +## Authentication Flows + +The server automatically selects the appropriate OAuth flow based on the environment: + +### 1. PKCE Flow (Browser-based) + +Used for local binary execution where a browser can be opened: + +1. Server starts a local callback server +2. Browser opens to GitHub authorization page +3. User authorizes the application +4. GitHub redirects to local callback with authorization code +5. Server exchanges code for access token + +### 2. Device Flow (Docker/Headless) + +Used when running in Docker or when a browser cannot be opened: + +1. Server requests a device code from GitHub +2. User is shown a URL and code to enter +3. User visits `github.com/login/device` and enters the code +4. Server polls GitHub until authorization is complete +5. Access token is retrieved + +## Usage Examples + +### Local Binary + +```bash +# Set OAuth credentials +export GITHUB_OAUTH_CLIENT_ID="your-client-id" +export GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" + +# Run without PAT - OAuth will trigger when tools are called +./github-mcp-server stdio +``` + +### Docker (with Device Flow) + +```bash +docker run -i --rm \ + -e GITHUB_OAUTH_CLIENT_ID="your-client-id" \ + -e GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" \ + ghcr.io/github/github-mcp-server stdio +``` + +### Docker (with PKCE Flow via port mapping) + +```bash +docker run -i --rm \ + --network=host \ + -e GITHUB_OAUTH_CLIENT_ID="your-client-id" \ + -e GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" \ + ghcr.io/github/github-mcp-server stdio --oauth-callback-port=8085 +``` + +### VS Code MCP Configuration + +```jsonc +{ + "mcpServers": { + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_OAUTH_CLIENT_ID=your-client-id", + "-e", "GITHUB_OAUTH_CLIENT_SECRET=your-client-secret", + "ghcr.io/github/github-mcp-server", + "stdio" + ], + "type": "stdio" + } + } +} +``` + +## Creating an OAuth App + +1. Go to **GitHub Settings** → **Developer settings** → **OAuth Apps** +2. Click **New OAuth App** +3. Fill in the details: + - **Application name**: Your app name (e.g., "GitHub MCP Server") + - **Homepage URL**: Your homepage or `https://github.com/github/github-mcp-server` + - **Authorization callback URL**: `http://localhost:8085/callback` (or your chosen port) +4. Click **Register application** +5. Copy the **Client ID** +6. Generate and copy the **Client Secret** + +## Scope Computation + +The server automatically computes the required OAuth scopes based on enabled tools: + +- If `--toolsets` or `--tools` are specified, only scopes for those tools are requested +- If no tools are specified, default scopes are used: `repo`, `user`, `gist`, `notifications`, `read:org`, `project` +- Custom scopes can be specified with `--oauth-scopes` + +## Security Considerations + +1. **Client Secret**: While optional for public OAuth apps, using a client secret is recommended for better security +2. **Token Storage**: OAuth tokens are stored in memory only and not persisted to disk +3. **Scope Minimization**: Request only the scopes needed for your use case +4. **PKCE**: The PKCE flow provides protection against authorization code interception attacks + +## Troubleshooting + +### "redirect_uri not associated with this client" + +Ensure the callback port matches your OAuth app's registered callback URL. Use `--oauth-callback-port` to specify the exact port. + +### Browser doesn't open automatically + +The server will fall back to displaying the authorization URL. In Docker, the device flow is used automatically. + +### Token not being used + +Verify that `GITHUB_PERSONAL_ACCESS_TOKEN` is not set, as it takes precedence over OAuth. diff --git a/go.mod b/go.mod index 5322b47ec..8e2fd92b4 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/subosito/gotenv v1.6.0 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 - golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/oauth2 v0.30.0 golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.28.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 6090063f1..6bf21fbe6 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -36,6 +36,15 @@ type MCPServerConfig struct { // GitHub Token to authenticate with the GitHub API Token string + // TokenProvider is an optional function to dynamically get the token. + // Used for OAuth flows where the token is obtained after server startup. + // If set, this takes precedence over Token for API requests. + TokenProvider func() string + + // PrebuiltInventory is an optional pre-built inventory to avoid double building + // When set, this inventory will be used instead of building a new one + PrebuiltInventory *inventory.Inventory + // EnabledToolsets is a list of toolsets to enable // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string @@ -88,9 +97,19 @@ type githubClients struct { } // createGitHubClients creates all the GitHub API clients needed by the server. -func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, error) { - // Construct REST client - restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token) +// If tokenProviderFn is provided, it will be used to get the token dynamically (for OAuth). +// Otherwise, cfg.Token is used as a static token. +func createGitHubClients(cfg MCPServerConfig, apiHost apiHost, tokenProviderFn tokenProvider) (*githubClients, error) { + // Create bearer auth transport that can use dynamic token + restTransport := &bearerAuthTransport{ + transport: http.DefaultTransport, + token: cfg.Token, + tokenProvider: tokenProviderFn, + } + + // Construct REST client with custom transport + restHTTPClient := &http.Client{Transport: restTransport} + restClient := gogithub.NewClient(restHTTPClient) restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) restClient.BaseURL = apiHost.baseRESTURL restClient.UploadURL = apiHost.uploadURL @@ -102,12 +121,13 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, transport: &github.GraphQLFeaturesTransport{ Transport: http.DefaultTransport, }, - token: cfg.Token, + token: cfg.Token, + tokenProvider: tokenProviderFn, }, } gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient) - // Create raw content client (shares REST client's HTTP transport) + // Create raw content client (inherits transport from REST client) rawClient := raw.NewClient(restClient, apiHost.rawURL) // Set up repo access cache for lockdown mode @@ -164,7 +184,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { return nil, fmt.Errorf("failed to parse API host: %w", err) } - clients, err := createGitHubClients(cfg, apiHost) + clients, err := createGitHubClients(cfg, apiHost, cfg.TokenProvider) if err != nil { return nil, fmt.Errorf("failed to create GitHub clients: %w", err) } @@ -204,7 +224,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP)) // Create feature checker - featureChecker := createFeatureChecker(cfg.EnabledFeatures) + featureChecker := inventory.NewSliceFeatureChecker(cfg.EnabledFeatures) // Create dependencies for tool handlers deps := github.NewBaseDeps( @@ -215,7 +235,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { cfg.Translator, github.FeatureFlags{ LockdownMode: cfg.LockdownMode, - InsiderMode: cfg.InsiderMode, + InsiderMode: cfg.InsiderMode, }, cfg.ContentWindowSize, featureChecker, @@ -229,24 +249,52 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { }) // Build and register the tool/resource/prompt inventory - inventoryBuilder := github.NewInventory(cfg.Translator). - WithDeprecatedAliases(github.DeprecatedToolAliases). - WithReadOnly(cfg.ReadOnly). - WithToolsets(enabledToolsets). - WithTools(cfg.EnabledTools). - WithFeatureChecker(featureChecker) - - // Apply token scope filtering if scopes are known (for PAT filtering) - if cfg.TokenScopes != nil { - inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) - } - - inventory, err := inventoryBuilder.Build() - if err != nil { - return nil, fmt.Errorf("failed to build inventory: %w", err) + var inv *inventory.Inventory + if cfg.PrebuiltInventory != nil { + // Use prebuilt inventory to avoid double building + inv = cfg.PrebuiltInventory + + // Apply scope filtering if needed (only if not already applied) + // Prebuilt inventory from OAuth scope computation doesn't have scope filter yet + if cfg.TokenScopes != nil { + // Need to rebuild with scope filter + inventoryBuilder := github.NewStandardBuilder(github.InventoryConfig{ + Translator: cfg.Translator, + ReadOnly: cfg.ReadOnly, + Toolsets: enabledToolsets, + Tools: cfg.EnabledTools, + EnabledFeatures: cfg.EnabledFeatures, + }).WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) + + var err error + inv, err = inventoryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to rebuild inventory with scope filter: %w", err) + } + } + } else { + // Build inventory from scratch + inventoryBuilder := github.NewStandardBuilder(github.InventoryConfig{ + Translator: cfg.Translator, + ReadOnly: cfg.ReadOnly, + Toolsets: enabledToolsets, + Tools: cfg.EnabledTools, + EnabledFeatures: cfg.EnabledFeatures, + }) + + // Apply token scope filtering if scopes are known (for PAT filtering) + if cfg.TokenScopes != nil { + inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) + } + + var err error + inv, err = inventoryBuilder.Build() + if err != nil { + return nil, fmt.Errorf("failed to build inventory: %w", err) + } } - if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 { + if unrecognized := inv.UnrecognizedToolsets(); len(unrecognized) > 0 { fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", ")) } @@ -254,12 +302,12 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { // In dynamic mode with no explicit toolsets, this is a no-op since enabledToolsets // is empty - users enable toolsets at runtime via the dynamic tools below (but can // enable toolsets or tools explicitly that do need registration). - inventory.RegisterAll(context.Background(), ghServer, deps) + inv.RegisterAll(context.Background(), ghServer, deps) // Register dynamic toolset management tools (enable/disable) - these are separate // meta-tools that control the inventory, not part of the inventory itself if cfg.DynamicToolsets { - registerDynamicTools(ghServer, inventory, deps, cfg.Translator) + registerDynamicTools(ghServer, inv, deps, cfg.Translator) } return ghServer, nil @@ -278,20 +326,6 @@ func registerDynamicTools(server *mcp.Server, inventory *inventory.Inventory, de } } -// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name -// is present in the provided list of enabled features. For the local server, -// this is populated from the --features CLI flag. -func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker { - // Build a set for O(1) lookup - featureSet := make(map[string]bool, len(enabledFeatures)) - for _, f := range enabledFeatures { - featureSet[f] = true - } - return func(_ context.Context, flagName string) (bool, error) { - return featureSet[flagName], nil - } -} - type StdioServerConfig struct { // Version of the server Version string @@ -302,6 +336,22 @@ type StdioServerConfig struct { // GitHub Token to authenticate with the GitHub API Token string + // OAuthManager handles OAuth authentication with lazy loading + // When set, tools will trigger OAuth flow when authentication is needed + OAuthManager interface { + HasToken() bool + GetAccessToken() string + RequestAuthentication(context.Context, *mcp.ServerSession) error + } + + // OAuthScopes contains the OAuth scopes that were requested + // When non-nil and OAuthManager is set, these scopes are used for scope filtering + OAuthScopes []string + + // PrebuiltInventory is an optional pre-built inventory to avoid double building + // When set, this inventory will be used instead of building a new one + PrebuiltInventory *inventory.Inventory + // EnabledToolsets is a list of toolsets to enable // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string @@ -372,7 +422,8 @@ func RunStdioServer(cfg StdioServerConfig) error { // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. // Fine-grained PATs and other token types don't support this, so we skip filtering. var tokenScopes []string - if strings.HasPrefix(cfg.Token, "ghp_") { + switch { + case strings.HasPrefix(cfg.Token, "ghp_"): fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) if err != nil { logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) @@ -380,14 +431,32 @@ func RunStdioServer(cfg StdioServerConfig) error { tokenScopes = fetchedScopes logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) } - } else { + case len(cfg.OAuthScopes) > 0: + // Use OAuth scopes for filtering when OAuth is configured + // This filters tools to only those compatible with the requested OAuth scopes + tokenScopes = cfg.OAuthScopes + logger.Info("using OAuth scopes for tool filtering", "scopes", tokenScopes) + default: logger.Debug("skipping scope filtering for non-PAT token") } + // Create token provider that checks OAuth first, then falls back to static token + var tokenProvider func() string + if cfg.OAuthManager != nil { + tokenProvider = func() string { + if token := cfg.OAuthManager.GetAccessToken(); token != "" { + return token + } + return cfg.Token + } + } + ghServer, err := NewMCPServer(MCPServerConfig{ Version: cfg.Version, Host: cfg.Host, Token: cfg.Token, + TokenProvider: tokenProvider, + PrebuiltInventory: cfg.PrebuiltInventory, EnabledToolsets: cfg.EnabledToolsets, EnabledTools: cfg.EnabledTools, EnabledFeatures: cfg.EnabledFeatures, @@ -405,6 +474,11 @@ func RunStdioServer(cfg StdioServerConfig) error { return fmt.Errorf("failed to create MCP server: %w", err) } + // Add OAuth authentication middleware if OAuth manager is configured + if cfg.OAuthManager != nil { + ghServer.AddReceivingMiddleware(createOAuthMiddleware(cfg.OAuthManager, logger)) + } + if cfg.ExportTranslations { // Once server is initialized, all translations are loaded dumpTranslations() @@ -633,14 +707,24 @@ func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error return t.transport.RoundTrip(req) } +// tokenProvider is a function that returns the current auth token +type tokenProvider func() string + type bearerAuthTransport struct { - transport http.RoundTripper - token string + transport http.RoundTripper + token string // static token (used if tokenProvider is nil) + tokenProvider tokenProvider // dynamic token provider (takes precedence) } func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) - req.Header.Set("Authorization", "Bearer "+t.token) + token := t.token + if t.tokenProvider != nil { + token = t.tokenProvider() + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } return t.transport.RoundTrip(req) } @@ -699,3 +783,43 @@ func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, return fetcher.FetchTokenScopes(ctx, token) } + +// createOAuthMiddleware creates middleware that triggers OAuth authentication when needed +func createOAuthMiddleware(oauthMgr interface { + HasToken() bool + GetAccessToken() string + RequestAuthentication(context.Context, *mcp.ServerSession) error +}, logger *slog.Logger) func(mcp.MethodHandler) mcp.MethodHandler { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + // Only check authentication for tool calls + if method != "tools/call" { + return next(ctx, method, req) + } + + // Check if we have a token + if !oauthMgr.HasToken() { + logger.Info("no authentication token available, triggering OAuth flow") + + // Get the session for elicitation + var session *mcp.ServerSession + if sess := req.GetSession(); sess != nil { + // Type assert to ServerSession + if ss, ok := sess.(*mcp.ServerSession); ok { + session = ss + } + } + + // Trigger OAuth authentication (blocks until complete) + if err := oauthMgr.RequestAuthentication(ctx, session); err != nil { + return nil, err + } + // OAuth completed successfully - fall through to execute the tool + logger.Info("OAuth authentication completed successfully") + } + + // Execute the tool with authentication + return next(ctx, method, req) + } + } +} diff --git a/internal/oauth/manager.go b/internal/oauth/manager.go new file mode 100644 index 000000000..e56037174 --- /dev/null +++ b/internal/oauth/manager.go @@ -0,0 +1,298 @@ +package oauth + +import ( + "context" + "crypto/rand" + "encoding/base64" + "fmt" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "golang.org/x/oauth2" +) + +// Manager handles OAuth authentication state with URL elicitation support +type Manager struct { + config Config + mu sync.RWMutex + token *Result + authInProgress bool + authDone chan struct{} // closed when auth completes +} + +// NewManager creates a new OAuth manager with the given configuration +func NewManager(cfg Config) *Manager { + return &Manager{ + config: cfg, + } +} + +// HasToken returns true if a valid token is available +func (m *Manager) HasToken() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.token != nil && m.token.AccessToken != "" +} + +// GetAccessToken returns the access token if available +func (m *Manager) GetAccessToken() string { + m.mu.RLock() + defer m.mu.RUnlock() + if m.token == nil { + return "" + } + return m.token.AccessToken +} + +// RequestAuthentication triggers the OAuth flow using URL elicitation +// Uses session.Elicit() for synchronous blocking auth if session is provided +// Falls back to URLElicitationRequiredError if session is not available +// If auth is already in progress, waits for it to complete instead of starting a new flow +func (m *Manager) RequestAuthentication(ctx context.Context, session *mcp.ServerSession) error { + // Check if auth is already in progress + m.mu.Lock() + if m.authInProgress { + // Wait for the existing auth to complete + authDone := m.authDone + m.mu.Unlock() + + select { + case <-authDone: + // Auth completed, check if we have a token now + if m.HasToken() { + return nil + } + // Auth failed, but don't start a new one - let the next request retry + return fmt.Errorf("authentication failed") + case <-ctx.Done(): + return ctx.Err() + } + } + + // Mark auth as in progress + m.authInProgress = true + m.authDone = make(chan struct{}) + m.mu.Unlock() + + // Ensure we clean up the in-progress state when done + defer func() { + m.mu.Lock() + m.authInProgress = false + close(m.authDone) + m.mu.Unlock() + }() + + // Determine which flow to use based on environment + useDeviceFlow := isRunningInDocker() && m.config.CallbackPort == 0 + + if useDeviceFlow { + return m.startDeviceFlowWithElicitation(ctx, session) + } + + return m.startPKCEFlowWithElicitation(ctx, session) +} + +// startDeviceFlowWithElicitation initiates device flow and uses session elicitation. +// Device flow is used when a callback server cannot be started (e.g., in Docker containers). +// It displays a code that the user must enter at the verification URL. +func (m *Manager) startDeviceFlowWithElicitation(ctx context.Context, session *mcp.ServerSession) error { + oauth2Cfg := &oauth2.Config{ + ClientID: m.config.ClientID, + ClientSecret: m.config.ClientSecret, + Scopes: m.config.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: m.config.AuthURL, + TokenURL: m.config.TokenURL, + DeviceAuthURL: m.config.DeviceAuthURL, + }, + } + + // Request device authorization + deviceAuth, err := oauth2Cfg.DeviceAuth(ctx) + if err != nil { + return fmt.Errorf("failed to get device authorization: %w", err) + } + + // Create cancellable context for polling + pollCtx, cancelPoll := context.WithCancel(ctx) + defer cancelPoll() + + // Use session elicitation if available to show the user the verification URL and code + if session != nil { + // Run elicitation in goroutine - if cancelled, abort the device flow + go func() { + elicitID, err := generateRandomToken() + if err != nil { + elicitID = "fallback-id" + } + result, err := session.Elicit(ctx, &mcp.ElicitParams{ + Mode: "url", + URL: deviceAuth.VerificationURI, + ElicitationID: elicitID, + Message: fmt.Sprintf("GitHub OAuth Device Authorization\n\nYour code: %s\n\nVisit the URL and enter this code to authenticate.", deviceAuth.UserCode), + }) + // If elicitation was cancelled or declined, abort the polling + if err != nil || result == nil || result.Action == "cancel" || result.Action == "decline" { + cancelPoll() + } + }() + } + + // Poll for the token (blocking, but respects context cancellation) + token, err := oauth2Cfg.DeviceAccessToken(pollCtx, deviceAuth) + if err != nil { + if pollCtx.Err() != nil { + return fmt.Errorf("OAuth authorization was cancelled by user") + } + return fmt.Errorf("failed to get device access token: %w", err) + } + + // Store the token + m.setToken(&Result{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + TokenType: token.TokenType, + Expiry: token.Expiry, + }) + + return nil +} + +// startPKCEFlowWithElicitation initiates PKCE flow with browser and session elicitation +// Uses session.Elicit() for synchronous blocking auth - the request waits until auth completes +func (m *Manager) startPKCEFlowWithElicitation(ctx context.Context, session *mcp.ServerSession) error { + // Generate PKCE verifier + verifier, err := generatePKCEVerifier() + if err != nil { + // Fall back to device flow if PKCE setup fails + return m.startDeviceFlowWithElicitation(ctx, session) + } + + // Generate state for CSRF protection + state, err := generateRandomToken() + if err != nil { + return m.startDeviceFlowWithElicitation(ctx, session) + } + + // Start local callback server + listener, port, err := startLocalServer(m.config.CallbackPort) + if err != nil { + // Cannot start callback server - fall back to device flow + return m.startDeviceFlowWithElicitation(ctx, session) + } + + // Create OAuth2 config + oauth2Cfg := &oauth2.Config{ + ClientID: m.config.ClientID, + ClientSecret: m.config.ClientSecret, + RedirectURL: fmt.Sprintf("http://localhost:%d/callback", port), + Scopes: m.config.Scopes, + Endpoint: oauth2.Endpoint{ + AuthURL: m.config.AuthURL, + TokenURL: m.config.TokenURL, + }, + } + + // Build authorization URL with PKCE + authURL := oauth2Cfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier)) + + // Setup callback handling + codeChan := make(chan string, 1) + errChan := make(chan error, 1) + + // Create and start callback server + server := createCallbackServer(state, codeChan, errChan, listener) + + // Cleanup function + cleanup := func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) + listener.Close() + } + + // Try to open browser - if it works, no elicitation needed + browserErr := openBrowser(authURL) + + // Channel to signal elicitation cancellation + elicitCancelChan := make(chan struct{}, 1) + + // Only elicit if browser failed to open (e.g., headless environment) + // and we need to show the user the URL manually + if browserErr != nil && session != nil { + // Run elicitation in goroutine so we can monitor callback in parallel + go func() { + elicitID, _ := generateRandomToken() + result, err := session.Elicit(ctx, &mcp.ElicitParams{ + Mode: "url", + URL: authURL, + ElicitationID: elicitID, + Message: "GitHub OAuth Authorization\n\nPlease visit the URL to authorize access.", + }) + // If elicitation was cancelled or declined, signal to abort + if err != nil || result == nil || result.Action == "cancel" || result.Action == "decline" { + select { + case elicitCancelChan <- struct{}{}: + default: + } + } + }() + } + + // Wait for callback with timeout + select { + case code := <-codeChan: + // Exchange code for token + token, err := oauth2Cfg.Exchange(ctx, code, oauth2.VerifierOption(verifier)) + cleanup() + if err != nil { + return fmt.Errorf("failed to exchange code for token: %w", err) + } + + // Store token + m.setToken(&Result{ + AccessToken: token.AccessToken, + RefreshToken: token.RefreshToken, + TokenType: token.TokenType, + Expiry: token.Expiry, + }) + + return nil + + case err := <-errChan: + cleanup() + return fmt.Errorf("OAuth callback error: %w", err) + + case <-elicitCancelChan: + cleanup() + return fmt.Errorf("OAuth authorization was cancelled by user") + + case <-ctx.Done(): + cleanup() + return ctx.Err() + + case <-time.After(DefaultAuthTimeout): + cleanup() + return fmt.Errorf("OAuth timeout after %v - please try again", DefaultAuthTimeout) + } +} + +// setToken stores the OAuth token +func (m *Manager) setToken(token *Result) { + m.mu.Lock() + defer m.mu.Unlock() + m.token = token +} + +// Helper functions + +// generateRandomToken generates a cryptographically random URL-safe token. +// Used for CSRF state and elicitation IDs. +func generateRandomToken() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go new file mode 100644 index 000000000..8848fe2f7 --- /dev/null +++ b/internal/oauth/oauth.go @@ -0,0 +1,258 @@ +package oauth + +import ( + "crypto/rand" + "embed" + "encoding/base64" + "fmt" + "html/template" + "io" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "time" +) + +//go:embed templates/*.html +var templateFS embed.FS + +var ( + errorTemplate *template.Template + successTemplate *template.Template +) + +func init() { + var err error + errorTemplate, err = template.ParseFS(templateFS, "templates/error.html") + if err != nil { + panic(fmt.Sprintf("failed to parse error template: %v", err)) + } + successTemplate, err = template.ParseFS(templateFS, "templates/success.html") + if err != nil { + panic(fmt.Sprintf("failed to parse success template: %v", err)) + } +} + +const ( + // DefaultAuthTimeout is the default timeout for the OAuth authorization flow + DefaultAuthTimeout = 5 * time.Minute +) + +// Config holds the OAuth configuration +type Config struct { + ClientID string + ClientSecret string // Recommended for GitHub OAuth apps + RedirectURL string + Scopes []string + AuthURL string + TokenURL string + Host string // GitHub host (for constructing OAuth URLs) + DeviceAuthURL string // Device authorization URL (for device flow) + CallbackPort int // Fixed callback port (0 for random) +} + +// Result contains the OAuth flow result +type Result struct { + AccessToken string + RefreshToken string + TokenType string + Expiry time.Time +} + +// generatePKCEVerifier generates a PKCE code verifier +func generatePKCEVerifier() (string, error) { + // Generate 32 random bytes (256 bits) + // Base64URL encoding of 32 bytes gives us 43 characters + verifierBytes := make([]byte, 32) + if _, err := rand.Read(verifierBytes); err != nil { + return "", fmt.Errorf("failed to generate random bytes: %w", err) + } + verifier := base64.RawURLEncoding.EncodeToString(verifierBytes) + return verifier, nil +} + +// isRunningInDocker detects if the process is running inside a Docker container +func isRunningInDocker() bool { + // Check for .dockerenv file (most common indicator) + if _, err := os.Stat("/.dockerenv"); err == nil { + return true + } + + // Check cgroup for docker (fallback) + data, err := os.ReadFile("/proc/1/cgroup") + if err == nil && (strings.Contains(string(data), "docker") || strings.Contains(string(data), "containerd")) { + return true + } + + return false +} + +// startLocalServer starts a local HTTP server on the specified port +// If port is 0, uses a random available port +func startLocalServer(port int) (net.Listener, int, error) { + addr := fmt.Sprintf("localhost:%d", port) + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, 0, fmt.Errorf("failed to start listener on %s: %w", addr, err) + } + + actualPort := listener.Addr().(*net.TCPAddr).Port + return listener, actualPort, nil +} + +// createCallbackHandler creates an HTTP handler for the OAuth callback +func createCallbackHandler(expectedState string, codeChan chan<- string, errChan chan<- error) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + // Check for errors from OAuth provider + if errMsg := r.URL.Query().Get("error"); errMsg != "" { + errDesc := r.URL.Query().Get("error_description") + if errDesc != "" { + errMsg = fmt.Sprintf("%s: %s", errMsg, errDesc) + } + errChan <- fmt.Errorf("authorization failed: %s", errMsg) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + // html/template auto-escapes ErrorMessage to prevent XSS + if err := errorTemplate.Execute(w, struct{ ErrorMessage string }{ErrorMessage: errMsg}); err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + } + return + } + + // Verify state for CSRF protection + if state := r.URL.Query().Get("state"); state != expectedState { + errChan <- fmt.Errorf("state mismatch (possible CSRF attack)") + http.Error(w, "State mismatch", http.StatusBadRequest) + return + } + + // Get authorization code + code := r.URL.Query().Get("code") + if code == "" { + errChan <- fmt.Errorf("no authorization code received") + http.Error(w, "No code received", http.StatusBadRequest) + return + } + + // Send code to channel + codeChan <- code + + // Display success page + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := successTemplate.Execute(w, nil); err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + } + }) + + return mux +} + +// createCallbackServer creates an HTTP server for the OAuth callback +// Used by Manager for proper lifecycle management +func createCallbackServer(expectedState string, codeChan chan<- string, errChan chan<- error, listener net.Listener) *http.Server { + handler := createCallbackHandler(expectedState, codeChan, errChan) + server := &http.Server{ + Handler: handler, + ReadHeaderTimeout: 10 * time.Second, // Prevent Slowloris attacks + } + + // Start server in background + go func() { + if err := server.Serve(listener); err != nil && err != http.ErrServerClosed { + errChan <- fmt.Errorf("server error: %w", err) + } + }() + + return server +} + +// openBrowser tries to open the URL in the default browser +func openBrowser(url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "linux": + // Try xdg-open first (most Linux distributions) + cmd = exec.Command("xdg-open", url) + case "darwin": + cmd = exec.Command("open", url) + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + default: + return fmt.Errorf("unsupported platform: %s", runtime.GOOS) + } + + // Redirect output to prevent noise + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + + return cmd.Start() +} + +// GetGitHubOAuthConfig returns the GitHub OAuth configuration for the specified host +// host can be empty for github.com, or a full URL like "https://github.enterprise.com" for GHES +func GetGitHubOAuthConfig(clientID, clientSecret string, scopes []string, host string, callbackPort int) Config { + authURL, tokenURL, deviceAuthURL := getOAuthEndpoints(host) + + return Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Scopes: scopes, + AuthURL: authURL, + TokenURL: tokenURL, + DeviceAuthURL: deviceAuthURL, + Host: host, + CallbackPort: callbackPort, + } +} + +// getOAuthEndpoints returns the appropriate OAuth endpoints based on the host +func getOAuthEndpoints(host string) (authURL, tokenURL, deviceAuthURL string) { + // Default to github.com + if host == "" { + return "https://github.com/login/oauth/authorize", + "https://github.com/login/oauth/access_token", + "https://github.com/login/device/code" + } + + // For GHES/GHEC, OAuth endpoints are at the main domain, not api subdomain + // Parse the host to extract the base domain + hostURL := host + if !strings.HasPrefix(host, "http://") && !strings.HasPrefix(host, "https://") { + hostURL = "https://" + host + } + + // Extract scheme and hostname + var scheme, hostname string + if strings.HasPrefix(hostURL, "https://") { + scheme = "https" + hostname = strings.TrimPrefix(hostURL, "https://") + } else if strings.HasPrefix(hostURL, "http://") { + scheme = "http" + hostname = strings.TrimPrefix(hostURL, "http://") + } + + // Remove any trailing slashes or paths + // strings.Index returns -1 if not found, and we want to keep everything if there's no slash + // If slash is at index 0, that would be invalid (e.g., "/example"), so we check > 0 + if idx := strings.Index(hostname, "/"); idx > 0 { + hostname = hostname[:idx] + } + + // For github.com, strip api. subdomain (api.github.com → github.com) + // For ghe.com (GHEC), keep the full tenant domain (mycompany.ghe.com stays as-is) + if hostname == "api.github.com" { + hostname = "github.com" + } + + authURL = fmt.Sprintf("%s://%s/login/oauth/authorize", scheme, hostname) + tokenURL = fmt.Sprintf("%s://%s/login/oauth/access_token", scheme, hostname) + deviceAuthURL = fmt.Sprintf("%s://%s/login/device/code", scheme, hostname) + + return authURL, tokenURL, deviceAuthURL +} diff --git a/internal/oauth/oauth_test.go b/internal/oauth/oauth_test.go new file mode 100644 index 000000000..3cf0e3ac8 --- /dev/null +++ b/internal/oauth/oauth_test.go @@ -0,0 +1,194 @@ +package oauth + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + // expectedPKCEVerifierMinLength is the expected minimum length of a PKCE verifier + // Base64URL encoding of 32 bytes = 43 characters (32 * 8 / 6, rounded up) + expectedPKCEVerifierMinLength = 43 +) + +func TestGeneratePKCEVerifier(t *testing.T) { + verifier, err := generatePKCEVerifier() + require.NoError(t, err) + require.NotEmpty(t, verifier) + + // Verifier should be at least 43 characters (base64url of 32 bytes) + assert.GreaterOrEqual(t, len(verifier), expectedPKCEVerifierMinLength) + + // Generate another one to ensure they're different + verifier2, err := generatePKCEVerifier() + require.NoError(t, err) + assert.NotEqual(t, verifier, verifier2) +} + +func TestGetGitHubOAuthConfig(t *testing.T) { + clientID := "test-client-id" + clientSecret := "test-client-secret" + scopes := []string{"repo", "user"} + + t.Run("default github.com", func(t *testing.T) { + cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "", 0) + + assert.Equal(t, clientID, cfg.ClientID) + assert.Equal(t, clientSecret, cfg.ClientSecret) + assert.Equal(t, scopes, cfg.Scopes) + assert.Equal(t, "https://github.com/login/oauth/authorize", cfg.AuthURL) + assert.Equal(t, "https://github.com/login/oauth/access_token", cfg.TokenURL) + assert.Equal(t, "https://github.com/login/device/code", cfg.DeviceAuthURL) + assert.Equal(t, "", cfg.Host) + assert.Equal(t, 0, cfg.CallbackPort) + }) + + t.Run("GHES host", func(t *testing.T) { + cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "https://github.enterprise.com", 8080) + + assert.Equal(t, clientID, cfg.ClientID) + assert.Equal(t, clientSecret, cfg.ClientSecret) + assert.Equal(t, scopes, cfg.Scopes) + assert.Equal(t, "https://github.enterprise.com/login/oauth/authorize", cfg.AuthURL) + assert.Equal(t, "https://github.enterprise.com/login/oauth/access_token", cfg.TokenURL) + assert.Equal(t, "https://github.enterprise.com/login/device/code", cfg.DeviceAuthURL) + assert.Equal(t, "https://github.enterprise.com", cfg.Host) + assert.Equal(t, 8080, cfg.CallbackPort) + }) + + t.Run("GHEC host (ghe.com)", func(t *testing.T) { + cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "https://mycompany.ghe.com", 0) + + assert.Equal(t, "https://mycompany.ghe.com/login/oauth/authorize", cfg.AuthURL) + assert.Equal(t, "https://mycompany.ghe.com/login/oauth/access_token", cfg.TokenURL) + assert.Equal(t, "https://mycompany.ghe.com/login/device/code", cfg.DeviceAuthURL) + }) + + t.Run("host without scheme", func(t *testing.T) { + cfg := GetGitHubOAuthConfig(clientID, clientSecret, scopes, "github.enterprise.com", 0) + + // Should default to https + assert.Equal(t, "https://github.enterprise.com/login/oauth/authorize", cfg.AuthURL) + }) +} + +func TestStartLocalServer(t *testing.T) { + t.Run("random port", func(t *testing.T) { + listener, port, err := startLocalServer(0) + require.NoError(t, err) + require.NotNil(t, listener) + defer listener.Close() + + assert.Greater(t, port, 0) + assert.Less(t, port, 65536) + }) + + t.Run("fixed port", func(t *testing.T) { + // Use a high port to avoid conflicts + fixedPort := 54321 + listener, port, err := startLocalServer(fixedPort) + require.NoError(t, err) + require.NotNil(t, listener) + defer listener.Close() + + assert.Equal(t, fixedPort, port) + }) +} + +// Manager tests + +func TestNewManager(t *testing.T) { + cfg := Config{ + ClientID: "test-client-id", + ClientSecret: "test-secret", + Scopes: []string{"repo"}, + } + + mgr := NewManager(cfg) + + assert.NotNil(t, mgr) + // Test observable behavior, not internal state + assert.False(t, mgr.HasToken()) + assert.Empty(t, mgr.GetAccessToken()) +} + +func TestManagerHasToken(t *testing.T) { + mgr := NewManager(Config{}) + + t.Run("no token initially", func(t *testing.T) { + assert.False(t, mgr.HasToken()) + }) + + t.Run("has token after setting", func(t *testing.T) { + mgr.setToken(&Result{ + AccessToken: "test-token", + TokenType: "Bearer", + }) + + assert.True(t, mgr.HasToken()) + }) + + t.Run("no token if empty access token", func(t *testing.T) { + mgr.setToken(&Result{ + AccessToken: "", + TokenType: "Bearer", + }) + + assert.False(t, mgr.HasToken()) + }) +} + +func TestManagerGetAccessToken(t *testing.T) { + mgr := NewManager(Config{}) + + t.Run("empty initially", func(t *testing.T) { + assert.Empty(t, mgr.GetAccessToken()) + }) + + t.Run("returns token after setting", func(t *testing.T) { + expectedToken := "gho_test123456" + mgr.setToken(&Result{ + AccessToken: expectedToken, + TokenType: "Bearer", + RefreshToken: "refresh-token", + Expiry: time.Now().Add(time.Hour), + }) + + assert.Equal(t, expectedToken, mgr.GetAccessToken()) + }) +} + +func TestManagerSetToken(t *testing.T) { + mgr := NewManager(Config{}) + + token := &Result{ + AccessToken: "test-access-token", + RefreshToken: "test-refresh-token", + TokenType: "Bearer", + Expiry: time.Now().Add(time.Hour), + } + + mgr.setToken(token) + + // Verify token is stored correctly + assert.Equal(t, token.AccessToken, mgr.GetAccessToken()) + assert.True(t, mgr.HasToken()) +} + +func TestGenerateRandomToken(t *testing.T) { + token1, err := generateRandomToken() + require.NoError(t, err) + require.NotEmpty(t, token1) + + // Token should be URL-safe base64 encoded + // 16 bytes of random data = ~22 chars in base64url + assert.GreaterOrEqual(t, len(token1), 20) + + // Each call should produce unique token + token2, err := generateRandomToken() + require.NoError(t, err) + assert.NotEqual(t, token1, token2) +} diff --git a/internal/oauth/templates/error.html b/internal/oauth/templates/error.html new file mode 100644 index 000000000..638252599 --- /dev/null +++ b/internal/oauth/templates/error.html @@ -0,0 +1,26 @@ + + + + + +Authorization Failed + + + + +
+ + + +

Authorization Failed

+
+ {{.ErrorMessage}} +
+

You can close this window.

+
+ + diff --git a/internal/oauth/templates/success.html b/internal/oauth/templates/success.html new file mode 100644 index 000000000..e05e3de29 --- /dev/null +++ b/internal/oauth/templates/success.html @@ -0,0 +1,26 @@ + + + + + +Authorization Successful + + + + +
+ + + +

Authorization Successful

+

You have successfully authorized the GitHub MCP Server.

+
+

You can close this window and retry your request.

+
+
+ + diff --git a/pkg/github/inventory.go b/pkg/github/inventory.go index 38c936d86..78accab84 100644 --- a/pkg/github/inventory.go +++ b/pkg/github/inventory.go @@ -16,3 +16,28 @@ func NewInventory(t translations.TranslationHelperFunc) *inventory.Builder { SetResources(AllResources(t)). SetPrompts(AllPrompts(t)) } + +// InventoryConfig holds configuration for building an inventory with standard filters. +// This struct enables consistent inventory building across the codebase. +type InventoryConfig struct { + Translator translations.TranslationHelperFunc + ReadOnly bool + Toolsets []string // nil = use defaults, empty = none + Tools []string // additional specific tools + EnabledFeatures []string // feature flags +} + +// NewStandardBuilder creates an inventory builder with all standard filters applied. +// This is the canonical way to create an inventory builder, ensuring consistency +// between OAuth scope computation, server initialization, and CLI tools. +// +// The returned builder can be further customized (e.g., WithFilter for scope filtering) +// before calling Build(). +func NewStandardBuilder(cfg InventoryConfig) *inventory.Builder { + return NewInventory(cfg.Translator). + WithDeprecatedAliases(DeprecatedToolAliases). + WithReadOnly(cfg.ReadOnly). + WithToolsets(cfg.Toolsets). + WithTools(cfg.Tools). + WithFeatureChecker(inventory.NewSliceFeatureChecker(cfg.EnabledFeatures)) +} diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index 533bba552..185f1e310 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -12,6 +12,18 @@ import ( // Returns (enabled, error). If error occurs, the caller should log and treat as false. type FeatureFlagChecker func(ctx context.Context, flagName string) (bool, error) +// NewSliceFeatureChecker creates a FeatureFlagChecker from a slice of enabled features. +// This is a simple implementation for CLI usage where features are specified via flags. +func NewSliceFeatureChecker(enabledFeatures []string) FeatureFlagChecker { + featureSet := make(map[string]bool, len(enabledFeatures)) + for _, f := range enabledFeatures { + featureSet[f] = true + } + return func(_ context.Context, flagName string) (bool, error) { + return featureSet[flagName], nil + } +} + // isToolsetEnabled checks if a toolset is enabled based on current filters. func (r *Inventory) isToolsetEnabled(toolsetID ToolsetID) bool { // Check enabled toolsets filter diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index fb4392fb9..1d09c4e5e 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -44,6 +44,7 @@ The following packages are included for the amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.30.0:LICENSE)) - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 564f20dcb..42518e475 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -44,6 +44,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.30.0:LICENSE)) - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 6b4dcfb97..8a4a6463d 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -45,6 +45,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [golang.org/x/exp](https://pkg.go.dev/golang.org/x/exp) ([BSD-3-Clause](https://cs.opensource.google/go/x/exp/+/8a7402ab:LICENSE)) - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.30.0:LICENSE)) - [golang.org/x/sys/windows](https://pkg.go.dev/golang.org/x/sys/windows) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) diff --git a/third-party/golang.org/x/oauth2/LICENSE b/third-party/golang.org/x/oauth2/LICENSE new file mode 100644 index 000000000..2a7cf70da --- /dev/null +++ b/third-party/golang.org/x/oauth2/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google LLC nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From c1268023e3f4fd446d1d4c1d48211409e099e639 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 26 Jan 2026 12:21:33 +0100 Subject: [PATCH 2/7] feat: add build-time OAuth credentials for seamless authentication - Add internal/buildinfo package for build-injected OAuth credentials - Update goreleaser and Dockerfile to inject credentials via ldflags - Update main.go to use baked-in credentials as fallback (PAT > explicit OAuth > baked-in) - Update GitHub Actions workflows to pass secrets to builds - Update documentation with new authentication flow --- .github/workflows/docker-publish.yml | 2 + .github/workflows/goreleaser.yml | 2 + .goreleaser.yaml | 2 +- Dockerfile | 4 +- cmd/github-mcp-server/main.go | 30 +++++++++- docs/oauth-authentication.md | 89 ++++++++++++++++++++++++---- internal/buildinfo/buildinfo.go | 31 ++++++++++ 7 files changed, 142 insertions(+), 18 deletions(-) create mode 100644 internal/buildinfo/buildinfo.go diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 43eca9fad..28abc0123 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -112,6 +112,8 @@ jobs: platforms: linux/amd64,linux/arm64 build-args: | VERSION=${{ github.ref_name }} + OAUTH_CLIENT_ID=${{ secrets.OAUTH_CLIENT_ID }} + OAUTH_CLIENT_SECRET=${{ secrets.OAUTH_CLIENT_SECRET }} # Sign the resulting Docker image digest except on PRs. # This will only write to the public Rekor transparency log when the Docker diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 167760cba..2984d0744 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -35,6 +35,8 @@ jobs: workdir: . env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }} + OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - name: Generate signed build provenance attestations for workflow artifacts uses: actions/attest-build-provenance@v3 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 54f6b9f40..36dfc47bc 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,7 +9,7 @@ builds: - env: - CGO_ENABLED=0 ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID={{ .Env.OAUTH_CLIENT_ID }} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret={{ .Env.OAUTH_CLIENT_SECRET }} goos: - linux - windows diff --git a/Dockerfile b/Dockerfile index 92ed52581..b6bdcbf08 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM golang:1.25.4-alpine AS build ARG VERSION="dev" +ARG OAUTH_CLIENT_ID="" +ARG OAUTH_CLIENT_SECRET="" # Set the working directory WORKDIR /build @@ -13,7 +15,7 @@ RUN --mount=type=cache,target=/var/cache/apk \ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=cache,target=/root/.cache/go-build \ --mount=type=bind,target=. \ - CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ) -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=${OAUTH_CLIENT_ID} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=${OAUTH_CLIENT_SECRET}" \ -o /bin/github-mcp-server cmd/github-mcp-server/main.go # Make a stage to run the app diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 33287461c..7a4afda62 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -8,6 +8,7 @@ import ( "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" @@ -72,9 +73,10 @@ var ( var oauthScopes []string var prebuiltInventory *inventory.Inventory - // If no token provided, setup OAuth manager if configured + // If no token provided, setup OAuth manager + // Priority: 1. Explicit OAuth config, 2. Build-time credentials, 3. None if token == "" { - oauthClientID := viper.GetString("oauth_client_id") + oauthClientID, oauthClientSecret := resolveOAuthCredentials() if oauthClientID != "" { // Get translation helper for inventory building t, _ := translations.TranslationHelper() @@ -87,7 +89,7 @@ var ( // Create OAuth manager for lazy authentication oauthCfg := oauth.GetGitHubOAuthConfig( oauthClientID, - viper.GetString("oauth_client_secret"), + oauthClientSecret, oauthScopes, viper.GetString("host"), viper.GetInt("oauth_callback_port"), @@ -274,3 +276,25 @@ func collectRequiredScopes(inv *inventory.Inventory) []string { return scopes } + +// resolveOAuthCredentials returns OAuth client credentials using the following priority: +// 1. Explicit configuration via flags/environment (--oauth-client-id, GITHUB_OAUTH_CLIENT_ID) +// 2. Build-time baked credentials (for official releases) +// +// This allows developers to override with their own OAuth app while providing +// a seamless "just works" experience for end users of official builds. +func resolveOAuthCredentials() (clientID, clientSecret string) { + // Priority 1: Explicit user configuration + clientID = viper.GetString("oauth_client_id") + if clientID != "" { + return clientID, viper.GetString("oauth_client_secret") + } + + // Priority 2: Build-time baked credentials + if buildinfo.HasOAuthCredentials() { + return buildinfo.OAuthClientID, buildinfo.OAuthClientSecret + } + + // No OAuth credentials available + return "", "" +} diff --git a/docs/oauth-authentication.md b/docs/oauth-authentication.md index 186d81727..0da142628 100644 --- a/docs/oauth-authentication.md +++ b/docs/oauth-authentication.md @@ -6,26 +6,80 @@ The GitHub MCP Server supports OAuth authentication for stdio mode, enabling int OAuth authentication allows users to authenticate with GitHub through their browser without pre-configuring a token. This is useful for: +- **End users** who want authentication to "just work" without configuration - **Interactive sessions** where users want to authenticate on-demand - **Docker deployments** where tokens shouldn't be baked into images - **Multi-user scenarios** where each user authenticates individually -## Configuration +## How It Works -### Required Environment Variables +Official releases of the GitHub MCP Server include built-in OAuth credentials, providing a seamless authentication experience. When you run the server without a PAT configured, it will automatically prompt for OAuth authentication when a tool requires it. + +### Authentication Priority + +The server uses the following priority for authentication: + +1. **Personal Access Token** (GITHUB_PERSONAL_ACCESS_TOKEN) - Highest priority, explicit user choice +2. **Explicit OAuth configuration** (--oauth-client-id flag/env) - Developer/power user override +3. **Built-in OAuth credentials** - Default for official releases, "just works" +4. **No authentication** - Warning displayed, tools will fail when called + +## Quick Start + +For most users, simply run the server without any configuration: + +```bash +# Official releases include built-in OAuth - just run and authenticate when prompted +./github-mcp-server stdio +``` + +The server will prompt for browser-based authentication when you first call a tool that requires GitHub access. + +## Developer Configuration + +Developers building from source or wanting to use their own OAuth app can provide credentials explicitly. + +### Environment Variables | Variable | Description | Required | |----------|-------------|----------| -| `GITHUB_OAUTH_CLIENT_ID` | OAuth app client ID | Yes | +| `GITHUB_OAUTH_CLIENT_ID` | OAuth app client ID | For custom OAuth apps | | `GITHUB_OAUTH_CLIENT_SECRET` | OAuth app client secret | Recommended | -### Optional Flags +### Command Line Flags | Flag | Environment Variable | Description | |------|---------------------|-------------| +| `--oauth-client-id` | `GITHUB_OAUTH_CLIENT_ID` | Override OAuth app client ID | +| `--oauth-client-secret` | `GITHUB_OAUTH_CLIENT_SECRET` | Override OAuth app client secret | | `--oauth-callback-port` | `GITHUB_OAUTH_CALLBACK_PORT` | Fixed port for OAuth callback (required for Docker with `-p` flag) | | `--oauth-scopes` | `GITHUB_OAUTH_SCOPES` | Custom OAuth scopes (comma-separated) | +### Building with Custom OAuth Credentials + +When building from source, you can bake in your own OAuth credentials: + +```bash +# Build with custom OAuth credentials +go build -ldflags="-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=your-client-id \ + -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=your-client-secret" \ + ./cmd/github-mcp-server + +# Or use environment variables during development +export GITHUB_OAUTH_CLIENT_ID="your-client-id" +export GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" +./github-mcp-server stdio +``` + +For Docker builds: + +```bash +docker build \ + --build-arg OAUTH_CLIENT_ID="your-client-id" \ + --build-arg OAUTH_CLIENT_SECRET="your-client-secret" \ + -t github-mcp-server . +``` + ## Authentication Flows The server automatically selects the appropriate OAuth flow based on the environment: @@ -52,18 +106,31 @@ Used when running in Docker or when a browser cannot be opened: ## Usage Examples -### Local Binary +### Local Binary (Official Release) ```bash -# Set OAuth credentials +# Official releases have built-in OAuth - just run! +./github-mcp-server stdio +# Authentication will be prompted when a tool is called +``` + +### Local Binary (Custom OAuth) + +```bash +# Override with your own OAuth app export GITHUB_OAUTH_CLIENT_ID="your-client-id" export GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" - -# Run without PAT - OAuth will trigger when tools are called ./github-mcp-server stdio ``` -### Docker (with Device Flow) +### Docker (Official Image) + +```bash +# Official images have built-in OAuth - just run! +docker run -i --rm ghcr.io/github/github-mcp-server stdio +``` + +### Docker (with Custom OAuth) ```bash docker run -i --rm \ @@ -77,8 +144,6 @@ docker run -i --rm \ ```bash docker run -i --rm \ --network=host \ - -e GITHUB_OAUTH_CLIENT_ID="your-client-id" \ - -e GITHUB_OAUTH_CLIENT_SECRET="your-client-secret" \ ghcr.io/github/github-mcp-server stdio --oauth-callback-port=8085 ``` @@ -91,8 +156,6 @@ docker run -i --rm \ "command": "docker", "args": [ "run", "-i", "--rm", - "-e", "GITHUB_OAUTH_CLIENT_ID=your-client-id", - "-e", "GITHUB_OAUTH_CLIENT_SECRET=your-client-secret", "ghcr.io/github/github-mcp-server", "stdio" ], diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go new file mode 100644 index 000000000..9128786d6 --- /dev/null +++ b/internal/buildinfo/buildinfo.go @@ -0,0 +1,31 @@ +// Package buildinfo contains build-time injected values. +// +// These values are set via -ldflags during the build process. +// For example: +// +// go build -ldflags="-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=xxx" +// +// The OAuth credentials are used as default values for stdio mode when no +// PAT or explicit OAuth configuration is provided. This enables a "just works" +// experience for most users while still allowing developer overrides. +// +// Note: These credentials are intentionally baked into the binary. While they +// can be reverse-engineered, this provides a barrier against trivial cloning +// and establishes clear provenance for the official GitHub MCP Server. +package buildinfo + +// OAuthClientID is the default GitHub OAuth App Client ID. +// Set at build time via -ldflags for official releases. +// Empty string means no default OAuth credentials are available. +var OAuthClientID string + +// OAuthClientSecret is the default GitHub OAuth App Client Secret. +// Set at build time via -ldflags for official releases. +// While called a "secret", OAuth client secrets in native apps cannot truly +// be kept secret and are considered public per RFC 8252. +var OAuthClientSecret string + +// HasOAuthCredentials returns true if build-time OAuth credentials are available. +func HasOAuthCredentials() bool { + return OAuthClientID != "" +} From a233bc4c74f4c5721b56eb23941f89df2e7587ba Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 26 Jan 2026 14:21:08 +0100 Subject: [PATCH 3/7] fix: address PR review feedback - Add runtime.GOOS check to Docker detection (macOS/Windows compatibility) - Use cancellable contexts for elicitation goroutines (pollCtx, elicitCtx) - Mark listener.Close() error as intentionally ignored in cleanup - Remove redundant oauthMgr.GetAccessToken() check (always empty in lazy auth) - Inline CSS in OAuth templates (remove CDN dependency) - Add documentation to Result struct about token refresh behavior --- cmd/github-mcp-server/main.go | 5 -- .../2026-01-intelligent-scope-features.md | 78 +++++++++++++++++++ internal/oauth/manager.go | 15 +++- internal/oauth/oauth.go | 20 ++++- internal/oauth/templates/error.html | 56 ++++++++++--- internal/oauth/templates/success.html | 52 ++++++++++--- 6 files changed, 191 insertions(+), 35 deletions(-) create mode 100644 docs/changelog/2026-01-intelligent-scope-features.md diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 7a4afda62..9973fcd7e 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -104,11 +104,6 @@ var ( } } - // Extract token from OAuth manager if available - if oauthMgr != nil && token == "" { - token = oauthMgr.GetAccessToken() - } - ttl := viper.GetDuration("repo-access-cache-ttl") stdioServerConfig := ghmcp.StdioServerConfig{ Version: version, diff --git a/docs/changelog/2026-01-intelligent-scope-features.md b/docs/changelog/2026-01-intelligent-scope-features.md new file mode 100644 index 000000000..19e3852ea --- /dev/null +++ b/docs/changelog/2026-01-intelligent-scope-features.md @@ -0,0 +1,78 @@ +--- +title: "Intelligent Scope Features" +date: 2026-01 +description: "OAuth scope challenges, automatic PAT filtering, and comprehensive scope documentation for smarter authentication" +category: feature +--- + +# Intelligent Scope Features + +GitHub MCP Server now intelligently handles OAuth scopes—filtering tools based on your permissions and enabling dynamic scope requests when needed. + +## What's New + +### OAuth Scope Challenges + +The server now implements [MCP scope challenge handling](https://modelcontextprotocol.io/specification/2025-11-05/basic/authorization#scope-challenge-handling). Instead of failing when you lack a required scope, it requests additional permissions dynamically—start with minimal permissions and expand them as needed. + +### PAT Scope Filtering + +For classic Personal Access Tokens (`ghp_`), tools are automatically filtered based on your token's scopes. The server discovers your scopes at startup and hides tools you can't use. + +**Example:** If your PAT only has `repo` and `gist` scopes, tools requiring `admin:org`, `project`, or `notifications` are hidden. + +### Server-to-Server Token Handling + +For server-to-server tokens (like `GITHUB_TOKEN` in Actions), the server hides user-context tools like `get_me` that don't apply without a human user. + +### Documented OAuth Scopes + +Every MCP tool now documents its required and accepted OAuth scopes in the README and tool metadata. + +### New `list-scopes` Command + +Discover what scopes your toolsets need: + +```bash +github-mcp-server list-scopes --output=summary +github-mcp-server list-scopes --toolsets=all --output=json +``` + +## Scope Hierarchy + +The server understands GitHub's scope hierarchy, so parent scopes satisfy child scope requirements: + +| Parent Scope | Covers | +|-------------|--------| +| `repo` | `public_repo`, `security_events` | +| `admin:org` | `write:org`, `read:org` | +| `project` | `read:project` | +| `write:org` | `read:org` | + +If a tool requires `read:org` and your token has `admin:org`, the tool is available. + +## Authentication Comparison + +| Authentication Method | Scope Handling | +|----------------------|----------------| +| **OAuth** (remote server) | Scope challenges — request permissions on-demand | +| **Classic PAT** (`ghp_`) | Automatic filtering — hide unavailable tools | +| **Fine-grained PAT** (`github_pat_`) | No filtering — fine-grained permissions, not OAuth scopes | +| **GitHub App** (`ghs_`) | No filtering — fine-grained permissions, not OAuth scopes | +| **Server-to-Server** (`GITHUB_TOKEN`) | User tools hidden — no user context available | + +## Getting Started + +**OAuth users:** No action required—scope challenges work automatically. + +**PAT users:** Run `list-scopes` to discover required scopes, create a PAT at [github.com/settings/tokens](https://github.com/settings/tokens), and start the server. + +## Related Documentation + +- [PAT Scope Filtering Guide](https://github.com/github/github-mcp-server/blob/v0.29.0/docs/scope-filtering.md) +- [OAuth Authentication Guide](https://github.com/github/github-mcp-server/blob/v0.29.0/docs/oauth-authentication.md) +- [Server Configuration](https://github.com/github/github-mcp-server/blob/v0.29.0/docs/server-configuration.md) + +## Feedback + +Share your experience in the [Scope filtering/challenging discussion](https://github.com/github/github-mcp-server/discussions/1802). We're exploring ways to better support fine-grained permissions in the future. diff --git a/internal/oauth/manager.go b/internal/oauth/manager.go index e56037174..8705e8b59 100644 --- a/internal/oauth/manager.go +++ b/internal/oauth/manager.go @@ -124,9 +124,11 @@ func (m *Manager) startDeviceFlowWithElicitation(ctx context.Context, session *m go func() { elicitID, err := generateRandomToken() if err != nil { + // Non-critical: use fallback ID if generation fails elicitID = "fallback-id" } - result, err := session.Elicit(ctx, &mcp.ElicitParams{ + // Use pollCtx so elicitation is cancelled when polling completes or is cancelled + result, err := session.Elicit(pollCtx, &mcp.ElicitParams{ Mode: "url", URL: deviceAuth.VerificationURI, ElicitationID: elicitID, @@ -209,7 +211,7 @@ func (m *Manager) startPKCEFlowWithElicitation(ctx context.Context, session *mcp shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() _ = server.Shutdown(shutdownCtx) - listener.Close() + _ = listener.Close() // Error intentionally ignored in cleanup } // Try to open browser - if it works, no elicitation needed @@ -218,13 +220,18 @@ func (m *Manager) startPKCEFlowWithElicitation(ctx context.Context, session *mcp // Channel to signal elicitation cancellation elicitCancelChan := make(chan struct{}, 1) + // Create cancellable context for elicitation + elicitCtx, cancelElicit := context.WithCancel(ctx) + defer cancelElicit() + // Only elicit if browser failed to open (e.g., headless environment) // and we need to show the user the URL manually if browserErr != nil && session != nil { // Run elicitation in goroutine so we can monitor callback in parallel go func() { - elicitID, _ := generateRandomToken() - result, err := session.Elicit(ctx, &mcp.ElicitParams{ + elicitID, _ := generateRandomToken() // Non-critical: empty ID is acceptable + // Use elicitCtx so elicitation is cancelled when auth completes + result, err := session.Elicit(elicitCtx, &mcp.ElicitParams{ Mode: "url", URL: authURL, ElicitationID: elicitID, diff --git a/internal/oauth/oauth.go b/internal/oauth/oauth.go index 8848fe2f7..b6ac0ecff 100644 --- a/internal/oauth/oauth.go +++ b/internal/oauth/oauth.go @@ -54,12 +54,16 @@ type Config struct { CallbackPort int // Fixed callback port (0 for random) } -// Result contains the OAuth flow result +// Result contains the OAuth flow result. +// +// Note: This implementation does not currently perform automatic token refresh. +// GitHub OAuth tokens for OAuth Apps do not expire, but GitHub Apps tokens do. +// Callers should handle re-authentication when API calls fail with auth errors. type Result struct { AccessToken string - RefreshToken string + RefreshToken string // Captured but not currently used for automatic refresh TokenType string - Expiry time.Time + Expiry time.Time // Zero value if token does not expire } // generatePKCEVerifier generates a PKCE code verifier @@ -74,8 +78,16 @@ func generatePKCEVerifier() (string, error) { return verifier, nil } -// isRunningInDocker detects if the process is running inside a Docker container +// isRunningInDocker detects if the process is running inside a Docker container. +// This detection is used to determine whether to use device flow (no browser available) +// or PKCE flow (browser can be opened). On non-Linux systems, this always returns false +// since the detection relies on Linux-specific paths. func isRunningInDocker() bool { + // Docker detection only works on Linux where /proc filesystem exists + if runtime.GOOS != "linux" { + return false + } + // Check for .dockerenv file (most common indicator) if _, err := os.Stat("/.dockerenv"); err == nil { return true diff --git a/internal/oauth/templates/error.html b/internal/oauth/templates/error.html index 638252599..9d8146159 100644 --- a/internal/oauth/templates/error.html +++ b/internal/oauth/templates/error.html @@ -1,26 +1,60 @@ - + Authorization Failed - - -
- + +
+ -

Authorization Failed

-
- {{.ErrorMessage}} +

Authorization Failed

+
+ {{.ErrorMessage}}
-

You can close this window.

+

You can close this window.

diff --git a/internal/oauth/templates/success.html b/internal/oauth/templates/success.html index e05e3de29..f3bcdadc9 100644 --- a/internal/oauth/templates/success.html +++ b/internal/oauth/templates/success.html @@ -1,25 +1,55 @@ - + Authorization Successful - - -
- + +
+ -

Authorization Successful

-

You have successfully authorized the GitHub MCP Server.

-
-

You can close this window and retry your request.

+

Authorization Successful

+

You have successfully authorized the GitHub MCP Server.

+
+

You can close this window and retry your request.

From bdf2045d7a5cf61d0e2f3eab633965b516099cd5 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 26 Jan 2026 23:47:50 +0100 Subject: [PATCH 4/7] feat: add manual Docker publish with ref/tag inputs - Add workflow_dispatch inputs for ref (branch/tag/SHA) and custom tag - Checkout specified ref for manual builds - Include OAuth credentials in all Docker builds - Allow disabling push for build-only testing --- .github/workflows/docker-publish.yml | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 28abc0123..6be9f5d45 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -16,11 +16,21 @@ on: branches: ["main", "next"] workflow_dispatch: inputs: - description: - required: false - description: "Description of the run." - type: string - default: "Manual run" + ref: + description: "Git ref to build (branch, tag, or SHA). Leave empty for default branch." + required: false + type: string + default: "" + tag: + description: "Custom image tag (e.g., 'test-oauth', 'pr-1836'). Required for manual runs." + required: false + type: string + default: "" + push: + description: "Push to registry" + required: false + type: boolean + default: true env: # Use docker.io for Docker Hub if empty @@ -41,6 +51,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + ref: ${{ inputs.ref || github.ref }} # Install the cosign tool except on PR # https://github.com/sigstore/cosign-installer @@ -83,6 +95,8 @@ jobs: type=semver,pattern={{major}} type=sha type=edge + # Custom tag for manual workflow dispatch + type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} # Custom rule to prevent pre-releases from getting latest tag type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} @@ -104,14 +118,14 @@ jobs: uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 with: context: . - push: ${{ github.event_name != 'pull_request' }} + push: ${{ github.event_name != 'pull_request' && (github.event_name != 'workflow_dispatch' || inputs.push) }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max platforms: linux/amd64,linux/arm64 build-args: | - VERSION=${{ github.ref_name }} + VERSION=${{ inputs.ref || github.ref_name }} OAUTH_CLIENT_ID=${{ secrets.OAUTH_CLIENT_ID }} OAUTH_CLIENT_SECRET=${{ secrets.OAUTH_CLIENT_SECRET }} From 554e0fbdd426b67e054d6c196b80f02fbb424df9 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 26 Jan 2026 23:48:26 +0100 Subject: [PATCH 5/7] fix: manual Docker builds only get SHA + custom tag Disable branch/tag/semver/edge/latest tags for workflow_dispatch to ensure manual test builds don't pollute release tags. --- .github/workflows/docker-publish.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6be9f5d45..ca234991e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -88,17 +88,17 @@ jobs: tags: | type=schedule type=ref,event=branch - type=ref,event=tag + type=ref,event=tag,enable=${{ github.event_name != 'workflow_dispatch' }} type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} + type=semver,pattern={{version}},enable=${{ github.event_name != 'workflow_dispatch' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name != 'workflow_dispatch' }} + type=semver,pattern={{major}},enable=${{ github.event_name != 'workflow_dispatch' }} type=sha - type=edge + type=edge,enable=${{ github.event_name != 'workflow_dispatch' }} # Custom tag for manual workflow dispatch type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} # Custom rule to prevent pre-releases from getting latest tag - type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} + type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' && github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} - name: Go Build Cache for Docker uses: actions/cache@v5 From aa6afa68c95575a87e7a2dbd50af382622379cec Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 27 Jan 2026 00:21:42 +0100 Subject: [PATCH 6/7] fix: prevent nil pointer panic when OAuthManager interface contains nil Go interface nil check fails when a typed nil pointer is assigned to an interface - the interface itself is non-nil but contains a nil value. This caused a panic when OAuth credentials weren't configured. Fix by only assigning OAuthManager after confirming oauthMgr is not nil. --- cmd/github-mcp-server/main.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 9973fcd7e..1c8688c5f 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -109,7 +109,6 @@ var ( Version: version, Host: viper.GetString("host"), Token: token, - OAuthManager: oauthMgr, OAuthScopes: oauthScopes, PrebuiltInventory: prebuiltInventory, EnabledToolsets: enabledToolsets, @@ -125,6 +124,11 @@ var ( InsiderMode: viper.GetBool("insider-mode"), RepoAccessCacheTTL: &ttl, } + // Only set OAuthManager if not nil - interface nil check requires this pattern + // to avoid a non-nil interface containing a nil pointer + if oauthMgr != nil { + stdioServerConfig.OAuthManager = oauthMgr + } return ghmcp.RunStdioServer(stdioServerConfig) }, } From 0ac2d65381d585df3551caed816c0ae04f0b4769 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 27 Jan 2026 00:57:37 +0100 Subject: [PATCH 7/7] Delete docs/changelog/2026-01-intelligent-scope-features.md --- .../2026-01-intelligent-scope-features.md | 78 ------------------- 1 file changed, 78 deletions(-) delete mode 100644 docs/changelog/2026-01-intelligent-scope-features.md diff --git a/docs/changelog/2026-01-intelligent-scope-features.md b/docs/changelog/2026-01-intelligent-scope-features.md deleted file mode 100644 index 19e3852ea..000000000 --- a/docs/changelog/2026-01-intelligent-scope-features.md +++ /dev/null @@ -1,78 +0,0 @@ ---- -title: "Intelligent Scope Features" -date: 2026-01 -description: "OAuth scope challenges, automatic PAT filtering, and comprehensive scope documentation for smarter authentication" -category: feature ---- - -# Intelligent Scope Features - -GitHub MCP Server now intelligently handles OAuth scopes—filtering tools based on your permissions and enabling dynamic scope requests when needed. - -## What's New - -### OAuth Scope Challenges - -The server now implements [MCP scope challenge handling](https://modelcontextprotocol.io/specification/2025-11-05/basic/authorization#scope-challenge-handling). Instead of failing when you lack a required scope, it requests additional permissions dynamically—start with minimal permissions and expand them as needed. - -### PAT Scope Filtering - -For classic Personal Access Tokens (`ghp_`), tools are automatically filtered based on your token's scopes. The server discovers your scopes at startup and hides tools you can't use. - -**Example:** If your PAT only has `repo` and `gist` scopes, tools requiring `admin:org`, `project`, or `notifications` are hidden. - -### Server-to-Server Token Handling - -For server-to-server tokens (like `GITHUB_TOKEN` in Actions), the server hides user-context tools like `get_me` that don't apply without a human user. - -### Documented OAuth Scopes - -Every MCP tool now documents its required and accepted OAuth scopes in the README and tool metadata. - -### New `list-scopes` Command - -Discover what scopes your toolsets need: - -```bash -github-mcp-server list-scopes --output=summary -github-mcp-server list-scopes --toolsets=all --output=json -``` - -## Scope Hierarchy - -The server understands GitHub's scope hierarchy, so parent scopes satisfy child scope requirements: - -| Parent Scope | Covers | -|-------------|--------| -| `repo` | `public_repo`, `security_events` | -| `admin:org` | `write:org`, `read:org` | -| `project` | `read:project` | -| `write:org` | `read:org` | - -If a tool requires `read:org` and your token has `admin:org`, the tool is available. - -## Authentication Comparison - -| Authentication Method | Scope Handling | -|----------------------|----------------| -| **OAuth** (remote server) | Scope challenges — request permissions on-demand | -| **Classic PAT** (`ghp_`) | Automatic filtering — hide unavailable tools | -| **Fine-grained PAT** (`github_pat_`) | No filtering — fine-grained permissions, not OAuth scopes | -| **GitHub App** (`ghs_`) | No filtering — fine-grained permissions, not OAuth scopes | -| **Server-to-Server** (`GITHUB_TOKEN`) | User tools hidden — no user context available | - -## Getting Started - -**OAuth users:** No action required—scope challenges work automatically. - -**PAT users:** Run `list-scopes` to discover required scopes, create a PAT at [github.com/settings/tokens](https://github.com/settings/tokens), and start the server. - -## Related Documentation - -- [PAT Scope Filtering Guide](https://github.com/github/github-mcp-server/blob/v0.29.0/docs/scope-filtering.md) -- [OAuth Authentication Guide](https://github.com/github/github-mcp-server/blob/v0.29.0/docs/oauth-authentication.md) -- [Server Configuration](https://github.com/github/github-mcp-server/blob/v0.29.0/docs/server-configuration.md) - -## Feedback - -Share your experience in the [Scope filtering/challenging discussion](https://github.com/github/github-mcp-server/discussions/1802). We're exploring ways to better support fine-grained permissions in the future.