feat: implement OAuth device flow authentication

When the server starts without a GITHUB_PERSONAL_ACCESS_TOKEN, it now
starts in 'unauthenticated mode' with only an auth_login tool available.

The auth_login tool:
- Initiates the OAuth device flow with GitHub
- Uses MCP URL elicitation to show the verification URL and user code
- Polls for completion while showing progress notifications
- Upon success, dynamically registers all configured GitHub tools

This enables a much simpler setup experience - users no longer need to
pre-configure a PAT. They can simply start the server and authenticate
interactively when prompted.

Key changes:
- New AuthManager in pkg/github/auth.go handles device flow state
- New auth_login tool in pkg/github/auth_tools.go
- NewUnauthenticatedMCPServer in internal/ghmcp/server.go for token-less startup
- CLI flags --oauth-client-id and --oauth-client-secret for enterprise scenarios
- Support for github.com, GHES, and GHEC hosts

The token is held in memory for the session duration - no persistent
storage, which is ideal for Docker --rm workflows.

Closes #132
This commit is contained in:
Sam Morrow
2025-12-19 01:08:52 +01:00
parent 311d581520
commit d322e26d91
8 changed files with 1416 additions and 21 deletions
+7 -4
View File
@@ -1,7 +1,6 @@
package main
import (
"errors"
"fmt"
"os"
"strings"
@@ -32,10 +31,8 @@ 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 is optional - if not provided, server starts in auth mode
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
@@ -84,6 +81,8 @@ var (
ContentWindowSize: viper.GetInt("content-window-size"),
LockdownMode: viper.GetBool("lockdown-mode"),
RepoAccessCacheTTL: &ttl,
OAuthClientID: viper.GetString("oauth-client-id"),
OAuthClientSecret: viper.GetString("oauth-client-secret"),
}
return ghmcp.RunStdioServer(stdioServerConfig)
},
@@ -109,6 +108,8 @@ func init() {
rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size")
rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode")
rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)")
rootCmd.PersistentFlags().String("oauth-client-id", "", "OAuth App client ID for device flow authentication (optional, uses default if not provided)")
rootCmd.PersistentFlags().String("oauth-client-secret", "", "OAuth App client secret for device flow authentication (optional, for confidential clients)")
// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
@@ -123,6 +124,8 @@ func init() {
_ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size"))
_ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-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"))
// Add subcommands
rootCmd.AddCommand(stdioCmd)
+321
View File
@@ -0,0 +1,321 @@
# OAuth Device Flow Authentication Design
## Overview
This document describes the implementation of OAuth Device Flow authentication for the GitHub MCP Server's stdio transport. The design enables users to authenticate without pre-configuring tokens, making setup significantly simpler.
## Problem Statement
Currently, users must:
1. Generate a Personal Access Token (PAT) manually on GitHub
2. Configure the token in their MCP host's configuration (often in plain text)
3. Manage token rotation manually
This creates friction for new users and security concerns around token storage.
## Proposed Solution
When the server starts without a `GITHUB_PERSONAL_ACCESS_TOKEN`, instead of failing, it starts in "unauthenticated mode" with only authentication tools available. Users authenticate through MCP tool calls:
1. **`auth_login`** - Initiates device flow, returns verification URL and user code
2. **`auth_verify`** - Completes the flow after user authorizes in browser
Once authenticated, the token is held in memory for the session and all regular tools become available.
## User Experience
### Before (Current)
```jsonc
{
"githubz": {
"command": "docker",
"args": ["run", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" // User must create PAT first
}
}
}
```
### After (New)
```jsonc
{
"github": {
"command": "docker",
"args": ["run", "--rm", "-i", "ghcr.io/github/github-mcp-server", "stdio", "--toolsets=all"]
// No token needed! User authenticates via tool call
}
}
```
### Authentication Flow (User Perspective)
1. User asks agent: "Create an issue on my repo"
2. Agent calls `auth_login` tool
3. Tool returns:
```
To authenticate, visit: https://github.com/login/device
Enter code: ABCD-1234
After authorizing, use the auth_verify tool to complete login.
```
4. User opens browser, enters code, clicks "Authorize"
5. Agent calls `auth_verify` tool
6. Tool returns: "Successfully authenticated as @username"
7. Agent proceeds with original request using now-available tools
## Technical Design
### Architecture
```
┌─────────────────────────────────────────────────────────────────┐
│ MCP Server │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Auth State │───▶│ Tool Filter │───▶│ GitHub Clients │ │
│ │ Manager │ │ │ │ (lazy init) │ │
│ └──────────────┘ └──────────────┘ └──────────────────┘ │
│ │ │ │
│ │ token │ │
│ ▼ ▼ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │ Device Flow │ │ REST/GraphQL │ │
│ │ Handler │ │ Clients │ │
│ └──────────────┘ └──────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### State Machine
```
┌─────────────────┐
│ UNAUTHENTICATED │ ◀──────────────────────────────┐
│ │ │
│ Tools: auth_* │ │
└────────┬────────┘ │
│ auth_login() │
▼ │
┌─────────────────┐ │
│ PENDING_AUTH │ │
│ │──── timeout/error ─────────────▶│
│ Tools: auth_* │ │
└────────┬────────┘ │
│ auth_verify() success │
▼ │
┌─────────────────┐ │
│ AUTHENTICATED │ │
│ │──── token invalid ─────────────▶│
│ Tools: all │ │
└─────────────────┘
```
### Host URL Derivation
For different GitHub products, device flow endpoints are derived from the configured host:
| Product | Host Config | Device Code Endpoint |
|---------|-------------|---------------------|
| github.com | (default) | `https://github.com/login/device/code` |
| GHEC | `https://tenant.ghe.com` | `https://tenant.ghe.com/login/device/code` |
| GHES | `https://github.example.com` | `https://github.example.com/login/device/code` |
### OAuth App Requirements
The device flow requires an OAuth App. Options:
1. **GitHub-provided OAuth App** (recommended) - We register a public OAuth App for this purpose
2. **User-provided OAuth App** - Via `--oauth-client-id` flag for enterprise scenarios
Default OAuth App scopes (matching `gh` CLI minimal scopes):
- `repo` - Full control of private repositories
- `read:org` - Read org membership
- `gist` - Create gists
### Key Components
#### 1. Auth State Manager (`pkg/github/auth_state.go`)
```go
type AuthState struct {
mu sync.RWMutex
token string
deviceCode *DeviceCodeResponse
pollInterval time.Duration
expiresAt time.Time
}
func (a *AuthState) IsAuthenticated() bool
func (a *AuthState) GetToken() string
func (a *AuthState) StartDeviceFlow(ctx context.Context, host apiHost, clientID string) (*DeviceCodeResponse, error)
func (a *AuthState) CompleteDeviceFlow(ctx context.Context) (string, error)
```
#### 2. Auth Tools (`pkg/github/auth_tools.go`)
```go
// auth_login tool - initiates device flow
func AuthLogin(ctx context.Context) (*AuthLoginResult, error)
// auth_verify tool - completes device flow
func AuthVerify(ctx context.Context) (*AuthVerifyResult, error)
```
#### 3. Dynamic Tool Registration
When unauthenticated, only auth tools are registered. After successful auth:
1. Initialize GitHub clients with new token
2. Register all configured toolsets
3. Send `tools/list_changed` notification to client
### Docker Considerations
With `--rm` containers:
- Token lives only in memory for the session duration
- User re-authenticates each time container starts
- This is acceptable UX since device flow is quick (~30 seconds)
For persistent auth (optional future enhancement):
- Mount a config volume: `-v ~/.config/github-mcp-server:/config`
- Server stores encrypted token in volume
- Requires user opt-in for security
### Security Considerations
1. **Token never in config** - Token obtained at runtime, never written to disk (in --rm mode)
2. **Short-lived session** - Token only valid for container lifetime
3. **Principle of least privilege** - Request minimal scopes
4. **PKCE** - Use PKCE extension for additional security (if supported)
5. **User verification** - User explicitly authorizes in browser with full visibility
### Error Handling
| Scenario | Behavior |
|----------|----------|
| Device flow timeout | Return error, user can retry `auth_login` |
| User denies authorization | Return error explaining denial |
| Network issues during poll | Retry with backoff, eventually timeout |
| Invalid client ID | Clear error message with setup instructions |
| Token expires mid-session | Return 401-like error, prompt re-auth via tools |
## Implementation Plan
### Phase 1: Core Auth Flow
1. Add `pkg/github/auth_state.go` - Auth state management
2. Add `pkg/github/auth_tools.go` - Auth tool implementations
3. Modify `internal/ghmcp/server.go` - Support unauthenticated startup
4. Add device flow endpoint derivation for all host types
### Phase 2: Dynamic Tool Registration
1. Implement `tools/list_changed` notification after auth
2. Add tool filtering based on auth state
3. Update inventory to support dynamic registration
### Phase 3: Polish & Documentation
1. Add comprehensive error messages
2. Update README with new usage
3. Add integration tests
4. Document OAuth App setup for enterprises
## Usage Documentation
### Quick Start (New Users)
```jsonc
// VS Code settings.json or mcp.json
{
"servers": {
"github": {
"command": "docker",
"args": ["run", "--rm", "-i", "ghcr.io/github/github-mcp-server", "stdio"],
"type": "stdio"
}
}
}
```
Then just ask your AI assistant to do something with GitHub - it will guide you through authentication!
### Native Installation
```bash
# Install
go install github.com/github/github-mcp-server/cmd/github-mcp-server@latest
# Run (will prompt for auth on first GitHub operation)
github-mcp-server stdio
```
### Enterprise (GHES/GHEC)
```jsonc
{
"servers": {
"github": {
"command": "github-mcp-server",
"args": ["stdio", "--gh-host", "https://github.mycompany.com"],
"type": "stdio"
}
}
}
```
### With Pre-configured Token (Legacy/CI)
```jsonc
{
"servers": {
"github": {
"command": "github-mcp-server",
"args": ["stdio"],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_xxxx"
},
"type": "stdio"
}
}
}
```
## Open Questions
1. **OAuth App ownership** - Should GitHub provide a first-party OAuth App, or require users to create their own?
2. **Token refresh** - Should we support refresh tokens for longer sessions, or is re-auth acceptable?
3. **Scope customization** - Should users be able to request additional scopes via tool parameters?
4. **Persistent storage** - Should we support optional persistent token storage for non-Docker installs?
## Appendix: Device Flow Sequence
```mermaid
sequenceDiagram
participant User
participant Agent as AI Agent
participant MCP as MCP Server
participant GH as GitHub
User->>Agent: "Create issue on my repo"
Agent->>MCP: tools/list
MCP-->>Agent: [auth_login, auth_verify]
Agent->>MCP: tools/call auth_login
MCP->>GH: POST /login/device/code
GH-->>MCP: device_code, user_code, verification_uri
MCP-->>Agent: "Visit github.com/login/device, enter ABCD-1234"
Agent->>User: "Please visit github.com/login/device and enter code ABCD-1234"
User->>GH: Opens browser, enters code, authorizes
Agent->>MCP: tools/call auth_verify
MCP->>GH: POST /login/oauth/access_token (polling)
GH-->>MCP: access_token
MCP->>MCP: Initialize GitHub clients
MCP-->>Agent: notifications/tools/list_changed
MCP-->>Agent: "Authenticated as @username"
Agent->>MCP: tools/list
MCP-->>Agent: [all tools now available]
Agent->>MCP: tools/call create_issue
MCP-->>Agent: Issue created!
Agent->>User: "Done! Created issue #123"
```
+190 -17
View File
@@ -67,6 +67,13 @@ type MCPServerConfig struct {
Logger *slog.Logger
// RepoAccessTTL overrides the default TTL for repository access cache entries.
RepoAccessTTL *time.Duration
// OAuthClientID is the OAuth App client ID for device flow authentication.
// If empty, the default GitHub MCP Server OAuth App is used.
OAuthClientID string
// OAuthClientSecret is the OAuth App client secret (optional, for confidential clients).
OAuthClientSecret string
}
// githubClients holds all the GitHub API clients created for a server instance.
@@ -265,6 +272,136 @@ func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker
}
}
// UnauthenticatedServerResult contains the server and components needed to complete
// authentication after the server is running.
type UnauthenticatedServerResult struct {
Server *mcp.Server
AuthManager *github.AuthManager
}
// NewUnauthenticatedMCPServer creates an MCP server with only authentication tools available.
// After successful authentication via the auth tools, call OnAuthenticated to initialize
// GitHub clients and register all other tools.
func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerResult, error) {
// Create OAuth host from the configured GitHub host
oauthHost := github.NewOAuthHostFromAPIHost(cfg.Host)
// Create auth manager
authManager := github.NewAuthManager(oauthHost, cfg.OAuthClientID, cfg.OAuthClientSecret, nil)
// Create the MCP server with capabilities advertised for dynamic tool registration
serverOpts := &mcp.ServerOptions{
Instructions: "GitHub MCP Server - Authentication Required\n\nYou are not currently authenticated with GitHub. Use the auth_login tool to start the authentication process, then auth_verify to complete it.",
Logger: cfg.Logger,
// Advertise capabilities since tools will be added after auth
Capabilities: &mcp.ServerCapabilities{
Tools: &mcp.ToolCapabilities{ListChanged: true},
Resources: &mcp.ResourceCapabilities{ListChanged: true},
Prompts: &mcp.PromptCapabilities{ListChanged: true},
},
}
ghServer := github.NewServer(cfg.Version, serverOpts)
// Add error context middleware
ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext)
// Create auth tool dependencies with a callback for when auth completes
authDeps := github.AuthToolDependencies{
AuthManager: authManager,
T: cfg.Translator,
Server: ghServer,
Logger: cfg.Logger,
OnAuthenticated: func(ctx context.Context, token string) error {
// Create API host for GitHub clients
apiHost, err := parseAPIHost(cfg.Host)
if err != nil {
return fmt.Errorf("failed to parse API host: %w", err)
}
// Create a new config with the token
authenticatedCfg := cfg
authenticatedCfg.Token = token
// Create GitHub clients
clients, err := createGitHubClients(authenticatedCfg, apiHost)
if err != nil {
return fmt.Errorf("failed to create GitHub clients: %w", err)
}
// Add user agent middleware
ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(authenticatedCfg, clients.rest, clients.gqlHTTP))
// Create dependencies for tool handlers
deps := github.NewBaseDeps(
clients.rest,
clients.gql,
clients.raw,
clients.repoAccess,
cfg.Translator,
github.FeatureFlags{LockdownMode: cfg.LockdownMode},
cfg.ContentWindowSize,
)
// Inject dependencies into context for all tool handlers
ghServer.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler {
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
return next(github.ContextWithDeps(ctx, deps), method, req)
}
})
// Resolve enabled toolsets
enabledToolsets := resolveEnabledToolsets(authenticatedCfg)
// Build and register the tool/resource/prompt inventory
inv := github.NewInventory(cfg.Translator).
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithReadOnly(cfg.ReadOnly).
WithToolsets(enabledToolsets).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)).
Build()
// Log how many tools we're about to register
availableTools := inv.AvailableTools(ctx)
if cfg.Logger != nil {
cfg.Logger.Info("registering tools after authentication", "count", len(availableTools))
}
// Register all GitHub tools/resources/prompts
inv.RegisterAll(ctx, ghServer, deps)
// Register dynamic toolset management tools if enabled
if cfg.DynamicToolsets {
registerDynamicTools(ghServer, inv, deps, cfg.Translator)
}
if cfg.Logger != nil {
cfg.Logger.Info("authentication complete, tools registered", "toolCount", len(availableTools))
}
return nil
},
OnAuthComplete: func() {
// Remove auth_login tool now that authentication is complete
ghServer.RemoveTools("auth_login")
if cfg.Logger != nil {
cfg.Logger.Info("auth tools removed after successful authentication")
}
},
}
// Register only auth tools
for _, tool := range github.AuthTools(cfg.Translator) {
tool.RegisterFunc(ghServer, authDeps)
}
return &UnauthenticatedServerResult{
Server: ghServer,
AuthManager: authManager,
}, nil
}
type StdioServerConfig struct {
// Version of the server
Version string
@@ -312,6 +449,13 @@ type StdioServerConfig struct {
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
RepoAccessCacheTTL *time.Duration
// OAuthClientID is the OAuth App client ID for device flow authentication.
// If empty, the default GitHub MCP Server OAuth App is used.
OAuthClientID string
// OAuthClientSecret is the OAuth App client secret (optional, for confidential clients).
OAuthClientSecret string
}
// RunStdioServer is not concurrent safe.
@@ -338,23 +482,52 @@ func RunStdioServer(cfg StdioServerConfig) error {
logger := slog.New(slogHandler)
logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode)
ghServer, err := NewMCPServer(MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
EnabledTools: cfg.EnabledTools,
EnabledFeatures: cfg.EnabledFeatures,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
Logger: logger,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
var ghServer *mcp.Server
// If no token is provided, start in unauthenticated mode with only auth tools
if cfg.Token == "" {
logger.Info("no token provided, starting in unauthenticated mode with auth tools")
result, err := NewUnauthenticatedMCPServer(MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Translator: t,
Logger: logger,
OAuthClientID: cfg.OAuthClientID,
OAuthClientSecret: cfg.OAuthClientSecret,
// Pass config for use after authentication
EnabledToolsets: cfg.EnabledToolsets,
EnabledTools: cfg.EnabledTools,
EnabledFeatures: cfg.EnabledFeatures,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
})
if err != nil {
return fmt.Errorf("failed to create unauthenticated MCP server: %w", err)
}
ghServer = result.Server
} else {
var err error
ghServer, err = NewMCPServer(MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
EnabledTools: cfg.EnabledTools,
EnabledFeatures: cfg.EnabledFeatures,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
Logger: logger,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
}
}
if cfg.ExportTranslations {
+11
View File
@@ -0,0 +1,11 @@
{
"annotations": {
"readOnlyHint": true,
"title": "Login to GitHub"
},
"description": "Initiate GitHub authentication using OAuth device flow. This will provide a URL and code that you can use to authenticate with GitHub. After visiting the URL and entering the code, authentication will complete automatically.",
"inputSchema": {
"type": "object"
},
"name": "auth_login"
}
+400
View File
@@ -0,0 +1,400 @@
package github
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
// AuthState represents the current authentication state of the server.
type AuthState int
const (
// AuthStateUnauthenticated means no token is available.
AuthStateUnauthenticated AuthState = iota
// AuthStatePending means device flow has been initiated, waiting for user.
AuthStatePending
// AuthStateAuthenticated means a valid token is available.
AuthStateAuthenticated
)
// DeviceCodeResponse represents the response from GitHub's device code endpoint.
type DeviceCodeResponse struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
// TokenResponse represents the response from GitHub's token endpoint.
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
Error string `json:"error,omitempty"`
ErrorDesc string `json:"error_description,omitempty"`
}
// AuthManager manages authentication state for the MCP server.
// It handles the OAuth device flow and token storage.
type AuthManager struct {
mu sync.RWMutex
state AuthState
token string
deviceCode *DeviceCodeResponse
expiresAt time.Time
clientID string
clientSecret string
scopes []string
// Host configuration for deriving OAuth endpoints
host OAuthHost
}
// OAuthHost contains the OAuth endpoints for a GitHub host.
type OAuthHost struct {
DeviceCodeURL string
TokenURL string
Hostname string
}
// NewOAuthHostFromAPIHost creates OAuth endpoints from the API host configuration.
func NewOAuthHostFromAPIHost(hostname string) OAuthHost {
if hostname == "" || hostname == "github.com" || hostname == "https://github.com" || hostname == "https://api.github.com" {
return OAuthHost{
DeviceCodeURL: "https://github.com/login/device/code",
TokenURL: "https://github.com/login/oauth/access_token",
Hostname: "github.com",
}
}
// If the hostname doesn't have a scheme, add https://
if !strings.HasPrefix(hostname, "http://") && !strings.HasPrefix(hostname, "https://") {
hostname = "https://" + hostname
}
// Parse the hostname to extract the base
u, err := url.Parse(hostname)
if err != nil || u.Hostname() == "" {
// Fallback: treat as hostname directly (shouldn't happen with scheme added)
return OAuthHost{
DeviceCodeURL: fmt.Sprintf("https://%s/login/device/code", hostname),
TokenURL: fmt.Sprintf("https://%s/login/oauth/access_token", hostname),
Hostname: hostname,
}
}
// For GHEC (ghe.com) and GHES, OAuth endpoints are on the main host
host := u.Hostname()
scheme := u.Scheme
if scheme == "" {
scheme = "https"
}
return OAuthHost{
DeviceCodeURL: fmt.Sprintf("%s://%s/login/device/code", scheme, host),
TokenURL: fmt.Sprintf("%s://%s/login/oauth/access_token", scheme, host),
Hostname: host,
}
}
// DefaultOAuthClientID is the OAuth App client ID for the GitHub MCP Server.
// This OAuth App is registered by GitHub for use with this server.
// The client ID is safe to embed in source code per OAuth 2.0 spec for public clients.
// Users can override this with --oauth-client-id for enterprise scenarios.
// currently a testing app.
const DefaultOAuthClientID = "Ov23ctTMsnT9LTRdBYYM"
// DefaultOAuthScopes are the standard scopes needed for complete MCP functionality.
var DefaultOAuthScopes = []string{
"gist",
"notifications",
"public_repo",
"repo",
"repo:status",
"repo_deployment",
"user",
"user:email",
"user:follow",
"read:gpg_key",
"read:org",
"project",
}
// NewAuthManager creates a new AuthManager.
func NewAuthManager(host OAuthHost, clientID, clientSecret string, scopes []string) *AuthManager {
if clientID == "" {
clientID = DefaultOAuthClientID
}
if len(scopes) == 0 {
scopes = DefaultOAuthScopes
}
return &AuthManager{
state: AuthStateUnauthenticated,
host: host,
clientID: clientID,
clientSecret: clientSecret,
scopes: scopes,
}
}
// NewAuthManagerWithToken creates an AuthManager that is already authenticated.
func NewAuthManagerWithToken(token string) *AuthManager {
return &AuthManager{
state: AuthStateAuthenticated,
token: token,
}
}
// State returns the current authentication state.
func (a *AuthManager) State() AuthState {
a.mu.RLock()
defer a.mu.RUnlock()
return a.state
}
// Token returns the current access token, or empty string if not authenticated.
func (a *AuthManager) Token() string {
a.mu.RLock()
defer a.mu.RUnlock()
return a.token
}
// IsAuthenticated returns true if a valid token is available.
func (a *AuthManager) IsAuthenticated() bool {
return a.State() == AuthStateAuthenticated
}
// StartDeviceFlow initiates the OAuth device authorization flow.
// Returns the device code response containing the user code and verification URL.
func (a *AuthManager) StartDeviceFlow(ctx context.Context) (*DeviceCodeResponse, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.state == AuthStateAuthenticated {
return nil, fmt.Errorf("already authenticated")
}
// Build the request
data := url.Values{}
data.Set("client_id", a.clientID)
data.Set("scope", joinScopes(a.scopes))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.host.DeviceCodeURL, bytes.NewBufferString(data.Encode()))
if err != nil {
return nil, fmt.Errorf("failed to create device code request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to request device code: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read device code response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("device code request failed with status %d: %s", resp.StatusCode, string(body))
}
var deviceResp DeviceCodeResponse
if err := json.Unmarshal(body, &deviceResp); err != nil {
return nil, fmt.Errorf("failed to parse device code response: %w", err)
}
// Store the device code and update state
a.deviceCode = &deviceResp
a.expiresAt = time.Now().Add(time.Duration(deviceResp.ExpiresIn) * time.Second)
a.state = AuthStatePending
return &deviceResp, nil
}
// CompleteDeviceFlow polls for the access token after the user has authorized.
// This should be called after StartDeviceFlow and after the user has entered the code.
func (a *AuthManager) CompleteDeviceFlow(ctx context.Context) error {
return a.CompleteDeviceFlowWithProgress(ctx, nil)
}
// ProgressCallback is called during polling to report progress.
// elapsed is seconds since polling started, total is the expiry time in seconds.
type ProgressCallback func(elapsed, total int, message string)
// CompleteDeviceFlowWithProgress polls for the access token with progress updates.
// The onProgress callback is called periodically during polling.
func (a *AuthManager) CompleteDeviceFlowWithProgress(ctx context.Context, onProgress ProgressCallback) error {
a.mu.Lock()
deviceCode := a.deviceCode
expiresAt := a.expiresAt
a.mu.Unlock()
if deviceCode == nil {
return fmt.Errorf("no pending device flow - call StartDeviceFlow first")
}
if time.Now().After(expiresAt) {
a.mu.Lock()
a.state = AuthStateUnauthenticated
a.deviceCode = nil
a.mu.Unlock()
return fmt.Errorf("device code expired - please start a new login flow")
}
// Poll for the token
interval := time.Duration(deviceCode.Interval) * time.Second
if interval < 5*time.Second {
interval = 5 * time.Second // Minimum poll interval per RFC 8628
}
startTime := time.Now()
totalSeconds := deviceCode.ExpiresIn
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
// Report progress before polling
if onProgress != nil {
elapsed := int(time.Since(startTime).Seconds())
onProgress(elapsed, totalSeconds, "⏳ Waiting for authorization...")
}
token, err := a.pollForToken(ctx, deviceCode.DeviceCode)
if err != nil {
// Check for specific error types
if err.Error() == "authorization_pending" {
continue // Keep polling
}
if err.Error() == "slow_down" {
// Increase interval by 5 seconds per RFC 8628
interval += 5 * time.Second
ticker.Reset(interval)
continue
}
// Other errors are terminal
a.mu.Lock()
a.state = AuthStateUnauthenticated
a.deviceCode = nil
a.mu.Unlock()
return err
}
// Success! Store the token
a.mu.Lock()
a.token = token
a.state = AuthStateAuthenticated
a.deviceCode = nil
a.mu.Unlock()
return nil
}
}
}
// pollForToken makes a single request to the token endpoint.
func (a *AuthManager) pollForToken(ctx context.Context, deviceCode string) (string, error) {
data := url.Values{}
data.Set("client_id", a.clientID)
data.Set("device_code", deviceCode)
data.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")
// Add client secret if provided (for confidential clients)
if a.clientSecret != "" {
data.Set("client_secret", a.clientSecret)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.host.TokenURL, bytes.NewBufferString(data.Encode()))
if err != nil {
return "", fmt.Errorf("failed to create token request: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to request token: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read token response: %w", err)
}
var tokenResp TokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return "", fmt.Errorf("failed to parse token response: %w", err)
}
// Check for OAuth errors
if tokenResp.Error != "" {
switch tokenResp.Error {
case "authorization_pending":
return "", fmt.Errorf("authorization_pending")
case "slow_down":
return "", fmt.Errorf("slow_down")
case "expired_token":
return "", fmt.Errorf("device code expired - please start a new login flow")
case "access_denied":
return "", fmt.Errorf("authorization was denied by the user")
default:
return "", fmt.Errorf("OAuth error: %s - %s", tokenResp.Error, tokenResp.ErrorDesc)
}
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("no access token in response")
}
return tokenResp.AccessToken, nil
}
// Reset clears any pending authentication state.
func (a *AuthManager) Reset() {
a.mu.Lock()
defer a.mu.Unlock()
if a.state == AuthStatePending {
a.state = AuthStateUnauthenticated
a.deviceCode = nil
}
}
// SetToken directly sets the authentication token (for testing or migration).
func (a *AuthManager) SetToken(token string) {
a.mu.Lock()
defer a.mu.Unlock()
a.token = token
a.state = AuthStateAuthenticated
a.deviceCode = nil
}
func joinScopes(scopes []string) string {
result := ""
for i, s := range scopes {
if i > 0 {
result += " "
}
result += s
}
return result
}
+279
View File
@@ -0,0 +1,279 @@
package github
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewOAuthHostFromAPIHost(t *testing.T) {
t.Parallel()
tests := []struct {
name string
apiHost string
expectedHostname string
expectedDevice string
expectedToken string
}{
{
name: "github.com (empty host)",
apiHost: "",
expectedHostname: "github.com",
expectedDevice: "https://github.com/login/device/code",
expectedToken: "https://github.com/login/oauth/access_token",
},
{
name: "github.com (explicit)",
apiHost: "github.com",
expectedHostname: "github.com",
expectedDevice: "https://github.com/login/device/code",
expectedToken: "https://github.com/login/oauth/access_token",
},
{
name: "GHES without scheme",
apiHost: "github.enterprise.com",
expectedHostname: "github.enterprise.com",
expectedDevice: "https://github.enterprise.com/login/device/code",
expectedToken: "https://github.enterprise.com/login/oauth/access_token",
},
{
name: "GHES with https scheme",
apiHost: "https://github.enterprise.com",
expectedHostname: "github.enterprise.com",
expectedDevice: "https://github.enterprise.com/login/device/code",
expectedToken: "https://github.enterprise.com/login/oauth/access_token",
},
{
name: "GHEC tenant",
apiHost: "company.ghe.com",
expectedHostname: "company.ghe.com",
expectedDevice: "https://company.ghe.com/login/device/code",
expectedToken: "https://company.ghe.com/login/oauth/access_token",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
host := NewOAuthHostFromAPIHost(tc.apiHost)
assert.Equal(t, tc.expectedHostname, host.Hostname)
assert.Equal(t, tc.expectedDevice, host.DeviceCodeURL)
assert.Equal(t, tc.expectedToken, host.TokenURL)
})
}
}
func TestAuthManager_StateTransitions(t *testing.T) {
t.Parallel()
host := NewOAuthHostFromAPIHost("")
authMgr := NewAuthManager(host, "test-client-id", "", nil)
// Initial state should be unauthenticated
assert.Equal(t, AuthStateUnauthenticated, authMgr.State())
assert.False(t, authMgr.IsAuthenticated())
assert.Empty(t, authMgr.Token())
// Cannot call Reset when not pending
authMgr.Reset()
assert.Equal(t, AuthStateUnauthenticated, authMgr.State())
}
func TestAuthManager_StartDeviceFlow(t *testing.T) {
t.Parallel()
// Create a mock OAuth server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/login/device/code" {
w.Header().Set("Content-Type", "application/json")
resp := map[string]interface{}{
"device_code": "test-device-code",
"user_code": "ABCD-1234",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 5,
}
_ = json.NewEncoder(w).Encode(resp)
return
}
http.NotFound(w, r)
}))
defer server.Close()
host := OAuthHost{
Hostname: "test.example.com",
DeviceCodeURL: server.URL + "/login/device/code",
TokenURL: server.URL + "/login/oauth/access_token",
}
authMgr := NewAuthManager(host, "test-client-id", "", nil)
// Start the device flow
deviceResp, err := authMgr.StartDeviceFlow(context.Background())
require.NoError(t, err)
assert.Equal(t, "test-device-code", deviceResp.DeviceCode)
assert.Equal(t, "ABCD-1234", deviceResp.UserCode)
assert.Equal(t, "https://github.com/login/device", deviceResp.VerificationURI)
// State should now be pending
assert.Equal(t, AuthStatePending, authMgr.State())
}
func TestAuthManager_CompleteDeviceFlow_Success(t *testing.T) {
t.Parallel()
// Track poll attempts
pollCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/login/device/code" {
w.Header().Set("Content-Type", "application/json")
resp := map[string]interface{}{
"device_code": "test-device-code",
"user_code": "ABCD-1234",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 1, // Short interval for test
}
_ = json.NewEncoder(w).Encode(resp)
return
}
if r.URL.Path == "/login/oauth/access_token" {
pollCount++
w.Header().Set("Content-Type", "application/json")
if pollCount < 2 {
// First poll returns pending
resp := map[string]interface{}{
"error": "authorization_pending",
}
_ = json.NewEncoder(w).Encode(resp)
} else {
// Second poll returns token
resp := map[string]interface{}{
"access_token": "gho_test_token_12345",
"token_type": "bearer",
"scope": "repo,read:org",
}
_ = json.NewEncoder(w).Encode(resp)
}
return
}
http.NotFound(w, r)
}))
defer server.Close()
host := OAuthHost{
Hostname: "test.example.com",
DeviceCodeURL: server.URL + "/login/device/code",
TokenURL: server.URL + "/login/oauth/access_token",
}
authMgr := NewAuthManager(host, "test-client-id", "", nil)
// Start the device flow
_, err := authMgr.StartDeviceFlow(context.Background())
require.NoError(t, err)
// Complete the flow
err = authMgr.CompleteDeviceFlow(context.Background())
require.NoError(t, err)
// Should now be authenticated
assert.Equal(t, AuthStateAuthenticated, authMgr.State())
assert.True(t, authMgr.IsAuthenticated())
assert.Equal(t, "gho_test_token_12345", authMgr.Token())
}
func TestAuthManager_CompleteDeviceFlow_AccessDenied(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/login/device/code" {
w.Header().Set("Content-Type", "application/json")
resp := map[string]interface{}{
"device_code": "test-device-code",
"user_code": "ABCD-1234",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 1,
}
_ = json.NewEncoder(w).Encode(resp)
return
}
if r.URL.Path == "/login/oauth/access_token" {
w.Header().Set("Content-Type", "application/json")
resp := map[string]interface{}{
"error": "access_denied",
"error_description": "The user has denied your request.",
}
_ = json.NewEncoder(w).Encode(resp)
return
}
http.NotFound(w, r)
}))
defer server.Close()
host := OAuthHost{
Hostname: "test.example.com",
DeviceCodeURL: server.URL + "/login/device/code",
TokenURL: server.URL + "/login/oauth/access_token",
}
authMgr := NewAuthManager(host, "test-client-id", "", nil)
// Start the device flow
_, err := authMgr.StartDeviceFlow(context.Background())
require.NoError(t, err)
// Complete the flow - should fail with access denied
err = authMgr.CompleteDeviceFlow(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "denied")
// Should be back to unauthenticated
assert.Equal(t, AuthStateUnauthenticated, authMgr.State())
}
func TestAuthManager_Reset(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/login/device/code" {
w.Header().Set("Content-Type", "application/json")
resp := map[string]interface{}{
"device_code": "test-device-code",
"user_code": "ABCD-1234",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 1,
}
_ = json.NewEncoder(w).Encode(resp)
return
}
http.NotFound(w, r)
}))
defer server.Close()
host := OAuthHost{
Hostname: "test.example.com",
DeviceCodeURL: server.URL + "/login/device/code",
TokenURL: server.URL + "/login/oauth/access_token",
}
authMgr := NewAuthManager(host, "test-client-id", "", nil)
// Start the device flow
_, err := authMgr.StartDeviceFlow(context.Background())
require.NoError(t, err)
assert.Equal(t, AuthStatePending, authMgr.State())
// Reset should clear the pending state
authMgr.Reset()
assert.Equal(t, AuthStateUnauthenticated, authMgr.State())
}
+176
View File
@@ -0,0 +1,176 @@
package github
import (
"context"
"fmt"
"log/slog"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/github/github-mcp-server/pkg/utils"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// AuthToolset is the toolset for authentication tools.
// This is a special toolset that's only available when unauthenticated.
var ToolsetMetadataAuth = inventory.ToolsetMetadata{
ID: "auth",
Description: "Authentication tools for logging into GitHub",
Icon: "key",
}
// AuthToolDependencies contains dependencies for auth tools.
type AuthToolDependencies struct {
AuthManager *AuthManager
T translations.TranslationHelperFunc
// Server is the MCP server, used to access sessions for notifications
Server *mcp.Server
// Logger for debug logging
Logger *slog.Logger
// OnAuthenticated is called when authentication completes successfully.
// It should initialize GitHub clients and register tools.
OnAuthenticated func(ctx context.Context, token string) error
// OnAuthComplete is called after authentication flow completes (success or failure).
// It can be used to clean up auth tools after they're no longer needed.
OnAuthComplete func()
}
// AuthTools returns the authentication tools.
// These are available when the server starts without a token.
func AuthTools(t translations.TranslationHelperFunc) []inventory.ServerTool {
return []inventory.ServerTool{
AuthLogin(t),
}
}
// AuthLogin creates a tool that initiates the OAuth device flow.
// It uses URL elicitation to show the user the authorization URL and code,
// then blocks while polling until the user completes authorization.
func AuthLogin(t translations.TranslationHelperFunc) inventory.ServerTool {
return inventory.ServerTool{
Tool: mcp.Tool{
Name: "auth_login",
Description: t("auth_login_description", "Initiate GitHub authentication using OAuth device flow. This will provide a URL and code that you can use to authenticate with GitHub. After visiting the URL and entering the code, authentication will complete automatically."),
Annotations: &mcp.ToolAnnotations{
Title: t("auth_login_title", "Login to GitHub"),
ReadOnlyHint: true,
},
InputSchema: &jsonschema.Schema{
Type: "object",
Properties: map[string]*jsonschema.Schema{},
},
},
Toolset: ToolsetMetadataAuth,
HandlerFunc: func(deps any) mcp.ToolHandler {
return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
authDeps := deps.(AuthToolDependencies)
authMgr := authDeps.AuthManager
if authMgr.IsAuthenticated() {
return utils.NewToolResultText("Already authenticated with GitHub."), nil
}
// Reset any pending flow before starting a new one
authMgr.Reset()
deviceResp, err := authMgr.StartDeviceFlow(ctx)
if err != nil {
return utils.NewToolResultError(fmt.Sprintf("Failed to start authentication: %v", err)), nil
}
if authDeps.Logger != nil {
authDeps.Logger.Info("starting auth flow", "expiresIn", deviceResp.ExpiresIn)
}
// Use URL elicitation to show the auth URL to the user
// This creates a nice UI in the client for the user to click
elicitResult, err := req.Session.Elicit(ctx, &mcp.ElicitParams{
Mode: "url",
Message: fmt.Sprintf("🔐 GitHub Authentication\n\nEnter code: %s", deviceResp.UserCode),
URL: deviceResp.VerificationURI,
})
if err != nil {
if authDeps.Logger != nil {
authDeps.Logger.Error("elicitation failed", "error", err)
}
// Elicitation not supported or failed - fall back to polling
return pollAndComplete(ctx, req.Session, authDeps, authMgr, deviceResp)
}
// Check if user cancelled
if elicitResult.Action == "cancel" || elicitResult.Action == "decline" {
authMgr.Reset()
return utils.NewToolResultText("Authentication cancelled."), nil
}
// User clicked the link - now poll for completion with progress
return pollAndComplete(ctx, req.Session, authDeps, authMgr, deviceResp)
}
},
}
}
// pollAndComplete polls for the auth token and completes the flow.
// It sends progress notifications during polling so the user knows it's working.
func pollAndComplete(ctx context.Context, session *mcp.ServerSession, authDeps AuthToolDependencies, authMgr *AuthManager, _ *DeviceCodeResponse) (*mcp.CallToolResult, error) {
// Poll for the token with progress updates
err := authMgr.CompleteDeviceFlowWithProgress(ctx, func(elapsed, total int, _ string) {
if authDeps.Logger != nil {
authDeps.Logger.Debug("auth polling", "elapsed", elapsed, "total", total)
}
// Send progress notification so user sees we're waiting
if session != nil {
_ = session.NotifyProgress(ctx, &mcp.ProgressNotificationParams{
ProgressToken: "auth-polling",
Progress: float64(elapsed),
Total: float64(total),
Message: "⏳ Waiting for GitHub authorization...",
})
}
})
if err != nil {
if authDeps.Logger != nil {
authDeps.Logger.Error("auth polling failed", "error", err)
}
return utils.NewToolResultError(fmt.Sprintf("Authentication failed: %v", err)), nil
}
if authDeps.Logger != nil {
authDeps.Logger.Info("auth polling succeeded, registering tools")
}
// Call the OnAuthenticated callback to initialize clients and register tools
if authDeps.OnAuthenticated != nil {
if err := authDeps.OnAuthenticated(ctx, authMgr.Token()); err != nil {
if authDeps.Logger != nil {
authDeps.Logger.Error("failed to initialize after auth", "error", err)
}
return nil, fmt.Errorf("authentication succeeded but failed to initialize: %w", err)
}
}
// Send a user-visible notification about successful authentication
if session != nil {
_ = session.Log(ctx, &mcp.LoggingMessageParams{
Level: "notice",
Logger: "github-mcp-server",
Data: "✅ Successfully authenticated with GitHub! All GitHub tools are now available.",
})
}
// Clean up auth tools now that we're authenticated
if authDeps.OnAuthComplete != nil {
authDeps.OnAuthComplete()
}
return utils.NewToolResultText(`✅ Successfully authenticated with GitHub!
All GitHub tools are now available. You can now most likely:
- Create and manage repositories
- Work with issues and pull requests
- Access your organizations and teams
- And much more, depending on configuration (mention the success and new tools to the user!
Call get_me to see who you're logged in as.`), nil
}
+32
View File
@@ -0,0 +1,32 @@
package github
import (
"testing"
"github.com/github/github-mcp-server/internal/toolsnaps"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAuthLogin(t *testing.T) {
t.Parallel()
// Verify tool definition
serverTool := AuthLogin(translations.NullTranslationHelper)
tool := serverTool.Tool
require.NoError(t, toolsnaps.Test(tool.Name, tool))
assert.Equal(t, "auth_login", tool.Name)
assert.NotEmpty(t, tool.Description)
assert.True(t, tool.Annotations.ReadOnlyHint, "auth_login tool should be read-only")
}
func TestAuthTools(t *testing.T) {
t.Parallel()
tools := AuthTools(translations.NullTranslationHelper)
require.Len(t, tools, 1)
assert.Equal(t, "auth_login", tools[0].Tool.Name)
}