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
47 changes: 43 additions & 4 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"errors"
"fmt"
"os"
"strconv"
"strings"

"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/pkg/github"
Expand Down Expand Up @@ -45,6 +47,9 @@ var (
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
}

// Parse multi-org installations
installations := parseOrgInstallations()

stdioServerConfig := ghmcp.StdioServerConfig{
Version: version,
Host: viper.GetString("host"),
Expand All @@ -55,20 +60,49 @@ var (
ExportTranslations: viper.GetBool("export-translations"),
EnableCommandLogging: viper.GetBool("enable-command-logging"),
LogFilePath: viper.GetString("log-file"),
Installations: installations,
}

return ghmcp.RunStdioServer(stdioServerConfig)
},
}
)

// parseOrgInstallations parses GITHUB_INSTALLATION_ID_<ORG> environment variables
// and returns a map of organization name to installation ID.
// Also includes the default GITHUB_INSTALLATION_ID under "_default" key if set.
func parseOrgInstallations() map[string]int64 {
installations := make(map[string]int64)
prefix := "GITHUB_INSTALLATION_ID_"

for _, env := range os.Environ() {
if strings.HasPrefix(env, prefix) {
parts := strings.SplitN(env, "=", 2)
if len(parts) == 2 {
org := strings.ToLower(strings.TrimPrefix(parts[0], prefix))
org = strings.ReplaceAll(org, "_", "-") // Normalize underscores to dashes
if id, err := strconv.ParseInt(parts[1], 10, 64); err == nil {
installations[org] = id
}
}
}
}

// Add default if set (for backwards compatibility)
if defaultID := viper.GetInt64("installation_id"); defaultID != 0 {
installations["_default"] = defaultID
}

return installations
}

func init() {
cobra.OnInitialize(initConfig)

rootCmd.SetVersionTemplate("{{.Short}}\n{{.Version}}\n")

// Add global flags that will be shared by all commands
rootCmd.PersistentFlags().StringSlice("toolsets", github.DefaultTools, "An optional comma separated list of groups of tools to allow, defaults to enabling all")
rootCmd.PersistentFlags().StringSlice("toolsets", github.DefaultTools, "An optional comma separated list of groups of tools to allow with optional modes (e.g., 'repos:rw,issues:ro,users'), defaults to enabling all")
rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets")
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
rootCmd.PersistentFlags().String("log-file", "", "Path to log file")
Expand All @@ -84,6 +118,7 @@ func init() {

// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
_ = viper.BindEnv("toolsets", "GITHUB_TOOLSETS")
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))
Expand Down Expand Up @@ -120,14 +155,18 @@ func validateAuthConfig() error {
hasInstallationID := installationID != 0
hasPrivateKey := privateKeyPath != "" || privateKey != ""

if (hasAppID || hasInstallationID || hasPrivateKey) && !(hasAppID && hasInstallationID && hasPrivateKey) {
return errors.New("incomplete GitHub App configuration: GITHUB_APP_ID, GITHUB_INSTALLATION_ID, and either GITHUB_PRIVATE_KEY_FILE_PATH or GITHUB_PRIVATE_KEY must all be set")
// Also check for multi-org installation IDs (GITHUB_INSTALLATION_ID_<ORG>)
hasMultiOrgInstallations := len(parseOrgInstallations()) > 0
hasAnyInstallation := hasInstallationID || hasMultiOrgInstallations

if (hasAppID || hasAnyInstallation || hasPrivateKey) && !(hasAppID && hasAnyInstallation && hasPrivateKey) {
return errors.New("incomplete GitHub App configuration: GITHUB_APP_ID, GITHUB_INSTALLATION_ID (or GITHUB_INSTALLATION_ID_<ORG>), and either GITHUB_PRIVATE_KEY_FILE_PATH or GITHUB_PRIVATE_KEY must all be set")
}

// Check PAT if GitHub App auth is not configured
token := viper.GetString("personal_access_token")
if !hasAppID && token == "" {
return errors.New("no authentication method configured: either set GITHUB_PERSONAL_ACCESS_TOKEN or configure GitHub App authentication with GITHUB_APP_ID, GITHUB_INSTALLATION_ID, and GITHUB_PRIVATE_KEY_FILE_PATH or GITHUB_PRIVATE_KEY")
return errors.New("no authentication method configured: either set GITHUB_PERSONAL_ACCESS_TOKEN or configure GitHub App authentication with GITHUB_APP_ID, GITHUB_INSTALLATION_ID (or GITHUB_INSTALLATION_ID_<ORG>), and GITHUB_PRIVATE_KEY_FILE_PATH or GITHUB_PRIVATE_KEY")
}

return nil
Expand Down
48 changes: 37 additions & 11 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"os/signal"
"strings"
"syscall"
"time"

"github.com/bradleyfalzon/ghinstallation/v2"
"github.com/github/github-mcp-server/pkg/github"
Expand All @@ -32,6 +31,14 @@ func createClient(cfg MCPServerConfig) (*gogithub.Client, error) {
appID := viper.GetInt64("app_id")
installationID := viper.GetInt64("installation_id")

// If no default installation ID, use first multi-org installation as fallback
if installationID == 0 && len(cfg.Installations) > 0 {
for _, id := range cfg.Installations {
installationID = id
break
}
}

// Check for private key - can be provided as file path or direct content
privateKeyPath := viper.GetString("private_key_file_path")
privateKeyContent := viper.GetString("private_key")
Expand Down Expand Up @@ -101,6 +108,14 @@ func createGQLClient(cfg MCPServerConfig) (*githubv4.Client, *http.Client, error
appID := viper.GetInt64("app_id")
installationID := viper.GetInt64("installation_id")

// If no default installation ID, use first multi-org installation as fallback
if installationID == 0 && len(cfg.Installations) > 0 {
for _, id := range cfg.Installations {
installationID = id
break
}
}

// Check for private key - can be provided as file path or direct content
privateKeyPath := viper.GetString("private_key_file_path")
privateKeyContent := viper.GetString("private_key")
Expand Down Expand Up @@ -201,6 +216,9 @@ type MCPServerConfig struct {
// ReadOnly indicates if we should only offer read-only tools
ReadOnly bool

// Installations maps organization names to GitHub App installation IDs
Installations map[string]int64

// Translator provides translated text for the server tooling
Translator translations.TranslationHelperFunc
}
Expand All @@ -212,8 +230,8 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
return nil, fmt.Errorf("failed to create GitHub REST client: %w", err)
}

// Create GraphQL client
gqlClient, gqlHTTPClient, err := createGQLClient(cfg)
// Create GraphQL client (for user agent hook only; actual GQL clients from factory)
_, gqlHTTPClient, err := createGQLClient(cfg)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub GraphQL client: %w", err)
}
Expand Down Expand Up @@ -252,13 +270,19 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
}
}

// Create repository-aware client factory with 1-hour cache TTL
clientFactory := github.NewRepoAwareClientFactory(restClient, 1*time.Hour)
// Create multi-org client factory
appID := viper.GetInt64("app_id")
privateKey := []byte(viper.GetString("private_key"))

clientFactory := github.NewMultiOrgClientFactory(
appID,
privateKey,
cfg.Installations,
cfg.Host,
cfg.Version,
)
getClient := clientFactory.GetClientFn()

getGQLClient := func(_ context.Context) (*githubv4.Client, error) {
return gqlClient, nil // closing over client
}
getGQLClient := clientFactory.GetGQLClientFn()

// Create default toolsets
toolsets, err := github.InitToolsets(
Expand All @@ -272,12 +296,10 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
return nil, fmt.Errorf("failed to initialize toolsets: %w", err)
}

context := github.InitContextToolset(getClient, cfg.Translator)
github.RegisterResources(ghServer, getClient, cfg.Translator)

// Register the tools with the server
toolsets.RegisterTools(ghServer)
context.RegisterTools(ghServer)

if cfg.DynamicToolsets {
dynamic := github.InitDynamicToolset(ghServer, toolsets, cfg.Translator)
Expand Down Expand Up @@ -317,6 +339,9 @@ type StdioServerConfig struct {

// Path to the log file if not stderr
LogFilePath string

// Installations maps organization names to GitHub App installation IDs
Installations map[string]int64
}

// RunStdioServer is not concurrent safe.
Expand All @@ -334,6 +359,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
EnabledToolsets: cfg.EnabledToolsets,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Installations: cfg.Installations,
Translator: t,
})
if err != nil {
Expand Down
4 changes: 2 additions & 2 deletions pkg/github/code_scanning.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ func GetCodeScanningAlert(getClient GetClientFn, t translations.TranslationHelpe
return mcp.NewToolResultError(err.Error()), nil
}

client, err := getClient(ctx)
client, err := getClient(ctx, owner)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
Expand Down Expand Up @@ -132,7 +132,7 @@ func ListCodeScanningAlerts(getClient GetClientFn, t translations.TranslationHel
return mcp.NewToolResultError(err.Error()), nil
}

client, err := getClient(ctx)
client, err := getClient(ctx, owner)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/context_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func GetMe(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mc
),
),
func(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) {
client, err := getClient(ctx)
client, err := getClient(ctx, "")
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
Expand Down
14 changes: 7 additions & 7 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool
return mcp.NewToolResultError(err.Error()), nil
}

client, err := getClient(ctx)
client, err := getClient(ctx, owner)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
Expand Down Expand Up @@ -123,7 +123,7 @@ func AddIssueComment(getClient GetClientFn, t translations.TranslationHelperFunc
Body: github.Ptr(body),
}

client, err := getClient(ctx)
client, err := getClient(ctx, owner)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
Expand Down Expand Up @@ -211,7 +211,7 @@ func SearchIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (
},
}

client, err := getClient(ctx)
client, err := getClient(ctx, "")
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
Expand Down Expand Up @@ -333,7 +333,7 @@ func CreateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
Milestone: milestoneNum,
}

client, err := getClient(ctx)
client, err := getClient(ctx, owner)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
Expand Down Expand Up @@ -455,7 +455,7 @@ func ListIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (to
opts.PerPage = int(perPage)
}

client, err := getClient(ctx)
client, err := getClient(ctx, owner)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
Expand Down Expand Up @@ -601,7 +601,7 @@ func UpdateIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (t
issueRequest.Milestone = &milestoneNum
}

client, err := getClient(ctx)
client, err := getClient(ctx, owner)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
Expand Down Expand Up @@ -684,7 +684,7 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun
},
}

client, err := getClient(ctx)
client, err := getClient(ctx, owner)
if err != nil {
return nil, fmt.Errorf("failed to get GitHub client: %w", err)
}
Expand Down
Loading