Skip to content
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,80 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:

</details>

### GitHub App Authentication

As an alternative to Personal Access Tokens, the MCP server supports authenticating as a [GitHub App](https://docs.github.com/en/apps) installation. This is useful for organizations that want to grant scoped, short-lived access without relying on individual PATs.

The server automatically generates JWTs, fetches installation tokens, and refreshes them before expiry (installation tokens are valid for 1 hour).

#### Required Environment Variables

| Variable | Description |
|---|---|
| `GITHUB_APP_ID` | The GitHub App ID |
| `GITHUB_APP_INSTALLATION_ID` | The installation ID of the GitHub App |
| `GITHUB_APP_PRIVATE_KEY` | The PEM-encoded private key (inline, `\n` for newlines) |
| `GITHUB_APP_PRIVATE_KEY_PATH` | Path to the private key file (alternative to inline) |

Either `GITHUB_APP_PRIVATE_KEY` or `GITHUB_APP_PRIVATE_KEY_PATH` must be set, but not both (they are mutually exclusive). When all three required variables (`GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, and a private key) are set, the server uses GitHub App authentication instead of a PAT. `GITHUB_PERSONAL_ACCESS_TOKEN` is not required in this case.

#### Example: Using a private key file

```bash
export GITHUB_APP_ID=12345
export GITHUB_APP_INSTALLATION_ID=67890
export GITHUB_APP_PRIVATE_KEY_PATH=/path/to/private-key.pem
github-mcp-server stdio
```

#### Example: Using an inline private key

```bash
export GITHUB_APP_ID=12345
export GITHUB_APP_INSTALLATION_ID=67890
export GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"
github-mcp-server stdio
```

#### Example: Docker with GitHub App authentication

```bash
docker run -i --rm \
-e GITHUB_APP_ID=12345 \
-e GITHUB_APP_INSTALLATION_ID=67890 \
-e GITHUB_APP_PRIVATE_KEY_PATH=/key/private-key.pem \
-v /path/to/private-key.pem:/key/private-key.pem:ro \
ghcr.io/github/github-mcp-server
```

#### Example: VS Code configuration

```json
{
"mcp": {
"servers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e", "GITHUB_APP_ID",
"-e", "GITHUB_APP_INSTALLATION_ID",
"-e", "GITHUB_APP_PRIVATE_KEY",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_APP_ID": "12345",
"GITHUB_APP_INSTALLATION_ID": "67890",
"GITHUB_APP_PRIVATE_KEY": "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"
}
}
}
}
}
```

### GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com)

The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set
Expand Down
66 changes: 64 additions & 2 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -34,8 +35,16 @@ var (
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")

// Parse GitHub App authentication config
appID, privateKey, installationID, err := parseAppAuthConfig()
if err != nil {
return err
}
useAppAuth := appID != 0 && len(privateKey) > 0 && installationID != 0

if token == "" && !useAppAuth {
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set (or configure GitHub App auth with GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY/GITHUB_APP_PRIVATE_KEY_PATH, and GITHUB_APP_INSTALLATION_ID)")
}

// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
Expand Down Expand Up @@ -94,6 +103,9 @@ var (
InsidersMode: viper.GetBool("insiders"),
ExcludeTools: excludeTools,
RepoAccessCacheTTL: &ttl,
AppID: appID,
PrivateKey: privateKey,
InstallationID: installationID,
}
return ghmcp.RunStdioServer(stdioServerConfig)
},
Expand Down Expand Up @@ -235,3 +247,53 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
}
return pflag.NormalizedName(name)
}

// parseAppAuthConfig reads GitHub App authentication config from environment variables.
// Returns (0, nil, 0, nil) when no App auth is configured.
func parseAppAuthConfig() (appID int64, privateKey []byte, installationID int64, err error) {
appIDStr := viper.GetString("app_id")
installationIDStr := viper.GetString("app_installation_id")
privateKeyStr := viper.GetString("app_private_key")
privateKeyPath := viper.GetString("app_private_key_path")
Comment thread
pyama86 marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cmd/github-mcp-server/main.go


// If none are set, App auth is not configured
if appIDStr == "" && installationIDStr == "" && privateKeyStr == "" && privateKeyPath == "" {
return 0, nil, 0, nil
}

// If some but not all are set, that's a configuration error
if appIDStr == "" || installationIDStr == "" || (privateKeyStr == "" && privateKeyPath == "") {
return 0, nil, 0, errors.New("incomplete GitHub App auth config: GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_PATH are all required")
}
Comment thread
pyama86 marked this conversation as resolved.

if privateKeyStr != "" && privateKeyPath != "" {
return 0, nil, 0, errors.New("GITHUB_APP_PRIVATE_KEY and GITHUB_APP_PRIVATE_KEY_PATH are mutually exclusive")
}

appID, err = strconv.ParseInt(appIDStr, 10, 64)
if err != nil {
return 0, nil, 0, fmt.Errorf("invalid GITHUB_APP_ID: %w", err)
}

installationID, err = strconv.ParseInt(installationIDStr, 10, 64)
if err != nil {
return 0, nil, 0, fmt.Errorf("invalid GITHUB_APP_INSTALLATION_ID: %w", err)
}

if privateKeyStr != "" {
// Environment variables often use literal "\n" instead of actual newlines.
// Only replace when the value has no real newlines to avoid corrupting
// keys that were correctly passed with actual newlines.
if !strings.Contains(privateKeyStr, "\n") {
privateKeyStr = strings.ReplaceAll(privateKeyStr, `\n`, "\n")
}
privateKey = []byte(privateKeyStr)
} else {
privateKey, err = os.ReadFile(privateKeyPath)
if err != nil {
return 0, nil, 0, fmt.Errorf("failed to read private key from %s: %w", privateKeyPath, err)
}
}

return appID, privateKey, installationID, nil
}
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
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/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
Expand Down
88 changes: 73 additions & 15 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (

"github.com/github/github-mcp-server/pkg/errors"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/github/appauth"
"github.com/github/github-mcp-server/pkg/http/transport"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/lockdown"
Expand All @@ -40,7 +41,8 @@ type githubClients struct {
}

// createGitHubClients creates all the GitHub API clients needed by the server.
func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolver) (*githubClients, error) {
// If authTransport is non-nil, it is used for authentication instead of cfg.Token.
func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolver, authTransport http.RoundTripper) (*githubClients, error) {
restURL, err := apiHost.BaseRESTURL(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get base REST URL: %w", err)
Expand All @@ -61,30 +63,55 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
return nil, fmt.Errorf("failed to get Raw URL: %w", err)
}

// Determine the base transport for REST and GraphQL clients
baseTransport := http.RoundTripper(http.DefaultTransport)
if authTransport != nil {
baseTransport = authTransport
}

// Construct REST client
restUATransport := &transport.UserAgentTransport{
Transport: http.DefaultTransport,
Transport: baseTransport,
Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version),
}
restClient, err := gogithub.NewClient(
gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}),
gogithub.WithAuthToken(cfg.Token),
gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()),
)
var restClient *gogithub.Client
if authTransport != nil {
restClient, err = gogithub.NewClient(
gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}),
gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()),
)
} else {
restClient, err = gogithub.NewClient(
gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}),
gogithub.WithAuthToken(cfg.Token),
gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()),
)
}
if err != nil {
return nil, fmt.Errorf("failed to create REST client: %w", err)
}

// Construct GraphQL client
// We use NewEnterpriseClient unconditionally since we already parsed the API host
gqlHTTPClient := &http.Client{
Transport: &transport.BearerAuthTransport{
var gqlTransport http.RoundTripper
if authTransport != nil {
// Auth transport already sets the Authorization header.
// Wrap with UserAgentTransport for consistency with the REST path.
gqlTransport = &transport.UserAgentTransport{
Transport: &transport.GraphQLFeaturesTransport{
Transport: authTransport,
},
Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version),
}
} else {
gqlTransport = &transport.BearerAuthTransport{
Transport: &transport.GraphQLFeaturesTransport{
Transport: http.DefaultTransport,
},
Token: cfg.Token,
},
}
}
gqlHTTPClient := &http.Client{Transport: gqlTransport}

gqlClient := githubv4.NewEnterpriseClient(graphQLURL.String(), gqlHTTPClient)

Expand Down Expand Up @@ -116,13 +143,13 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
}, nil
}

func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Server, error) {
func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig, authTransport http.RoundTripper) (*mcp.Server, error) {
apiHost, err := utils.NewAPIHost(cfg.Host)
if err != nil {
return nil, fmt.Errorf("failed to parse API host: %w", err)
}

clients, err := createGitHubClients(cfg, apiHost)
clients, err := createGitHubClients(cfg, apiHost, authTransport)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
}
Expand Down Expand Up @@ -238,6 +265,13 @@ type StdioServerConfig struct {

// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
RepoAccessCacheTTL *time.Duration

// GitHub App authentication (alternative to Token)
// When AppID, PrivateKey, and InstallationID are all set, the server
// authenticates as a GitHub App installation instead of using a PAT.
AppID int64
PrivateKey []byte
InstallationID int64
}

// RunStdioServer is not concurrent safe.
Expand All @@ -264,19 +298,43 @@ func RunStdioServer(cfg StdioServerConfig) error {
logger := slog.New(slogHandler)
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)

// Set up GitHub App authentication transport if configured
var appAuthTransport http.RoundTripper
if cfg.AppID != 0 && len(cfg.PrivateKey) > 0 && cfg.InstallationID != 0 {
apiHost, err := utils.NewAPIHost(cfg.Host)
if err != nil {
return fmt.Errorf("failed to parse API host for app auth: %w", err)
}
baseURL, err := apiHost.BaseRESTURL(ctx)
if err != nil {
return fmt.Errorf("failed to get base REST URL for app auth: %w", err)
}
tr, err := appauth.NewTransport(http.DefaultTransport, appauth.Config{
AppID: cfg.AppID,
PrivateKey: cfg.PrivateKey,
InstallationID: cfg.InstallationID,
BaseURL: baseURL.String(),
})
if err != nil {
return fmt.Errorf("failed to create GitHub App auth transport: %w", err)
}
appAuthTransport = tr
logger.Info("using GitHub App authentication", "appID", cfg.AppID, "installationID", cfg.InstallationID)
}

// Fetch token scopes for scope-based tool filtering (PAT tokens only)
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
// Fine-grained PATs and other token types don't support this, so we skip filtering.
var tokenScopes []string
if strings.HasPrefix(cfg.Token, "ghp_") {
if appAuthTransport == nil && strings.HasPrefix(cfg.Token, "ghp_") {
fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host)
if err != nil {
logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err)
} else {
tokenScopes = fetchedScopes
logger.Info("token scopes fetched for filtering", "scopes", tokenScopes)
}
} else {
} else if appAuthTransport == nil {
logger.Debug("skipping scope filtering for non-PAT token")
}

Expand All @@ -296,7 +354,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
Logger: logger,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
TokenScopes: tokenScopes,
})
}, appAuthTransport)
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}
Expand Down
Loading