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
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# GitHub MCP Server - OAuth Configuration
#
# To use OAuth authentication:
# 1. Copy this file to .env: cp .env.example .env
# 2. Create a GitHub OAuth App at: https://github.com/settings/developers
# - Click "New OAuth App"
# - Application name: github-mcp-server (personal)
# - Homepage URL: https://github.com/github/github-mcp-server
# - Authorization callback URL: http://localhost:8080/callback
# 3. Copy your Client ID and Client Secret below
# 4. Run: source .env && github-mcp-server stdio --oauth

# OAuth App Client ID (public identifier)
GITHUB_OAUTH_CLIENT_ID=your_client_id_here

# OAuth App Client Secret (keep this secret!)
GITHUB_OAUTH_CLIENT_SECRET=your_client_secret_here

# Optional: Custom token storage path
# GITHUB_OAUTH_TOKEN_PATH=~/.config/github-mcp-server/tokens.json

# Optional: Custom callback port
# GITHUB_OAUTH_CALLBACK_PORT=8080
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,8 @@ e2e.test

.history
conformance-report/

# Environment variables (contains OAuth secrets)
.env
.env.local
.env.*.local
80 changes: 78 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,9 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to

1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed.
2. Once Docker is installed, you will also need to ensure Docker is running. The Docker image is available at `ghcr.io/github/github-mcp-server`. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`.
3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new).
The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)).
3. Choose your authentication method:
- **OAuth (Recommended)**: More secure with automatic token refresh. See [OAuth Authentication](#oauth-authentication-local-server) section below.
- **Personal Access Token (PAT)**: [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). Enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)).

<details><summary><b>Handling PATs Securely</b></summary>

Expand Down Expand Up @@ -238,6 +239,81 @@ To keep your GitHub PAT secure and reusable across different MCP hosts:

</details>

### OAuth Authentication (Local Server)

The local MCP server supports OAuth 2.0 authentication for a more secure and user-friendly experience compared to Personal Access Tokens.

#### Benefits of OAuth

- **Automatic token refresh**: No need to manually regenerate expired tokens
- **Browser-based authentication**: Familiar GitHub login flow
- **Revocable access**: Easy to revoke from GitHub settings
- **Scoped permissions**: Only request permissions your tools need

#### Setup

1. **Create a GitHub OAuth App**:
- Go to https://github.com/settings/developers
- Click "New OAuth App"
- **Application name**: `github-mcp-server (personal)`
- **Homepage URL**: `https://github.com/github/github-mcp-server`
- **Authorization callback URL**: `http://localhost:8080/callback`
- Click "Register application"
- Copy the **Client ID** and generate a **Client Secret**

2. **Configure environment variables**:
```bash
# Copy the example file
cp .env.example .env

# Edit .env and add your credentials:
# GITHUB_OAUTH_CLIENT_ID=your_client_id_here
# GITHUB_OAUTH_CLIENT_SECRET=your_client_secret_here
```

3. **Start the server with OAuth**:
```bash
# Load environment variables
source .env

# Start with OAuth authentication
github-mcp-server stdio --oauth
```

4. **Authenticate**:
- A browser window will open automatically
- Log in to GitHub and authorize the application
- Return to the terminal - the server will start automatically

#### Token Storage

OAuth tokens are securely stored at `~/.config/github-mcp-server/tokens.json` with restricted file permissions (0600). The server automatically refreshes expired tokens.

#### Re-authentication

If you need to re-authenticate (e.g., to grant new scopes):

```bash
source .env
github-mcp-server stdio --oauth --reauth
```

#### Configuration Options

| Flag | Environment Variable | Default |
|------|---------------------|---------|
| `--oauth` | `GITHUB_OAUTH` | `false` |
| `--oauth-client-id` | `GITHUB_OAUTH_CLIENT_ID` | Required |
| `--oauth-client-secret` | `GITHUB_OAUTH_CLIENT_SECRET` | Required |
| `--oauth-token-path` | `GITHUB_OAUTH_TOKEN_PATH` | `~/.config/github-mcp-server/tokens.json` |
| `--oauth-callback-port` | `GITHUB_OAUTH_CALLBACK_PORT` | `8080` |

#### Revoking Access

To revoke the OAuth app's access, visit: https://github.com/settings/applications

---

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

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

"github.com/github/github-mcp-server/internal/ghmcp"
"github.com/github/github-mcp-server/pkg/auth"
"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/tokenmanager"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"github.com/spf13/viper"
Expand All @@ -32,11 +35,111 @@ 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")
// Determine authentication method
pat := viper.GetString("personal_access_token")
useOAuth := viper.GetBool("oauth")

var finalToken string
var tokenManager *tokenmanager.Manager

if pat != "" {
// Backward compatibility: PAT takes precedence if set
finalToken = pat
} else if useOAuth {
// OAuth authentication flow
clientID := viper.GetString("oauth_client_id")
if clientID == "" {
clientID = auth.DefaultClientID
if clientID == "" {
return errors.New("OAuth client ID not configured. Please set GITHUB_OAUTH_CLIENT_ID or use --oauth-client-id flag. See .env.example for setup instructions.")
}
}
clientSecret := viper.GetString("oauth_client_secret")
if clientSecret == "" {
clientSecret = auth.DefaultClientSecret
if clientSecret == "" {
return errors.New("OAuth client secret not configured. Please set GITHUB_OAUTH_CLIENT_SECRET or use --oauth-client-secret flag. See .env.example for setup instructions.")
}
}
tokenPath := viper.GetString("oauth_token_path")
if tokenPath == "" {
tokenPath = auth.GetDefaultTokenPath()
}

// Initialize token storage
storage, err := auth.NewTokenStorage(tokenPath)
if err != nil {
return fmt.Errorf("failed to init token storage: %w", err)
}

host := viper.GetString("host")
if host == "" {
host = "github.com"
}

// Load existing token
var token *auth.Token
if !viper.GetBool("reauth") {
token, err = storage.Load(host)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to load token: %v\n", err)
}
}

// If no token or invalid, start OAuth flow
if token == nil || !token.IsValid() {
fmt.Fprintf(os.Stderr, "Starting OAuth authentication...\n")

// Calculate required scopes
var enabledToolsets []string
if viper.IsSet("toolsets") {
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
}
}

scopes := auth.GetRequiredScopes(enabledToolsets)

oauthConfig := &auth.OAuthConfig{
ClientID: clientID,
ClientSecret: clientSecret,
Scopes: scopes,
RedirectURL: fmt.Sprintf("http://localhost:%d/callback", viper.GetInt("oauth_callback_port")),
AuthURL: getAuthURL(host),
TokenURL: getTokenURL(host),
}

flow := auth.NewOAuthFlow(oauthConfig)
token, err = flow.StartFlow(context.Background(), viper.GetInt("oauth_callback_port"))
if err != nil {
return fmt.Errorf("OAuth authentication failed: %w", err)
}

if err := storage.Save(host, token); err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to save token: %v\n", err)
}

fmt.Fprintf(os.Stderr, "✓ Authentication successful!\n")
}

// Create token manager
tokenManager = tokenmanager.NewManager(storage, &auth.OAuthConfig{
ClientID: clientID,
ClientSecret: clientSecret,
AuthURL: getAuthURL(host),
TokenURL: getTokenURL(host),
}, host, token)

// Get valid token (refresh if necessary)
finalToken, err = tokenManager.GetValidToken(context.Background())
if err != nil {
return fmt.Errorf("failed to get valid token: %w", err)
}
} else {
return errors.New("no authentication configured: set GITHUB_PERSONAL_ACCESS_TOKEN or use --oauth")
}

// Parse toolsets, tools, and features
// 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 All @@ -50,17 +153,14 @@ var (
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
}
}
// else: enabledToolsets stays nil, meaning "use defaults"

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

// Parse enabled features (similar to toolsets)
var enabledFeatures []string
if viper.IsSet("features") {
if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil {
Expand All @@ -72,7 +172,8 @@ var (
stdioServerConfig := ghmcp.StdioServerConfig{
Version: version,
Host: viper.GetString("host"),
Token: token,
Token: finalToken,
TokenManager: tokenManager,
EnabledToolsets: enabledToolsets,
EnabledTools: enabledTools,
EnabledFeatures: enabledFeatures,
Expand Down Expand Up @@ -112,6 +213,14 @@ func init() {
rootCmd.PersistentFlags().Bool("insiders", false, "Enable insiders features")
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")

// OAuth authentication flags
rootCmd.PersistentFlags().Bool("oauth", false, "Use OAuth authentication instead of PAT")
rootCmd.PersistentFlags().String("oauth-client-id", "", "OAuth App client ID (uses default if empty)")
rootCmd.PersistentFlags().String("oauth-client-secret", "", "OAuth App client secret (uses default if empty)")
rootCmd.PersistentFlags().String("oauth-token-path", "", "Path to OAuth token storage")
rootCmd.PersistentFlags().Int("oauth-callback-port", 8080, "Local port for OAuth callback")
rootCmd.PersistentFlags().Bool("reauth", false, "Force re-authentication (use with --oauth)")

// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
Expand All @@ -126,6 +235,12 @@ func init() {
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode"))
_ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders"))
_ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl"))
_ = viper.BindPFlag("oauth", rootCmd.PersistentFlags().Lookup("oauth"))
_ = 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_token_path", rootCmd.PersistentFlags().Lookup("oauth-token-path"))
_ = viper.BindPFlag("oauth_callback_port", rootCmd.PersistentFlags().Lookup("oauth-callback-port"))
_ = viper.BindPFlag("reauth", rootCmd.PersistentFlags().Lookup("reauth"))

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

// getAuthURL returns the OAuth authorization URL for the given host
func getAuthURL(host string) string {
if host == "" || host == "github.com" {
return "https://github.com/login/oauth/authorize"
}
return fmt.Sprintf("https://%s/login/oauth/authorize", host)
}

// getTokenURL returns the OAuth token URL for the given host
func getTokenURL(host string) string {
if host == "" || host == "github.com" {
return "https://github.com/login/oauth/access_token"
}
return fmt.Sprintf("https://%s/login/oauth/access_token", host)
}
Loading