diff --git a/README.md b/README.md index 7b9e20fc..a937cf37 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,16 @@ automation and interaction capabilities for developers and tools. 3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). +## Authentication +GitHub personal access token can be substituted with GitHub App authentication using the environment variables: +GITHUB_APP_ID + GITHUB_INSTALLATION_ID + GITHUB_PRIVATE_KEY_PEM instead of GITHUB_PERSONAL_ACCESS_TOKEN + +1. Create a GitHub App in your account or organization +2. Configure the app with the desired permissions +3. Install the app in your organization (or on specific repositories) +4. Generate and download a private key for the app +4. Note your App ID and Installation ID (found in the app settings) + ## Installation ### Usage with VS Code diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index fb716f78..4e886ef8 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -1,7 +1,6 @@ package main import ( - "errors" "fmt" "os" @@ -29,9 +28,10 @@ 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 { - token := viper.GetString("personal_access_token") - if token == "" { - return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set") + // Validate authentication configuration + authConfig, err := ghmcp.BuildAuthConfig() + if err != nil { + return err } // If you're wondering why we're not using viper.GetStringSlice("toolsets"), @@ -46,7 +46,7 @@ var ( stdioServerConfig := ghmcp.StdioServerConfig{ Version: version, Host: viper.GetString("host"), - Token: token, + Auth: authConfig, EnabledToolsets: enabledToolsets, DynamicToolsets: viper.GetBool("dynamic_toolsets"), ReadOnly: viper.GetBool("read-only"), @@ -74,7 +74,12 @@ func init() { rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file") rootCmd.PersistentFlags().String("gh-host", "", "Specify the GitHub hostname (for GitHub Enterprise etc.)") - // Bind flag to viper + // Add GitHub App authentication flags + rootCmd.PersistentFlags().String("app-id", "", "GitHub App ID") + rootCmd.PersistentFlags().String("installation-id", "", "GitHub App Installation ID") + rootCmd.PersistentFlags().String("private-key-pem", "", "GitHub App private key PEM content") + + // Bind flags to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets")) _ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only")) @@ -83,6 +88,11 @@ func init() { _ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations")) _ = viper.BindPFlag("host", rootCmd.PersistentFlags().Lookup("gh-host")) + // Bind GitHub App flags to viper + _ = viper.BindPFlag("app_id", rootCmd.PersistentFlags().Lookup("app-id")) + _ = viper.BindPFlag("installation_id", rootCmd.PersistentFlags().Lookup("installation-id")) + _ = viper.BindPFlag("private_key_pem", rootCmd.PersistentFlags().Lookup("private-key-pem")) + // Add subcommands rootCmd.AddCommand(stdioCmd) } diff --git a/go.mod b/go.mod index 5b50d311..6c5343e2 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/github/github-mcp-server go 1.23.7 require ( + github.com/bradleyfalzon/ghinstallation/v2 v2.16.0 github.com/google/go-github/v72 v72.0.0 github.com/josephburnett/jd v1.9.2 github.com/mark3labs/mcp-go v0.30.0 @@ -16,6 +17,8 @@ require ( require ( github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/swag v0.21.1 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/google/go-github/v62 v62.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect diff --git a/go.sum b/go.sum index 6e1562d6..8a7fb53c 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/bradleyfalzon/ghinstallation/v2 v2.11.0 h1:R9d0v+iobRHSaE4wKUnXFiZp53AL4ED5MzgEMwGTZag= +github.com/bradleyfalzon/ghinstallation/v2 v2.11.0/go.mod h1:0LWKQwOHewXO/1acI6TtyE0Xc4ObDb2rFN7eHBAG71M= +github.com/bradleyfalzon/ghinstallation/v2 v2.16.0 h1:B91r9bHtXp/+XRgS5aZm6ZzTdz3ahgJYmkt4xZkgDz8= +github.com/bradleyfalzon/ghinstallation/v2 v2.16.0/go.mod h1:OeVe5ggFzoBnmgitZe/A+BqGOnv1DvU/0uiLQi1wutM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -15,9 +19,15 @@ github.com/go-openapi/swag v0.21.1 h1:wm0rhTb5z7qpJRHBdPOMuY4QjVUMbF6/kwoYeRAOrK github.com/go-openapi/swag v0.21.1/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4= github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= github.com/google/go-github/v72 v72.0.0 h1:FcIO37BLoVPBO9igQQ6tStsv2asG4IPcYFi655PPvBM= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 8f5e16bc..3d4bf425 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -2,6 +2,7 @@ package ghmcp import ( "context" + "errors" "fmt" "io" "log" @@ -9,9 +10,11 @@ import ( "net/url" "os" "os/signal" + "strconv" "strings" "syscall" + "github.com/bradleyfalzon/ghinstallation/v2" "github.com/github/github-mcp-server/pkg/github" mcplog "github.com/github/github-mcp-server/pkg/log" "github.com/github/github-mcp-server/pkg/translations" @@ -20,8 +23,20 @@ import ( "github.com/mark3labs/mcp-go/server" "github.com/shurcooL/githubv4" "github.com/sirupsen/logrus" + "github.com/spf13/viper" ) +// AuthConfig represents authentication configuration +type AuthConfig struct { + // Personal Access Token authentication + Token string + + // GitHub App authentication + AppID string + InstallationID string + PrivateKeyPEM string +} + type MCPServerConfig struct { // Version of the server Version string @@ -29,8 +44,8 @@ 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 - Token string + // Authentication configuration + Auth AuthConfig // EnabledToolsets is a list of toolsets to enable // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration @@ -47,37 +62,165 @@ type MCPServerConfig struct { Translator translations.TranslationHelperFunc } +// authMethod represents the authentication method being used +type authMethod int + +const ( + authToken authMethod = iota + authGitHubApp +) + +// getAuthMethod determines which authentication method to use based on the config +func (cfg *MCPServerConfig) getAuthMethod() (authMethod, error) { + hasToken := cfg.Auth.Token != "" + hasApp := cfg.Auth.AppID != "" && cfg.Auth.InstallationID != "" && cfg.Auth.PrivateKeyPEM != "" + + if hasToken && hasApp { + return 0, fmt.Errorf("cannot specify both token and GitHub App authentication") + } + + if !hasToken && !hasApp { + return 0, fmt.Errorf("must specify either token or GitHub App authentication") + } + + if hasToken { + return authToken, nil + } + + return authGitHubApp, nil +} + +// createGitHubAppTransport creates an authenticated transport for GitHub App +func (cfg *MCPServerConfig) createGitHubAppTransport() (http.RoundTripper, error) { + appID, err := strconv.ParseInt(cfg.Auth.AppID, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid app ID: %w", err) + } + + installationID, err := strconv.ParseInt(cfg.Auth.InstallationID, 10, 64) + if err != nil { + return nil, fmt.Errorf("invalid installation ID: %w", err) + } + + transport, err := ghinstallation.New( + http.DefaultTransport, + appID, + installationID, + []byte(cfg.Auth.PrivateKeyPEM), + ) + if err != nil { + return nil, fmt.Errorf("failed to create GitHub App transport: %w", err) + } + + return transport, nil +} + +// BuildAuthConfig creates an AuthConfig based on environment variables and flags +func BuildAuthConfig() (AuthConfig, error) { + var authConfig AuthConfig + + // Check for Personal Access Token + token := viper.GetString("personal_access_token") + + // Check for GitHub App credentials + appID := viper.GetString("app_id") + installationID := viper.GetString("installation_id") + privateKeyPEM := viper.GetString("private_key_pem") + + // Determine authentication method + hasToken := token != "" + hasApp := appID != "" && installationID != "" && privateKeyPEM != "" + + if !hasToken && !hasApp { + return authConfig, errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN or GitHub App credentials (GITHUB_APP_ID, GITHUB_INSTALLATION_ID, and GITHUB_PRIVATE_KEY_PEM)") + } + + if hasToken && hasApp { + return authConfig, errors.New("cannot specify both personal access token and GitHub App authentication") + } + + if hasToken { + authConfig.Token = token + } else { + authConfig.AppID = appID + authConfig.InstallationID = installationID + authConfig.PrivateKeyPEM = privateKeyPEM + } + + return authConfig, nil +} + func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { apiHost, err := parseAPIHost(cfg.Host) if err != nil { return nil, fmt.Errorf("failed to parse API host: %w", err) } + authMethod, err := cfg.getAuthMethod() + if err != nil { + return nil, fmt.Errorf("authentication configuration error: %w", err) + } + + // Create HTTP client based on authentication method + var httpClient *http.Client + var userAgent string + + switch authMethod { + case authToken: + // Use token-based authentication + httpClient = &http.Client{ + Transport: &bearerAuthTransport{ + transport: http.DefaultTransport, + token: cfg.Auth.Token, + }, + } + userAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) + + case authGitHubApp: + // Use GitHub App authentication + transport, err := cfg.createGitHubAppTransport() + if err != nil { + return nil, err + } + + httpClient = &http.Client{Transport: transport} + userAgent = fmt.Sprintf("github-mcp-server/%s (GitHub App)", cfg.Version) + } + // Construct our REST client - restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token) - restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) + var restClient *gogithub.Client + if authMethod == authToken { + restClient = gogithub.NewClient(nil).WithAuthToken(cfg.Auth.Token) + } else { + restClient = gogithub.NewClient(httpClient) + } + + restClient.UserAgent = userAgent restClient.BaseURL = apiHost.baseRESTURL restClient.UploadURL = apiHost.uploadURL // Construct our GraphQL client - // We're using NewEnterpriseClient here unconditionally as opposed to NewClient because we already - // did the necessary API host parsing so that github.com will return the correct URL anyway. - gqlHTTPClient := &http.Client{ - Transport: &bearerAuthTransport{ - transport: http.DefaultTransport, - token: cfg.Token, - }, - } // We're going to wrap the Transport later in beforeInit + gqlHTTPClient := &http.Client{Transport: httpClient.Transport} gqlClient := githubv4.NewEnterpriseClient(apiHost.graphqlURL.String(), gqlHTTPClient) // When a client send an initialize request, update the user agent to include the client info. beforeInit := func(_ context.Context, _ any, message *mcp.InitializeRequest) { - userAgent := fmt.Sprintf( - "github-mcp-server/%s (%s/%s)", - cfg.Version, - message.Params.ClientInfo.Name, - message.Params.ClientInfo.Version, - ) + var userAgent string + if authMethod == authGitHubApp { + userAgent = fmt.Sprintf( + "github-mcp-server/%s (%s/%s) (GitHub App)", + cfg.Version, + message.Params.ClientInfo.Name, + message.Params.ClientInfo.Version, + ) + } else { + userAgent = fmt.Sprintf( + "github-mcp-server/%s (%s/%s)", + cfg.Version, + message.Params.ClientInfo.Name, + message.Params.ClientInfo.Version, + ) + } restClient.UserAgent = userAgent @@ -146,8 +289,8 @@ 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 - Token string + // Authentication configuration + Auth AuthConfig // EnabledToolsets is a list of toolsets to enable // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration @@ -182,7 +325,7 @@ func RunStdioServer(cfg StdioServerConfig) error { ghServer, err := NewMCPServer(MCPServerConfig{ Version: cfg.Version, Host: cfg.Host, - Token: cfg.Token, + Auth: cfg.Auth, EnabledToolsets: cfg.EnabledToolsets, DynamicToolsets: cfg.DynamicToolsets, ReadOnly: cfg.ReadOnly, diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go new file mode 100644 index 00000000..7bbb88e3 --- /dev/null +++ b/internal/ghmcp/server_test.go @@ -0,0 +1,227 @@ +package ghmcp + +import ( + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +func TestBuildAuthConfig(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + expectError bool + expectType string + }{ + { + name: "valid PAT", + envVars: map[string]string{ + "personal_access_token": "ghp_test123", + }, + expectType: "token", + }, + { + name: "valid GitHub App", + envVars: map[string]string{ + "app_id": "123456", + "installation_id": "789012", + "private_key_pem": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + expectType: "app", + }, + { + name: "missing auth", + envVars: map[string]string{}, + expectError: true, + }, + { + name: "conflicting auth", + envVars: map[string]string{ + "personal_access_token": "ghp_test123", + "app_id": "123456", + "installation_id": "789012", + "private_key_pem": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + expectError: true, + }, + { + name: "incomplete GitHub App - missing app_id", + envVars: map[string]string{ + "installation_id": "789012", + "private_key_pem": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + expectError: true, + }, + { + name: "incomplete GitHub App - missing installation_id", + envVars: map[string]string{ + "app_id": "123456", + "private_key_pem": "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + expectError: true, + }, + { + name: "incomplete GitHub App - missing private_key_pem", + envVars: map[string]string{ + "app_id": "123456", + "installation_id": "789012", + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear viper state + viper.Reset() + + // Set up environment variables + for k, v := range tt.envVars { + viper.Set(k, v) + } + + config, err := BuildAuthConfig() + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + + // Verify the config type based on expectType + switch tt.expectType { + case "token": + assert.NotEmpty(t, config.Token) + assert.Empty(t, config.AppID) + assert.Empty(t, config.InstallationID) + assert.Empty(t, config.PrivateKeyPEM) + case "app": + assert.Empty(t, config.Token) + assert.NotEmpty(t, config.AppID) + assert.NotEmpty(t, config.InstallationID) + assert.NotEmpty(t, config.PrivateKeyPEM) + } + } + }) + } +} + +func TestMCPServerConfig_getAuthMethod(t *testing.T) { + tests := []struct { + name string + config MCPServerConfig + expectedMethod authMethod + expectError bool + }{ + { + name: "token authentication", + config: MCPServerConfig{ + Auth: AuthConfig{ + Token: "ghp_test123", + }, + }, + expectedMethod: authToken, + expectError: false, + }, + { + name: "GitHub App authentication", + config: MCPServerConfig{ + Auth: AuthConfig{ + AppID: "123456", + InstallationID: "789012", + PrivateKeyPEM: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + }, + expectedMethod: authGitHubApp, + expectError: false, + }, + { + name: "no authentication", + config: MCPServerConfig{ + Auth: AuthConfig{}, + }, + expectError: true, + }, + { + name: "conflicting authentication", + config: MCPServerConfig{ + Auth: AuthConfig{ + Token: "ghp_test123", + AppID: "123456", + InstallationID: "789012", + PrivateKeyPEM: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + }, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + method, err := tt.config.getAuthMethod() + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedMethod, method) + } + }) + } +} + +func TestMCPServerConfig_createGitHubAppTransport(t *testing.T) { + tests := []struct { + name string + config MCPServerConfig + expectError bool + }{ + { + name: "invalid app ID", + config: MCPServerConfig{ + Auth: AuthConfig{ + AppID: "not-a-number", + InstallationID: "789012", + PrivateKeyPEM: "any-key", + }, + }, + expectError: true, + }, + { + name: "invalid installation ID", + config: MCPServerConfig{ + Auth: AuthConfig{ + AppID: "123456", + InstallationID: "not-a-number", + PrivateKeyPEM: "any-key", + }, + }, + expectError: true, + }, + { + name: "invalid private key", + config: MCPServerConfig{ + Auth: AuthConfig{ + AppID: "123456", + InstallationID: "789012", + PrivateKeyPEM: "invalid-key", + }, + }, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + transport, err := tt.config.createGitHubAppTransport() + + if tt.expectError { + assert.Error(t, err) + assert.Nil(t, transport) + } else { + assert.NoError(t, err) + assert.NotNil(t, transport) + } + }) + } +}