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
2 changes: 2 additions & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,8 @@ jobs:
platforms: linux/amd64,linux/arm64
build-args: |
VERSION=${{ github.ref_name }}
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 @@ -35,6 +35,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
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
FROM golang:1.25.4-alpine AS build
ARG VERSION="dev"
ARG OAUTH_CLIENT_ID=""
ARG OAUTH_CLIENT_SECRET=""

# Set the working directory
WORKDIR /build
Expand All @@ -13,7 +15,7 @@ RUN --mount=type=cache,target=/var/cache/apk \
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=bind,target=. \
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)" \
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/main.go

# Make a stage to run the app
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,37 @@ 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 automatically selects the appropriate flow:

| Environment | Flow | Setup |
|-------------|------|-------|
| Native binary | PKCE (browser auto-opens) | Just set `GITHUB_OAUTH_CLIENT_ID` |
| Docker | Device flow (enter code at github.com/login/device) | Just set `GITHUB_OAUTH_CLIENT_ID` |
| Docker with port | PKCE (browser auto-opens) | Set `GITHUB_OAUTH_CALLBACK_PORT` and bind port |

**Example MCP configuration (Docker with 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"
}
}
}
}
```

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
31 changes: 16 additions & 15 deletions cmd/github-mcp-server/list_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,27 +101,28 @@ func runListScopes() error {
}
}

// Get enabled features (similar to toolsets)
var enabledFeatures []string
if viper.IsSet("features") {
if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil {
return fmt.Errorf("failed to unmarshal features: %w", err)
}
}

readOnly := viper.GetBool("read-only")
outputFormat := viper.GetString("list-scopes-output")

// Create translation helper
t, _ := translations.TranslationHelper()

// Build inventory using the same logic as the stdio server
inventoryBuilder := github.NewInventory(t).
WithReadOnly(readOnly)

// Configure toolsets (same as stdio)
if enabledToolsets != nil {
inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets)
}

// Configure specific tools
if len(enabledTools) > 0 {
inventoryBuilder = inventoryBuilder.WithTools(enabledTools)
}

inv, err := inventoryBuilder.Build()
// Build inventory using the shared builder for consistency
inv, err := github.NewStandardBuilder(github.InventoryConfig{
Translator: t,
ReadOnly: readOnly,
Toolsets: enabledToolsets,
Tools: enabledTools,
EnabledFeatures: enabledFeatures,
}).Build()
if err != nil {
return fmt.Errorf("failed to build inventory: %w", err)
}
Expand Down
151 changes: 145 additions & 6 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
package main

import (
"errors"
"context"
"fmt"
"os"
"sort"
"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"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
Expand All @@ -32,11 +37,6 @@ var (
Short: "Start stdio server",
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")
}

// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
// it's because viper doesn't handle comma-separated values correctly for env
// vars when using GetStringSlice.
Expand Down Expand Up @@ -68,11 +68,50 @@ var (
}
}

token := viper.GetString("personal_access_token")
var oauthMgr *oauth.Manager
var oauthScopes []string
var prebuiltInventory *inventory.Inventory

// If no token provided, setup OAuth manager
// Priority: 1. Explicit OAuth config, 2. Build-time credentials, 3. None
if token == "" {
oauthClientID, oauthClientSecret := resolveOAuthCredentials()
if oauthClientID != "" {
// Get translation helper for inventory building
t, _ := translations.TranslationHelper()

// Compute OAuth scopes and get inventory (avoids double building)
scopesResult := getOAuthScopes(enabledToolsets, enabledTools, enabledFeatures, t)
oauthScopes = scopesResult.scopes
prebuiltInventory = scopesResult.inventory

// Create OAuth manager for lazy authentication
oauthCfg := oauth.GetGitHubOAuthConfig(
oauthClientID,
oauthClientSecret,
oauthScopes,
viper.GetString("host"),
viper.GetInt("oauth_callback_port"),
)
oauthMgr = oauth.NewManager(oauthCfg)
fmt.Fprintf(os.Stderr, "OAuth configured - will prompt for authentication when needed\n")
} else {
fmt.Fprintf(os.Stderr, "Warning: No authentication configured\n")
fmt.Fprintf(os.Stderr, " - Set GITHUB_PERSONAL_ACCESS_TOKEN, or\n")
fmt.Fprintf(os.Stderr, " - Configure OAuth with --oauth-client-id\n")
fmt.Fprintf(os.Stderr, "Tools will prompt for authentication when called\n")
}
}

ttl := viper.GetDuration("repo-access-cache-ttl")
stdioServerConfig := ghmcp.StdioServerConfig{
Version: version,
Host: viper.GetString("host"),
Token: token,
OAuthManager: oauthMgr,
OAuthScopes: oauthScopes,
PrebuiltInventory: prebuiltInventory,
EnabledToolsets: enabledToolsets,
EnabledTools: enabledTools,
EnabledFeatures: enabledFeatures,
Expand Down Expand Up @@ -112,6 +151,12 @@ func init() {
rootCmd.PersistentFlags().Bool("insider-mode", false, "Enable insider features")
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")

// OAuth flags (stdio mode only)
rootCmd.PersistentFlags().String("oauth-client-id", "", "GitHub OAuth app client ID (enables interactive OAuth flow if token not set)")
rootCmd.PersistentFlags().String("oauth-client-secret", "", "GitHub OAuth app client secret (recommended)")
rootCmd.PersistentFlags().StringSlice("oauth-scopes", nil, "OAuth scopes to request (comma-separated)")
rootCmd.PersistentFlags().Int("oauth-callback-port", 0, "Fixed port for OAuth callback (0 for random, required for Docker with -p flag)")

// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
Expand All @@ -126,6 +171,10 @@ func init() {
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
_ = viper.BindPFlag("insider-mode", rootCmd.PersistentFlags().Lookup("insider-mode"))
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
_ = viper.BindPFlag("oauth_client_id", rootCmd.PersistentFlags().Lookup("oauth-client-id"))
_ = viper.BindPFlag("oauth_client_secret", rootCmd.PersistentFlags().Lookup("oauth-client-secret"))
_ = viper.BindPFlag("oauth_scopes", rootCmd.PersistentFlags().Lookup("oauth-scopes"))
_ = viper.BindPFlag("oauth_callback_port", rootCmd.PersistentFlags().Lookup("oauth-callback-port"))

// Add subcommands
rootCmd.AddCommand(stdioCmd)
Expand Down Expand Up @@ -154,3 +203,93 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
}
return pflag.NormalizedName(name)
}

// oauthScopesResult holds the result of OAuth scope computation
type oauthScopesResult struct {
scopes []string
inventory *inventory.Inventory // reused inventory to avoid double building
}

// getOAuthScopes returns the OAuth scopes to request based on enabled tools
// Also returns the built inventory to avoid building it twice
// Uses custom scopes if explicitly provided, otherwise computes required scopes
// from the tools that will be enabled based on user configuration
func getOAuthScopes(enabledToolsets, enabledTools, enabledFeatures []string, t translations.TranslationHelperFunc) oauthScopesResult {
// Allow explicit override via --oauth-scopes flag
var scopeList []string
if viper.IsSet("oauth_scopes") {
if err := viper.UnmarshalKey("oauth_scopes", &scopeList); err == nil && len(scopeList) > 0 {
// When scopes are explicit, don't build inventory (will be built in server)
return oauthScopesResult{scopes: scopeList}
}
}

// Build inventory with the same configuration that will be used at runtime
// This allows us to determine which tools will actually be available
// and avoids building the inventory twice
inventoryBuilder := github.NewStandardBuilder(github.InventoryConfig{
Translator: t,
ReadOnly: viper.GetBool("read-only"),
Toolsets: enabledToolsets,
Tools: enabledTools,
EnabledFeatures: enabledFeatures,
})

inv, err := inventoryBuilder.Build()
if err != nil {
// Inventory build only fails if invalid tool names are passed via --tools
// In that case, return empty scopes - the error will surface when server starts
return oauthScopesResult{scopes: nil}
}

// Collect all required scopes from available tools
// This is the canonical source of OAuth scopes for the enabled tools
requiredScopes := collectRequiredScopes(inv)
return oauthScopesResult{scopes: requiredScopes, inventory: inv}
}

// collectRequiredScopes collects all unique required scopes from available tools
// Returns a sorted, deduplicated list of OAuth scopes needed for the enabled tools
func collectRequiredScopes(inv *inventory.Inventory) []string {
scopeSet := make(map[string]bool)

// Get available tools (respects filters like read-only, toolsets, etc.)
for _, tool := range inv.AvailableTools(context.Background()) {
for _, scope := range tool.RequiredScopes {
if scope != "" {
scopeSet[scope] = true
}
}
}

// Convert to sorted slice for deterministic output
scopes := make([]string, 0, len(scopeSet))
for scope := range scopeSet {
scopes = append(scopes, scope)
}
sort.Strings(scopes)

return scopes
}

// resolveOAuthCredentials returns OAuth client credentials using the following priority:
// 1. Explicit configuration via flags/environment (--oauth-client-id, GITHUB_OAUTH_CLIENT_ID)
// 2. Build-time baked credentials (for official releases)
//
// This allows developers to override with their own OAuth app while providing
// a seamless "just works" experience for end users of official builds.
func resolveOAuthCredentials() (clientID, clientSecret string) {
// Priority 1: Explicit user configuration
clientID = viper.GetString("oauth_client_id")
if clientID != "" {
return clientID, viper.GetString("oauth_client_secret")
}

// Priority 2: Build-time baked credentials
if buildinfo.HasOAuthCredentials() {
return buildinfo.OAuthClientID, buildinfo.OAuthClientSecret
}

// No OAuth credentials available
return "", ""
}
Loading