Skip to content
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
74 changes: 62 additions & 12 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"net/http"
"os"
"strings"
"time"
Expand Down Expand Up @@ -149,12 +150,25 @@ var (
stdioServerConfig.OAuthScopes = scopes
}

// With an installation ID, the server authenticates as that single
// installation. Without one, it discovers every installation of the
// app and picks the one that owns the resource each request
// addresses, so repositories spread across organizations all work
// from one app ID and private key.
if appAuthRequested {
tokenProvider, err := newGitHubAppTokenProvider(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
if err != nil {
return err
if appInstallationID != "" {
tokenProvider, err := newGitHubAppTokenProvider(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
if err != nil {
return err
}
stdioServerConfig.TokenProvider = tokenProvider
} else {
requestTokenProvider, err := newGitHubAppRequestTokenProvider(appID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
if err != nil {
return err
}
stdioServerConfig.RequestTokenProvider = requestTokenProvider
}
stdioServerConfig.TokenProvider = tokenProvider
}

return ghmcp.RunStdioServer(stdioServerConfig)
Expand Down Expand Up @@ -257,7 +271,7 @@ func init() {

// The private key has no flag because passing it in argv would expose it.
stdioCmd.Flags().String("app-id", "", "GitHub App ID or client ID, enabling non-interactive server-to-server authentication")
stdioCmd.Flags().String("app-installation-id", "", "GitHub App installation ID to mint installation access tokens for")
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")
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")

// HTTP-specific flags
Expand Down Expand Up @@ -322,27 +336,63 @@ func newGitHubAppTokenProvider(appID, installationID, keyPath, keyInline, host s
return nil, err
}

apiHost, err := utils.NewAPIHost(host)
if err != nil {
return nil, fmt.Errorf("failed to parse host for GitHub App authentication: %w", err)
}
restURL, err := apiHost.BaseRESTURL(context.Background())
restURL, err := appRESTBaseURL(host)
if err != nil {
return nil, fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err)
return nil, err
}

provider, err := githubapp.NewProvider(githubapp.Config{
AppID: appID,
InstallationID: installationID,
PrivateKeyPEM: keyBytes,
BaseRESTURL: restURL.String(),
BaseRESTURL: restURL,
}, nil)
if err != nil {
return nil, fmt.Errorf("failed to configure GitHub App authentication: %w", err)
}
return provider.AccessToken, nil
}

// newGitHubAppRequestTokenProvider builds a token provider for a GitHub App
// installed on more than one account. It mints a token per installation on
// demand, routing each request to the installation that owns the resource it
// addresses.
func newGitHubAppRequestTokenProvider(appID, keyPath, keyInline, host string) (func(*http.Request) string, error) {
keyBytes, err := loadAppPrivateKey(keyPath, keyInline)
if err != nil {
return nil, err
}

restURL, err := appRESTBaseURL(host)
if err != nil {
return nil, err
}

provider, err := githubapp.NewMultiProvider(githubapp.MultiConfig{
AppID: appID,
PrivateKeyPEM: keyBytes,
BaseRESTURL: restURL,
}, nil)
if err != nil {
return nil, fmt.Errorf("failed to configure GitHub App authentication: %w", err)
}
return provider.TokenForRequest, nil
}

// appRESTBaseURL resolves the REST API base used to mint installation tokens
// for the configured host.
func appRESTBaseURL(host string) (string, error) {
apiHost, err := utils.NewAPIHost(host)
if err != nil {
return "", fmt.Errorf("failed to parse host for GitHub App authentication: %w", err)
}
restURL, err := apiHost.BaseRESTURL(context.Background())
if err != nil {
return "", fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err)
}
return restURL.String(), nil
}

func loadAppPrivateKey(path, inline string) ([]byte, error) {
switch {
case path != "":
Expand Down
33 changes: 32 additions & 1 deletion docs/github-app-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ authentication.
| Flag | Environment variable | Description |
|------|----------------------|-------------|
| `--app-id` | `GITHUB_APP_ID` | App ID or client ID used as the JWT issuer |
| `--app-installation-id` | `GITHUB_APP_INSTALLATION_ID` | Installation whose access token is used |
| `--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)) |
| `--app-private-key-path` | `GITHUB_APP_PRIVATE_KEY_PATH` | Path to the private key PEM |
| _(none)_ | `GITHUB_APP_PRIVATE_KEY` | PEM contents, optionally with literal `\n` escapes |

Expand Down Expand Up @@ -57,6 +57,33 @@ docker run -i --rm \
ghcr.io/github/github-mcp-server
```

## Multiple organizations

A GitHub App can be installed on several accounts, and each installation has its
own ID and its own access token. Omit `--app-installation-id` to work across all
of them from a single app ID and private key:

```bash
github-mcp-server stdio \
--app-id 123456 \
--app-private-key-path /secrets/github-app.pem
```

The server then lists the app's installations, caches the map of account to
installation, and mints a token per installation on demand. Each API request is
routed to the installation that owns the resource it addresses: REST requests by
the owner in the path (`/repos/{owner}/...`, `/orgs/{org}/...`,
`/users/{user}/...`), and GraphQL requests by the `owner` or `login` variable in
the query. The installation directory is refreshed at most every 10 minutes,
when a lookup misses, so installing the app on a new organization is picked up
without a restart.

Requests that name no owner are sent unauthenticated, and so are requests for an
account the app is not installed on — the server does not fall back to another
installation's token. Endpoints that are not owner-scoped (`/user`,
`/rate_limit`, `/repositories/{id}`) therefore do not work in this mode; set
`--app-installation-id` to authenticate as one specific installation instead.

For GitHub Enterprise Server or `ghe.com`, also set `--gh-host` or
`GITHUB_HOST`. The server derives the installation-token endpoint from that
host.
Expand All @@ -71,3 +98,7 @@ host.
private key, target host, and system clock.
- **404 from the installation-token endpoint**: verify the installation ID and
that the app is installed on the target host.
- **401 or 404 for one organization only** (multi-installation mode): the app is
not installed on that account, or the tool call named an owner that does not
match the account login. The server logs `GitHub App is not installed on this
account` once per owner.
27 changes: 18 additions & 9 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,10 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
Transport: &transport.GraphQLFeaturesTransport{
Transport: http.DefaultTransport,
},
Token: cfg.Token,
TokenProvider: cfg.TokenProvider,
AllowedHosts: allowedHosts,
Token: cfg.Token,
TokenProvider: cfg.TokenProvider,
RequestTokenProvider: cfg.RequestTokenProvider,
AllowedHosts: allowedHosts,
},
}

Expand Down Expand Up @@ -157,10 +158,11 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
func newRESTClient(cfg github.MCPServerConfig, uaTransport *transport.UserAgentTransport, restURL, uploadURL string, allowedHosts []string) (*gogithub.Client, error) {
return gogithub.NewClient(
gogithub.WithHTTPClient(&http.Client{Transport: &transport.BearerAuthTransport{
Transport: uaTransport,
Token: cfg.Token,
TokenProvider: cfg.TokenProvider,
AllowedHosts: allowedHosts,
Transport: uaTransport,
Token: cfg.Token,
TokenProvider: cfg.TokenProvider,
RequestTokenProvider: cfg.RequestTokenProvider,
AllowedHosts: allowedHosts,
}}),
gogithub.WithEnterpriseURLs(restURL, uploadURL),
)
Expand Down Expand Up @@ -303,18 +305,24 @@ type StdioServerConfig struct {

// TokenProvider supplies a token for each GitHub API request.
TokenProvider func() string

// RequestTokenProvider supplies a token for each GitHub API request based on
// the request itself. GitHub App authentication that spans several
// installations uses it to pick the installation that owns the resource
// being addressed.
RequestTokenProvider func(*http.Request) string
}

// RunStdioServer is not concurrent safe.
func RunStdioServer(cfg StdioServerConfig) error {
authModes := 0
for _, on := range []bool{cfg.Token != "", cfg.OAuthManager != nil, cfg.TokenProvider != nil} {
for _, on := range []bool{cfg.Token != "", cfg.OAuthManager != nil, cfg.TokenProvider != nil, cfg.RequestTokenProvider != nil} {
if on {
authModes++
}
}
if authModes > 1 {
return fmt.Errorf("choose exactly one authentication mode: a static Token, OAuthManager, or TokenProvider")
return fmt.Errorf("choose exactly one authentication mode: a static Token, OAuthManager, TokenProvider, or RequestTokenProvider")
}

// Create app context
Expand Down Expand Up @@ -384,6 +392,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
RepoAccessTTL: cfg.RepoAccessCacheTTL,
TokenScopes: tokenScopes,
TokenProvider: tokenProvider,
RequestTokenProvider: cfg.RequestTokenProvider,
ToolHandlerMiddleware: toolHandlerMiddleware,
})
if err != nil {
Expand Down
Loading