Skip to content

Commit 97476bb

Browse files
authored
Merge pull request #94 from smart-mcp-proxy/fix/oauth-server-auto-connect
Fix: Immediate OAuth server connection and real-time UI updates
2 parents 2276011 + 7f8326d commit 97476bb

7 files changed

Lines changed: 204 additions & 37 deletions

File tree

frontend/src/components/ServerCard.vue

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33
<div class="card-body">
44
<!-- Header -->
55
<div class="flex justify-between items-start mb-4">
6-
<div>
7-
<h3 class="card-title text-lg">{{ server.name }}</h3>
8-
<p class="text-sm text-base-content/70">
6+
<div class="flex-1 min-w-0 mr-2">
7+
<h3 class="card-title text-lg truncate">{{ server.name }}</h3>
8+
<p class="text-sm text-base-content/70 truncate">
99
{{ server.protocol }} • {{ server.url || server.command || 'No endpoint' }}
1010
</p>
1111
</div>
1212

1313
<!-- Status indicator -->
1414
<div
1515
:class="[
16-
'badge',
16+
'badge badge-sm flex-shrink-0',
1717
server.connected ? 'badge-success' :
1818
server.connecting ? 'badge-warning' :
1919
'badge-error'
@@ -125,12 +125,27 @@ const systemStore = useSystemStore()
125125
const loading = ref(false)
126126
127127
const needsOAuth = computed(() => {
128-
// Simplified check - in reality, you'd check if the server supports OAuth
129-
// and if authentication is required
130-
return (props.server.protocol === 'http' || props.server.protocol === 'streamable-http') &&
131-
!props.server.connected &&
132-
props.server.enabled &&
133-
props.server.last_error?.includes('authorization')
128+
// Check if server requires OAuth authentication
129+
const isHttpProtocol = props.server.protocol === 'http' || props.server.protocol === 'streamable-http'
130+
const notConnected = !props.server.connected
131+
const isEnabled = props.server.enabled
132+
133+
// Check for OAuth-related errors in last_error
134+
const hasOAuthError = props.server.last_error && (
135+
props.server.last_error.includes('authorization') ||
136+
props.server.last_error.includes('OAuth') ||
137+
props.server.last_error.includes('401') ||
138+
props.server.last_error.includes('invalid_token') ||
139+
props.server.last_error.includes('Missing or invalid access token')
140+
)
141+
142+
// Check if server has OAuth configuration
143+
const hasOAuthConfig = props.server.oauth !== null && props.server.oauth !== undefined
144+
145+
// Check if server is authenticated
146+
const notAuthenticated = !props.server.authenticated
147+
148+
return isHttpProtocol && notConnected && isEnabled && (hasOAuthError || (hasOAuthConfig && notAuthenticated))
134149
})
135150
136151
async function toggleEnabled() {

frontend/src/types/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@ export interface Server {
1515
quarantined: boolean
1616
connected: boolean
1717
connecting: boolean
18+
authenticated?: boolean
1819
tool_count: number
1920
last_error: string
2021
tool_list_token_size?: number
22+
oauth?: {
23+
client_id: string
24+
auth_url: string
25+
token_url: string
26+
}
2127
}
2228

2329
// Tool types

internal/httpapi/server.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,30 @@ func (s *Server) handleRestartServer(w http.ResponseWriter, r *http.Request) {
488488
select {
489489
case err := <-done:
490490
if err != nil {
491+
// Check if error is OAuth-related (expected state, not a failure)
492+
errStr := err.Error()
493+
isOAuthError := strings.Contains(errStr, "OAuth authorization") ||
494+
strings.Contains(errStr, "oauth") ||
495+
strings.Contains(errStr, "authorization required") ||
496+
strings.Contains(errStr, "no valid token")
497+
498+
if isOAuthError {
499+
// OAuth required is not a failure - restart succeeded but OAuth is needed
500+
s.logger.Info("Server restart completed, OAuth login required",
501+
"server", serverID,
502+
"error", errStr)
503+
504+
response := contracts.ServerActionResponse{
505+
Server: serverID,
506+
Action: "restart",
507+
Success: true,
508+
Async: false,
509+
}
510+
s.writeSuccess(w, response)
511+
return
512+
}
513+
514+
// Non-OAuth error - treat as failure
491515
s.logger.Error("Failed to restart server", "server", serverID, "error", err)
492516
s.writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restart server: %v", err))
493517
return

internal/runtime/lifecycle.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99

1010
"mcpproxy-go/internal/config"
1111
"mcpproxy-go/internal/runtime/configsvc"
12+
"mcpproxy-go/internal/runtime/supervisor"
1213
)
1314

1415
const connectAttemptTimeout = 45 * time.Second
@@ -43,6 +44,9 @@ func (r *Runtime) StartBackgroundInitialization() {
4344
}
4445
})
4546
r.logger.Info("Reactive tool discovery callback registered")
47+
48+
// Subscribe to supervisor events and emit servers.changed for Web UI updates
49+
go r.supervisorEventForwarder()
4650
}
4751

4852
go r.backgroundInitialization()
@@ -763,3 +767,54 @@ func (r *Runtime) cleanupOrphanedIndexEntries() {
763767
r.logger.Debug("Orphaned index cleanup completed",
764768
zap.Int("active_servers", len(activeServers)))
765769
}
770+
771+
// supervisorEventForwarder subscribes to supervisor events and emits runtime events
772+
// to notify Web UI via SSE when server connection state changes.
773+
func (r *Runtime) supervisorEventForwarder() {
774+
eventCh := r.supervisor.Subscribe()
775+
defer r.supervisor.Unsubscribe(eventCh)
776+
777+
r.logger.Info("Supervisor event forwarder started - will emit servers.changed on connection state changes")
778+
779+
// Get app context once with proper locking
780+
appCtx := r.AppContext()
781+
782+
for {
783+
select {
784+
case event, ok := <-eventCh:
785+
if !ok {
786+
r.logger.Info("Supervisor event channel closed, stopping event forwarder")
787+
return
788+
}
789+
790+
// Emit servers.changed event for connection state changes
791+
// This triggers Web UI to refresh server list via SSE
792+
switch event.Type {
793+
case supervisor.EventServerConnected:
794+
r.logger.Info("Server connected - emitting servers.changed event",
795+
zap.String("server", event.ServerName))
796+
r.emitServersChanged("server_connected", map[string]any{
797+
"server": event.ServerName,
798+
})
799+
800+
case supervisor.EventServerDisconnected:
801+
r.logger.Info("Server disconnected - emitting servers.changed event",
802+
zap.String("server", event.ServerName))
803+
r.emitServersChanged("server_disconnected", map[string]any{
804+
"server": event.ServerName,
805+
})
806+
807+
case supervisor.EventServerStateChanged:
808+
r.logger.Debug("Server state changed - emitting servers.changed event",
809+
zap.String("server", event.ServerName))
810+
r.emitServersChanged("server_state_changed", map[string]any{
811+
"server": event.ServerName,
812+
})
813+
}
814+
815+
case <-appCtx.Done():
816+
r.logger.Info("App context cancelled, stopping supervisor event forwarder")
817+
return
818+
}
819+
}
820+
}

internal/runtime/supervisor/supervisor.go

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"mcpproxy-go/internal/config"
1313
"mcpproxy-go/internal/runtime/configsvc"
1414
"mcpproxy-go/internal/runtime/stateview"
15+
"mcpproxy-go/internal/upstream/types"
1516
)
1617

1718
// Supervisor manages the desired vs actual state reconciliation for upstream servers.
@@ -487,6 +488,30 @@ func (s *Supervisor) updateStateView(name string, state *ServerState) {
487488

488489
// Update connection info if available
489490
if state.ConnectionInfo != nil {
491+
// Extract LastError from ConnectionInfo and convert to string with limit
492+
if state.ConnectionInfo.LastError != nil {
493+
errorStr := state.ConnectionInfo.LastError.Error()
494+
// Limit error string to 500 characters to prevent UI hangs
495+
const maxErrorLen = 500
496+
if len(errorStr) > maxErrorLen {
497+
errorStr = errorStr[:maxErrorLen] + "... (truncated)"
498+
}
499+
status.LastError = errorStr
500+
501+
// Set last error time if available
502+
if !state.ConnectionInfo.LastRetryTime.IsZero() {
503+
t := state.ConnectionInfo.LastRetryTime
504+
status.LastErrorTime = &t
505+
}
506+
} else {
507+
status.LastError = ""
508+
status.LastErrorTime = nil
509+
}
510+
511+
// Copy retry count
512+
status.RetryCount = state.ConnectionInfo.RetryCount
513+
514+
// Store full connection info in metadata for debugging
490515
if status.Metadata == nil {
491516
status.Metadata = make(map[string]interface{})
492517
}
@@ -628,16 +653,18 @@ func (s *Supervisor) updateSnapshotFromEvent(event Event) {
628653
state.LastSeen = event.Timestamp
629654

630655
// Phase 7.1 FIX: Don't fetch tools on connect events - let background indexing handle it
631-
// Just update the cached tool count from the client (non-blocking)
656+
// Just update the cached tool count and connection info from the client (non-blocking)
632657
var toolCount int
633-
if connected {
634-
if actualState, err := s.upstream.GetServerState(event.ServerName); err == nil {
635-
toolCount = actualState.ToolCount
636-
} else {
637-
s.logger.Warn("Failed to get server state for tool count",
638-
zap.String("server", event.ServerName),
639-
zap.Error(err))
640-
}
658+
var connInfo *types.ConnectionInfo
659+
if actualState, err := s.upstream.GetServerState(event.ServerName); err == nil {
660+
toolCount = actualState.ToolCount
661+
// Update ConnectionInfo for error propagation to UI
662+
state.ConnectionInfo = actualState.ConnectionInfo
663+
connInfo = actualState.ConnectionInfo
664+
} else {
665+
s.logger.Warn("Failed to get server state for tool count",
666+
zap.String("server", event.ServerName),
667+
zap.Error(err))
641668
}
642669

643670
// Update stateview
@@ -661,6 +688,27 @@ func (s *Supervisor) updateSnapshotFromEvent(event Event) {
661688
status.Tools = nil // Clear tools on disconnect
662689
status.ToolCount = 0
663690
}
691+
692+
// Update ConnectionInfo for immediate error propagation to UI
693+
if connInfo != nil {
694+
if connInfo.LastError != nil {
695+
errorStr := connInfo.LastError.Error()
696+
const maxErrorLen = 500
697+
if len(errorStr) > maxErrorLen {
698+
errorStr = errorStr[:maxErrorLen] + "... (truncated)"
699+
}
700+
status.LastError = errorStr
701+
702+
if !connInfo.LastRetryTime.IsZero() {
703+
t := connInfo.LastRetryTime
704+
status.LastErrorTime = &t
705+
}
706+
} else {
707+
status.LastError = ""
708+
status.LastErrorTime = nil
709+
}
710+
status.RetryCount = connInfo.RetryCount
711+
}
664712
})
665713

666714
// Trigger reactive tool discovery when server connects

internal/server/mcp.go

Lines changed: 21 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1430,31 +1430,34 @@ func (p *MCPProxyServer) handleAddUpstream(ctx context.Context, request mcp.Call
14301430
return mcp.NewToolResultError(fmt.Sprintf("Failed to add upstream: %v", err)), nil
14311431
}
14321432

1433-
// Add to upstream manager and connect
1434-
var connectionStatus, connectionMessage string
1435-
if enabled {
1436-
if err := p.upstreamManager.AddServer(name, serverConfig); err != nil {
1437-
p.logger.Warn("Failed to add and connect upstream server", zap.String("name", name), zap.Error(err))
1438-
connectionStatus = statusError
1439-
connectionMessage = fmt.Sprintf("Failed to add server: %v", err)
1440-
} else {
1441-
// Monitor connection status for 1 minute to see final state
1442-
connectionStatus, connectionMessage = p.monitorConnectionStatus(ctx, name, 1*time.Minute)
1443-
}
1444-
} else {
1445-
connectionStatus = statusDisabled
1446-
connectionMessage = messageServerDisabled
1447-
}
1448-
1449-
// Trigger configuration save and update
1433+
// Trigger configuration save which will notify supervisor to reconcile and connect
14501434
if p.mainServer != nil {
14511435
// Save configuration first to ensure servers are persisted to config file
1436+
// This triggers ConfigService update which notifies supervisor to reconcile
1437+
// Note: SaveConfiguration may fail in test environments without config file - that's OK
14521438
if err := p.mainServer.SaveConfiguration(); err != nil {
1453-
p.logger.Error("Failed to save configuration after adding server", zap.Error(err))
1439+
p.logger.Warn("Failed to save configuration after adding server (may be test environment)",
1440+
zap.Error(err))
1441+
// Continue anyway - server is saved to storage, supervisor will reconcile
14541442
}
14551443
p.mainServer.OnUpstreamServerChange()
14561444
}
14571445

1446+
// Wait briefly for supervisor to reconcile and connect (if enabled)
1447+
// This gives us immediate status for the response
1448+
var connectionStatus, connectionMessage string
1449+
if enabled {
1450+
// Give supervisor time to reconcile and attempt connection
1451+
time.Sleep(2 * time.Second)
1452+
1453+
// Monitor connection status for up to 10 seconds to get immediate state
1454+
// This quickly detects OAuth requirements, connection errors, or success
1455+
connectionStatus, connectionMessage = p.monitorConnectionStatus(ctx, name, 10*time.Second)
1456+
} else {
1457+
connectionStatus = statusDisabled
1458+
connectionMessage = messageServerDisabled
1459+
}
1460+
14581461
// Check for Docker isolation warnings
14591462
var dockerWarnings []string
14601463
if isolationManager := p.getIsolationManager(); isolationManager != nil {

internal/upstream/manager.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,22 @@ func (m *Manager) AddServer(id string, serverConfig *config.ServerConfig) error
223223
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
224224
defer cancel()
225225
if err := client.Connect(ctx); err != nil {
226+
// Check if this is an OAuth error - don't fail AddServer for OAuth
227+
errStr := err.Error()
228+
isOAuthError := strings.Contains(errStr, "OAuth") ||
229+
strings.Contains(errStr, "oauth") ||
230+
strings.Contains(errStr, "authorization required")
231+
232+
if isOAuthError {
233+
m.logger.Info("Server requires OAuth authorization - connection will complete after OAuth login",
234+
zap.String("id", id),
235+
zap.String("name", serverConfig.Name),
236+
zap.Error(err))
237+
// Don't return error - server is added successfully, just needs OAuth
238+
return nil
239+
}
240+
241+
// For non-OAuth errors, still return error
226242
return fmt.Errorf("failed to connect to server %s: %w", serverConfig.Name, err)
227243
}
228244
} else {

0 commit comments

Comments
 (0)