Skip to content
Draft
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
146 changes: 146 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,152 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:

</details>

### OAuth Authentication (stdio mode)

For stdio mode (local binary execution), you can use OAuth 2.1 with PKCE instead of a Personal Access Token. This provides an interactive browser-based login flow.

**The OAuth flow automatically adapts to your environment:**
- **Native binary**: Uses interactive PKCE flow (browser opens automatically)
- **Docker container**: Uses device flow (displays code + URL, no callback needed)
- **Docker with port binding**: Can use PKCE flow with `--oauth-callback-port`

#### Quick Setup

**Option 1: Device Flow (Easiest for Docker)**
```bash
# 1. Create GitHub OAuth App at https://github.com/settings/developers
# 2. Run with Docker (device flow automatic)
docker run -i --rm \
-e GITHUB_OAUTH_CLIENT_ID=your_client_id \
-e GITHUB_OAUTH_CLIENT_SECRET=your_client_secret \
ghcr.io/github/github-mcp-server
# → Displays: Visit https://github.com/login/device and enter code: ABCD-1234
```

**Option 2: Interactive Flow (Best UX)**
```bash
# For native binary
export GITHUB_OAUTH_CLIENT_ID=your_client_id
export GITHUB_OAUTH_CLIENT_SECRET=your_client_secret
./github-mcp-server stdio
# → Browser opens automatically

# For Docker with port binding (requires setup in OAuth app callback)
docker run -i --rm -p 8080:8080 \
-e GITHUB_OAUTH_CLIENT_ID=your_client_id \
-e GITHUB_OAUTH_CLIENT_SECRET=your_client_secret \
-e GITHUB_OAUTH_CALLBACK_PORT=8080 \
ghcr.io/github/github-mcp-server
# → Browser opens automatically (callback works via bound port)
```

#### Prerequisites for OAuth

1. Create a GitHub OAuth App at [https://github.com/settings/developers](https://github.com/settings/developers)
- For native binary: Set callback URL to `http://localhost` (port is dynamic)
- For Docker with port binding: Set callback URL to `http://localhost:PORT/callback` (your chosen port)
- For Docker with device flow: No callback URL needed

2. Set your OAuth app credentials:
```bash
export GITHUB_OAUTH_CLIENT_ID=your_client_id
export GITHUB_OAUTH_CLIENT_SECRET=your_client_secret
```

3. Run the server without a PAT:
```bash
# Native binary - interactive PKCE flow
./github-mcp-server stdio

# Docker - device flow (automatic)
docker run -i --rm \
-e GITHUB_OAUTH_CLIENT_ID=your_client_id \
-e GITHUB_OAUTH_CLIENT_SECRET=your_client_secret \
ghcr.io/github/github-mcp-server

# Docker with port binding - interactive PKCE flow
docker run -i --rm -p 8080:8080 \
-e GITHUB_OAUTH_CLIENT_ID=your_client_id \
-e GITHUB_OAUTH_CLIENT_SECRET=your_client_secret \
-e GITHUB_OAUTH_CALLBACK_PORT=8080 \
ghcr.io/github/github-mcp-server
```

The server will automatically detect the environment and use the appropriate OAuth flow.

#### OAuth Configuration Options

- `--oauth-client-id` / `GITHUB_OAUTH_CLIENT_ID` - Your GitHub OAuth app client ID (required for OAuth flow)
- `--oauth-client-secret` / `GITHUB_OAUTH_CLIENT_SECRET` - Your GitHub OAuth app client secret (required)
- `--oauth-scopes` / `GITHUB_OAUTH_SCOPES` - Comma-separated list of scopes (defaults: `repo,user,gist,notifications,read:org,project`)
- `--oauth-callback-port` / `GITHUB_OAUTH_CALLBACK_PORT` - Fixed callback port for Docker (0 for random)

Example with custom scopes:
```bash
./github-mcp-server stdio \
--oauth-client-id YOUR_CLIENT_ID \
--oauth-client-secret YOUR_CLIENT_SECRET \
--oauth-scopes repo,user
```

#### Pre-configured MCP Host Setup

OAuth can be pre-configured for MCP hosts (similar to PAT distribution). For Docker with port binding:

**Claude Desktop/Code:**
```bash
claude mcp add github \
-e GITHUB_OAUTH_CLIENT_ID=your_client_id \
-e GITHUB_OAUTH_CLIENT_SECRET=your_client_secret \
-e GITHUB_OAUTH_CALLBACK_PORT=8080 \
-- docker run -i --rm -p 8080:8080 \
-e GITHUB_OAUTH_CLIENT_ID \
-e GITHUB_OAUTH_CLIENT_SECRET \
-e GITHUB_OAUTH_CALLBACK_PORT \
ghcr.io/github/github-mcp-server
```

**VS Code (settings.json):**
```json
{
"mcp": {
"servers": {
"github": {
"command": "docker",
"args": ["run", "-i", "--rm", "-p", "8080:8080",
"-e", "GITHUB_OAUTH_CLIENT_ID",
"-e", "GITHUB_OAUTH_CLIENT_SECRET",
"-e", "GITHUB_OAUTH_CALLBACK_PORT",
"ghcr.io/github/github-mcp-server"],
"env": {
"GITHUB_OAUTH_CLIENT_ID": "${input:github_oauth_client_id}",
"GITHUB_OAUTH_CLIENT_SECRET": "${input:github_oauth_client_secret}",
"GITHUB_OAUTH_CALLBACK_PORT": "8080"
}
}
}
}
}
```

Port binding setup is straightforward and can be automated through installation instructions.

#### Device Flow vs Interactive Flow

**Device Flow** (automatic in Docker):
- Displays a verification URL and code
- User visits URL in browser and enters code
- No callback server required
- Works in any environment

**Interactive PKCE Flow** (automatic for native binary):
- Opens browser automatically
- User approves scopes
- Faster and more seamless
- Requires callback server (localhost)

> **Note**: OAuth authentication is only available in stdio mode. For remote server usage, use Personal Access Tokens as described above.

### 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
158 changes: 152 additions & 6 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package main

import (
"errors"
"context"
"fmt"
"os"
"sort"
"strings"
"time"

"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 +36,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 +67,54 @@ 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 if configured
if token == "" {
oauthClientID := viper.GetString("oauth_client_id")
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,
viper.GetString("oauth_client_secret"),
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")
}
}

// Extract token from OAuth manager if available
if oauthMgr != nil && token == "" {
token = oauthMgr.GetAccessToken()
}

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 +154,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 +174,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 +206,97 @@ 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 scopes []string
if viper.IsSet("oauth_scopes") {
if err := viper.UnmarshalKey("oauth_scopes", &scopes); err == nil && len(scopes) > 0 {
// When scopes are explicit, don't build inventory (will be built in server)
return oauthScopesResult{scopes: scopes}
}
}

// 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.NewInventory(t).
WithReadOnly(viper.GetBool("read-only")).
WithToolsets(enabledToolsets).
WithTools(enabledTools).
WithFeatureChecker(createFeatureChecker(enabledFeatures))

inv, err := inventoryBuilder.Build()
if err != nil {
// If inventory build fails, fall back to default scopes without inventory
return oauthScopesResult{scopes: getDefaultOAuthScopes()}
}

// Collect all required scopes from available tools
requiredScopes := collectRequiredScopes(inv)
if len(requiredScopes) == 0 {
// If no tools require scopes, use defaults
return oauthScopesResult{scopes: getDefaultOAuthScopes(), inventory: inv}
}

return oauthScopesResult{scopes: requiredScopes, inventory: inv}
}

// getDefaultOAuthScopes returns the default scopes for GitHub MCP Server
// Based on the protected resource metadata at https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp
func getDefaultOAuthScopes() []string {
return []string{
"repo",
"user",
"gist",
"notifications",
"read:org",
"project",
}
Comment on lines +258 to +265
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot, this is actually a bit weak. We have both typed scopes, and ability to discern what is required based on tools the user will enable via provided configuration, so we should not hard code but instead possibly determine this between filtering tools and registering them with new server, so the flow is seamless, and minimum required scopes based on selected scopes are automatically requested.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented dynamic scope computation in commit 5e50dfd. The OAuth flow now:

  1. Builds an inventory using the same configuration that will be used at runtime (toolsets, read-only, features)
  2. Collects RequiredScopes from all tools that will be available
  3. Requests only the minimum scopes needed (least privilege)
  4. Still allows explicit override via --oauth-scopes flag
  5. Falls back to sensible defaults if computation fails

For example:

  • --toolsets repos → requests only repo scope
  • --read-only --toolsets repos,issues → minimal read scopes
  • No toolsets specified → all default toolset scopes

This seamlessly integrates with the existing tool filtering system and automatically adapts when tool selection changes.

}

// 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
}

// createFeatureChecker creates a feature flag checker from enabled features list
func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker {
// Build a set for O(1) lookup
featureSet := make(map[string]bool, len(enabledFeatures))
for _, f := range enabledFeatures {
featureSet[f] = true
}
return func(_ context.Context, flagName string) (bool, error) {
return featureSet[flagName], nil
}
}
Loading