Skip to content

Commit d0636ea

Browse files
drtootsieclaude
andcommitted
Add multi-account support with automatic account routing
Implements multi-account support allowing users to configure multiple GitHub accounts with automatic switching based on repository context. Key features: - Account configuration via JSON file with --accounts-config flag - Automatic account selection based on organization or repository patterns - Environment variable expansion for tokens (e.g., ${GITHUB_WORK_TOKEN}) - Three matcher types: org (organization), repo_pattern (wildcards), all - Backward compatible: single GITHUB_PERSONAL_ACCESS_TOKEN still works - Default account fallback when no match found Implementation: - pkg/accounts: Core account routing logic with Config, Router, and matcher system - Comprehensive test coverage with multiple matching scenarios - CLI flag: --accounts-config <path-to-json> - Example configuration in accounts.example.json Use cases: - Work + personal GitHub accounts (EMU-friendly) - Multiple organization access - Repository-specific authentication Fixes #1940 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 1820a0f commit d0636ea

File tree

5 files changed

+731
-14
lines changed

5 files changed

+731
-14
lines changed

accounts.example.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"accounts": [
3+
{
4+
"name": "work",
5+
"token": "${GITHUB_WORK_TOKEN}",
6+
"matcher": {
7+
"type": "org",
8+
"values": ["my-company", "my-company-org"]
9+
}
10+
},
11+
{
12+
"name": "personal",
13+
"token": "${GITHUB_PERSONAL_TOKEN}",
14+
"matcher": {
15+
"type": "org",
16+
"values": ["drtootsie", "my-personal-org"]
17+
},
18+
"default": true
19+
},
20+
{
21+
"name": "specific-repos",
22+
"token": "${GITHUB_SPECIFIC_TOKEN}",
23+
"matcher": {
24+
"type": "repo_pattern",
25+
"values": ["some-org/specific-repo", "other-org/*"]
26+
}
27+
}
28+
]
29+
}

cmd/github-mcp-server/main.go

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

33
import (
4+
"encoding/json"
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/pkg/accounts"
1113
"github.com/github/github-mcp-server/pkg/github"
1214
"github.com/spf13/cobra"
1315
"github.com/spf13/pflag"
@@ -32,9 +34,21 @@ var (
3234
Short: "Start stdio server",
3335
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
3436
RunE: func(_ *cobra.Command, _ []string) error {
37+
// Load accounts config if specified
38+
var accountsConfig *accounts.Config
39+
accountsConfigPath := viper.GetString("accounts-config")
40+
if accountsConfigPath != "" {
41+
cfg, err := loadAccountsConfig(accountsConfigPath)
42+
if err != nil {
43+
return fmt.Errorf("failed to load accounts config: %w", err)
44+
}
45+
accountsConfig = cfg
46+
}
47+
48+
// For backward compatibility, still check for single token if no accounts config
3549
token := viper.GetString("personal_access_token")
36-
if token == "" {
37-
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
50+
if accountsConfig == nil && token == "" {
51+
return errors.New("either GITHUB_PERSONAL_ACCESS_TOKEN or --accounts-config must be set")
3852
}
3953

4054
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
@@ -73,6 +87,7 @@ var (
7387
Version: version,
7488
Host: viper.GetString("host"),
7589
Token: token,
90+
AccountsConfig: accountsConfig,
7691
EnabledToolsets: enabledToolsets,
7792
EnabledTools: enabledTools,
7893
EnabledFeatures: enabledFeatures,
@@ -111,6 +126,7 @@ func init() {
111126
rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode")
112127
rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features")
113128
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
129+
rootCmd.PersistentFlags().String("accounts-config", "", "Path to JSON file with multi-account configuration")
114130

115131
// Bind flag to viper
116132
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
@@ -126,6 +142,7 @@ func init() {
126142
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
127143
_ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders"))
128144
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
145+
_ = viper.BindPFlag("accounts-config", rootCmd.PersistentFlags().Lookup("accounts-config"))
129146

130147
// Add subcommands
131148
rootCmd.AddCommand(stdioCmd)
@@ -153,3 +170,24 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
153170
}
154171
return pflag.NormalizedName(name)
155172
}
173+
174+
// loadAccountsConfig reads and parses the accounts configuration from a JSON file.
175+
// It also expands environment variables in token values (e.g., ${GITHUB_WORK_TOKEN}).
176+
func loadAccountsConfig(path string) (*accounts.Config, error) {
177+
data, err := os.ReadFile(path)
178+
if err != nil {
179+
return nil, fmt.Errorf("failed to read config file: %w", err)
180+
}
181+
182+
var config accounts.Config
183+
if err := json.Unmarshal(data, &config); err != nil {
184+
return nil, fmt.Errorf("failed to parse config file: %w", err)
185+
}
186+
187+
// Expand environment variables in tokens
188+
for i := range config.Accounts {
189+
config.Accounts[i].Token = os.ExpandEnv(config.Accounts[i].Token)
190+
}
191+
192+
return &config, nil
193+
}

internal/ghmcp/server.go

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"syscall"
1414
"time"
1515

16+
"github.com/github/github-mcp-server/pkg/accounts"
1617
"github.com/github/github-mcp-server/pkg/errors"
1718
"github.com/github/github-mcp-server/pkg/github"
1819
"github.com/github/github-mcp-server/pkg/inventory"
@@ -33,9 +34,14 @@ type MCPServerConfig struct {
3334
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
3435
Host string
3536

36-
// GitHub Token to authenticate with the GitHub API
37+
// GitHub Token to authenticate with the GitHub API (for single-account mode)
38+
// Deprecated: Use AccountsConfig for multi-account support
3739
Token string
3840

41+
// AccountsConfig provides multi-account configuration and routing
42+
// When set, Token is ignored and accounts are selected dynamically based on repository context
43+
AccountsConfig *accounts.Config
44+
3945
// EnabledToolsets is a list of toolsets to enable
4046
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
4147
EnabledToolsets []string
@@ -87,11 +93,40 @@ type githubClients struct {
8793
repoAccess *lockdown.RepoAccessCache
8894
}
8995

90-
// createGitHubClients creates all the GitHub API clients needed by the server.
91-
func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, error) {
96+
// normalizeAccountsConfig ensures AccountsConfig is set, creating a default single-account
97+
// config from Token if needed (for backward compatibility).
98+
func normalizeAccountsConfig(cfg *MCPServerConfig) error {
99+
if cfg.AccountsConfig != nil {
100+
// Multi-account config provided, validate it
101+
return cfg.AccountsConfig.Validate()
102+
}
103+
104+
// Backward compatibility: create single-account config from Token
105+
if cfg.Token == "" {
106+
return fmt.Errorf("either Token or AccountsConfig must be provided")
107+
}
108+
109+
cfg.AccountsConfig = &accounts.Config{
110+
Accounts: []accounts.Account{
111+
{
112+
Name: "default",
113+
Token: cfg.Token,
114+
Matcher: accounts.AccountMatcher{
115+
Type: "all",
116+
},
117+
Default: true,
118+
},
119+
},
120+
}
121+
122+
return cfg.AccountsConfig.Validate()
123+
}
124+
125+
// createGitHubClients creates all the GitHub API clients needed by the server for a specific token.
126+
func createGitHubClients(version string, token string, lockdownMode bool, repoAccessTTL *time.Duration, logger *slog.Logger, apiHost apiHost) (*githubClients, error) {
92127
// Construct REST client
93-
restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token)
94-
restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version)
128+
restClient := gogithub.NewClient(nil).WithAuthToken(token)
129+
restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", version)
95130
restClient.BaseURL = apiHost.baseRESTURL
96131
restClient.UploadURL = apiHost.uploadURL
97132

@@ -102,7 +137,7 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients,
102137
transport: &github.GraphQLFeaturesTransport{
103138
Transport: http.DefaultTransport,
104139
},
105-
token: cfg.Token,
140+
token: token,
106141
},
107142
}
108143
gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient)
@@ -112,12 +147,12 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients,
112147

113148
// Set up repo access cache for lockdown mode
114149
var repoAccessCache *lockdown.RepoAccessCache
115-
if cfg.LockdownMode {
150+
if lockdownMode {
116151
opts := []lockdown.RepoAccessOption{
117-
lockdown.WithLogger(cfg.Logger.With("component", "lockdown")),
152+
lockdown.WithLogger(logger.With("component", "lockdown")),
118153
}
119-
if cfg.RepoAccessTTL != nil {
120-
opts = append(opts, lockdown.WithTTL(*cfg.RepoAccessTTL))
154+
if repoAccessTTL != nil {
155+
opts = append(opts, lockdown.WithTTL(*repoAccessTTL))
121156
}
122157
repoAccessCache = lockdown.GetInstance(gqlClient, opts...)
123158
}
@@ -159,12 +194,23 @@ func resolveEnabledToolsets(cfg MCPServerConfig) []string {
159194
}
160195

161196
func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
197+
// Normalize accounts config (handles backward compatibility for single Token)
198+
if err := normalizeAccountsConfig(&cfg); err != nil {
199+
return nil, fmt.Errorf("failed to normalize accounts config: %w", err)
200+
}
201+
162202
apiHost, err := parseAPIHost(cfg.Host)
163203
if err != nil {
164204
return nil, fmt.Errorf("failed to parse API host: %w", err)
165205
}
166206

167-
clients, err := createGitHubClients(cfg, apiHost)
207+
// Use default account's token for client creation
208+
defaultAccount := cfg.AccountsConfig.GetDefaultAccount()
209+
if defaultAccount == nil {
210+
return nil, fmt.Errorf("no default account found")
211+
}
212+
213+
clients, err := createGitHubClients(cfg.Version, defaultAccount.Token, cfg.LockdownMode, cfg.RepoAccessTTL, cfg.Logger, apiHost)
168214
if err != nil {
169215
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
170216
}
@@ -293,9 +339,13 @@ type StdioServerConfig struct {
293339
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
294340
Host string
295341

296-
// GitHub Token to authenticate with the GitHub API
342+
// GitHub Token to authenticate with the GitHub API (for single-account mode)
343+
// Deprecated: Use AccountsConfig for multi-account support
297344
Token string
298345

346+
// AccountsConfig provides multi-account configuration
347+
AccountsConfig *accounts.Config
348+
299349
// EnabledToolsets is a list of toolsets to enable
300350
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
301351
EnabledToolsets []string
@@ -382,6 +432,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
382432
Version: cfg.Version,
383433
Host: cfg.Host,
384434
Token: cfg.Token,
435+
AccountsConfig: cfg.AccountsConfig,
385436
EnabledToolsets: cfg.EnabledToolsets,
386437
EnabledTools: cfg.EnabledTools,
387438
EnabledFeatures: cfg.EnabledFeatures,

0 commit comments

Comments
 (0)