Skip to content

Commit d30d95f

Browse files
feat(auth): add --all flag to auth login command (smart-mcp-proxy#267)
Add support for batch OAuth authentication with `mcpproxy auth login --all`. Features: - `--all` flag to authenticate all servers requiring login - `--force` flag to bypass confirmation prompt - Interactive confirmation showing server list and browser tab warning - Detailed progress tracking with [n/m] indicators - Summary report showing successes/failures with error details - Suppresses usage output for operational errors (only shows for flag validation) Examples: mcpproxy auth login --all mcpproxy auth login --all --force Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent b640020 commit d30d95f

2 files changed

Lines changed: 285 additions & 9 deletions

File tree

cmd/mcpproxy/auth_cmd.go

Lines changed: 151 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,16 +30,20 @@ var (
3030
authLoginCmd = &cobra.Command{
3131
Use: "login",
3232
Short: "Manually authenticate with an OAuth-enabled server",
33-
Long: `Manually trigger OAuth authentication flow for a specific upstream server.
33+
Long: `Manually trigger OAuth authentication flow for a specific upstream server or all servers.
3434
This is useful when:
3535
- A server requires OAuth but automatic attempts have been rate limited
3636
- You want to authenticate proactively before using server tools
3737
- OAuth tokens have expired and need refreshing
38+
- Multiple servers need re-authentication
3839
3940
The command will open your default browser for OAuth authorization.
41+
Use --all to authenticate all servers that require OAuth (confirmation prompt unless --force is used).
4042
4143
Examples:
4244
mcpproxy auth login --server=Sentry-2
45+
mcpproxy auth login --all
46+
mcpproxy auth login --all --force
4347
mcpproxy auth login --server=github-server --log-level=debug
4448
mcpproxy auth login --server=google-calendar --timeout=5m`,
4549
RunE: runAuthLogin,
@@ -82,6 +86,7 @@ Examples:
8286
authConfigPath string
8387
authTimeout time.Duration
8488
authAll bool
89+
authForce bool
8590
)
8691

8792
// GetAuthCommand returns the auth command for adding to the root command
@@ -96,7 +101,9 @@ func init() {
96101
authCmd.AddCommand(authLogoutCmd)
97102

98103
// Define flags for auth login command
99-
authLoginCmd.Flags().StringVarP(&authServerName, "server", "s", "", "Server name to authenticate with (required)")
104+
authLoginCmd.Flags().StringVarP(&authServerName, "server", "s", "", "Server name to authenticate with")
105+
authLoginCmd.Flags().BoolVar(&authAll, "all", false, "Authenticate all servers that require OAuth")
106+
authLoginCmd.Flags().BoolVar(&authForce, "force", false, "Skip confirmation prompt when using --all")
100107
authLoginCmd.Flags().StringVarP(&authLogLevel, "log-level", "l", "info", "Log level (trace, debug, info, warn, error)")
101108
authLoginCmd.Flags().StringVarP(&authConfigPath, "config", "c", "", "Path to MCP configuration file (default: ~/.mcpproxy/mcp_config.json)")
102109
authLoginCmd.Flags().DurationVar(&authTimeout, "timeout", 5*time.Minute, "Authentication timeout")
@@ -114,12 +121,8 @@ func init() {
114121
authLogoutCmd.Flags().DurationVar(&authTimeout, "timeout", 30*time.Second, "Logout timeout")
115122

116123
// Mark required flags
117-
err := authLoginCmd.MarkFlagRequired("server")
118-
if err != nil {
119-
panic(fmt.Sprintf("Failed to mark server flag as required: %v", err))
120-
}
121-
122-
err = authLogoutCmd.MarkFlagRequired("server")
124+
// Note: auth login doesn't mark --server as required because --all can be used instead
125+
err := authLogoutCmd.MarkFlagRequired("server")
123126
if err != nil {
124127
panic(fmt.Sprintf("Failed to mark server flag as required: %v", err))
125128
}
@@ -128,6 +131,12 @@ func init() {
128131
authLoginCmd.Example = ` # Authenticate with Sentry-2 server
129132
mcpproxy auth login --server=Sentry-2
130133
134+
# Authenticate all servers that require OAuth
135+
mcpproxy auth login --all
136+
137+
# Authenticate all servers without confirmation prompt
138+
mcpproxy auth login --all --force
139+
131140
# Authenticate with debug logging
132141
mcpproxy auth login --server=github-server --log-level=debug
133142
@@ -150,7 +159,18 @@ func init() {
150159
mcpproxy auth logout --server=github-server --log-level=debug`
151160
}
152161

153-
func runAuthLogin(_ *cobra.Command, _ []string) error {
162+
func runAuthLogin(cmd *cobra.Command, _ []string) error {
163+
// Validate flags: either --server or --all must be provided (but not both)
164+
if authAll && authServerName != "" {
165+
return fmt.Errorf("cannot use both --server and --all flags together")
166+
}
167+
if !authAll && authServerName == "" {
168+
return fmt.Errorf("either --server or --all flag is required")
169+
}
170+
171+
// After validation, silence usage for operational errors
172+
cmd.SilenceUsage = true
173+
154174
ctx, cancel := context.WithTimeout(context.Background(), authTimeout)
155175
defer cancel()
156176

@@ -160,6 +180,12 @@ func runAuthLogin(_ *cobra.Command, _ []string) error {
160180
return fmt.Errorf("failed to load configuration: %w", err)
161181
}
162182

183+
// Handle --all flag: authenticate all servers
184+
if authAll {
185+
return runAuthLoginAll(ctx, cfg.DataDir)
186+
}
187+
188+
// Handle single server authentication
163189
// Check if daemon is running and use client mode
164190
if shouldUseAuthDaemon(cfg.DataDir) {
165191
return runAuthLoginClientMode(ctx, cfg.DataDir, authServerName)
@@ -169,6 +195,122 @@ func runAuthLogin(_ *cobra.Command, _ []string) error {
169195
return runAuthLoginStandalone(ctx, authServerName)
170196
}
171197

198+
// runAuthLoginAll authenticates all servers that require OAuth authentication.
199+
func runAuthLoginAll(ctx context.Context, dataDir string) error {
200+
// Auth login --all REQUIRES daemon
201+
if !shouldUseAuthDaemon(dataDir) {
202+
return fmt.Errorf("auth login --all requires running daemon. Start with: mcpproxy serve")
203+
}
204+
205+
socketPath := socket.DetectSocketPath(dataDir)
206+
logger, err := logs.SetupCommandLogger(false, authLogLevel, false, "")
207+
if err != nil {
208+
return fmt.Errorf("failed to create logger: %w", err)
209+
}
210+
defer func() { _ = logger.Sync() }()
211+
212+
client := cliclient.NewClient(socketPath, logger.Sugar())
213+
214+
// Ping daemon to verify connectivity
215+
pingCtx, pingCancel := context.WithTimeout(ctx, 2*time.Second)
216+
defer pingCancel()
217+
if err := client.Ping(pingCtx); err != nil {
218+
return fmt.Errorf("failed to connect to daemon: %w", err)
219+
}
220+
221+
// Fetch all servers to find those that need OAuth authentication
222+
servers, err := client.GetServers(ctx)
223+
if err != nil {
224+
return fmt.Errorf("failed to get servers from daemon: %w", err)
225+
}
226+
227+
// Filter servers that need login (health.action == "login")
228+
var serversNeedingAuth []string
229+
for _, srv := range servers {
230+
name, _ := srv["name"].(string)
231+
if health, ok := srv["health"].(map[string]interface{}); ok && health != nil {
232+
if action, ok := health["action"].(string); ok && action == "login" {
233+
serversNeedingAuth = append(serversNeedingAuth, name)
234+
}
235+
}
236+
}
237+
238+
if len(serversNeedingAuth) == 0 {
239+
fmt.Println("✅ No servers require authentication")
240+
fmt.Println(" All OAuth-enabled servers are already authenticated.")
241+
return nil
242+
}
243+
244+
// Display servers that will be authenticated
245+
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
246+
fmt.Println("🔐 Batch OAuth Authentication")
247+
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
248+
fmt.Printf("\nThe following %d server(s) require authentication:\n", len(serversNeedingAuth))
249+
for i, name := range serversNeedingAuth {
250+
fmt.Printf(" %d. %s\n", i+1, name)
251+
}
252+
fmt.Println()
253+
254+
// Show warning about multiple browser tabs
255+
if len(serversNeedingAuth) > 1 {
256+
fmt.Printf("⚠️ This will open %d browser tabs for OAuth authorization.\n\n", len(serversNeedingAuth))
257+
}
258+
259+
// Prompt for confirmation unless --force is used
260+
if !authForce {
261+
fmt.Print("Do you want to continue? (yes/no): ")
262+
var response string
263+
if _, err := fmt.Scanln(&response); err != nil {
264+
return fmt.Errorf("failed to read confirmation: %w", err)
265+
}
266+
response = strings.TrimSpace(strings.ToLower(response))
267+
if response != "yes" && response != "y" {
268+
fmt.Println("\n❌ Authentication cancelled")
269+
return nil
270+
}
271+
fmt.Println()
272+
}
273+
274+
// Authenticate each server
275+
var successful, failed int
276+
failedServers := make(map[string]string) // server name -> error message
277+
278+
fmt.Println("Starting authentication...")
279+
fmt.Println()
280+
281+
for i, serverName := range serversNeedingAuth {
282+
fmt.Printf("[%d/%d] Authenticating %s...\n", i+1, len(serversNeedingAuth), serverName)
283+
284+
if err := client.TriggerOAuthLogin(ctx, serverName); err != nil {
285+
fmt.Printf(" ❌ Failed: %v\n", err)
286+
failed++
287+
failedServers[serverName] = err.Error()
288+
} else {
289+
fmt.Printf(" ✅ Success\n")
290+
successful++
291+
}
292+
fmt.Println()
293+
}
294+
295+
// Display summary
296+
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
297+
fmt.Println("📊 Authentication Summary")
298+
fmt.Println("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
299+
fmt.Printf("Total: %d | Successful: %d | Failed: %d\n", len(serversNeedingAuth), successful, failed)
300+
301+
if failed > 0 {
302+
fmt.Println("\n❌ Failed servers:")
303+
for serverName, errMsg := range failedServers {
304+
fmt.Printf(" • %s: %s\n", serverName, errMsg)
305+
}
306+
fmt.Println("\nTip: Run 'mcpproxy auth login --server=<name>' to retry individual servers")
307+
return fmt.Errorf("%d server(s) failed to authenticate", failed)
308+
}
309+
310+
fmt.Println("\n✅ All servers authenticated successfully!")
311+
return nil
312+
}
313+
172314
func runAuthStatus(_ *cobra.Command, _ []string) error {
173315
ctx, cancel := context.WithTimeout(context.Background(), authTimeout)
174316
defer cancel()

cmd/mcpproxy/auth_cmd_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package main
22

33
import (
4+
"context"
45
"runtime"
56
"strings"
67
"testing"
78

89
"github.com/smart-mcp-proxy/mcpproxy-go/internal/socket"
910

11+
"github.com/spf13/cobra"
1012
"github.com/stretchr/testify/assert"
13+
"github.com/stretchr/testify/require"
1114
)
1215

1316
func TestShouldUseAuthDaemon(t *testing.T) {
@@ -47,3 +50,134 @@ func TestDetectSocketPath_Auth(t *testing.T) {
4750
"Unix socket should have unix:// prefix")
4851
}
4952
}
53+
54+
func TestAuthLogin_FlagValidation(t *testing.T) {
55+
tests := []struct {
56+
name string
57+
serverName string
58+
allFlag bool
59+
wantErr bool
60+
errContains string
61+
}{
62+
{
63+
name: "both server and all flags",
64+
serverName: "test-server",
65+
allFlag: true,
66+
wantErr: true,
67+
errContains: "cannot use both --server and --all",
68+
},
69+
{
70+
name: "neither server nor all flags",
71+
serverName: "",
72+
allFlag: false,
73+
wantErr: true,
74+
errContains: "either --server or --all flag is required",
75+
},
76+
{
77+
name: "only server flag - valid",
78+
serverName: "test-server",
79+
allFlag: false,
80+
wantErr: false, // validation passes, but will fail later due to no daemon
81+
},
82+
{
83+
name: "only all flag - valid",
84+
serverName: "",
85+
allFlag: true,
86+
wantErr: false, // validation passes, but will fail later due to no daemon
87+
},
88+
}
89+
90+
for _, tt := range tests {
91+
t.Run(tt.name, func(t *testing.T) {
92+
// Set up test flags
93+
authServerName = tt.serverName
94+
authAll = tt.allFlag
95+
authConfigPath = "" // Use default
96+
authTimeout = 0 // Will use command default
97+
98+
// Create a mock command
99+
cmd := &cobra.Command{
100+
Use: "login",
101+
}
102+
103+
// Run the validation logic (first part of runAuthLogin)
104+
err := runAuthLogin(cmd, []string{})
105+
106+
if tt.wantErr {
107+
require.Error(t, err)
108+
assert.Contains(t, err.Error(), tt.errContains)
109+
} else {
110+
// For these cases, we expect failure due to no daemon/config,
111+
// but NOT due to flag validation
112+
if err != nil {
113+
assert.NotContains(t, err.Error(), "cannot use both")
114+
assert.NotContains(t, err.Error(), "either --server or --all")
115+
}
116+
}
117+
118+
// Reset flags
119+
authServerName = ""
120+
authAll = false
121+
})
122+
}
123+
}
124+
125+
func TestAuthLogin_SilenceUsageAfterValidation(t *testing.T) {
126+
// Set up valid flags
127+
authServerName = "test-server"
128+
authAll = false
129+
authConfigPath = "" // Use default, will fail to load but that's after validation
130+
defer func() {
131+
authServerName = ""
132+
authAll = false
133+
}()
134+
135+
cmd := &cobra.Command{
136+
Use: "login",
137+
}
138+
139+
// SilenceUsage should be false initially
140+
assert.False(t, cmd.SilenceUsage, "SilenceUsage should be false initially")
141+
142+
// Run the command (it will fail due to no config, but we just check the flag)
143+
_ = runAuthLogin(cmd, []string{})
144+
145+
// SilenceUsage should be true after flag validation
146+
assert.True(t, cmd.SilenceUsage, "SilenceUsage should be true after flag validation")
147+
}
148+
149+
func TestRunAuthLoginAll_NoDaemon(t *testing.T) {
150+
ctx := context.Background()
151+
tmpDir := t.TempDir()
152+
153+
err := runAuthLoginAll(ctx, tmpDir)
154+
require.Error(t, err)
155+
assert.Contains(t, err.Error(), "requires running daemon")
156+
}
157+
158+
func TestAuthLogin_ForceFlagWithoutAll(t *testing.T) {
159+
// The --force flag should only be meaningful with --all
160+
// but it's not an error to use it with --server (it just has no effect)
161+
authServerName = "test-server"
162+
authAll = false
163+
authForce = true
164+
authConfigPath = ""
165+
defer func() {
166+
authServerName = ""
167+
authAll = false
168+
authForce = false
169+
}()
170+
171+
cmd := &cobra.Command{
172+
Use: "login",
173+
}
174+
175+
// This should not fail validation
176+
err := runAuthLogin(cmd, []string{})
177+
178+
// It will fail for other reasons (no daemon/config), but not validation
179+
if err != nil {
180+
assert.NotContains(t, err.Error(), "cannot use both")
181+
assert.NotContains(t, err.Error(), "either --server or --all")
182+
}
183+
}

0 commit comments

Comments
 (0)