From d322e26d91cf2e2d466c8a0278093ce7dd42fe75 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 19 Dec 2025 01:08:52 +0100 Subject: [PATCH 1/8] 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 --- cmd/github-mcp-server/main.go | 11 +- docs/auth-design.md | 321 ++++++++++++++++++ internal/ghmcp/server.go | 207 +++++++++++- pkg/github/__toolsnaps__/auth_login.snap | 11 + pkg/github/auth.go | 400 +++++++++++++++++++++++ pkg/github/auth_test.go | 279 ++++++++++++++++ pkg/github/auth_tools.go | 176 ++++++++++ pkg/github/auth_tools_test.go | 32 ++ 8 files changed, 1416 insertions(+), 21 deletions(-) create mode 100644 docs/auth-design.md create mode 100644 pkg/github/__toolsnaps__/auth_login.snap create mode 100644 pkg/github/auth.go create mode 100644 pkg/github/auth_test.go create mode 100644 pkg/github/auth_tools.go create mode 100644 pkg/github/auth_tools_test.go diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index cfb68be4e..11d09a2d1 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -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) diff --git a/docs/auth-design.md b/docs/auth-design.md new file mode 100644 index 000000000..6b7fea40e --- /dev/null +++ b/docs/auth-design.md @@ -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" +``` diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 9859e2e9b..bd159233c 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -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 { diff --git a/pkg/github/__toolsnaps__/auth_login.snap b/pkg/github/__toolsnaps__/auth_login.snap new file mode 100644 index 000000000..6abf6d2b2 --- /dev/null +++ b/pkg/github/__toolsnaps__/auth_login.snap @@ -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" +} \ No newline at end of file diff --git a/pkg/github/auth.go b/pkg/github/auth.go new file mode 100644 index 000000000..347f32690 --- /dev/null +++ b/pkg/github/auth.go @@ -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 +} diff --git a/pkg/github/auth_test.go b/pkg/github/auth_test.go new file mode 100644 index 000000000..75432c5b4 --- /dev/null +++ b/pkg/github/auth_test.go @@ -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()) +} diff --git a/pkg/github/auth_tools.go b/pkg/github/auth_tools.go new file mode 100644 index 000000000..aced38d79 --- /dev/null +++ b/pkg/github/auth_tools.go @@ -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 +} diff --git a/pkg/github/auth_tools_test.go b/pkg/github/auth_tools_test.go new file mode 100644 index 000000000..69c3d0122 --- /dev/null +++ b/pkg/github/auth_tools_test.go @@ -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) +} From 63545b8dbc2a1b053d07cf76eb12468593a21780 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 19 Dec 2025 01:49:17 +0100 Subject: [PATCH 2/8] Delete auth-design.md --- docs/auth-design.md | 321 -------------------------------------------- 1 file changed, 321 deletions(-) delete mode 100644 docs/auth-design.md diff --git a/docs/auth-design.md b/docs/auth-design.md deleted file mode 100644 index 6b7fea40e..000000000 --- a/docs/auth-design.md +++ /dev/null @@ -1,321 +0,0 @@ -# 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" -``` From 4e68d8f0c6ca1d1e9e97bc5bf48a5523f79c194d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 06:20:54 +0000 Subject: [PATCH 3/8] Address PR review feedback and add OAuth authentication documentation - Fix OAuth client ID comment to clarify it's production-ready - Replace custom joinScopes function with strings.Join - Fix URL parsing fallback to avoid double-https:// prefix - Fix typo in auth success message - Update server instructions to remove auth_verify references - Add comprehensive OAuth authentication documentation - Update README with authentication method comparison and links to OAuth docs Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- README.md | 57 ++++- docs/oauth-authentication.md | 460 +++++++++++++++++++++++++++++++++++ internal/ghmcp/server.go | 2 +- pkg/github/auth.go | 25 +- pkg/github/auth_tools.go | 4 +- 5 files changed, 526 insertions(+), 22 deletions(-) create mode 100644 docs/oauth-authentication.md diff --git a/README.md b/README.md index ce6eb81cb..0d4b2ca39 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Built for developers who want to connect their AI tools to GitHub context and ca The remote GitHub MCP Server is hosted by GitHub and provides the easiest method for getting up and running. If your MCP host does not support remote MCP servers, don't worry! You can use the [local version of the GitHub MCP Server](https://github.com/github/github-mcp-server?tab=readme-ov-file#local-github-mcp-server) instead. +> **Note:** The remote server does not support OAuth device flow authentication. For OAuth authentication, use the [local GitHub MCP Server](#local-github-mcp-server) with the [OAuth authentication guide](/docs/oauth-authentication.md). + ### Prerequisites 1. A compatible MCP host with remote server support (VS Code 1.101+, Claude Desktop, Cursor, Windsurf, etc.) @@ -130,10 +132,57 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to ### Prerequisites -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 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)). +**For OAuth Device Flow Authentication (Recommended):** +1. Docker installed and running (or build from source) +2. A web browser to complete authentication +3. Network access to GitHub.com (or your GitHub Enterprise instance) + +**For Personal Access Token (PAT) Authentication:** +1. Docker installed and running (or build from source) +2. [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate permissions + +> **💡 Tip**: New users should try [OAuth device flow authentication](/docs/oauth-authentication.md) first - it requires no pre-configuration! Simply start the server without a token and authenticate through your browser. See the [authentication guide](/docs/oauth-authentication.md) for detailed instructions. + +### Authentication Methods + +The local GitHub MCP Server supports two authentication methods: + +#### 1. OAuth Device Flow (Recommended for Interactive Use) + +No pre-configuration needed! Start the server without a token: + +```json +{ + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server", "stdio"] + } +} +``` + +The server will guide you through browser-based authentication when you first use it. [Learn more in the OAuth authentication guide](/docs/oauth-authentication.md). + +#### 2. Personal Access Token (For Automation & Offline Use) + +Create a [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) and configure it: + +```json +{ + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token_here" + } + } +} +``` + +See the [OAuth vs PAT comparison](/docs/oauth-authentication.md#comparison-with-pat-authentication) to choose the best method for your use case. + +### Personal Access Token Configuration + +If you choose to use a Personal Access Token, 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)).
Handling PATs Securely diff --git a/docs/oauth-authentication.md b/docs/oauth-authentication.md new file mode 100644 index 000000000..8aaa4e16a --- /dev/null +++ b/docs/oauth-authentication.md @@ -0,0 +1,460 @@ +# OAuth Device Flow Authentication + +The GitHub MCP Server supports OAuth device flow authentication as an alternative to Personal Access Tokens (PATs). This provides a streamlined authentication experience where users can authenticate directly through the MCP server without pre-configuring tokens. + +## Table of Contents + +- [Overview](#overview) +- [How It Works](#how-it-works) +- [Getting Started](#getting-started) +- [Configuration](#configuration) + - [Using Default OAuth App](#using-default-oauth-app) + - [Using Custom OAuth Apps](#using-custom-oauth-apps) + - [GitHub Enterprise Server (GHES)](#github-enterprise-server-ghes) + - [GitHub Enterprise Cloud (GHEC)](#github-enterprise-cloud-ghec) +- [CLI Flags](#cli-flags) +- [Environment Variables](#environment-variables) +- [Scopes and Permissions](#scopes-and-permissions) +- [Security Considerations](#security-considerations) +- [Troubleshooting](#troubleshooting) +- [Comparison with PAT Authentication](#comparison-with-pat-authentication) + +## Overview + +OAuth device flow authentication allows users to authenticate with GitHub by: +1. Starting the authentication process through the MCP server +2. Receiving a user code and verification URL +3. Visiting the URL in a browser and entering the code +4. Automatically completing authentication once approved + +This method eliminates the need to manually create and configure Personal Access Tokens, making it easier for users to get started with the GitHub MCP Server. + +## How It Works + +The OAuth device flow follows these steps: + +1. **User requests authentication**: When the server starts without a token, only the `auth_login` tool is available. The user (or their AI agent) calls this tool to initiate authentication. + +2. **Server requests device code**: The server requests a device code from GitHub's OAuth device flow endpoint (`https://github.com/login/device/code` or your enterprise equivalent). + +3. **User receives verification URL**: The server displays a verification URL and user code to the user via MCP's URL elicitation feature (if supported by the client). + +4. **User authorizes in browser**: The user opens the verification URL in their browser, enters the code, and authorizes the application. + +5. **Server polls for token**: While the user is authorizing, the server polls GitHub's token endpoint (`https://github.com/login/oauth/access_token`) until the user completes authorization or the request expires. + +6. **Authentication completes**: Once the user authorizes the app, the server receives an access token and automatically registers all GitHub tools. + +The entire process is handled by a single `auth_login` tool call that blocks until authentication completes or fails. + +## Getting Started + +### Quick Start (No Configuration Needed) + +The simplest way to use OAuth authentication is to start the server without providing a token: + +**Docker:** +```json +{ + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "ghcr.io/github/github-mcp-server", "stdio"] + } +} +``` + +**Binary:** +```json +{ + "github": { + "command": "/path/to/github-mcp-server", + "args": ["stdio"] + } +} +``` + +When the server starts without a `GITHUB_PERSONAL_ACCESS_TOKEN`, it will automatically enter authentication mode. Your AI agent can then call the `auth_login` tool to initiate the OAuth flow. + +### Backward Compatibility + +OAuth authentication is completely optional. If you provide a `GITHUB_PERSONAL_ACCESS_TOKEN`, the server will use it and skip OAuth authentication entirely. This means existing configurations continue to work without modification. + +```json +{ + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "ghcr.io/github/github-mcp-server"], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "ghp_your_token_here" + } + } +} +``` + +## Configuration + +### Using Default OAuth App + +The GitHub MCP Server includes a default OAuth App registered and managed by GitHub. This OAuth App works with: +- GitHub.com (https://github.com) +- GitHub Enterprise Cloud with data residency (ghe.com) +- GitHub Enterprise Server (GHES) 3.0+ + +**No additional configuration is required** to use the default OAuth App. Simply omit the PAT token when starting the server. + +The default OAuth App client ID is embedded in the source code (which is safe per OAuth 2.0 specifications for public clients) and requires no client secret. + +### Using Custom OAuth Apps + +For enterprise scenarios, you may want to use your own OAuth App. This is useful when: +- You want to customize the app name and branding +- You need to restrict access to specific organizations +- You want to use a confidential client (with client secret) +- Your organization's policies require using organization-owned OAuth Apps + +#### Creating a Custom OAuth App + +1. **Navigate to OAuth App settings:** + - For personal apps: https://github.com/settings/developers + - For organization apps: https://github.com/organizations/YOUR_ORG/settings/applications + +2. **Create a new OAuth App:** + - Click "New OAuth App" + - **Application name**: "GitHub MCP Server (Custom)" + - **Homepage URL**: https://github.com/github/github-mcp-server (or your fork) + - **Authorization callback URL**: Not used for device flow, but required. Use: `http://localhost:8080/callback` + - **Enable Device Flow**: Make sure this is checked + +3. **Configure the scopes**: The OAuth App will request the scopes defined in the server (see [Scopes and Permissions](#scopes-and-permissions)). + +4. **Get your credentials:** + - **Client ID**: Copy the client ID (e.g., `Ov23liAbcdefg1234567`) + - **Client Secret** (optional): Generate a client secret if you want to use a confidential client + +#### Using Your Custom OAuth App + +Provide the client ID (and optionally client secret) using CLI flags or environment variables: + +**CLI Flags:** +```json +{ + "github": { + "command": "/path/to/github-mcp-server", + "args": [ + "stdio", + "--oauth-client-id", "Ov23liYourClientID", + "--oauth-client-secret", "your_client_secret_if_needed" + ] + } +} +``` + +**Environment Variables:** +```json +{ + "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": "Ov23liYourClientID", + "GITHUB_OAUTH_CLIENT_SECRET": "your_client_secret_if_needed" + } + } +} +``` + +### GitHub Enterprise Server (GHES) + +To use OAuth device flow with GitHub Enterprise Server: + +1. **Ensure GHES supports device flow**: Device flow is available in GHES 3.0 and later. + +2. **Create an OAuth App** on your GHES instance: + - Navigate to: `https://YOUR_GHES_HOSTNAME/settings/developers` + - Follow the steps in [Creating a Custom OAuth App](#creating-a-custom-oauth-app) + +3. **Configure the server** with your GHES hostname: + +```json +{ + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_HOST", + "-e", "GITHUB_OAUTH_CLIENT_ID", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_HOST": "https://github.yourcompany.com", + "GITHUB_OAUTH_CLIENT_ID": "your_ghes_oauth_client_id" + } + } +} +``` + +**Important**: Always prefix GHES hostnames with `https://` to ensure proper URL construction. + +#### Example: Full GHES Configuration + +```json +{ + "github": { + "command": "/path/to/github-mcp-server", + "args": ["stdio", "--gh-host", "https://github.yourcompany.com", "--oauth-client-id", "Ov23liGHESClientID"] + } +} +``` + +### GitHub Enterprise Cloud (GHEC) + +GitHub Enterprise Cloud with data residency (ghe.com) works with the default OAuth App. However, you may want to create a custom OAuth App for organization-specific branding or policies. + +1. **Create an OAuth App** on your GHEC organization: + - Navigate to: `https://YOUR_SUBDOMAIN.ghe.com/settings/developers` or your organization settings + - Follow the steps in [Creating a Custom OAuth App](#creating-a-custom-oauth-app) + +2. **Configure the server** with your GHEC hostname: + +```json +{ + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_HOST", + "-e", "GITHUB_OAUTH_CLIENT_ID", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_HOST": "https://yourcompany.ghe.com", + "GITHUB_OAUTH_CLIENT_ID": "your_ghec_oauth_client_id" + } + } +} +``` + +## CLI Flags + +The GitHub MCP Server provides the following CLI flags for OAuth configuration: + +| Flag | Description | Default | Example | +|------|-------------|---------|---------| +| `--oauth-client-id` | OAuth App client ID for device flow authentication | Default GitHub MCP OAuth App | `--oauth-client-id Ov23liYourClientID` | +| `--oauth-client-secret` | OAuth App client secret (optional, for confidential clients) | None | `--oauth-client-secret your_client_secret` | +| `--gh-host` | GitHub hostname for API requests and OAuth endpoints | `github.com` | `--gh-host https://github.yourcompany.com` | + +### Usage Examples + +**Minimal configuration (uses defaults):** +```bash +github-mcp-server stdio +``` + +**Custom OAuth App:** +```bash +github-mcp-server stdio --oauth-client-id Ov23liYourClientID +``` + +**GHES with custom OAuth App:** +```bash +github-mcp-server stdio --gh-host https://github.yourcompany.com --oauth-client-id Ov23liGHESClientID +``` + +**Confidential client with secret:** +```bash +github-mcp-server stdio --oauth-client-id Ov23liYourClientID --oauth-client-secret your_secret +``` + +## Environment Variables + +All CLI flags can also be configured via environment variables with the `GITHUB_` prefix: + +| Environment Variable | Equivalent CLI Flag | Example | +|---------------------|---------------------|---------| +| `GITHUB_OAUTH_CLIENT_ID` | `--oauth-client-id` | `export GITHUB_OAUTH_CLIENT_ID=Ov23liYourClientID` | +| `GITHUB_OAUTH_CLIENT_SECRET` | `--oauth-client-secret` | `export GITHUB_OAUTH_CLIENT_SECRET=your_secret` | +| `GITHUB_HOST` | `--gh-host` | `export GITHUB_HOST=https://github.yourcompany.com` | +| `GITHUB_PERSONAL_ACCESS_TOKEN` | N/A (disables OAuth) | `export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_token` | + +**Note**: If `GITHUB_PERSONAL_ACCESS_TOKEN` is set, the server will use PAT authentication and skip OAuth device flow entirely. + +### Environment Variable Priority + +The server uses the following priority for configuration: +1. CLI flags (highest priority) +2. Environment variables +3. Default values (lowest priority) + +## Scopes and Permissions + +The OAuth device flow requests the following scopes by default (defined in `DefaultOAuthScopes`): + +| Scope | Description | Required For | +|-------|-------------|--------------| +| `repo` | Full control of private repositories | Repository operations, issues, PRs | +| `repo:status` | Access commit status | CI/CD workflow monitoring | +| `repo_deployment` | Access deployment status | Deployment operations | +| `public_repo` | Access public repositories | Public repository operations | +| `gist` | Create and manage gists | Gist operations | +| `notifications` | Access notifications | Notification tools | +| `user` | Read and write user profile data | User information | +| `user:email` | Access user email addresses | User contact information | +| `user:follow` | Follow and unfollow users | Social features | +| `read:org` | Read organization data | Organization and team access | +| `read:gpg_key` | Read GPG keys | Signature verification | +| `project` | Read and write project data | Project board management | + +These scopes form a superset of the `gh` CLI minimal scopes (`repo`, `read:org`, `gist`) to support all GitHub MCP tools while following least-privilege principles. + +### Customizing Scopes + +Currently, scopes are not customizable via CLI flags or environment variables. They are defined in the source code (`pkg/github/auth.go`) as `DefaultOAuthScopes`. To customize scopes, you would need to: + +1. Fork the repository +2. Modify the `DefaultOAuthScopes` variable +3. Build your custom version + +## Security Considerations + +### Token Storage + +- **In-memory only**: Access tokens obtained via OAuth device flow are stored only in memory and are never written to disk. +- **Ephemeral sessions**: Tokens are lost when the server process terminates (e.g., when a Docker container is stopped with `--rm`). +- **No persistent storage**: This design prioritizes security over convenience, making it ideal for short-lived sessions. + +### Public vs. Confidential Clients + +- **Public clients**: The default OAuth App is a public client (no client secret). The client ID can be safely embedded in source code per OAuth 2.0 specifications. +- **Confidential clients**: If you provide a client secret via `--oauth-client-secret`, the app becomes a confidential client with additional security but requires secure secret storage. + +### User Authorization + +- **Explicit consent**: Users must explicitly authorize the OAuth App in their browser, providing full visibility into what access is being granted. +- **Revocable access**: Users can revoke access at any time from their GitHub settings: https://github.com/settings/applications + +### Best Practices + +1. **Use organization-owned OAuth Apps** for enterprise deployments to maintain control +2. **Regularly review authorized applications** in GitHub settings +3. **Use confidential clients** (with secrets) only when you can securely store the secret +4. **Prefer ephemeral tokens** (OAuth) over long-lived PATs when possible +5. **Enable organization policies** to restrict which OAuth Apps can access your organization's data + +## Troubleshooting + +### Common Issues + +#### "Device code expired" + +**Cause**: The user didn't complete authorization within the expiration time (typically 15 minutes). + +**Solution**: Call `auth_login` again to start a new authentication flow. + +#### "Authorization was denied by the user" + +**Cause**: The user clicked "Cancel" or "Deny" during the authorization flow. + +**Solution**: Call `auth_login` again and ensure the user completes the authorization. + +#### "Failed to start authentication" + +**Possible causes**: +- Network connectivity issues +- Invalid OAuth App configuration +- GitHub service issues + +**Solutions**: +- Check network connectivity to GitHub +- Verify your OAuth client ID is correct +- Check GitHub's status page: https://www.githubstatus.com + +#### OAuth not working with GHES + +**Possible causes**: +- GHES version doesn't support device flow (requires GHES 3.0+) +- OAuth App not properly configured on GHES +- Incorrect hostname format + +**Solutions**: +- Verify GHES version: `https://YOUR_GHES_HOSTNAME/api/v3/meta` +- Ensure OAuth App has device flow enabled in GHES settings +- Use full URL with scheme: `--gh-host https://github.yourcompany.com` + +#### "Elicitation failed" message in logs + +**Cause**: The MCP client doesn't support URL elicitation (an optional MCP feature). + +**Effect**: The authentication flow still works, but the user won't see a clickable link. They'll need to manually copy/paste the verification URL. + +**Solution**: No action needed - this is expected for some MCP clients. + +### Debug Logging + +Enable debug logging to troubleshoot authentication issues: + +```json +{ + "github": { + "command": "/path/to/github-mcp-server", + "args": ["stdio", "--log-file", "/tmp/github-mcp-server.log"] + } +} +``` + +Check the log file for detailed information about the authentication flow. + +## Comparison with PAT Authentication + +| Feature | OAuth Device Flow | Personal Access Token | +|---------|------------------|---------------------| +| **Initial Setup** | No pre-configuration needed | Must manually create token | +| **Token Storage** | In-memory only | Stored in config files | +| **Token Lifetime** | Session-based (ephemeral) | Long-lived (until revoked) | +| **Security** | Explicit browser authorization | Token visible in config | +| **User Experience** | Interactive flow | Copy/paste token | +| **Enterprise Control** | Organization OAuth App policies | Token-level restrictions | +| **Offline Use** | Requires initial online auth | Works offline after setup | +| **Multiple Clients** | Each client needs separate auth | Same token can be reused | + +### When to Use Each Method + +**Use OAuth Device Flow when:** +- You want the simplest setup experience +- You're using Docker with `--rm` (ephemeral containers) +- You prefer not storing tokens in config files +- Your organization uses OAuth App policies + +**Use PAT when:** +- You need offline access after initial setup +- You want to reuse the same token across multiple MCP clients +- You need long-lived credentials for automation +- You're using an environment where interactive authentication isn't possible + +### Migration Between Methods + +You can switch between authentication methods at any time: + +**From PAT to OAuth**: Simply remove the `GITHUB_PERSONAL_ACCESS_TOKEN` environment variable from your configuration and restart the server. + +**From OAuth to PAT**: Add `GITHUB_PERSONAL_ACCESS_TOKEN` to your configuration with your token. The server will use PAT authentication and skip OAuth entirely. + +## Additional Resources + +- [OAuth 2.0 Device Authorization Grant (RFC 8628)](https://datatracker.ietf.org/doc/html/rfc8628) +- [GitHub OAuth Apps Documentation](https://docs.github.com/en/apps/oauth-apps) +- [GitHub Device Flow Documentation](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow) +- [Creating a Personal Access Token](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +- [GitHub MCP Server Documentation](https://github.com/github/github-mcp-server) + +## Need Help? + +If you encounter issues not covered in this guide: + +1. Check the [GitHub Discussions](https://github.com/github/github-mcp-server/discussions) +2. Search [existing issues](https://github.com/github/github-mcp-server/issues) +3. [Open a new issue](https://github.com/github/github-mcp-server/issues/new) with: + - Your server configuration (redact sensitive data) + - Error messages or unexpected behavior + - Debug logs (if available) + - GitHub environment (github.com, GHES, GHEC) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index bd159233c..1c277ca3d 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -291,7 +291,7 @@ func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerRes // 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.", + Instructions: "GitHub MCP Server - Authentication Required\n\nYou are not currently authenticated with GitHub. Use the auth_login tool to complete authentication. This is a single, blocking call that will guide you through the entire device authorization flow and return once authentication has finished.", Logger: cfg.Logger, // Advertise capabilities since tools will be added after auth Capabilities: &mcp.ServerCapabilities{ diff --git a/pkg/github/auth.go b/pkg/github/auth.go index 347f32690..6b3b0a25c 100644 --- a/pkg/github/auth.go +++ b/pkg/github/auth.go @@ -84,17 +84,20 @@ func NewOAuthHostFromAPIHost(hostname string) OAuthHost { // Parse the hostname to extract the base u, err := url.Parse(hostname) + var host string if err != nil || u.Hostname() == "" { - // Fallback: treat as hostname directly (shouldn't happen with scheme added) + // Fallback: strip scheme if it was added, use original hostname + host = strings.TrimPrefix(hostname, "https://") + host = strings.TrimPrefix(host, "http://") return OAuthHost{ - DeviceCodeURL: fmt.Sprintf("https://%s/login/device/code", hostname), - TokenURL: fmt.Sprintf("https://%s/login/oauth/access_token", hostname), - Hostname: hostname, + DeviceCodeURL: fmt.Sprintf("https://%s/login/device/code", host), + TokenURL: fmt.Sprintf("https://%s/login/oauth/access_token", host), + Hostname: host, } } // For GHEC (ghe.com) and GHES, OAuth endpoints are on the main host - host := u.Hostname() + host = u.Hostname() scheme := u.Scheme if scheme == "" { scheme = "https" @@ -108,10 +111,9 @@ func NewOAuthHostFromAPIHost(hostname string) OAuthHost { } // DefaultOAuthClientID is the OAuth App client ID for the GitHub MCP Server. -// This OAuth App is registered by GitHub for use with this server. +// This OAuth App is registered and managed 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. @@ -389,12 +391,5 @@ func (a *AuthManager) SetToken(token string) { } func joinScopes(scopes []string) string { - result := "" - for i, s := range scopes { - if i > 0 { - result += " " - } - result += s - } - return result + return strings.Join(scopes, " ") } diff --git a/pkg/github/auth_tools.go b/pkg/github/auth_tools.go index aced38d79..841ef4bc5 100644 --- a/pkg/github/auth_tools.go +++ b/pkg/github/auth_tools.go @@ -166,11 +166,11 @@ func pollAndComplete(ctx context.Context, session *mcp.ServerSession, authDeps A return utils.NewToolResultText(`✅ Successfully authenticated with GitHub! -All GitHub tools are now available. You can now most likely: +All GitHub tools are now available. You can now: - 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! +- And much more, depending on your GitHub configuration Call get_me to see who you're logged in as.`), nil } From 6b631f816ad1d1b330c8fcb7076918993002a4a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 06:32:18 +0000 Subject: [PATCH 4/8] Add OAuth scopes customization via CLI flag and environment variable - Add --oauth-scopes CLI flag to allow users to limit requested scopes - Add GITHUB_OAUTH_SCOPES environment variable support - Update OAuth authentication documentation with scopes customization guide - Add examples for minimal, read-only, and custom scope configurations - Update OAuth client ID comment to indicate it's a testing app (TODO for production) Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- cmd/github-mcp-server/main.go | 11 ++++++ docs/oauth-authentication.md | 72 +++++++++++++++++++++++++++++++++-- internal/ghmcp/server.go | 11 +++++- pkg/github/auth.go | 3 +- 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 11d09a2d1..3f6a6935e 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -65,6 +65,14 @@ var ( } } + // Parse OAuth scopes (similar to toolsets) + var oauthScopes []string + if viper.IsSet("oauth-scopes") { + if err := viper.UnmarshalKey("oauth-scopes", &oauthScopes); err != nil { + return fmt.Errorf("failed to unmarshal oauth-scopes: %w", err) + } + } + ttl := viper.GetDuration("repo-access-cache-ttl") stdioServerConfig := ghmcp.StdioServerConfig{ Version: version, @@ -83,6 +91,7 @@ var ( RepoAccessCacheTTL: &ttl, OAuthClientID: viper.GetString("oauth-client-id"), OAuthClientSecret: viper.GetString("oauth-client-secret"), + OAuthScopes: oauthScopes, } return ghmcp.RunStdioServer(stdioServerConfig) }, @@ -110,6 +119,7 @@ func init() { 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)") + rootCmd.PersistentFlags().StringSlice("oauth-scopes", nil, "Comma-separated list of OAuth scopes to request during device flow authentication (optional, uses default if not provided)") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) @@ -126,6 +136,7 @@ func init() { _ = 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")) // Add subcommands rootCmd.AddCommand(stdioCmd) diff --git a/docs/oauth-authentication.md b/docs/oauth-authentication.md index 8aaa4e16a..83f81d268 100644 --- a/docs/oauth-authentication.md +++ b/docs/oauth-authentication.md @@ -242,6 +242,7 @@ The GitHub MCP Server provides the following CLI flags for OAuth configuration: |------|-------------|---------|---------| | `--oauth-client-id` | OAuth App client ID for device flow authentication | Default GitHub MCP OAuth App | `--oauth-client-id Ov23liYourClientID` | | `--oauth-client-secret` | OAuth App client secret (optional, for confidential clients) | None | `--oauth-client-secret your_client_secret` | +| `--oauth-scopes` | Comma-separated list of OAuth scopes to request | Default scopes (see below) | `--oauth-scopes repo,read:org,gist` | | `--gh-host` | GitHub hostname for API requests and OAuth endpoints | `github.com` | `--gh-host https://github.yourcompany.com` | ### Usage Examples @@ -256,6 +257,11 @@ github-mcp-server stdio github-mcp-server stdio --oauth-client-id Ov23liYourClientID ``` +**Limiting scopes (minimal permissions):** +```bash +github-mcp-server stdio --oauth-scopes repo,read:org,gist +``` + **GHES with custom OAuth App:** ```bash github-mcp-server stdio --gh-host https://github.yourcompany.com --oauth-client-id Ov23liGHESClientID @@ -266,6 +272,11 @@ github-mcp-server stdio --gh-host https://github.yourcompany.com --oauth-client- github-mcp-server stdio --oauth-client-id Ov23liYourClientID --oauth-client-secret your_secret ``` +**Custom scopes with custom OAuth App:** +```bash +github-mcp-server stdio --oauth-client-id Ov23liYourClientID --oauth-scopes repo,read:org,user:email +``` + ## Environment Variables All CLI flags can also be configured via environment variables with the `GITHUB_` prefix: @@ -274,6 +285,7 @@ All CLI flags can also be configured via environment variables with the `GITHUB_ |---------------------|---------------------|---------| | `GITHUB_OAUTH_CLIENT_ID` | `--oauth-client-id` | `export GITHUB_OAUTH_CLIENT_ID=Ov23liYourClientID` | | `GITHUB_OAUTH_CLIENT_SECRET` | `--oauth-client-secret` | `export GITHUB_OAUTH_CLIENT_SECRET=your_secret` | +| `GITHUB_OAUTH_SCOPES` | `--oauth-scopes` | `export GITHUB_OAUTH_SCOPES=repo,read:org,gist` | | `GITHUB_HOST` | `--gh-host` | `export GITHUB_HOST=https://github.yourcompany.com` | | `GITHUB_PERSONAL_ACCESS_TOKEN` | N/A (disables OAuth) | `export GITHUB_PERSONAL_ACCESS_TOKEN=ghp_token` | @@ -309,11 +321,63 @@ These scopes form a superset of the `gh` CLI minimal scopes (`repo`, `read:org`, ### Customizing Scopes -Currently, scopes are not customizable via CLI flags or environment variables. They are defined in the source code (`pkg/github/auth.go`) as `DefaultOAuthScopes`. To customize scopes, you would need to: +You can customize the requested scopes using the `--oauth-scopes` CLI flag or `GITHUB_OAUTH_SCOPES` environment variable: + +**CLI Flag:** +```bash +github-mcp-server stdio --oauth-scopes repo,read:org,gist +``` + +**Environment Variable:** +```bash +export GITHUB_OAUTH_SCOPES=repo,read:org,user:email +github-mcp-server stdio +``` -1. Fork the repository -2. Modify the `DefaultOAuthScopes` variable -3. Build your custom version +**Docker with environment variable:** +```json +{ + "github": { + "command": "docker", + "args": ["run", "-i", "--rm", "-e", "GITHUB_OAUTH_SCOPES", "ghcr.io/github/github-mcp-server"], + "env": { + "GITHUB_OAUTH_SCOPES": "repo,read:org,gist" + } + } +} +``` + +#### Minimal Recommended Scopes + +For basic functionality, you can use a minimal set of scopes: + +```bash +--oauth-scopes repo,read:org,gist +``` + +This provides: +- `repo`: Access to repositories, issues, and PRs +- `read:org`: Read organization and team information +- `gist`: Manage gists + +**Note**: Some MCP tools may not function properly with reduced scopes. Review the scopes table above to understand which scopes are required for specific functionality. + +#### Common Scope Combinations + +**Read-only operations:** +```bash +--oauth-scopes repo,read:org,read:user +``` + +**Full repository access with notifications:** +```bash +--oauth-scopes repo,read:org,notifications,user:email +``` + +**Enterprise with minimal scopes:** +```bash +--oauth-scopes repo,read:org,read:gpg_key +``` ## Security Considerations diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 1c277ca3d..186d558fd 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -74,6 +74,10 @@ type MCPServerConfig struct { // OAuthClientSecret is the OAuth App client secret (optional, for confidential clients). OAuthClientSecret string + + // OAuthScopes is a list of OAuth scopes to request during device flow authentication. + // If empty, the default scopes defined in DefaultOAuthScopes are used. + OAuthScopes []string } // githubClients holds all the GitHub API clients created for a server instance. @@ -287,7 +291,7 @@ func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerRes oauthHost := github.NewOAuthHostFromAPIHost(cfg.Host) // Create auth manager - authManager := github.NewAuthManager(oauthHost, cfg.OAuthClientID, cfg.OAuthClientSecret, nil) + authManager := github.NewAuthManager(oauthHost, cfg.OAuthClientID, cfg.OAuthClientSecret, cfg.OAuthScopes) // Create the MCP server with capabilities advertised for dynamic tool registration serverOpts := &mcp.ServerOptions{ @@ -456,6 +460,10 @@ type StdioServerConfig struct { // OAuthClientSecret is the OAuth App client secret (optional, for confidential clients). OAuthClientSecret string + + // OAuthScopes is a list of OAuth scopes to request during device flow authentication. + // If empty, the default scopes defined in DefaultOAuthScopes are used. + OAuthScopes []string } // RunStdioServer is not concurrent safe. @@ -494,6 +502,7 @@ func RunStdioServer(cfg StdioServerConfig) error { Logger: logger, OAuthClientID: cfg.OAuthClientID, OAuthClientSecret: cfg.OAuthClientSecret, + OAuthScopes: cfg.OAuthScopes, // Pass config for use after authentication EnabledToolsets: cfg.EnabledToolsets, EnabledTools: cfg.EnabledTools, diff --git a/pkg/github/auth.go b/pkg/github/auth.go index 6b3b0a25c..3de5e69b4 100644 --- a/pkg/github/auth.go +++ b/pkg/github/auth.go @@ -111,7 +111,8 @@ func NewOAuthHostFromAPIHost(hostname string) OAuthHost { } // DefaultOAuthClientID is the OAuth App client ID for the GitHub MCP Server. -// This OAuth App is registered and managed by GitHub for use with this server. +// TODO: This is currently a testing OAuth App. An official GitHub-managed OAuth App +// will be registered before production release. // 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. const DefaultOAuthClientID = "Ov23ctTMsnT9LTRdBYYM" From 4a1c61e9f851010cefe26719849be1e3434595b0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 06:43:22 +0000 Subject: [PATCH 5/8] Add GitHub Apps support documentation to OAuth authentication guide - Add new "Using GitHub Apps" section explaining device flow with GitHub Apps - Document key differences between OAuth Apps and GitHub Apps - Explain that scopes are controlled by app configuration, not --oauth-scopes flag - Include instructions for creating and using GitHub Apps - Update table of contents and overview to mention GitHub Apps support - No code changes needed - existing implementation already supports both OAuth Apps and GitHub Apps Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- docs/oauth-authentication.md | 46 ++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/docs/oauth-authentication.md b/docs/oauth-authentication.md index 83f81d268..ade5580b9 100644 --- a/docs/oauth-authentication.md +++ b/docs/oauth-authentication.md @@ -10,6 +10,7 @@ The GitHub MCP Server supports OAuth device flow authentication as an alternativ - [Configuration](#configuration) - [Using Default OAuth App](#using-default-oauth-app) - [Using Custom OAuth Apps](#using-custom-oauth-apps) + - [Using GitHub Apps](#using-github-apps) - [GitHub Enterprise Server (GHES)](#github-enterprise-server-ghes) - [GitHub Enterprise Cloud (GHEC)](#github-enterprise-cloud-ghec) - [CLI Flags](#cli-flags) @@ -29,6 +30,8 @@ OAuth device flow authentication allows users to authenticate with GitHub by: This method eliminates the need to manually create and configure Personal Access Tokens, making it easier for users to get started with the GitHub MCP Server. +The device flow authentication works with both **OAuth Apps** and **GitHub Apps**. OAuth Apps use scope-based permissions that can be customized via the `--oauth-scopes` flag, while GitHub Apps use fine-grained permissions that are controlled by the app's configuration in GitHub settings. + ## How It Works The OAuth device flow follows these steps: @@ -163,6 +166,49 @@ Provide the client ID (and optionally client secret) using CLI flags or environm } ``` +### Using GitHub Apps + +The GitHub MCP Server also supports authentication via GitHub Apps using the device flow. GitHub Apps provide more granular permissions and better security controls compared to OAuth Apps. + +**Key Differences:** + +- **Permissions Model**: GitHub Apps use fine-grained permissions instead of OAuth scopes. The `--oauth-scopes` flag does not apply when using GitHub Apps, as permissions are controlled by the GitHub App's configuration in GitHub's settings. +- **App-Controlled Access**: When authenticating via a GitHub App, the available repositories, organizations, and resources are determined by the app's installation and permissions, not by the scopes requested during authentication. +- **Installation-Based**: GitHub Apps must be installed on organizations or repositories before users can authenticate through them. + +#### Creating a GitHub App + +1. **Navigate to GitHub App settings:** + - For personal apps: https://github.com/settings/apps + - For organization apps: https://github.com/organizations/YOUR_ORG/settings/apps + +2. **Create a new GitHub App:** + - Click "New GitHub App" + - **GitHub App name**: "GitHub MCP Server (Custom)" + - **Homepage URL**: https://github.com/github/github-mcp-server + - **Callback URL**: Not used for device flow, but required. Use: `http://localhost:8080/callback` + - **Request user authorization (OAuth) during installation**: Uncheck this + - **Enable Device Flow**: Make sure this is checked + - **Webhook**: Can be set to inactive if not needed + +3. **Configure permissions**: Set the repository and organization permissions based on your needs (equivalent to the scopes in OAuth Apps). + +4. **Get your credentials:** + - **Client ID**: Copy the client ID from the app settings + - **Client Secret**: Generate a client secret if needed + +5. **Install the GitHub App**: Install the app on the organizations or repositories where you want to use it. + +#### Using Your GitHub App + +Use the same `--oauth-client-id` and `--oauth-client-secret` flags with your GitHub App's credentials: + +```bash +github-mcp-server stdio --oauth-client-id Iv1.your_github_app_client_id +``` + +**Note**: When using GitHub Apps, the `--oauth-scopes` flag is ignored. Access and permissions are controlled by the GitHub App's configuration and installation settings. + ### GitHub Enterprise Server (GHES) To use OAuth device flow with GitHub Enterprise Server: From b99bceae513c8bbbfd8c174f6ce80c739b9fc631 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 06:52:39 +0000 Subject: [PATCH 6/8] Improve code documentation and architectural clarity - Add comprehensive doc comments explaining NewMCPServer initialization steps - Document the two-phase initialization pattern in NewUnauthenticatedMCPServer - Explain architectural tradeoffs and acknowledge code duplication - Add notes about future refactoring opportunities for common setup logic - Clarify manual tool filtering approach and suggest toolset-based alternative - Document MCPServerConfig as shared configuration struct - No functional changes - documentation and comments only Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- internal/ghmcp/server.go | 33 ++++++++++++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 186d558fd..b231f6996 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -25,6 +25,9 @@ import ( "github.com/shurcooL/githubv4" ) +// MCPServerConfig contains all configuration needed to create an MCP server. +// This config struct is used by both NewMCPServer and NewUnauthenticatedMCPServer +// to ensure consistent configuration across authentication modes. type MCPServerConfig struct { // Version of the server Version string @@ -158,6 +161,17 @@ func resolveEnabledToolsets(cfg MCPServerConfig) []string { return nil } +// NewMCPServer creates a fully initialized MCP server with GitHub API access. +// This constructor is used when a token is available at startup (PAT authentication). +// For OAuth device flow authentication without a pre-configured token, use NewUnauthenticatedMCPServer instead. +// +// The server creation involves several steps: +// 1. Parse API host configuration and create GitHub clients +// 2. Resolve which toolsets to enable +// 3. Create MCP server with appropriate capabilities +// 4. Add middleware for error handling, user agents, and dependency injection +// 5. Build and register the tool/resource/prompt inventory +// 6. Optionally register dynamic toolset management tools func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { apiHost, err := parseAPIHost(cfg.Host) if err != nil { @@ -284,8 +298,17 @@ type UnauthenticatedServerResult struct { } // 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. +// This constructor is used for OAuth device flow when no token is available at startup. +// After successful authentication via the auth tools, the OnAuthenticated callback +// initializes GitHub clients and registers all other tools dynamically. +// +// Architecture note: This shares significant setup logic with NewMCPServer. The duplication +// exists because the two modes have different initialization timing: +// - NewMCPServer: All setup happens at construction time (clients + tools) +// - NewUnauthenticatedMCPServer: Setup happens in two phases (auth tools first, then clients + tools after auth) +// +// Future improvement: Consider extracting common setup logic into shared helper functions +// to reduce duplication while maintaining the two-phase initialization pattern. func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerResult, error) { // Create OAuth host from the configured GitHub host oauthHost := github.NewOAuthHostFromAPIHost(cfg.Host) @@ -387,7 +410,11 @@ func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerRes return nil }, OnAuthComplete: func() { - // Remove auth_login tool now that authentication is complete + // Remove auth tools after authentication completes. + // Note: This manual removal ensures auth tools don't remain available after login. + // The auth_login tool is removed by name here to keep the tool list clean. + // Future improvement: Consider using toolset-based filtering to automatically + // exclude auth toolset after authentication, removing the need for manual cleanup. ghServer.RemoveTools("auth_login") if cfg.Logger != nil { cfg.Logger.Info("auth tools removed after successful authentication") From 810ec9548c48d9bbde03baab66334ea2055da5bb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 07:13:55 +0000 Subject: [PATCH 7/8] Refactor to build inventory once before authentication fork - Move inventory building outside OnAuthenticated callback in NewUnauthenticatedMCPServer - Build inventory once before creating auth tools, eliminating duplication - Reuse the same inventory instance in OnAuthenticated via closure - Tool filters are now applied only once, preventing stale auth tools - Reduces code duplication without changing architecture - All tests pass, 0 lint issues Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- internal/ghmcp/server.go | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index b231f6996..a61b0ceee 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -316,6 +316,20 @@ func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerRes // Create auth manager authManager := github.NewAuthManager(oauthHost, cfg.OAuthClientID, cfg.OAuthClientSecret, cfg.OAuthScopes) + // Build the tool inventory once before forking - this ensures tool filters are applied once + enabledToolsets := resolveEnabledToolsets(cfg) + inventory := github.NewInventory(cfg.Translator). + WithDeprecatedAliases(github.DeprecatedToolAliases). + WithReadOnly(cfg.ReadOnly). + WithToolsets(enabledToolsets). + WithTools(github.CleanTools(cfg.EnabledTools)). + WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)). + Build() + + if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 { + fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", ")) + } + // 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 complete authentication. This is a single, blocking call that will guide you through the entire device authorization flow and return once authentication has finished.", @@ -377,33 +391,16 @@ func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerRes } }) - // 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 all GitHub tools/resources/prompts using the pre-built inventory + inventory.RegisterAll(ctx, ghServer, deps) // Register dynamic toolset management tools if enabled if cfg.DynamicToolsets { - registerDynamicTools(ghServer, inv, deps, cfg.Translator) + registerDynamicTools(ghServer, inventory, deps, cfg.Translator) } if cfg.Logger != nil { + availableTools := inventory.AvailableTools(ctx) cfg.Logger.Info("authentication complete, tools registered", "toolCount", len(availableTools)) } From 2b89711fdae36f44a1d08cb36cd25f72a3e5ca8e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 19 Dec 2025 07:37:14 +0000 Subject: [PATCH 8/8] Remove server instructions for unauthenticated mode - Remove custom Instructions text from unauthenticated server - Tool schema is sufficient - auth tool describes itself - Auth tool is conditionally present only when needed - Simplifies server initialization Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- internal/ghmcp/server.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index a61b0ceee..7fe526869 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -332,8 +332,7 @@ func NewUnauthenticatedMCPServer(cfg MCPServerConfig) (*UnauthenticatedServerRes // 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 complete authentication. This is a single, blocking call that will guide you through the entire device authorization flow and return once authentication has finished.", - Logger: cfg.Logger, + Logger: cfg.Logger, // Advertise capabilities since tools will be added after auth Capabilities: &mcp.ServerCapabilities{ Tools: &mcp.ToolCapabilities{ListChanged: true},