Skip to content

Commit db89352

Browse files
committed
feat: add GitHub App authentication support
Add native GitHub App authentication as an alternative to Personal Access Tokens. The server can now authenticate using App ID, private key, and installation ID to automatically generate and refresh installation tokens. - Add `pkg/github/appauth` package with JWT generation and installation token management using only the standard library - Auto-refresh tokens before expiry (5-minute buffer on 1-hour tokens) - Support private key via env var (GITHUB_APP_PRIVATE_KEY) or file path (GITHUB_APP_PRIVATE_KEY_PATH) - Handle literal `\n` in env var PEM keys - Add comprehensive tests (13 tests covering key parsing, JWT generation, token caching, refresh, round-trip, and error handling) Closes #1333
1 parent e091ea6 commit db89352

6 files changed

Lines changed: 708 additions & 15 deletions

File tree

README.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,80 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:
239239

240240
</details>
241241

242+
### GitHub App Authentication
243+
244+
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.
245+
246+
The server automatically generates JWTs, fetches installation tokens, and refreshes them before expiry (installation tokens are valid for 1 hour).
247+
248+
#### Required Environment Variables
249+
250+
| Variable | Description |
251+
|---|---|
252+
| `GITHUB_APP_ID` | The GitHub App ID |
253+
| `GITHUB_APP_INSTALLATION_ID` | The installation ID of the GitHub App |
254+
| `GITHUB_APP_PRIVATE_KEY` | The PEM-encoded private key (inline, `\n` for newlines) |
255+
| `GITHUB_APP_PRIVATE_KEY_PATH` | Path to the private key file (alternative to inline) |
256+
257+
Either `GITHUB_APP_PRIVATE_KEY` or `GITHUB_APP_PRIVATE_KEY_PATH` must be set, but not both. 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.
258+
259+
#### Example: Using a private key file
260+
261+
```bash
262+
export GITHUB_APP_ID=12345
263+
export GITHUB_APP_INSTALLATION_ID=67890
264+
export GITHUB_APP_PRIVATE_KEY_PATH=/path/to/private-key.pem
265+
github-mcp-server stdio
266+
```
267+
268+
#### Example: Using an inline private key
269+
270+
```bash
271+
export GITHUB_APP_ID=12345
272+
export GITHUB_APP_INSTALLATION_ID=67890
273+
export GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"
274+
github-mcp-server stdio
275+
```
276+
277+
#### Example: Docker with GitHub App authentication
278+
279+
```bash
280+
docker run -i --rm \
281+
-e GITHUB_APP_ID=12345 \
282+
-e GITHUB_APP_INSTALLATION_ID=67890 \
283+
-e GITHUB_APP_PRIVATE_KEY_PATH=/key/private-key.pem \
284+
-v /path/to/private-key.pem:/key/private-key.pem:ro \
285+
ghcr.io/github/github-mcp-server
286+
```
287+
288+
#### Example: VS Code configuration
289+
290+
```json
291+
{
292+
"mcp": {
293+
"servers": {
294+
"github": {
295+
"command": "docker",
296+
"args": [
297+
"run",
298+
"-i",
299+
"--rm",
300+
"-e", "GITHUB_APP_ID",
301+
"-e", "GITHUB_APP_INSTALLATION_ID",
302+
"-e", "GITHUB_APP_PRIVATE_KEY",
303+
"ghcr.io/github/github-mcp-server"
304+
],
305+
"env": {
306+
"GITHUB_APP_ID": "12345",
307+
"GITHUB_APP_INSTALLATION_ID": "67890",
308+
"GITHUB_APP_PRIVATE_KEY": "-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n-----END RSA PRIVATE KEY-----"
309+
}
310+
}
311+
}
312+
}
313+
}
314+
```
315+
242316
### GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com)
243317

244318
The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set

cmd/github-mcp-server/main.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"errors"
55
"fmt"
66
"os"
7+
"strconv"
78
"strings"
89
"time"
910

@@ -34,8 +35,16 @@ var (
3435
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
3536
RunE: func(_ *cobra.Command, _ []string) error {
3637
token := viper.GetString("personal_access_token")
37-
if token == "" {
38-
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
38+
39+
// Parse GitHub App authentication config
40+
appID, privateKey, installationID, err := parseAppAuthConfig()
41+
if err != nil {
42+
return err
43+
}
44+
useAppAuth := appID != 0 && len(privateKey) > 0 && installationID != 0
45+
46+
if token == "" && !useAppAuth {
47+
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)")
3948
}
4049

4150
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
@@ -94,6 +103,9 @@ var (
94103
InsidersMode: viper.GetBool("insiders"),
95104
ExcludeTools: excludeTools,
96105
RepoAccessCacheTTL: &ttl,
106+
AppID: appID,
107+
PrivateKey: privateKey,
108+
InstallationID: installationID,
97109
}
98110
return ghmcp.RunStdioServer(stdioServerConfig)
99111
},
@@ -235,3 +247,44 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
235247
}
236248
return pflag.NormalizedName(name)
237249
}
250+
251+
// parseAppAuthConfig reads GitHub App authentication config from environment variables.
252+
// Returns (0, nil, 0, nil) when no App auth is configured.
253+
func parseAppAuthConfig() (appID int64, privateKey []byte, installationID int64, err error) {
254+
appIDStr := viper.GetString("app_id")
255+
installationIDStr := viper.GetString("app_installation_id")
256+
privateKeyStr := viper.GetString("app_private_key")
257+
privateKeyPath := viper.GetString("app_private_key_path")
258+
259+
// If none are set, App auth is not configured
260+
if appIDStr == "" && installationIDStr == "" && privateKeyStr == "" && privateKeyPath == "" {
261+
return 0, nil, 0, nil
262+
}
263+
264+
// If some but not all are set, that's a configuration error
265+
if appIDStr == "" || installationIDStr == "" || (privateKeyStr == "" && privateKeyPath == "") {
266+
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")
267+
}
268+
269+
appID, err = strconv.ParseInt(appIDStr, 10, 64)
270+
if err != nil {
271+
return 0, nil, 0, fmt.Errorf("invalid GITHUB_APP_ID: %w", err)
272+
}
273+
274+
installationID, err = strconv.ParseInt(installationIDStr, 10, 64)
275+
if err != nil {
276+
return 0, nil, 0, fmt.Errorf("invalid GITHUB_APP_INSTALLATION_ID: %w", err)
277+
}
278+
279+
if privateKeyStr != "" {
280+
// Environment variables often use literal "\n" instead of actual newlines
281+
privateKey = []byte(strings.ReplaceAll(privateKeyStr, `\n`, "\n"))
282+
} else {
283+
privateKey, err = os.ReadFile(privateKeyPath)
284+
if err != nil {
285+
return 0, nil, 0, fmt.Errorf("failed to read private key from %s: %w", privateKeyPath, err)
286+
}
287+
}
288+
289+
return appID, privateKey, installationID, nil
290+
}

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
1111
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
1212
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
1313
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
14+
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
15+
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
1416
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
1517
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
1618
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=

internal/ghmcp/server.go

Lines changed: 62 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414

1515
"github.com/github/github-mcp-server/pkg/errors"
1616
"github.com/github/github-mcp-server/pkg/github"
17+
"github.com/github/github-mcp-server/pkg/github/appauth"
1718
"github.com/github/github-mcp-server/pkg/http/transport"
1819
"github.com/github/github-mcp-server/pkg/inventory"
1920
"github.com/github/github-mcp-server/pkg/lockdown"
@@ -40,7 +41,8 @@ type githubClients struct {
4041
}
4142

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

66+
// Determine the base transport for REST and GraphQL clients
67+
baseTransport := http.RoundTripper(http.DefaultTransport)
68+
if authTransport != nil {
69+
baseTransport = authTransport
70+
}
71+
6472
// Construct REST client
6573
restUATransport := &transport.UserAgentTransport{
66-
Transport: http.DefaultTransport,
74+
Transport: baseTransport,
6775
Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version),
6876
}
69-
restClient, err := gogithub.NewClient(
77+
restClientOpts := []gogithub.Option{
7078
gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}),
71-
gogithub.WithAuthToken(cfg.Token),
7279
gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()),
73-
)
80+
}
81+
if authTransport == nil {
82+
restClientOpts = append(restClientOpts, gogithub.WithAuthToken(cfg.Token))
83+
}
84+
restClient, err := gogithub.NewClient(restClientOpts...)
7485
if err != nil {
7586
return nil, fmt.Errorf("failed to create REST client: %w", err)
7687
}
7788

7889
// Construct GraphQL client
7990
// We use NewEnterpriseClient unconditionally since we already parsed the API host
80-
gqlHTTPClient := &http.Client{
81-
Transport: &transport.BearerAuthTransport{
91+
var gqlTransport http.RoundTripper
92+
if authTransport != nil {
93+
// Auth transport already sets the Authorization header
94+
gqlTransport = &transport.GraphQLFeaturesTransport{
95+
Transport: authTransport,
96+
}
97+
} else {
98+
gqlTransport = &transport.BearerAuthTransport{
8299
Transport: &transport.GraphQLFeaturesTransport{
83100
Transport: http.DefaultTransport,
84101
},
85102
Token: cfg.Token,
86-
},
103+
}
87104
}
105+
gqlHTTPClient := &http.Client{Transport: gqlTransport}
88106

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

@@ -116,13 +134,13 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
116134
}, nil
117135
}
118136

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

125-
clients, err := createGitHubClients(cfg, apiHost)
143+
clients, err := createGitHubClients(cfg, apiHost, authTransport)
126144
if err != nil {
127145
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
128146
}
@@ -238,6 +256,13 @@ type StdioServerConfig struct {
238256

239257
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
240258
RepoAccessCacheTTL *time.Duration
259+
260+
// GitHub App authentication (alternative to Token)
261+
// When AppID, PrivateKey, and InstallationID are all set, the server
262+
// authenticates as a GitHub App installation instead of using a PAT.
263+
AppID int64
264+
PrivateKey []byte
265+
InstallationID int64
241266
}
242267

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

292+
// Set up GitHub App authentication transport if configured
293+
var appAuthTransport http.RoundTripper
294+
if cfg.AppID != 0 && len(cfg.PrivateKey) > 0 && cfg.InstallationID != 0 {
295+
apiHost, err := utils.NewAPIHost(cfg.Host)
296+
if err != nil {
297+
return fmt.Errorf("failed to parse API host for app auth: %w", err)
298+
}
299+
baseURL, err := apiHost.BaseRESTURL(ctx)
300+
if err != nil {
301+
return fmt.Errorf("failed to get base REST URL for app auth: %w", err)
302+
}
303+
tr, err := appauth.NewTransport(http.DefaultTransport, appauth.Config{
304+
AppID: cfg.AppID,
305+
PrivateKey: cfg.PrivateKey,
306+
InstallationID: cfg.InstallationID,
307+
BaseURL: baseURL.String(),
308+
})
309+
if err != nil {
310+
return fmt.Errorf("failed to create GitHub App auth transport: %w", err)
311+
}
312+
appAuthTransport = tr
313+
logger.Info("using GitHub App authentication", "appID", cfg.AppID, "installationID", cfg.InstallationID)
314+
}
315+
267316
// Fetch token scopes for scope-based tool filtering (PAT tokens only)
268317
// Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header.
269318
// Fine-grained PATs and other token types don't support this, so we skip filtering.
270319
var tokenScopes []string
271-
if strings.HasPrefix(cfg.Token, "ghp_") {
320+
if appAuthTransport == nil && strings.HasPrefix(cfg.Token, "ghp_") {
272321
fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host)
273322
if err != nil {
274323
logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err)
275324
} else {
276325
tokenScopes = fetchedScopes
277326
logger.Info("token scopes fetched for filtering", "scopes", tokenScopes)
278327
}
279-
} else {
328+
} else if appAuthTransport == nil {
280329
logger.Debug("skipping scope filtering for non-PAT token")
281330
}
282331

@@ -296,7 +345,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
296345
Logger: logger,
297346
RepoAccessTTL: cfg.RepoAccessCacheTTL,
298347
TokenScopes: tokenScopes,
299-
})
348+
}, appAuthTransport)
300349
if err != nil {
301350
return fmt.Errorf("failed to create MCP server: %w", err)
302351
}

0 commit comments

Comments
 (0)