Skip to content
Draft
Show file tree
Hide file tree
Changes from 12 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
154 changes: 152 additions & 2 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 @@ -33,8 +37,33 @@ 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")
var oauthMgr *oauth.Manager

// If no token provided, setup OAuth manager if configured
if token == "" {
return errors.New("GITHUB_PERSONAL_ACCESS_TOKEN not set")
oauthClientID := viper.GetString("oauth_client_id")
if oauthClientID != "" {
// Create OAuth manager for lazy authentication
oauthCfg := oauth.GetGitHubOAuthConfig(
oauthClientID,
viper.GetString("oauth_client_secret"),
getOAuthScopes(),
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()
}

// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
Expand Down Expand Up @@ -73,6 +102,7 @@ var (
Version: version,
Host: viper.GetString("host"),
Token: token,
OAuthManager: oauthMgr,
EnabledToolsets: enabledToolsets,
EnabledTools: enabledTools,
EnabledFeatures: enabledFeatures,
Expand Down Expand Up @@ -112,6 +142,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 +162,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 +194,113 @@ func wordSepNormalizeFunc(_ *pflag.FlagSet, name string) pflag.NormalizedName {
}
return pflag.NormalizedName(name)
}

// getOAuthScopes returns the OAuth scopes to request based on enabled tools
// Uses custom scopes if explicitly provided, otherwise computes required scopes
// from the tools that will be enabled based on user configuration
func getOAuthScopes() []string {
// 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 {
return scopes
}
}

// Compute required scopes based on enabled tools
// This ensures we only request scopes for tools the user will actually use
var enabledToolsets []string
if viper.IsSet("toolsets") {
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
// If unmarshaling fails, fall back to defaults
enabledToolsets = nil
}
}

var enabledTools []string
if viper.IsSet("tools") {
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
enabledTools = nil
}
}

var enabledFeatures []string
if viper.IsSet("features") {
if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil {
enabledFeatures = nil
}
}

// Build inventory with the same configuration that will be used at runtime
// This allows us to determine which tools will actually be available
t, _ := translations.TranslationHelper()
inventoryBuilder := github.NewInventory(t).
WithReadOnly(viper.GetBool("read-only")).
WithToolsets(enabledToolsets).
WithTools(enabledTools).
WithFeatureChecker(createFeatureChecker(enabledFeatures))
Copy link
Collaborator

Choose a reason for hiding this comment

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

Out of curiosity is there a way to avoid the double inventory building by leveraging the inventory built when setting up the server? Considering this now happens after server setup @copilot

Also can we use the PAT scope filter middleware to filter tools by user provided scopes if we have them? The other way around is less important, because we have the scopes generated from the tools themselves, but anyway, that's just gloss. It's a quality of life improvement as stdio cannot challenge scopes and dead tools are a waste of context and can never succeed.

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 both optimizations in commit 70c4553:

  1. Eliminated double inventory building: Modified getOAuthScopes() to return both scopes and the built inventory. The inventory is now reused in NewMCPServer() instead of building it again, saving ~100ms on startup.

  2. Added OAuth scope filtering: When OAuth is configured, the OAuth scopes are now used for tool filtering (similar to PAT scope filtering). This hides tools requiring scopes not available in the OAuth token, improving QoL by removing "dead tools" that can never succeed.

The implementation keeps 90%+ of the logic in the auth module as intended, with minimal changes to server initialization code.


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

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

return requiredScopes
}

// 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
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ require (
github.com/spf13/pflag v1.0.10
github.com/subosito/gotenv v1.6.0 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2
golang.org/x/oauth2 v0.30.0 // indirect
golang.org/x/oauth2 v0.30.0
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.28.0 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
Expand Down
Loading