Skip to content

Commit c754ab1

Browse files
committed
Support GitHub App auth across multiple installations
A GitHub App can be installed on several accounts, but each installation has its own ID and mints its own access token. Today the server takes a single installation ID, so an enterprise whose repositories are spread across organizations needs one server process per organization. Make --app-installation-id optional. Without it, the server lists the app's installations, caches the account-to-installation map, and mints a token per installation on demand, routing each API request to the installation that owns the resource it addresses: REST requests by the owner in the path, GraphQL requests by the owner or login variable. Routing needs the request, which the existing func() string token provider cannot see, so BearerAuthTransport gains an optional RequestTokenProvider that takes precedence over it. A request that names no owner, or names an account the app is not installed on, is sent unauthenticated rather than falling back to another installation's token, so a misrouted call fails visibly instead of running against the wrong organization. Passing --app-installation-id keeps the existing single-installation behavior unchanged.
1 parent 12d16ed commit c754ab1

10 files changed

Lines changed: 869 additions & 23 deletions

File tree

cmd/github-mcp-server/main.go

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"net/http"
78
"os"
89
"strings"
910
"time"
@@ -149,12 +150,25 @@ var (
149150
stdioServerConfig.OAuthScopes = scopes
150151
}
151152

153+
// With an installation ID, the server authenticates as that single
154+
// installation. Without one, it discovers every installation of the
155+
// app and picks the one that owns the resource each request
156+
// addresses, so repositories spread across organizations all work
157+
// from one app ID and private key.
152158
if appAuthRequested {
153-
tokenProvider, err := newGitHubAppTokenProvider(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
154-
if err != nil {
155-
return err
159+
if appInstallationID != "" {
160+
tokenProvider, err := newGitHubAppTokenProvider(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
161+
if err != nil {
162+
return err
163+
}
164+
stdioServerConfig.TokenProvider = tokenProvider
165+
} else {
166+
requestTokenProvider, err := newGitHubAppRequestTokenProvider(appID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
167+
if err != nil {
168+
return err
169+
}
170+
stdioServerConfig.RequestTokenProvider = requestTokenProvider
156171
}
157-
stdioServerConfig.TokenProvider = tokenProvider
158172
}
159173

160174
return ghmcp.RunStdioServer(stdioServerConfig)
@@ -257,7 +271,7 @@ func init() {
257271

258272
// The private key has no flag because passing it in argv would expose it.
259273
stdioCmd.Flags().String("app-id", "", "GitHub App ID or client ID, enabling non-interactive server-to-server authentication")
260-
stdioCmd.Flags().String("app-installation-id", "", "GitHub App installation ID to mint installation access tokens for")
274+
stdioCmd.Flags().String("app-installation-id", "", "GitHub App installation ID to mint installation access tokens for. Omit to use every installation of the app, selecting the one that owns each requested resource")
261275
stdioCmd.Flags().String("app-private-key-path", "", "Path to the GitHub App private key (PEM). Preferred over GITHUB_APP_PRIVATE_KEY: keeps the key off the command line and out of the environment")
262276

263277
// HTTP-specific flags
@@ -322,27 +336,63 @@ func newGitHubAppTokenProvider(appID, installationID, keyPath, keyInline, host s
322336
return nil, err
323337
}
324338

325-
apiHost, err := utils.NewAPIHost(host)
326-
if err != nil {
327-
return nil, fmt.Errorf("failed to parse host for GitHub App authentication: %w", err)
328-
}
329-
restURL, err := apiHost.BaseRESTURL(context.Background())
339+
restURL, err := appRESTBaseURL(host)
330340
if err != nil {
331-
return nil, fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err)
341+
return nil, err
332342
}
333343

334344
provider, err := githubapp.NewProvider(githubapp.Config{
335345
AppID: appID,
336346
InstallationID: installationID,
337347
PrivateKeyPEM: keyBytes,
338-
BaseRESTURL: restURL.String(),
348+
BaseRESTURL: restURL,
339349
}, nil)
340350
if err != nil {
341351
return nil, fmt.Errorf("failed to configure GitHub App authentication: %w", err)
342352
}
343353
return provider.AccessToken, nil
344354
}
345355

356+
// newGitHubAppRequestTokenProvider builds a token provider for a GitHub App
357+
// installed on more than one account. It mints a token per installation on
358+
// demand, routing each request to the installation that owns the resource it
359+
// addresses.
360+
func newGitHubAppRequestTokenProvider(appID, keyPath, keyInline, host string) (func(*http.Request) string, error) {
361+
keyBytes, err := loadAppPrivateKey(keyPath, keyInline)
362+
if err != nil {
363+
return nil, err
364+
}
365+
366+
restURL, err := appRESTBaseURL(host)
367+
if err != nil {
368+
return nil, err
369+
}
370+
371+
provider, err := githubapp.NewMultiProvider(githubapp.MultiConfig{
372+
AppID: appID,
373+
PrivateKeyPEM: keyBytes,
374+
BaseRESTURL: restURL,
375+
}, nil)
376+
if err != nil {
377+
return nil, fmt.Errorf("failed to configure GitHub App authentication: %w", err)
378+
}
379+
return provider.TokenForRequest, nil
380+
}
381+
382+
// appRESTBaseURL resolves the REST API base used to mint installation tokens
383+
// for the configured host.
384+
func appRESTBaseURL(host string) (string, error) {
385+
apiHost, err := utils.NewAPIHost(host)
386+
if err != nil {
387+
return "", fmt.Errorf("failed to parse host for GitHub App authentication: %w", err)
388+
}
389+
restURL, err := apiHost.BaseRESTURL(context.Background())
390+
if err != nil {
391+
return "", fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err)
392+
}
393+
return restURL.String(), nil
394+
}
395+
346396
func loadAppPrivateKey(path, inline string) ([]byte, error) {
347397
switch {
348398
case path != "":

docs/github-app-auth.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ authentication.
2121
| Flag | Environment variable | Description |
2222
|------|----------------------|-------------|
2323
| `--app-id` | `GITHUB_APP_ID` | App ID or client ID used as the JWT issuer |
24-
| `--app-installation-id` | `GITHUB_APP_INSTALLATION_ID` | Installation whose access token is used |
24+
| `--app-installation-id` | `GITHUB_APP_INSTALLATION_ID` | Installation whose access token is used. Omit to use every installation of the app (see [Multiple organizations](#multiple-organizations)) |
2525
| `--app-private-key-path` | `GITHUB_APP_PRIVATE_KEY_PATH` | Path to the private key PEM |
2626
| _(none)_ | `GITHUB_APP_PRIVATE_KEY` | PEM contents, optionally with literal `\n` escapes |
2727

@@ -57,6 +57,33 @@ docker run -i --rm \
5757
ghcr.io/github/github-mcp-server
5858
```
5959

60+
## Multiple organizations
61+
62+
A GitHub App can be installed on several accounts, and each installation has its
63+
own ID and its own access token. Omit `--app-installation-id` to work across all
64+
of them from a single app ID and private key:
65+
66+
```bash
67+
github-mcp-server stdio \
68+
--app-id 123456 \
69+
--app-private-key-path /secrets/github-app.pem
70+
```
71+
72+
The server then lists the app's installations, caches the map of account to
73+
installation, and mints a token per installation on demand. Each API request is
74+
routed to the installation that owns the resource it addresses: REST requests by
75+
the owner in the path (`/repos/{owner}/...`, `/orgs/{org}/...`,
76+
`/users/{user}/...`), and GraphQL requests by the `owner` or `login` variable in
77+
the query. The installation directory is refreshed at most every 10 minutes,
78+
when a lookup misses, so installing the app on a new organization is picked up
79+
without a restart.
80+
81+
Requests that name no owner are sent unauthenticated, and so are requests for an
82+
account the app is not installed on — the server does not fall back to another
83+
installation's token. Endpoints that are not owner-scoped (`/user`,
84+
`/rate_limit`, `/repositories/{id}`) therefore do not work in this mode; set
85+
`--app-installation-id` to authenticate as one specific installation instead.
86+
6087
For GitHub Enterprise Server or `ghe.com`, also set `--gh-host` or
6188
`GITHUB_HOST`. The server derives the installation-token endpoint from that
6289
host.
@@ -71,3 +98,7 @@ host.
7198
private key, target host, and system clock.
7299
- **404 from the installation-token endpoint**: verify the installation ID and
73100
that the app is installed on the target host.
101+
- **401 or 404 for one organization only** (multi-installation mode): the app is
102+
not installed on that account, or the tool call named an owner that does not
103+
match the account login. The server logs `GitHub App is not installed on this
104+
account` once per owner.

internal/ghmcp/server.go

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,9 +104,10 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
104104
Transport: &transport.GraphQLFeaturesTransport{
105105
Transport: http.DefaultTransport,
106106
},
107-
Token: cfg.Token,
108-
TokenProvider: cfg.TokenProvider,
109-
AllowedHosts: allowedHosts,
107+
Token: cfg.Token,
108+
TokenProvider: cfg.TokenProvider,
109+
RequestTokenProvider: cfg.RequestTokenProvider,
110+
AllowedHosts: allowedHosts,
110111
},
111112
}
112113

@@ -157,10 +158,11 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
157158
func newRESTClient(cfg github.MCPServerConfig, uaTransport *transport.UserAgentTransport, restURL, uploadURL string, allowedHosts []string) (*gogithub.Client, error) {
158159
return gogithub.NewClient(
159160
gogithub.WithHTTPClient(&http.Client{Transport: &transport.BearerAuthTransport{
160-
Transport: uaTransport,
161-
Token: cfg.Token,
162-
TokenProvider: cfg.TokenProvider,
163-
AllowedHosts: allowedHosts,
161+
Transport: uaTransport,
162+
Token: cfg.Token,
163+
TokenProvider: cfg.TokenProvider,
164+
RequestTokenProvider: cfg.RequestTokenProvider,
165+
AllowedHosts: allowedHosts,
164166
}}),
165167
gogithub.WithEnterpriseURLs(restURL, uploadURL),
166168
)
@@ -303,18 +305,24 @@ type StdioServerConfig struct {
303305

304306
// TokenProvider supplies a token for each GitHub API request.
305307
TokenProvider func() string
308+
309+
// RequestTokenProvider supplies a token for each GitHub API request based on
310+
// the request itself. GitHub App authentication that spans several
311+
// installations uses it to pick the installation that owns the resource
312+
// being addressed.
313+
RequestTokenProvider func(*http.Request) string
306314
}
307315

308316
// RunStdioServer is not concurrent safe.
309317
func RunStdioServer(cfg StdioServerConfig) error {
310318
authModes := 0
311-
for _, on := range []bool{cfg.Token != "", cfg.OAuthManager != nil, cfg.TokenProvider != nil} {
319+
for _, on := range []bool{cfg.Token != "", cfg.OAuthManager != nil, cfg.TokenProvider != nil, cfg.RequestTokenProvider != nil} {
312320
if on {
313321
authModes++
314322
}
315323
}
316324
if authModes > 1 {
317-
return fmt.Errorf("choose exactly one authentication mode: a static Token, OAuthManager, or TokenProvider")
325+
return fmt.Errorf("choose exactly one authentication mode: a static Token, OAuthManager, TokenProvider, or RequestTokenProvider")
318326
}
319327

320328
// Create app context
@@ -384,6 +392,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
384392
RepoAccessTTL: cfg.RepoAccessCacheTTL,
385393
TokenScopes: tokenScopes,
386394
TokenProvider: tokenProvider,
395+
RequestTokenProvider: cfg.RequestTokenProvider,
387396
ToolHandlerMiddleware: toolHandlerMiddleware,
388397
})
389398
if err != nil {

0 commit comments

Comments
 (0)