Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions accounts.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"accounts": [
{
"name": "work",
"token": "${GITHUB_WORK_TOKEN}",
"matcher": {
"type": "org",
"values": ["my-company", "my-company-org"]
}
},
{
"name": "personal",
"token": "${GITHUB_PERSONAL_TOKEN}",
"matcher": {
"type": "org",
"values": ["drtootsie", "my-personal-org"]
},
"default": true
},
{
"name": "specific-repos",
"token": "${GITHUB_SPECIFIC_TOKEN}",
"matcher": {
"type": "repo_pattern",
"values": ["some-org/specific-repo", "other-org/*"]
}
}
]
}
42 changes: 40 additions & 2 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package main

import (
"encoding/json"
"errors"
"fmt"
"os"
"strings"
"time"

"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/pkg/accounts"
"github.com/github/github-mcp-server/pkg/github"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
Expand All @@ -32,9 +34,21 @@ 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 {
// Load accounts config if specified
var accountsConfig *accounts.Config
accountsConfigPath := viper.GetString("accounts-config")
if accountsConfigPath != "" {
cfg, err := loadAccountsConfig(accountsConfigPath)
if err != nil {
return fmt.Errorf("failed to load accounts config: %w", err)
}
accountsConfig = cfg
}

// For backward compatibility, still check for single token if no accounts config
token := viper.GetString("personal_access_token")
if token == "" {
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
if accountsConfig == nil && token == "" {
return errors.New("either GITHUB_PERSONAL_ACCESS_TOKEN or --accounts-config must be set")
}

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

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

// Add subcommands
rootCmd.AddCommand(stdioCmd)
Expand Down Expand Up @@ -153,3 +170,24 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
}
return pflag.NormalizedName(name)
}

// loadAccountsConfig reads and parses the accounts configuration from a JSON file.
// It also expands environment variables in token values (e.g., ${GITHUB_WORK_TOKEN}).
func loadAccountsConfig(path string) (*accounts.Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}

var config accounts.Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, fmt.Errorf("failed to parse config file: %w", err)
}

// Expand environment variables in tokens
for i := range config.Accounts {
config.Accounts[i].Token = os.ExpandEnv(config.Accounts[i].Token)
}

return &config, nil
}
75 changes: 63 additions & 12 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"syscall"
"time"

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

// GitHub Token to authenticate with the GitHub API
// GitHub Token to authenticate with the GitHub API (for single-account mode)
// Deprecated: Use AccountsConfig for multi-account support
Token string

// AccountsConfig provides multi-account configuration and routing
// When set, Token is ignored and accounts are selected dynamically based on repository context
AccountsConfig *accounts.Config

// EnabledToolsets is a list of toolsets to enable
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
EnabledToolsets []string
Expand Down Expand Up @@ -87,11 +93,40 @@ type githubClients struct {
repoAccess *lockdown.RepoAccessCache
}

// createGitHubClients creates all the GitHub API clients needed by the server.
func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients, error) {
// normalizeAccountsConfig ensures AccountsConfig is set, creating a default single-account
// config from Token if needed (for backward compatibility).
func normalizeAccountsConfig(cfg *MCPServerConfig) error {
if cfg.AccountsConfig != nil {
// Multi-account config provided, validate it
return cfg.AccountsConfig.Validate()
}

// Backward compatibility: create single-account config from Token
if cfg.Token == "" {
return fmt.Errorf("either Token or AccountsConfig must be provided")
}

cfg.AccountsConfig = &accounts.Config{
Accounts: []accounts.Account{
{
Name: "default",
Token: cfg.Token,
Matcher: accounts.AccountMatcher{
Type: "all",
},
Default: true,
},
},
}

return cfg.AccountsConfig.Validate()
}

// createGitHubClients creates all the GitHub API clients needed by the server for a specific token.
func createGitHubClients(version string, token string, lockdownMode bool, repoAccessTTL *time.Duration, logger *slog.Logger, apiHost apiHost) (*githubClients, error) {
// Construct REST client
restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token)
restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version)
restClient := gogithub.NewClient(nil).WithAuthToken(token)
restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", version)
restClient.BaseURL = apiHost.baseRESTURL
restClient.UploadURL = apiHost.uploadURL

Expand All @@ -102,7 +137,7 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients,
transport: &github.GraphQLFeaturesTransport{
Transport: http.DefaultTransport,
},
token: cfg.Token,
token: token,
},
}
gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient)
Expand All @@ -112,12 +147,12 @@ func createGitHubClients(cfg MCPServerConfig, apiHost apiHost) (*githubClients,

// Set up repo access cache for lockdown mode
var repoAccessCache *lockdown.RepoAccessCache
if cfg.LockdownMode {
if lockdownMode {
opts := []lockdown.RepoAccessOption{
lockdown.WithLogger(cfg.Logger.With("component", "lockdown")),
lockdown.WithLogger(logger.With("component", "lockdown")),
}
if cfg.RepoAccessTTL != nil {
opts = append(opts, lockdown.WithTTL(*cfg.RepoAccessTTL))
if repoAccessTTL != nil {
opts = append(opts, lockdown.WithTTL(*repoAccessTTL))
}
repoAccessCache = lockdown.GetInstance(gqlClient, opts...)
}
Expand Down Expand Up @@ -159,12 +194,23 @@ func resolveEnabledToolsets(cfg MCPServerConfig) []string {
}

func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) {
// Normalize accounts config (handles backward compatibility for single Token)
if err := normalizeAccountsConfig(&cfg); err != nil {
return nil, fmt.Errorf("failed to normalize accounts config: %w", err)
}

apiHost, err := parseAPIHost(cfg.Host)
if err != nil {
return nil, fmt.Errorf("failed to parse API host: %w", err)
}

clients, err := createGitHubClients(cfg, apiHost)
// Use default account's token for client creation
defaultAccount := cfg.AccountsConfig.GetDefaultAccount()
if defaultAccount == nil {
return nil, fmt.Errorf("no default account found")
}

clients, err := createGitHubClients(cfg.Version, defaultAccount.Token, cfg.LockdownMode, cfg.RepoAccessTTL, cfg.Logger, apiHost)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
}
Expand Down Expand Up @@ -293,9 +339,13 @@ type StdioServerConfig struct {
// GitHub Host to target for API requests (e.g. github.com or github.enterprise.com)
Host string

// GitHub Token to authenticate with the GitHub API
// GitHub Token to authenticate with the GitHub API (for single-account mode)
// Deprecated: Use AccountsConfig for multi-account support
Token string

// AccountsConfig provides multi-account configuration
AccountsConfig *accounts.Config

// EnabledToolsets is a list of toolsets to enable
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
EnabledToolsets []string
Expand Down Expand Up @@ -382,6 +432,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
AccountsConfig: cfg.AccountsConfig,
EnabledToolsets: cfg.EnabledToolsets,
EnabledTools: cfg.EnabledTools,
EnabledFeatures: cfg.EnabledFeatures,
Expand Down
Loading