Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,8 @@ Add one of the following JSON blocks to your IDE's MCP settings.

See **[Local Server OAuth Login](docs/oauth-login.md)** for the native-binary flow (no fixed port needed), the headless/device-code fallback, GitHub Enterprise Server / `ghe.com`, and bringing your own OAuth or GitHub App.

For non-interactive stdio deployments, see **[GitHub App Authentication](docs/github-app-auth.md)**.

**Or authenticate with a Personal Access Token.** Set `GITHUB_PERSONAL_ACCESS_TOKEN` instead (it takes precedence over OAuth):

```json
Expand Down
81 changes: 77 additions & 4 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"context"
"errors"
"fmt"
"os"
Expand All @@ -9,10 +10,12 @@ import (

"github.com/github/github-mcp-server/internal/buildinfo"
"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/internal/githubapp"
"github.com/github/github-mcp-server/internal/oauth"
"github.com/github/github-mcp-server/pkg/github"
ghhttp "github.com/github/github-mcp-server/pkg/http"
ghoauth "github.com/github/github-mcp-server/pkg/http/oauth"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
Expand All @@ -37,6 +40,12 @@ 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")
appID := viper.GetString("app-id")
appInstallationID := viper.GetString("app-installation-id")
appPrivateKeyPath := viper.GetString("app-private-key-path")
appPrivateKeyInline := viper.GetString("app-private-key")
appAuthRequested := appID != "" || appInstallationID != "" || appPrivateKeyPath != "" || appPrivateKeyInline != ""

oauthClientID := viper.GetString("oauth-client-id")
oauthClientSecret := viper.GetString("oauth-client-secret")
// Fall back to the build-time baked-in client (official releases) when none is
Expand All @@ -46,12 +55,18 @@ var (
// GITHUB_HOST=github.com (or api.github.com) still counts as the default and keeps
// zero-config login working. The secret tracks the id, so an explicitly provided
// id with no secret never picks up the baked-in secret.
if oauthClientID == "" && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" {
if oauthClientID == "" && !appAuthRequested && oauth.NormalizeHost(viper.GetString("host")) == "https://github.com" {
oauthClientID = buildinfo.OAuthClientID
oauthClientSecret = buildinfo.OAuthClientSecret
}
if token == "" && oauthClientID == "" {
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, or pass --oauth-client-id to log in via OAuth")
if token == "" && !appAuthRequested && oauthClientID == "" {
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth, or pass --oauth-client-id to log in via OAuth")
}
if appAuthRequested && token != "" {
return errors.New("GitHub App authentication and GITHUB_PERSONAL_ACCESS_TOKEN are mutually exclusive: set only one")
}
if appAuthRequested && oauthClientID != "" {
return errors.New("GitHub App authentication and OAuth login (--oauth-client-id) are mutually exclusive: set only one")
}

// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
Expand Down Expand Up @@ -116,7 +131,7 @@ var (
// client. The requested scopes default to the full supported set
// (which filters out no tools); an explicit, narrower --oauth-scopes
// both narrows the grant and hides tools needing other scopes.
if token == "" {
if token == "" && !appAuthRequested {
scopes := ghoauth.SupportedScopes
if viper.IsSet("oauth-scopes") {
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil {
Expand All @@ -134,6 +149,14 @@ var (
stdioServerConfig.OAuthScopes = scopes
}

if appAuthRequested {
tokenProvider, err := newGitHubAppTokenProvider(appID, appInstallationID, appPrivateKeyPath, appPrivateKeyInline, viper.GetString("host"))
if err != nil {
return err
}
stdioServerConfig.TokenProvider = tokenProvider
}

return ghmcp.RunStdioServer(stdioServerConfig)
},
}
Expand Down Expand Up @@ -230,6 +253,11 @@ func init() {
stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Comma-separated OAuth scopes to request; also filters tools to those scopes. Defaults to the full supported set")
stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed local port for the OAuth callback server. Defaults to a random port; set a fixed port when mapping it through Docker")

// 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-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
httpCmd.Flags().Int("port", 8082, "HTTP server port")
httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.")
Expand All @@ -256,6 +284,9 @@ func init() {
_ = viper.BindPFlag("oauth-client-secret", stdioCmd.Flags().Lookup("oauth-client-secret"))
_ = viper.BindPFlag("oauth-scopes", stdioCmd.Flags().Lookup("oauth-scopes"))
_ = viper.BindPFlag("oauth-callback-port", stdioCmd.Flags().Lookup("oauth-callback-port"))
_ = viper.BindPFlag("app-id", stdioCmd.Flags().Lookup("app-id"))
_ = viper.BindPFlag("app-installation-id", stdioCmd.Flags().Lookup("app-installation-id"))
_ = viper.BindPFlag("app-private-key-path", stdioCmd.Flags().Lookup("app-private-key-path"))
_ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port"))
_ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host"))
_ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url"))
Expand All @@ -281,6 +312,48 @@ func main() {
}
}

func newGitHubAppTokenProvider(appID, installationID, keyPath, keyInline, host string) (func() string, error) {
keyBytes, err := loadAppPrivateKey(keyPath, keyInline)
if err != nil {
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())
if err != nil {
return nil, fmt.Errorf("failed to resolve REST URL for GitHub App authentication: %w", err)
}

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

func loadAppPrivateKey(path, inline string) ([]byte, error) {
switch {
case path != "":
data, err := os.ReadFile(path) //#nosec G304 -- operator-supplied path to their own key
if err != nil {
return nil, fmt.Errorf("reading GitHub App private key file: %w", err)
}
return data, nil
case inline != "":
return []byte(strings.ReplaceAll(inline, `\n`, "\n")), nil
default:
return nil, errors.New("GitHub App authentication requires a private key: set GITHUB_APP_PRIVATE_KEY_PATH (preferred) or GITHUB_APP_PRIVATE_KEY")
}
}

func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
from := []string{"_"}
to := "-"
Expand Down
38 changes: 38 additions & 0 deletions cmd/github-mcp-server/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestLoadAppPrivateKey(t *testing.T) {
t.Run("file", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "app.pem")
require.NoError(t, os.WriteFile(path, []byte("from-file"), 0o600))

key, err := loadAppPrivateKey(path, "from-inline")
require.NoError(t, err)
assert.Equal(t, []byte("from-file"), key)
})

t.Run("inline", func(t *testing.T) {
key, err := loadAppPrivateKey("", `first\nsecond`)
require.NoError(t, err)
assert.Equal(t, []byte("first\nsecond"), key)
})

t.Run("missing", func(t *testing.T) {
_, err := loadAppPrivateKey("", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "private key")
})
}

func TestGitHubAppFlagsAreStdioOnly(t *testing.T) {
assert.NotNil(t, stdioCmd.Flags().Lookup("app-id"))
assert.Nil(t, httpCmd.Flags().Lookup("app-id"))
}
73 changes: 73 additions & 0 deletions docs/github-app-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# GitHub App authentication

The local stdio server can authenticate as a GitHub App installation without a
browser, device flow, or elicitation. It signs a short-lived JWT with the app's
private key, exchanges it for an installation access token, and refreshes the
token before it expires.

This authentication mode is not available for the `http` command. HTTP clients
must continue to provide their own `Authorization` token.

> [!WARNING]
> The private key can mint tokens for every repository and permission granted to
> the installation. Keep it out of source control, restrict access to the server
> process, and install the app only on the repositories it needs.

## Configuration

Configure exactly one of a Personal Access Token, OAuth login, or GitHub App
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-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 |

A mounted private-key file is preferred. There is no flag for inline PEM
contents because command-line arguments may be visible to other processes.

## Usage

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

The equivalent environment configuration is:

```bash
export GITHUB_APP_ID=123456
export GITHUB_APP_INSTALLATION_ID=7891011
export GITHUB_APP_PRIVATE_KEY_PATH=/secrets/github-app.pem
github-mcp-server stdio
```

For Docker, mount the key read-only:

```bash
docker run -i --rm \
-v /secrets/github-app.pem:/secrets/github-app.pem:ro \
-e GITHUB_APP_ID=123456 \
-e GITHUB_APP_INSTALLATION_ID=7891011 \
-e GITHUB_APP_PRIVATE_KEY_PATH=/secrets/github-app.pem \
ghcr.io/github/github-mcp-server
```

For GitHub Enterprise Server or `ghe.com`, also set `--gh-host` or
`GITHUB_HOST`. The server derives the installation-token endpoint from that
host.

## Troubleshooting

- **Private key required**: set `GITHUB_APP_PRIVATE_KEY_PATH` or
`GITHUB_APP_PRIVATE_KEY`.
- **Invalid private key**: provide the RSA PEM generated in the GitHub App
settings. PKCS#1 and PKCS#8 keys are supported.
- **401 from the installation-token endpoint**: verify the app ID or client ID,
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.
3 changes: 3 additions & 0 deletions docs/oauth-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ pass `--oauth-client-id` (see [Bring your own app](#bring-your-own-app)).
> `http` command have their own authentication; see
> [Remote Server](remote-server.md).
> For non-interactive stdio deployments, see
> [GitHub App authentication](github-app-auth.md).
## Contents

- [How it works](#how-it-works)
Expand Down
44 changes: 29 additions & 15 deletions internal/ghmcp/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,26 +538,40 @@ func TestOAuthMultiRoundTripResultType(t *testing.T) {
assert.False(t, toolRan)
}

// TestRunStdioServerRejectsTokenAndOAuth verifies the mutually-exclusive guard:
// supplying both a static token and an OAuth manager is rejected before the
// server starts, rather than silently preferring one for auth and the other for
// scope filtering.
func TestRunStdioServerRejectsTokenAndOAuth(t *testing.T) {
func TestRunStdioServerRejectsMultipleAuthModes(t *testing.T) {
t.Parallel()

mgr := oauth.NewManager(oauth.NewGitHubConfig("client-id", "", nil, "", 0), discardLogger())
err := RunStdioServer(StdioServerConfig{
Token: "ghp_static",
OAuthManager: mgr,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")

tests := []struct {
name string
cfg StdioServerConfig
}{
{
name: "token and oauth",
cfg: StdioServerConfig{Token: "ghp_static", OAuthManager: mgr},
},
{
name: "token and provider",
cfg: StdioServerConfig{Token: "ghp_static", TokenProvider: func() string { return "token" }},
},
{
name: "oauth and provider",
cfg: StdioServerConfig{OAuthManager: mgr, TokenProvider: func() string { return "token" }},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := RunStdioServer(tt.cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly one authentication mode")
})
}
}

// TestCreateGitHubClientsTokenProvider proves the OAuth wiring: when a
// TokenProvider is configured the REST client authenticates with the provider's
// current token on every request (and never pins a stale one), which is what the
// lazy, refreshing OAuth token depends on.
// TestCreateGitHubClientsTokenProvider verifies that clients resolve the
// provider for every request instead of pinning a token.
func TestCreateGitHubClientsTokenProvider(t *testing.T) {
t.Parallel()

Expand Down
22 changes: 13 additions & 9 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv
return nil, fmt.Errorf("failed to get Raw URL: %w", err)
}

// Construct REST client. When a TokenProvider is configured (OAuth), we
// Construct REST client. When a TokenProvider is configured, we
// authenticate via BearerAuthTransport and skip go-github's WithAuthToken:
// the latter installs its own round tripper that would pin the static token
// and shadow the dynamic one.
Expand Down Expand Up @@ -257,15 +257,21 @@ type StdioServerConfig struct {
// are hidden. The default set is the full supported list, which hides
// nothing; an explicit, narrower list filters accordingly.
OAuthScopes []string

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

// RunStdioServer is not concurrent safe.
func RunStdioServer(cfg StdioServerConfig) error {
// OAuth login and a static token are mutually exclusive: they would
// disagree on how the token is sourced (lazy provider vs. static) and on
// scope filtering, so reject the ambiguous combination up front.
if cfg.OAuthManager != nil && cfg.Token != "" {
return fmt.Errorf("OAuthManager and a static Token are mutually exclusive: provide one or the other")
authModes := 0
for _, on := range []bool{cfg.Token != "", cfg.OAuthManager != nil, cfg.TokenProvider != nil} {
if on {
authModes++
}
}
if authModes > 1 {
return fmt.Errorf("choose exactly one authentication mode: a static Token, OAuthManager, or TokenProvider")
}

// Create app context
Expand Down Expand Up @@ -311,9 +317,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
logger.Debug("skipping scope filtering for non-PAT token")
}

// For OAuth, the token is resolved lazily: empty until the user authorizes
// on the first tool call, then refreshed for the rest of the session.
var tokenProvider func() string
tokenProvider := cfg.TokenProvider
var toolHandlerMiddleware []inventory.ToolHandlerMiddleware
if cfg.OAuthManager != nil {
tokenProvider = cfg.OAuthManager.AccessToken
Expand Down
Loading
Loading