Skip to content

Commit 39b60d6

Browse files
Implement OAuth with URL elicitation and lazy authentication
Complete implementation of OAuth 2.1 authentication with MCP URL elicitation support: **Core Features:** - OAuth Manager with URL elicitation integration - Lazy authentication (OAuth triggered on first tool call) - Automatic flow selection (PKCE for native, device for Docker) - Callback server cleanup after OAuth completion - PKCE → Device flow fallback on failures **URL Elicitation Integration:** - PKCE flow: Browser auto-open with URL elicitation fallback - Device flow: Always uses URL elicitation (no stderr) - Returns mcp.URLElicitationRequiredError for client UI integration - Background polling for token completion - Automatic retry after authentication completes **Architecture:** - internal/oauth/manager.go: OAuth state management with elicitation - Authentication middleware in server.go triggers OAuth on tool calls - Dynamic scope computation based on enabled tools - Zero-config ready (server starts without token) **Flow Selection:** - Docker without port → Device flow (automatic) - Native binary → PKCE flow (browser opens) - Docker with --oauth-callback-port → PKCE flow - PKCE failure → Device flow fallback **Security:** - PKCE S256 for code exchange - Device flow OAuth 2.0 standard - State parameter prevents CSRF - ReadHeaderTimeout prevents Slowloris - Callback server closes after completion (frees port) - Tokens never persisted All tests pass ✓ Linting passes ✓ Builds successfully ✓ Co-authored-by: SamMorrowDrums <[email protected]>
1 parent 88c632b commit 39b60d6

File tree

4 files changed

+371
-19
lines changed

4 files changed

+371
-19
lines changed

cmd/github-mcp-server/main.go

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,41 +35,37 @@ var (
3535
Use: "stdio",
3636
Short: "Start stdio server",
3737
Long: `Start a server that communicates via standard input/output streams using JSON-RPC messages.`,
38-
RunE: func(cmd *cobra.Command, _ []string) error {
38+
RunE: func(_ *cobra.Command, _ []string) error {
3939
token := viper.GetString("personal_access_token")
40+
var oauthMgr *oauth.Manager
4041

41-
// If no token provided, attempt OAuth if configured
42+
// If no token provided, setup OAuth manager if configured
4243
if token == "" {
4344
oauthClientID := viper.GetString("oauth_client_id")
4445
if oauthClientID != "" {
45-
// Perform interactive OAuth flow with the command's context
46+
// Create OAuth manager for lazy authentication
4647
oauthCfg := oauth.GetGitHubOAuthConfig(
4748
oauthClientID,
4849
viper.GetString("oauth_client_secret"),
4950
getOAuthScopes(),
50-
viper.GetString("host"), // Pass the gh-host configuration
51+
viper.GetString("host"),
5152
viper.GetInt("oauth_callback_port"),
5253
)
53-
54-
result, err := oauth.StartOAuthFlow(cmd.Context(), oauthCfg)
55-
if err != nil {
56-
// OAuth flow failed - warn but allow server to start
57-
// This enables future zero-configuration fallback patterns
58-
fmt.Fprintf(os.Stderr, "Warning: OAuth flow failed: %v\n", err)
59-
fmt.Fprintf(os.Stderr, "Starting server without authentication - tools will fail until authenticated\n")
60-
} else {
61-
token = result.AccessToken
62-
}
54+
oauthMgr = oauth.NewManager(oauthCfg)
55+
fmt.Fprintf(os.Stderr, "OAuth configured - will prompt for authentication when needed\n")
6356
} else {
64-
// No token and no OAuth configured - warn but allow server to start
65-
// This enables future zero-configuration use with baked-in fallback app
6657
fmt.Fprintf(os.Stderr, "Warning: No authentication configured\n")
67-
fmt.Fprintf(os.Stderr, " - Set GITHUB_PERSONAL_ACCESS_TOKEN environment variable, or\n")
68-
fmt.Fprintf(os.Stderr, " - Configure OAuth with --oauth-client-id (or GITHUB_OAUTH_CLIENT_ID)\n")
69-
fmt.Fprintf(os.Stderr, "Starting server without authentication - tools will fail until authenticated\n")
58+
fmt.Fprintf(os.Stderr, " - Set GITHUB_PERSONAL_ACCESS_TOKEN, or\n")
59+
fmt.Fprintf(os.Stderr, " - Configure OAuth with --oauth-client-id\n")
60+
fmt.Fprintf(os.Stderr, "Tools will prompt for authentication when called\n")
7061
}
7162
}
7263

64+
// Extract token from OAuth manager if available
65+
if oauthMgr != nil && token == "" {
66+
token = oauthMgr.GetAccessToken()
67+
}
68+
7369
// If you're wondering why we're not using viper.GetStringSlice("toolsets"),
7470
// it's because viper doesn't handle comma-separated values correctly for env
7571
// vars when using GetStringSlice.
@@ -106,6 +102,7 @@ var (
106102
Version: version,
107103
Host: viper.GetString("host"),
108104
Token: token,
105+
OAuthManager: oauthMgr,
109106
EnabledToolsets: enabledToolsets,
110107
EnabledTools: enabledTools,
111108
EnabledFeatures: enabledFeatures,

internal/ghmcp/server.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,14 @@ type StdioServerConfig struct {
302302
// GitHub Token to authenticate with the GitHub API
303303
Token string
304304

305+
// OAuthManager handles OAuth authentication with lazy loading
306+
// When set, tools will trigger OAuth flow when authentication is needed
307+
OAuthManager interface {
308+
HasToken() bool
309+
GetAccessToken() string
310+
RequestAuthentication(context.Context) error
311+
}
312+
305313
// EnabledToolsets is a list of toolsets to enable
306314
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
307315
EnabledToolsets []string
@@ -405,6 +413,11 @@ func RunStdioServer(cfg StdioServerConfig) error {
405413
return fmt.Errorf("failed to create MCP server: %w", err)
406414
}
407415

416+
// Add OAuth authentication middleware if OAuth manager is configured
417+
if cfg.OAuthManager != nil {
418+
ghServer.AddReceivingMiddleware(createOAuthMiddleware(cfg.OAuthManager, logger))
419+
}
420+
408421
if cfg.ExportTranslations {
409422
// Once server is initialized, all translations are loaded
410423
dumpTranslations()
@@ -699,3 +712,35 @@ func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string,
699712

700713
return fetcher.FetchTokenScopes(ctx, token)
701714
}
715+
716+
// createOAuthMiddleware creates middleware that triggers OAuth authentication when needed
717+
func createOAuthMiddleware(oauthMgr interface {
718+
HasToken() bool
719+
GetAccessToken() string
720+
RequestAuthentication(context.Context) error
721+
}, logger *slog.Logger) func(mcp.MethodHandler) mcp.MethodHandler {
722+
return func(next mcp.MethodHandler) mcp.MethodHandler {
723+
return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) {
724+
// Only check authentication for tool calls
725+
if method != "tools/call" {
726+
return next(ctx, method, req)
727+
}
728+
729+
// Check if we have a token
730+
if !oauthMgr.HasToken() {
731+
logger.Info("no authentication token available, triggering OAuth flow")
732+
// Trigger OAuth authentication
733+
// This will return an MCP URL elicitation error that the client will handle
734+
if err := oauthMgr.RequestAuthentication(ctx); err != nil {
735+
// Return the error (which should be a URL elicitation error)
736+
return nil, err
737+
}
738+
// If we get here without error, OAuth completed immediately
739+
// Fall through to execute the tool with the new token
740+
}
741+
742+
// Execute the tool with authentication
743+
return next(ctx, method, req)
744+
}
745+
}
746+
}

0 commit comments

Comments
 (0)