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
3 changes: 3 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ jobs:
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ github.ref_name }}
secrets: |
oauth_client_id=${{ secrets.OAUTH_CLIENT_ID }}
oauth_client_secret=${{ secrets.OAUTH_CLIENT_SECRET }}
# Sign the resulting Docker image digest except on PRs.
# This will only write to the public Rekor transparency log when the Docker
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/goreleaser.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ jobs:
workdir: .
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OAUTH_CLIENT_ID: ${{ secrets.OAUTH_CLIENT_ID }}
OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }}

- name: Generate signed build provenance attestations for workflow artifacts
uses: actions/attest-build-provenance@v3
Expand Down
2 changes: 1 addition & 1 deletion .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ builds:
- env:
- CGO_ENABLED=0
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}}
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID={{ .Env.OAUTH_CLIENT_ID }} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret={{ .Env.OAUTH_CLIENT_SECRET }}
goos:
- linux
- windows
Expand Down
9 changes: 7 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,15 @@ COPY . .
# Copy built UI assets over the placeholder
COPY --from=ui-build /app/pkg/github/ui_dist/* ./pkg/github/ui_dist/

# Build the server
# Build the server. OAuth credentials are injected via build secrets to avoid
# leaking them in image history. Secrets are read at build time only.
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--mount=type=secret,id=oauth_client_id \
--mount=type=secret,id=oauth_client_secret \
export OAUTH_CLIENT_ID="$(cat /run/secrets/oauth_client_id 2>/dev/null || echo '')" && \
export OAUTH_CLIENT_SECRET="$(cat /run/secrets/oauth_client_secret 2>/dev/null || echo '')" && \
CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ) -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=${OAUTH_CLIENT_ID} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=${OAUTH_CLIENT_SECRET}" \
-o /bin/github-mcp-server ./cmd/github-mcp-server

# Make a stage to run the app
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,39 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:

</details>

### OAuth Authentication (stdio mode)

For stdio mode, you can use OAuth 2.1 instead of a Personal Access Token. The server triggers the OAuth flow on the first tool call:

| Environment | Flow | Setup |
|-------------|------|-------|
| Docker with port | PKCE (URL elicitation) | Set `GITHUB_OAUTH_CLIENT_ID` + bind port |
| Docker without port | Device flow (enter code at github.com/login/device) | Set `GITHUB_OAUTH_CLIENT_ID` |
| Native binary | PKCE (browser auto-opens) | Set `GITHUB_OAUTH_CLIENT_ID` |

**Docker example (PKCE with bound port — recommended):**
```json
{
"mcpServers": {
"github": {
"command": "docker",
"args": ["run", "-i", "--rm",
"-e", "GITHUB_OAUTH_CLIENT_ID",
"-e", "GITHUB_OAUTH_CLIENT_SECRET",
"-e", "GITHUB_OAUTH_CALLBACK_PORT=8085",
"-p", "127.0.0.1:8085:8085",
"ghcr.io/github/github-mcp-server"],
"env": {
"GITHUB_OAUTH_CLIENT_ID": "your_client_id",
"GITHUB_OAUTH_CLIENT_SECRET": "your_client_secret"
}
}
}
}
```

See [docs/oauth-authentication.md](docs/oauth-authentication.md) for full setup instructions, including how to create a GitHub OAuth App.

### GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com)

The flag `--gh-host` and the environment variable `GITHUB_HOST` can be used to set
Expand Down
72 changes: 70 additions & 2 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ import (
"strings"
"time"

"github.com/github/github-mcp-server/internal/buildinfo"
"github.com/github/github-mcp-server/internal/ghmcp"
"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/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
Expand All @@ -34,8 +37,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")
if token == "" {
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")

// Resolve OAuth credentials: explicit config > build-time > none
oauthClientID, oauthClientSecret := resolveOAuthCredentials()

if token == "" && oauthClientID == "" {
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set and no OAuth credentials available")
}

// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
Expand Down Expand Up @@ -96,6 +103,22 @@ var (
ExcludeTools: excludeTools,
RepoAccessCacheTTL: &ttl,
}

// Configure OAuth if credentials are available and no PAT is set.
// PAT takes priority — if both are configured, PAT is used directly.
if token == "" && oauthClientID != "" {
oauthScopes := getOAuthScopes()
oauthCfg := oauth.GetGitHubOAuthConfig(
oauthClientID,
oauthClientSecret,
oauthScopes,
viper.GetString("host"),
viper.GetInt("oauth-callback-port"),
)
stdioServerConfig.OAuthManager = oauth.NewManager(oauthCfg, nil)
stdioServerConfig.OAuthScopes = oauthScopes
}

return ghmcp.RunStdioServer(stdioServerConfig)
},
}
Expand Down Expand Up @@ -154,6 +177,12 @@ func init() {
httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)")
httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses")

// OAuth flags (stdio only)
stdioCmd.Flags().String("oauth-client-id", "", "OAuth client ID for browser-based authentication")
stdioCmd.Flags().String("oauth-client-secret", "", "OAuth client secret")
stdioCmd.Flags().StringSlice("oauth-scopes", nil, "Explicit OAuth scopes to request (overrides automatic computation)")
stdioCmd.Flags().Int("oauth-callback-port", 0, "Fixed port for OAuth callback server (0 for random, required for Docker with -p)")

// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
Expand All @@ -173,6 +202,10 @@ func init() {
_ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url"))
_ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path"))
_ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge"))
_ = viper.BindPFlag("oauth-client-id", stdioCmd.Flags().Lookup("oauth-client-id"))
_ = 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"))
// Add subcommands
rootCmd.AddCommand(stdioCmd)
rootCmd.AddCommand(httpCmd)
Expand Down Expand Up @@ -200,3 +233,38 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
}
return pflag.NormalizedName(name)
}

// resolveOAuthCredentials returns OAuth client credentials from the best
// available source. Priority: explicit config > build-time baked > none.
func resolveOAuthCredentials() (clientID, clientSecret string) {
clientID = viper.GetString("oauth-client-id")
clientSecret = viper.GetString("oauth-client-secret")
if clientID != "" {
return clientID, clientSecret
}

if buildinfo.OAuthClientID != "" {
return buildinfo.OAuthClientID, buildinfo.OAuthClientSecret
}

return "", ""
}

// getOAuthScopes returns the OAuth scopes to request. Uses explicit override
// if provided, otherwise falls back to the canonical SupportedScopes list
// which covers all tools the server may expose.
func getOAuthScopes() []string {

if viper.IsSet("oauth-scopes") {
var scopes []string
if err := viper.UnmarshalKey("oauth-scopes", &scopes); err == nil && len(scopes) > 0 {
return scopes
}
}

// Use the canonical list maintained alongside the HTTP OAuth metadata.
// This requests all scopes any tool might need. The consent screen shows
// the user exactly what is being requested, and scope-based tool filtering
// hides tools the granted token cannot satisfy.
return ghoauth.SupportedScopes
}
131 changes: 131 additions & 0 deletions docs/oauth-authentication.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# OAuth Authentication (stdio mode)

The GitHub MCP Server supports OAuth 2.1 authentication for stdio mode, allowing users to authenticate via their browser instead of manually creating Personal Access Tokens.

## How It Works

When no `GITHUB_PERSONAL_ACCESS_TOKEN` is configured and OAuth credentials are available, the server starts without a token. On the first tool call, it triggers the OAuth flow:

1. **PKCE flow** (primary): A local callback server starts, your browser opens to GitHub's authorization page, and the token is received via callback. If the browser cannot open (e.g., Docker), the authorization URL is shown via [MCP URL elicitation](https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation).

2. **Device flow** (fallback): If the callback server cannot start (e.g., Docker without port binding), the server falls back to GitHub's [device flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow). A code is displayed that you enter at [github.com/login/device](https://github.com/login/device).

### Authentication Priority

| Priority | Source | Notes |
|----------|--------|-------|
| 1 (highest) | `GITHUB_PERSONAL_ACCESS_TOKEN` | PAT is used directly, OAuth is skipped |
| 2 | `GITHUB_OAUTH_CLIENT_ID` (env/flag) | Explicit OAuth credentials |
| 3 | Built-in credentials | Baked into official releases via build flags |

## Docker Setup (Recommended)

Docker is the standard distribution method. The recommended setup uses PKCE with a bound port:

```json
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "GITHUB_OAUTH_CLIENT_ID",
"-e", "GITHUB_OAUTH_CLIENT_SECRET",
"-e", "GITHUB_OAUTH_CALLBACK_PORT=8085",
"-p", "127.0.0.1:8085:8085",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CLIENT_ID": "your_client_id",
"GITHUB_OAUTH_CLIENT_SECRET": "your_client_secret"
}
}
}
}
```

> **Security**: Always bind to `127.0.0.1` (not `0.0.0.0`) to restrict the callback to localhost.

### Docker Without Port Binding (Device Flow)

If you cannot bind a port, the server falls back to device flow:

```json
{
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-e", "GITHUB_OAUTH_CLIENT_ID",
"-e", "GITHUB_OAUTH_CLIENT_SECRET",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_OAUTH_CLIENT_ID": "your_client_id",
"GITHUB_OAUTH_CLIENT_SECRET": "your_client_secret"
}
}
}
}
```

## Native Binary Setup

For native binaries, PKCE works automatically with a random port:

```bash
export GITHUB_OAUTH_CLIENT_ID="your_client_id"
export GITHUB_OAUTH_CLIENT_SECRET="your_client_secret"
./github-mcp-server stdio
```

The browser opens automatically. No port configuration needed.

## Creating a GitHub OAuth App

1. Go to **GitHub Settings** → **Developer settings** → **OAuth Apps**
2. Click **New OAuth App**
3. Fill in:
- **Application name**: e.g., "GitHub MCP Server"
- **Homepage URL**: `https://github.com/github/github-mcp-server`
- **Authorization callback URL**: `http://localhost:8085/callback` (match your `--oauth-callback-port`)
4. Click **Register application**
5. Copy the **Client ID** and generate a **Client Secret**

> **Note**: The callback URL must be registered even for device flow, though it won't be used.

## Configuration Reference

| Environment Variable | Flag | Description |
|---------------------|------|-------------|
| `GITHUB_OAUTH_CLIENT_ID` | `--oauth-client-id` | OAuth client ID |
| `GITHUB_OAUTH_CLIENT_SECRET` | `--oauth-client-secret` | OAuth client secret |
| `GITHUB_OAUTH_CALLBACK_PORT` | `--oauth-callback-port` | Fixed callback port (0 = random) |
| `GITHUB_OAUTH_SCOPES` | `--oauth-scopes` | Override automatic scope selection |

## Security Design

### PKCE (Proof Key for Code Exchange)
All authorization code flows use PKCE with S256 challenge, preventing authorization code interception even if an attacker can observe the callback.

### Fixed Port Considerations
Docker requires a fixed callback port for port mapping. This is acceptable because:
- **PKCE verifier** is generated per-flow and never leaves the process — an attacker who intercepts the callback cannot exchange the code
- **State parameter** prevents CSRF — the callback validates state match
- **Callback server binds to 127.0.0.1** — not accessible from outside the host
- **Short-lived** — the server shuts down immediately after receiving the callback

### Token Handling
- Tokens are stored **in memory only** — never written to disk
- OAuth token takes precedence over PAT if both become available
- The server requests only the scopes needed by the configured tools

### URL Elicitation Security
When the browser cannot auto-open, the authorization URL is shown via MCP URL-mode elicitation. This is secure because:
- URL elicitation presents the URL to the user without exposing it to the LLM context
- The MCP client shows the full URL for user inspection before navigation
- Credentials flow directly between the user's browser and GitHub — never through the MCP channel

### Device Flow as Fallback
Device flow is more susceptible to social engineering than PKCE (the device code could theoretically be phished), which is why PKCE is always attempted first. Device flow is only used when a callback server cannot be started.
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ require (
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
github.com/yosida95/uritemplate/v3 v3.0.2
golang.org/x/oauth2 v0.34.0
)

require (
Expand All @@ -45,7 +46,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
Expand Down
16 changes: 16 additions & 0 deletions internal/buildinfo/buildinfo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Package buildinfo contains variables that are set at build time via ldflags.
// These allow official releases to include default OAuth credentials without
// requiring end-user configuration.
//
// Example ldflags usage:
//
// go build -ldflags="-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=xxx"
package buildinfo

// OAuthClientID is the default OAuth client ID, set at build time.
var OAuthClientID string

// OAuthClientSecret is the default OAuth client secret, set at build time.
// Note: For public OAuth clients (native apps), the client secret is not
// truly secret per OAuth 2.1 — security relies on PKCE, not the secret.
var OAuthClientSecret string
Loading