@@ -47,6 +47,12 @@ func (m *MCPManager) GetClients() []schemas.MCPClientState {
4747// Returns:
4848// - error: Any error that occurred during reconnection
4949func (m * MCPManager ) ReconnectClient (id string ) error {
50+ // Acquire per-client reconnect/update guard before reading config snapshot.
51+ if _ , alreadyReconnecting := m .reconnectingClients .LoadOrStore (id , true ); alreadyReconnecting {
52+ return fmt .Errorf ("reconnect already in progress for this client" )
53+ }
54+ defer m .reconnectingClients .Delete (id )
55+
5056 m .mu .Lock ()
5157 client , ok := m .clientMap [id ]
5258 if ! ok {
@@ -62,14 +68,6 @@ func (m *MCPManager) ReconnectClient(id string) error {
6268 config := client .ExecutionConfig
6369 m .mu .Unlock ()
6470
65- // Guard against concurrent reconnects for the same client from any caller
66- // (health monitor, manual API call, etc.). LoadOrStore is atomic — whichever
67- // caller arrives second gets the "already in progress" error immediately.
68- if _ , alreadyReconnecting := m .reconnectingClients .LoadOrStore (id , true ); alreadyReconnecting {
69- return fmt .Errorf ("reconnect already in progress for this client" )
70- }
71- defer m .reconnectingClients .Delete (id )
72-
7371 // Reconnect using the client's configuration
7472 // Retry logic is handled internally by connectToMCPClient
7573 if err := m .connectToMCPClient (config ); err != nil {
@@ -347,6 +345,11 @@ func (m *MCPManager) DisableClient(id string) error {
347345 if id == BifrostMCPClientKey {
348346 return fmt .Errorf ("cannot disable internal bifrost client" )
349347 }
348+ // Use LoadOrStore (not Load) so the check and the sentinel insertion are atomic.
349+ if _ , alreadyInFlight := m .reconnectingClients .LoadOrStore (id , true ); alreadyInFlight {
350+ return fmt .Errorf ("reconnect or connection credential update already in progress for MCP client %s" , id )
351+ }
352+ defer m .reconnectingClients .Delete (id )
350353
351354 m .mu .Lock ()
352355 defer m .mu .Unlock ()
@@ -482,6 +485,11 @@ func (m *MCPManager) EnableClient(id string) error {
482485// Returns:
483486// - error: Any error that occurred during client update or tool retrieval
484487func (m * MCPManager ) UpdateClient (id string , updatedConfig * schemas.MCPClientConfig ) error {
488+ if _ , alreadyInFlight := m .reconnectingClients .LoadOrStore (id , true ); alreadyInFlight {
489+ return fmt .Errorf ("reconnect or connection credential update already in progress for MCP client %s" , id )
490+ }
491+ defer m .reconnectingClients .Delete (id )
492+
485493 m .mu .Lock ()
486494 defer m .mu .Unlock ()
487495
@@ -506,9 +514,17 @@ func (m *MCPManager) UpdateClient(id string, updatedConfig *schemas.MCPClientCon
506514 if updatedConfig .InProcessServer != nil && updatedConfig .InProcessServer != client .ExecutionConfig .InProcessServer {
507515 return fmt .Errorf ("in-process server cannot be updated for client %s" , id )
508516 }
517+ if updatedConfig .AuthType != "" && updatedConfig .AuthType != client .ExecutionConfig .AuthType {
518+ return fmt .Errorf ("auth_type cannot be updated for client %s" , id )
519+ }
509520
510521 oldName := client .ExecutionConfig .Name
511522
523+ oauthConfigID := client .ExecutionConfig .OauthConfigID
524+ if updatedConfig .OauthConfigID != nil {
525+ oauthConfigID = updatedConfig .OauthConfigID
526+ }
527+
512528 // Create a new config struct (immutable pattern) to avoid race conditions
513529 // with concurrent reads. Any snapshot holding the old ExecutionConfig pointer
514530 // will continue to see consistent data.
@@ -519,7 +535,7 @@ func (m *MCPManager) UpdateClient(id string, updatedConfig *schemas.MCPClientCon
519535 ConnectionString : client .ExecutionConfig .ConnectionString ,
520536 StdioConfig : client .ExecutionConfig .StdioConfig ,
521537 AuthType : client .ExecutionConfig .AuthType ,
522- OauthConfigID : client . ExecutionConfig . OauthConfigID ,
538+ OauthConfigID : oauthConfigID ,
523539 State : client .ExecutionConfig .State ,
524540 InProcessServer : client .ExecutionConfig .InProcessServer ,
525541 ConfigHash : client .ExecutionConfig .ConfigHash ,
@@ -576,6 +592,73 @@ func (m *MCPManager) UpdateClient(id string, updatedConfig *schemas.MCPClientCon
576592 return nil
577593}
578594
595+ // UpdateClientConnection updates auth-related fields (headers) for an existing MCP client by
596+ // closing the current connection and establishing a new one so the new credentials are verified
597+ // before being committed. Non-credential metadata (name, tools, etc.) is preserved from the
598+ // current execution config.
599+ //
600+ // On failure the clientMap entry is left in Disconnected state but its ExecutionConfig is restored
601+ // to the previous value, allowing the health monitor to recover the client using the old credentials.
602+ //
603+ // Parameters:
604+ // - id: ID of the client whose credentials should be updated
605+ // - newConfig: Partial config carrying the updated auth fields (Headers). All other fields
606+ // are ignored and taken from the current execution config.
607+ //
608+ // Returns:
609+ // - error: Any connection error; nil on success
610+ func (m * MCPManager ) UpdateClientConnection (id string , newConfig * schemas.MCPClientConfig ) error {
611+ if newConfig == nil {
612+ return fmt .Errorf ("newConfig must not be nil" )
613+ }
614+ // Hold the per-client reconnect guard for the entire read + long reconnect so
615+ // UpdateClient/DisableClient cannot mutate ExecutionConfig while a failed reconnect
616+ // restores the pre-attempt snapshot.
617+ if _ , alreadyReconnecting := m .reconnectingClients .LoadOrStore (id , true ); alreadyReconnecting {
618+ return fmt .Errorf ("reconnect already in progress for this client" )
619+ }
620+ defer m .reconnectingClients .Delete (id )
621+
622+ m .mu .RLock ()
623+ client , ok := m .clientMap [id ]
624+ if ! ok {
625+ m .mu .RUnlock ()
626+ return fmt .Errorf ("client %s not found" , id )
627+ }
628+ // Per-user OAuth clients have no persistent connection — reconnect is not applicable.
629+ if client .ExecutionConfig != nil && client .ExecutionConfig .AuthType == schemas .MCPAuthTypePerUserOauth {
630+ m .mu .RUnlock ()
631+ return fmt .Errorf ("connection update is not supported for per_user_oauth clients" )
632+ }
633+ if client .ExecutionConfig == nil {
634+ m .mu .RUnlock ()
635+ return fmt .Errorf ("client %s has no execution config; cannot update connection" , id )
636+ }
637+ // Snapshot old execution config and build the merged config while still holding the
638+ // read lock so the struct copy is consistent with what the map currently holds.
639+ oldConfig := client .ExecutionConfig
640+ mergedConfig := * oldConfig
641+ if newConfig .Headers != nil {
642+ mergedConfig .Headers = maps .Clone (newConfig .Headers )
643+ }
644+ if newConfig .OauthConfigID != nil {
645+ mergedConfig .OauthConfigID = newConfig .OauthConfigID
646+ }
647+ m .mu .RUnlock ()
648+
649+ // connectToMCPClient will close the current connection and create a new clientMap entry.
650+ if err := m .connectToMCPClient (& mergedConfig ); err != nil {
651+ m .mu .Lock ()
652+ if cs , exists := m .clientMap [id ]; exists {
653+ cs .ExecutionConfig = oldConfig
654+ }
655+ m .mu .Unlock ()
656+ return fmt .Errorf ("failed to reconnect with updated credentials: %w" , err )
657+ }
658+
659+ return nil
660+ }
661+
579662func stdioConfigEqual (a , b * schemas.MCPStdioConfig ) bool {
580663 if a == nil || b == nil {
581664 return a == b
0 commit comments