Skip to content

Commit 13f9216

Browse files
Add OAuth 2.1 PKCE flow for stdio mode
- Create internal/oauth package with standard OAuth implementation - Use golang.org/x/oauth2 for PKCE flow with S256 challenge - Support interactive browser-based authorization - Add CLI flags: --oauth-client-id, --oauth-client-secret, --oauth-scopes - Respect --gh-host for GHES/GHEC OAuth endpoints - Secure implementation with ReadHeaderTimeout, state validation - Comprehensive tests and linting passing Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com>
1 parent 361bcb8 commit 13f9216

File tree

4 files changed

+433
-3
lines changed

4 files changed

+433
-3
lines changed

cmd/github-mcp-server/main.go

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
package main
22

33
import (
4+
"context"
45
"errors"
56
"fmt"
67
"os"
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"
1214
"github.com/spf13/cobra"
1315
"github.com/spf13/pflag"
@@ -33,8 +35,29 @@ var (
3335
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
3436
RunE: func(_ *cobra.Command, _ []string) error {
3537
token := viper.GetString("personal_access_token")
38+
39+
// If no token provided, check if OAuth is configured
3640
if token == "" {
37-
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
41+
oauthClientID := viper.GetString("oauth_client_id")
42+
if oauthClientID != "" {
43+
// Perform interactive OAuth flow
44+
ctx := context.Background()
45+
oauthCfg := oauth.GetGitHubOAuthConfig(
46+
oauthClientID,
47+
viper.GetString("oauth_client_secret"),
48+
getOAuthScopes(),
49+
viper.GetString("host"), // Pass the gh-host configuration
50+
)
51+
52+
result, err := oauth.StartInteractiveFlow(ctx, oauthCfg)
53+
if err != nil {
54+
return fmt.Errorf("OAuth flow failed: %w", err)
55+
}
56+
57+
token = result.AccessToken
58+
} else {
59+
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set and OAuth not configured (set GITHUB_OAUTH_CLIENT_ID)")
60+
}
3861
}
3962

4063
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
@@ -112,6 +135,11 @@ func init() {
112135
rootCmd.PersistentFlags().Bool("insider-mode", false, "Enable insider features")
113136
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
114137

138+
// OAuth flags (stdio mode only)
139+
rootCmd.PersistentFlags().String("oauth-client-id", "", "GitHub OAuth app client ID (enables interactive OAuth flow if token not set)")
140+
rootCmd.PersistentFlags().String("oauth-client-secret", "", "GitHub OAuth app client secret (optional for public clients with PKCE)")
141+
rootCmd.PersistentFlags().StringSlice("oauth-scopes", nil, "OAuth scopes to request (comma-separated)")
142+
115143
// Bind flag to viper
116144
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
117145
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
@@ -126,6 +154,9 @@ func init() {
126154
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
127155
_ = viper.BindPFlag("insider-mode", rootCmd.PersistentFlags().Lookup("insider-mode"))
128156
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
157+
_ = viper.BindPFlag("oauth_client_id", rootCmd.PersistentFlags().Lookup("oauth-client-id"))
158+
_ = viper.BindPFlag("oauth_client_secret", rootCmd.PersistentFlags().Lookup("oauth-client-secret"))
159+
_ = viper.BindPFlag("oauth_scopes", rootCmd.PersistentFlags().Lookup("oauth-scopes"))
129160

130161
// Add subcommands
131162
rootCmd.AddCommand(stdioCmd)
@@ -154,3 +185,25 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
154185
}
155186
return pflag.NormalizedName(name)
156187
}
188+
189+
// getOAuthScopes returns the OAuth scopes to request
190+
// Uses custom scopes if provided, otherwise defaults to common scopes
191+
func getOAuthScopes() []string {
192+
var scopes []string
193+
if viper.IsSet("oauth_scopes") {
194+
if err := viper.UnmarshalKey("oauth_scopes", &scopes); err == nil && len(scopes) > 0 {
195+
return scopes
196+
}
197+
}
198+
199+
// Default scopes for GitHub MCP Server
200+
// Based on the protected resource metadata at https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp
201+
return []string{
202+
"repo",
203+
"user",
204+
"gist",
205+
"notifications",
206+
"read:org",
207+
"project",
208+
}
209+
}

internal/ghmcp/server.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
215215
cfg.Translator,
216216
github.FeatureFlags{
217217
LockdownMode: cfg.LockdownMode,
218-
InsiderMode: cfg.InsiderMode,
218+
InsiderMode: cfg.InsiderMode,
219219
},
220220
cfg.ContentWindowSize,
221221
featureChecker,
@@ -235,7 +235,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
235235
WithToolsets(enabledToolsets).
236236
WithTools(cfg.EnabledTools).
237237
WithFeatureChecker(featureChecker)
238-
238+
239239
// Apply token scope filtering if scopes are known (for PAT filtering)
240240
if cfg.TokenScopes != nil {
241241
inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes))

0 commit comments

Comments
 (0)