Skip to content

Commit aede36c

Browse files
committed
feat: ability to edit prexisting mcp oauth details
1 parent 9f6de2d commit aede36c

13 files changed

Lines changed: 735 additions & 88 deletions

File tree

core/bifrost.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3723,6 +3723,14 @@ func (bifrost *Bifrost) UpdateMCPClient(id string, updatedConfig *schemas.MCPCli
37233723
return bifrost.MCPManager.UpdateClient(id, updatedConfig)
37243724
}
37253725

3726+
// UpdateMCPClientConnection reconnects an existing MCP client using updated headers
3727+
func (bifrost *Bifrost) UpdateMCPClientConnection(id string, newConfig *schemas.MCPClientConfig) error {
3728+
if bifrost.MCPManager == nil {
3729+
return fmt.Errorf("mcp is not configured in this bifrost instance")
3730+
}
3731+
return bifrost.MCPManager.UpdateClientConnection(id, newConfig)
3732+
}
3733+
37263734
// ReconnectMCPClient attempts to reconnect an MCP client if it is disconnected.
37273735
//
37283736
// Parameters:

core/mcp/clientmanager.go

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,12 @@ func (m *MCPManager) GetClients() []schemas.MCPClientState {
4747
// Returns:
4848
// - error: Any error that occurred during reconnection
4949
func (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
484487
func (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+
579662
func stdioConfigEqual(a, b *schemas.MCPStdioConfig) bool {
580663
if a == nil || b == nil {
581664
return a == b

core/mcp/interface.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ type MCPManagerInterface interface {
5959
// UpdateClient updates an existing MCP client configuration
6060
UpdateClient(id string, updatedConfig *schemas.MCPClientConfig) error
6161

62+
// UpdateClientConnection reconnects an existing MCP client using updated headers
63+
UpdateClientConnection(id string, newConfig *schemas.MCPClientConfig) error
64+
6265
// ReconnectClient reconnects an MCP client by ID
6366
ReconnectClient(id string) error
6467

docs/mcp/connecting-to-servers.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ Use OAuth 2.0 for secure, user-based authentication with automatic token refresh
112112
- Admin-managed third-party connections
113113
- Compliance with OAuth 2.0 standards
114114

115+
<Note>
116+
For existing MCP clients, the edit flow supports rotating OAuth credentials (`client_id` and `client_secret`) while preserving the same client ID. Connection type, auth type, and connection URL remain immutable after creation.
117+
</Note>
118+
115119
#### Per-User OAuth
116120

117121
Use per-user OAuth when each end-user should access the upstream service under their own account (e.g., each user's personal Notion workspace or GitHub repos). Bifrost acts as an OAuth 2.1 Authorization Server - users authenticate through a consent flow and their tokens are stored per-identity.

framework/configstore/rdb.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1509,6 +1509,22 @@ func (s *RDBConfigStore) UpdateMCPClientConfig(ctx context.Context, id string, c
15091509
if err != nil {
15101510
return fmt.Errorf("failed to marshal tool_pricing: %w", err)
15111511
}
1512+
discoveredToolsJSON := ""
1513+
if clientConfig.DiscoveredTools != nil {
1514+
data, marshalErr := json.Marshal(clientConfig.DiscoveredTools)
1515+
if marshalErr != nil {
1516+
return fmt.Errorf("failed to marshal discovered_tools: %w", marshalErr)
1517+
}
1518+
discoveredToolsJSON = string(data)
1519+
}
1520+
toolNameMappingJSON := ""
1521+
if clientConfig.DiscoveredToolNameMapping != nil {
1522+
data, marshalErr := json.Marshal(clientConfig.DiscoveredToolNameMapping)
1523+
if marshalErr != nil {
1524+
return fmt.Errorf("failed to marshal tool_name_mapping: %w", marshalErr)
1525+
}
1526+
toolNameMappingJSON = string(data)
1527+
}
15121528

15131529
headersJSONStr := string(headersJSON)
15141530
if encrypt.IsEnabled() && headersJSONStr != "" && headersJSONStr != "{}" {
@@ -1537,6 +1553,15 @@ func (s *RDBConfigStore) UpdateMCPClientConfig(ctx context.Context, id string, c
15371553
if encrypt.IsEnabled() {
15381554
updates["encryption_status"] = encryptionStatusEncrypted
15391555
}
1556+
if clientConfigCopy.OauthConfigID != nil {
1557+
updates["oauth_config_id"] = clientConfigCopy.OauthConfigID
1558+
}
1559+
if discoveredToolsJSON != "" {
1560+
updates["discovered_tools_json"] = discoveredToolsJSON
1561+
}
1562+
if toolNameMappingJSON != "" {
1563+
updates["tool_name_mapping_json"] = toolNameMappingJSON
1564+
}
15401565
// Config-file driven reconciliation passes ConfigHash. In this mode we should
15411566
// also sync connection/auth metadata from config.json and persist the hash.
15421567
if clientConfigCopy.ConfigHash != "" {

0 commit comments

Comments
 (0)