Skip to content
75 changes: 72 additions & 3 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 @@ -37,6 +38,13 @@ 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")

// Parse GitHub App authentication config.
appID, privateKey, installationID, err := parseAppAuthConfig()
if err != nil {
return err
}
useAppAuth := appID != 0 && len(privateKey) > 0 && installationID != 0
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 @@ -50,8 +58,8 @@ var (
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 == "" && !useAppAuth && oauthClientID == "" {
return errors.New("authentication required: set GITHUB_PERSONAL_ACCESS_TOKEN, configure GitHub App auth with GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, and GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_PATH, or pass --oauth-client-id to log in via OAuth")
}

// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
Expand Down Expand Up @@ -110,13 +118,16 @@ var (
InsidersMode: viper.GetBool("insiders"),
ExcludeTools: excludeTools,
RepoAccessCacheTTL: &ttl,
AppID: appID,
PrivateKey: privateKey,
InstallationID: installationID,
}

// When no static token is provided, log in via OAuth using the given
// 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 == "" && !useAppAuth {
scopes := ghoauth.SupportedScopes
if viper.IsSet("oauth-scopes") {
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err != nil {
Expand Down Expand Up @@ -172,6 +183,11 @@ var (
}
}

appID, privateKey, installationID, err := parseAppAuthConfig()
if err != nil {
return err
}

ttl := viper.GetDuration("repo-access-cache-ttl")
httpConfig := ghhttp.ServerConfig{
Version: version,
Expand All @@ -194,6 +210,9 @@ var (
EnabledFeatures: enabledFeatures,
InsidersMode: viper.GetBool("insiders"),
TrustProxyHeaders: viper.GetBool("trust-proxy-headers"),
AppID: appID,
PrivateKey: privateKey,
InstallationID: installationID,
}

return ghhttp.RunHTTPServer(httpConfig)
Expand Down Expand Up @@ -289,3 +308,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
}
195 changes: 195 additions & 0 deletions docs/github-app-auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# Local Server GitHub App Authentication

The local GitHub MCP Server can authenticate directly as a GitHub App
installation instead of a Personal Access Token (PAT) or browser-based OAuth
login. This works for both the `stdio` and `http` commands. The server signs a
short-lived JWT with your app's private key, exchanges it for an installation
access token, and refreshes that installation token automatically before it
expires.

> This guide covers installation authentication using `GITHUB_APP_ID`,
> `GITHUB_APP_INSTALLATION_ID`, and a private key. For browser-based user login
> via OAuth, see [Local Server OAuth Login](oauth-login.md). The remote server
> has its own auth model; see [Remote Server](remote-server.md).

## Contents

- [How it works](#how-it-works)
- [GitHub App setup](#github-app-setup)
- [Quick start](#quick-start)
- [Configuration reference](#configuration-reference)
- [Running in Docker](#running-in-docker)
- [Using the `http` command](#using-the-http-command)
- [GitHub Enterprise Server and ghe.com](#github-enterprise-server-and-ghecom)
- [Troubleshooting](#troubleshooting)

## How it works

When all required app environment variables are present, the local server
authenticates as the configured GitHub App installation instead of as a user:

1. It signs a JWT with the app's private key.
2. It exchanges that JWT for an installation access token.
3. It reuses that installation token for GitHub API calls and refreshes it
automatically before it expires.

Because this is installation authentication, access is controlled by the app's
granted permissions and repository selection — not by PAT scopes or the OAuth
scopes described in [Local Server OAuth Login](oauth-login.md). No browser
callback, OAuth app, or device-code flow is involved.

## GitHub App setup

Before starting the server, create or choose a GitHub App and install it on the
account or organization that owns the repositories you want to access.

You will need:

- The **App ID**
- The **Installation ID**
- A **private key** for that app

Make sure the installation has the repository access and permissions your
workflows need. For example, repository-writing tools need write permissions on
the relevant resources; read-only setups can grant narrower access.

> GitHub App installation auth does **not** require an OAuth callback URL. If
> you only want installation auth, you do not need to enable device flow or
> browser login in the app settings.

## Quick start

**Native binary (`stdio`)** 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
```

Using an inline private key (for hosts that only accept environment variables):

```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
```

Once these variables are set, `GITHUB_PERSONAL_ACCESS_TOKEN` is not required.

## Configuration reference

GitHub App auth is configured with environment variables. The same variables
apply to both `stdio` and `http`.

| Environment variable | Description |
|----------------------|-------------|
| `GITHUB_APP_ID` | GitHub App ID |
| `GITHUB_APP_INSTALLATION_ID` | Installation ID of the app installation the server should act as |
| `GITHUB_APP_PRIVATE_KEY` | PEM-encoded private key inline. If your environment variable system cannot preserve real newlines, use literal `\n` escapes. |
| `GITHUB_APP_PRIVATE_KEY_PATH` | Path to a PEM private key file |
| `GITHUB_HOST` | Optional GitHub Enterprise Server / `ghe.com` host. Omit for github.com. |

Rules:

- Set **exactly one** of `GITHUB_APP_PRIVATE_KEY` or
`GITHUB_APP_PRIVATE_KEY_PATH`.
- `GITHUB_APP_ID`, `GITHUB_APP_INSTALLATION_ID`, and a private key are all
required together.
- If none of these variables are set, the server falls back to PAT or OAuth
auth depending on how you start it.

## Running in Docker

GitHub App auth does **not** use the browser callback flow, so Docker does
**not** need the OAuth callback port mapping required by
[Local Server OAuth Login](oauth-login.md). You only need to provide the app
credentials and, if you use a key file, mount it into the container.

```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
```

VS Code (`.vscode/mcp.json`) with an inline private key:

```json
{
"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-----"
}
}
}
}
```

## Using the `http` command

The same environment variables work for the local HTTP server:

```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 http --port 8082
```

This is useful when you want a long-running local MCP HTTP endpoint but still
want installation-scoped, automatically refreshed credentials.

## GitHub Enterprise Server and ghe.com

GitHub App auth works against GitHub Enterprise Server and `ghe.com` too, as
long as:

- the app is registered on that same host,
- the installation exists on that host, and
- you set `GITHUB_HOST` / `--gh-host` to that host.

Example:

```bash
export GITHUB_HOST=https://github.example.com
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
```

The server derives the REST API base URL from `GITHUB_HOST`, so installation
tokens are requested from the correct instance.

## Troubleshooting

- **"incomplete GitHub App auth config"** — set `GITHUB_APP_ID`,
`GITHUB_APP_INSTALLATION_ID`, and one private key source together.
- **"GITHUB_APP_PRIVATE_KEY and GITHUB_APP_PRIVATE_KEY_PATH are mutually
exclusive"** — keep only one.
- **403 or 404 from GitHub** — the app is often not installed on the target
repo/org, or it lacks the needed permissions.
- **Private key parsing or JWT errors** — make sure the private key matches the
configured app and is valid PEM.
- **Unexpected fallback to PAT/OAuth** — confirm the app variables are present
in the actual server process, not just in your shell.
4 changes: 4 additions & 0 deletions docs/oauth-login.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ pass `--oauth-client-id` (see [Bring your own app](#bring-your-own-app)).
> OAuth login applies to the **stdio** server only. The remote server and the
> `http` command have their own authentication; see
> [Remote Server](remote-server.md).
>
> To authenticate directly as a GitHub App installation via `GITHUB_APP_ID`,
> `GITHUB_APP_INSTALLATION_ID`, and a private key, see
> [Local Server GitHub App Authentication](github-app-auth.md).

## Contents

Expand Down
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.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM=
github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
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
16 changes: 15 additions & 1 deletion internal/ghmcp/oauth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,20 @@ func TestRunStdioServerRejectsTokenAndOAuth(t *testing.T) {
assert.Contains(t, err.Error(), "mutually exclusive")
}

func TestRunStdioServerRejectsAppAuthAndOAuth(t *testing.T) {
t.Parallel()

mgr := oauth.NewManager(oauth.NewGitHubConfig("client-id", "", nil, "", 0), discardLogger())
err := RunStdioServer(StdioServerConfig{
AppID: 1,
PrivateKey: []byte("not-a-real-key"),
InstallationID: 2,
OAuthManager: mgr,
})
require.Error(t, err)
assert.Contains(t, err.Error(), "mutually exclusive")
}

// 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
Expand All @@ -369,7 +383,7 @@ func TestCreateGitHubClientsTokenProvider(t *testing.T) {
clients, err := createGitHubClients(github.MCPServerConfig{
Version: "test",
TokenProvider: func() string { return current },
}, apiHost)
}, apiHost, nil)
require.NoError(t, err)

do := func() {
Expand Down
Loading