Skip to content

add app based authentication support #464

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 16 additions & 6 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package main

import (
"errors"
"fmt"
"os"

Expand Down Expand Up @@ -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"),
Expand All @@ -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"),
Expand Down Expand Up @@ -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"))
Expand All @@ -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)
}
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 10 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand All @@ -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=
Expand Down
185 changes: 164 additions & 21 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,19 @@ package ghmcp

import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"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"
Expand All @@ -20,17 +23,29 @@ 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

// 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
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Loading