11package main
22
33import (
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"
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