Skip to content

Commit 6044f36

Browse files
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
1 parent 7c053f1 commit 6044f36

File tree

17 files changed

+1356
-67
lines changed

17 files changed

+1356
-67
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,37 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:
186186

187187
</details>
188188

189+
### OAuth Authentication (stdio mode)
190+
191+
For stdio mode, you can use OAuth 2.1 instead of a Personal Access Token. The server automatically selects the appropriate flow:
192+
193+
| Environment | Flow | Setup |
194+
|-------------|------|-------|
195+
| Native binary | PKCE (browser auto-opens) | Just set `GITHUB_OAUTH_CLIENT_ID` |
196+
| Docker | Device flow (enter code at github.com/login/device) | Just set `GITHUB_OAUTH_CLIENT_ID` |
197+
| Docker with port | PKCE (browser auto-opens) | Set `GITHUB_OAUTH_CALLBACK_PORT` and bind port |
198+
199+
**Example MCP configuration (Docker with device flow):**
200+
```json
201+
{
202+
"mcpServers": {
203+
"github": {
204+
"command": "docker",
205+
"args": ["run", "-i", "--rm",
206+
"-e", "GITHUB_OAUTH_CLIENT_ID",
207+
"-e", "GITHUB_OAUTH_CLIENT_SECRET",
208+
"ghcr.io/github/github-mcp-server"],
209+
"env": {
210+
"GITHUB_OAUTH_CLIENT_ID": "your_client_id",
211+
"GITHUB_OAUTH_CLIENT_SECRET": "your_client_secret"
212+
}
213+
}
214+
}
215+
}
216+
```
217+
218+
See [docs/oauth-authentication.md](docs/oauth-authentication.md) for full setup instructions, including how to create a GitHub OAuth App.
219+
189220
### GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com)
190221

191222
The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set

cmd/github-mcp-server/list_scopes.go

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -101,27 +101,28 @@ func runListScopes() error {
101101
}
102102
}
103103

104+
// Get enabled features (similar to toolsets)
105+
var enabledFeatures []string
106+
if viper.IsSet("features") {
107+
if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil {
108+
return fmt.Errorf("failed to unmarshal features: %w", err)
109+
}
110+
}
111+
104112
readOnly := viper.GetBool("read-only")
105113
outputFormat := viper.GetString("list-scopes-output")
106114

107115
// Create translation helper
108116
t, _ := translations.TranslationHelper()
109117

110-
// Build inventory using the same logic as the stdio server
111-
inventoryBuilder := github.NewInventory(t).
112-
WithReadOnly(readOnly)
113-
114-
// Configure toolsets (same as stdio)
115-
if enabledToolsets != nil {
116-
inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets)
117-
}
118-
119-
// Configure specific tools
120-
if len(enabledTools) > 0 {
121-
inventoryBuilder = inventoryBuilder.WithTools(enabledTools)
122-
}
123-
124-
inv, err := inventoryBuilder.Build()
118+
// Build inventory using the shared builder for consistency
119+
inv, err := github.NewStandardBuilder(github.InventoryConfig{
120+
Translator: t,
121+
ReadOnly: readOnly,
122+
Toolsets: enabledToolsets,
123+
Tools: enabledTools,
124+
EnabledFeatures: enabledFeatures,
125+
}).Build()
125126
if err != nil {
126127
return fmt.Errorf("failed to build inventory: %w", err)
127128
}

cmd/github-mcp-server/main.go

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
package main
22

33
import (
4-
"errors"
4+
"context"
55
"fmt"
66
"os"
7+
"sort"
78
"strings"
89
"time"
910

1011
"github.com/github/github-mcp-server/internal/ghmcp"
12+
"github.com/github/github-mcp-server/internal/oauth"
1113
"github.com/github/github-mcp-server/pkg/github"
14+
"github.com/github/github-mcp-server/pkg/inventory"
15+
"github.com/github/github-mcp-server/pkg/translations"
1216
"github.com/spf13/cobra"
1317
"github.com/spf13/pflag"
1418
"github.com/spf13/viper"
@@ -32,11 +36,6 @@ var (
3236
Short: "Start stdio server",
3337
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
3438
RunE: func(_ *cobra.Command, _ []string) error {
35-
token := viper.GetString("personal_access_token")
36-
if token == "" {
37-
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
38-
}
39-
4039
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
4140
// it's because viper doesn't handle comma-separated values correctly for env
4241
// vars when using GetStringSlice.
@@ -68,11 +67,54 @@ var (
6867
}
6968
}
7069

70+
token := viper.GetString("personal_access_token")
71+
var oauthMgr *oauth.Manager
72+
var oauthScopes []string
73+
var prebuiltInventory *inventory.Inventory
74+
75+
// If no token provided, setup OAuth manager if configured
76+
if token == "" {
77+
oauthClientID := viper.GetString("oauth_client_id")
78+
if oauthClientID != "" {
79+
// Get translation helper for inventory building
80+
t, _ := translations.TranslationHelper()
81+
82+
// Compute OAuth scopes and get inventory (avoids double building)
83+
scopesResult := getOAuthScopes(enabledToolsets, enabledTools, enabledFeatures, t)
84+
oauthScopes = scopesResult.scopes
85+
prebuiltInventory = scopesResult.inventory
86+
87+
// Create OAuth manager for lazy authentication
88+
oauthCfg := oauth.GetGitHubOAuthConfig(
89+
oauthClientID,
90+
viper.GetString("oauth_client_secret"),
91+
oauthScopes,
92+
viper.GetString("host"),
93+
viper.GetInt("oauth_callback_port"),
94+
)
95+
oauthMgr = oauth.NewManager(oauthCfg)
96+
fmt.Fprintf(os.Stderr, "OAuth configured - will prompt for authentication when needed\n")
97+
} else {
98+
fmt.Fprintf(os.Stderr, "Warning: No authentication configured\n")
99+
fmt.Fprintf(os.Stderr, " - Set GITHUB_PERSONAL_ACCESS_TOKEN, or\n")
100+
fmt.Fprintf(os.Stderr, " - Configure OAuth with --oauth-client-id\n")
101+
fmt.Fprintf(os.Stderr, "Tools will prompt for authentication when called\n")
102+
}
103+
}
104+
105+
// Extract token from OAuth manager if available
106+
if oauthMgr != nil && token == "" {
107+
token = oauthMgr.GetAccessToken()
108+
}
109+
71110
ttl := viper.GetDuration("repo-access-cache-ttl")
72111
stdioServerConfig := ghmcp.StdioServerConfig{
73112
Version: version,
74113
Host: viper.GetString("host"),
75114
Token: token,
115+
OAuthManager: oauthMgr,
116+
OAuthScopes: oauthScopes,
117+
PrebuiltInventory: prebuiltInventory,
76118
EnabledToolsets: enabledToolsets,
77119
EnabledTools: enabledTools,
78120
EnabledFeatures: enabledFeatures,
@@ -112,6 +154,12 @@ func init() {
112154
rootCmd.PersistentFlags().Bool("insider-mode", false, "Enable insider features")
113155
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
114156

157+
// OAuth flags (stdio mode only)
158+
rootCmd.PersistentFlags().String("oauth-client-id", "", "GitHub OAuth app client ID (enables interactive OAuth flow if token not set)")
159+
rootCmd.PersistentFlags().String("oauth-client-secret", "", "GitHub OAuth app client secret (recommended)")
160+
rootCmd.PersistentFlags().StringSlice("oauth-scopes", nil, "OAuth scopes to request (comma-separated)")
161+
rootCmd.PersistentFlags().Int("oauth-callback-port", 0, "Fixed port for OAuth callback (0 for random, required for Docker with -p flag)")
162+
115163
// Bind flag to viper
116164
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
117165
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
@@ -126,6 +174,10 @@ func init() {
126174
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
127175
_ = viper.BindPFlag("insider-mode", rootCmd.PersistentFlags().Lookup("insider-mode"))
128176
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
177+
_ = viper.BindPFlag("oauth_client_id", rootCmd.PersistentFlags().Lookup("oauth-client-id"))
178+
_ = viper.BindPFlag("oauth_client_secret", rootCmd.PersistentFlags().Lookup("oauth-client-secret"))
179+
_ = viper.BindPFlag("oauth_scopes", rootCmd.PersistentFlags().Lookup("oauth-scopes"))
180+
_ = viper.BindPFlag("oauth_callback_port", rootCmd.PersistentFlags().Lookup("oauth-callback-port"))
129181

130182
// Add subcommands
131183
rootCmd.AddCommand(stdioCmd)
@@ -154,3 +206,71 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
154206
}
155207
return pflag.NormalizedName(name)
156208
}
209+
210+
// oauthScopesResult holds the result of OAuth scope computation
211+
type oauthScopesResult struct {
212+
scopes []string
213+
inventory *inventory.Inventory // reused inventory to avoid double building
214+
}
215+
216+
// getOAuthScopes returns the OAuth scopes to request based on enabled tools
217+
// Also returns the built inventory to avoid building it twice
218+
// Uses custom scopes if explicitly provided, otherwise computes required scopes
219+
// from the tools that will be enabled based on user configuration
220+
func getOAuthScopes(enabledToolsets, enabledTools, enabledFeatures []string, t translations.TranslationHelperFunc) oauthScopesResult {
221+
// Allow explicit override via --oauth-scopes flag
222+
var scopeList []string
223+
if viper.IsSet("oauth_scopes") {
224+
if err := viper.UnmarshalKey("oauth_scopes", &scopeList); err == nil && len(scopeList) > 0 {
225+
// When scopes are explicit, don't build inventory (will be built in server)
226+
return oauthScopesResult{scopes: scopeList}
227+
}
228+
}
229+
230+
// Build inventory with the same configuration that will be used at runtime
231+
// This allows us to determine which tools will actually be available
232+
// and avoids building the inventory twice
233+
inventoryBuilder := github.NewStandardBuilder(github.InventoryConfig{
234+
Translator: t,
235+
ReadOnly: viper.GetBool("read-only"),
236+
Toolsets: enabledToolsets,
237+
Tools: enabledTools,
238+
EnabledFeatures: enabledFeatures,
239+
})
240+
241+
inv, err := inventoryBuilder.Build()
242+
if err != nil {
243+
// Inventory build only fails if invalid tool names are passed via --tools
244+
// In that case, return empty scopes - the error will surface when server starts
245+
return oauthScopesResult{scopes: nil}
246+
}
247+
248+
// Collect all required scopes from available tools
249+
// This is the canonical source of OAuth scopes for the enabled tools
250+
requiredScopes := collectRequiredScopes(inv)
251+
return oauthScopesResult{scopes: requiredScopes, inventory: inv}
252+
}
253+
254+
// collectRequiredScopes collects all unique required scopes from available tools
255+
// Returns a sorted, deduplicated list of OAuth scopes needed for the enabled tools
256+
func collectRequiredScopes(inv *inventory.Inventory) []string {
257+
scopeSet := make(map[string]bool)
258+
259+
// Get available tools (respects filters like read-only, toolsets, etc.)
260+
for _, tool := range inv.AvailableTools(context.Background()) {
261+
for _, scope := range tool.RequiredScopes {
262+
if scope != "" {
263+
scopeSet[scope] = true
264+
}
265+
}
266+
}
267+
268+
// Convert to sorted slice for deterministic output
269+
scopes := make([]string, 0, len(scopeSet))
270+
for scope := range scopeSet {
271+
scopes = append(scopes, scope)
272+
}
273+
sort.Strings(scopes)
274+
275+
return scopes
276+
}

0 commit comments

Comments
 (0)