Skip to content

Commit 511bd47

Browse files
Pratham-Mishra04BearTS
authored andcommitted
feat: adds option to disable mcp clients
1 parent 7eb2631 commit 511bd47

22 files changed

Lines changed: 524 additions & 24 deletions

File tree

core/bifrost.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3738,6 +3738,25 @@ func (bifrost *Bifrost) ReconnectMCPClient(id string) error {
37383738
return bifrost.MCPManager.ReconnectClient(id)
37393739
}
37403740

3741+
// DisableMCPClient shuts down an MCP client's connection, health monitor, and tool
3742+
// syncer without removing it. The client entry is kept in a "disabled" state so it
3743+
// can be re-enabled via EnableMCPClient.
3744+
func (bifrost *Bifrost) DisableMCPClient(id string) error {
3745+
if bifrost.MCPManager == nil {
3746+
return fmt.Errorf("mcp is not configured in this bifrost instance")
3747+
}
3748+
return bifrost.MCPManager.DisableClient(id)
3749+
}
3750+
3751+
// EnableMCPClient reconnects a previously disabled MCP client and restarts its
3752+
// health monitor and tool syncer.
3753+
func (bifrost *Bifrost) EnableMCPClient(id string) error {
3754+
if bifrost.MCPManager == nil {
3755+
return fmt.Errorf("mcp is not configured in this bifrost instance")
3756+
}
3757+
return bifrost.MCPManager.EnableClient(id)
3758+
}
3759+
37413760
// VerifyPerUserOAuthConnection delegates to the MCP manager to verify an MCP
37423761
// server using a temporary access token and discover available tools. The
37433762
// connection is closed after verification. If the MCP manager is not yet

core/mcp/clientmanager.go

Lines changed: 153 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,33 @@ func (m *MCPManager) AddClient(config *schemas.MCPClientConfig) error {
109109
return fmt.Errorf("client %s already exists", config.Name)
110110
}
111111

112+
// Disabled clients get a dormant placeholder — no connection, no workers.
113+
if config.Disabled {
114+
clientState := &schemas.MCPClientState{
115+
Name: config.Name,
116+
ExecutionConfig: config,
117+
State: schemas.MCPConnectionStateDisabled,
118+
ToolMap: make(map[string]schemas.ChatTool),
119+
ToolNameMapping: make(map[string]string),
120+
ConnectionInfo: &schemas.MCPClientConnectionInfo{Type: config.ConnectionType},
121+
}
122+
// Persisted tools for per_user_oauth survive restarts in ExecutionConfig.
123+
if config.AuthType == schemas.MCPAuthTypePerUserOauth && len(config.DiscoveredTools) > 0 {
124+
for toolName, tool := range config.DiscoveredTools {
125+
clientState.ToolMap[toolName] = tool
126+
}
127+
clientState.ToolNameMapping = config.DiscoveredToolNameMapping
128+
if config.ConnectionString != nil {
129+
url := config.ConnectionString.GetValue()
130+
clientState.ConnectionInfo.ConnectionURL = &url
131+
}
132+
}
133+
m.clientMap[config.ID] = clientState
134+
m.mu.Unlock()
135+
m.logger.Debug("%s MCP client '%s' registered in disabled state", MCPLogPrefix, config.Name)
136+
return nil
137+
}
138+
112139
// Create placeholder entry
113140
m.clientMap[config.ID] = &schemas.MCPClientState{
114141
Name: config.Name,
@@ -141,10 +168,10 @@ func (m *MCPManager) AddClient(config *schemas.MCPClientConfig) error {
141168
}
142169
client.ToolNameMapping = config.DiscoveredToolNameMapping
143170
client.State = schemas.MCPConnectionStateConnected
144-
m.logger.Info("%s Per-user OAuth MCP client '%s' restored with %d tools", MCPLogPrefix, config.Name, len(config.DiscoveredTools))
171+
m.logger.Debug("%s Per-user OAuth MCP client '%s' restored with %d tools", MCPLogPrefix, config.Name, len(config.DiscoveredTools))
145172
} else {
146173
client.State = schemas.MCPConnectionStatePendingTools
147-
m.logger.Info("%s Per-user OAuth MCP client '%s' registered (connection deferred to runtime)", MCPLogPrefix, config.Name)
174+
m.logger.Debug("%s Per-user OAuth MCP client '%s' registered (connection deferred to runtime)", MCPLogPrefix, config.Name)
148175
}
149176
}
150177
m.mu.Unlock()
@@ -306,6 +333,129 @@ func (m *MCPManager) removeClientUnsafe(id string) error {
306333
return nil
307334
}
308335

336+
// DisableClient shuts down a client's connection, health monitor, and tool syncer
337+
// without removing it from the manager. The client entry is kept in clientMap with
338+
// state MCPConnectionStateDisabled so it can be re-enabled later.
339+
//
340+
// Parameters:
341+
// - id: ID of the client to disable
342+
//
343+
// Returns:
344+
// - error: Any error that occurred during disable
345+
func (m *MCPManager) DisableClient(id string) error {
346+
m.mu.Lock()
347+
defer m.mu.Unlock()
348+
349+
clientState, ok := m.clientMap[id]
350+
if !ok {
351+
return fmt.Errorf("client %s not found", id)
352+
}
353+
if clientState.State == schemas.MCPConnectionStateDisabled {
354+
return fmt.Errorf("client %s is already disabled", clientState.ExecutionConfig.Name)
355+
}
356+
357+
m.logger.Debug("%s Disabling MCP client '%s'", MCPLogPrefix, clientState.ExecutionConfig.Name)
358+
359+
m.healthMonitorManager.StopMonitoring(id)
360+
m.toolSyncManager.StopSyncing(id)
361+
362+
if clientState.CancelFunc != nil {
363+
clientState.CancelFunc()
364+
clientState.CancelFunc = nil
365+
}
366+
if clientState.Conn != nil {
367+
if err := clientState.Conn.Close(); err != nil {
368+
m.logger.Error("%s Failed to close connection for MCP client '%s': %v", MCPLogPrefix, clientState.ExecutionConfig.Name, err)
369+
}
370+
clientState.Conn = nil
371+
}
372+
373+
// Per-user OAuth clients have no persistent connection — their ToolMap holds
374+
// tools discovered via OAuth that can only be recovered by re-running the OAuth
375+
// flow. Preserve the ToolMap so re-enabling restores tools immediately.
376+
if clientState.ExecutionConfig.AuthType != schemas.MCPAuthTypePerUserOauth {
377+
clientState.ToolMap = make(map[string]schemas.ChatTool)
378+
clientState.ToolNameMapping = make(map[string]string)
379+
}
380+
clientState.State = schemas.MCPConnectionStateDisabled
381+
clientState.ExecutionConfig.Disabled = true
382+
m.logger.Debug("%s MCP client '%s' disabled successfully", MCPLogPrefix, clientState.ExecutionConfig.Name)
383+
return nil
384+
}
385+
386+
// EnableClient re-enables a previously disabled MCP client by reconnecting it
387+
// and restarting its health monitor and tool syncer.
388+
//
389+
// Parameters:
390+
// - id: ID of the client to enable
391+
//
392+
// Returns:
393+
// - error: Any error that occurred during enable or connection
394+
func (m *MCPManager) EnableClient(id string) error {
395+
m.mu.Lock()
396+
clientState, ok := m.clientMap[id]
397+
if !ok {
398+
m.mu.Unlock()
399+
return fmt.Errorf("client %s not found", id)
400+
}
401+
if clientState.State != schemas.MCPConnectionStateDisabled {
402+
m.mu.Unlock()
403+
return fmt.Errorf("client %s is not disabled (current state: %s)", clientState.ExecutionConfig.Name, clientState.State)
404+
}
405+
406+
clientState.ExecutionConfig.Disabled = false
407+
configCopy := clientState.ExecutionConfig
408+
m.mu.Unlock()
409+
410+
m.logger.Debug("%s Enabling MCP client '%s'", MCPLogPrefix, configCopy.Name)
411+
412+
// Per-user OAuth clients have no persistent connection — auth is per-request.
413+
// Mirror the AddClient early-return path: just restore the runtime state based
414+
// on whether tools were previously discovered.
415+
if configCopy.AuthType == schemas.MCPAuthTypePerUserOauth {
416+
m.mu.Lock()
417+
if cs, exists := m.clientMap[id]; exists {
418+
if len(cs.ToolMap) > 0 {
419+
cs.State = schemas.MCPConnectionStateConnected
420+
} else {
421+
cs.State = schemas.MCPConnectionStatePendingTools
422+
}
423+
}
424+
m.mu.Unlock()
425+
m.logger.Debug("%s Per-user OAuth MCP client '%s' enabled (no persistent connection)", MCPLogPrefix, configCopy.Name)
426+
return nil
427+
}
428+
429+
// Guard against concurrent reconnects for the same client from any caller
430+
// (health monitor, manual API call, etc.). LoadOrStore is atomic — whichever
431+
// caller arrives second gets the "already in progress" error immediately.
432+
if _, alreadyReconnecting := m.reconnectingClients.LoadOrStore(id, true); alreadyReconnecting {
433+
return fmt.Errorf("reconnect already in progress for this client")
434+
}
435+
defer m.reconnectingClients.Delete(id)
436+
437+
if err := m.connectToMCPClient(configCopy); err != nil {
438+
// Connection failed — leave the entry as Disconnected so the health monitor can recover it
439+
m.mu.Lock()
440+
if cs, exists := m.clientMap[id]; exists {
441+
cs.State = schemas.MCPConnectionStateDisconnected
442+
}
443+
m.mu.Unlock()
444+
445+
isPingAvailable := true
446+
if configCopy.IsPingAvailable != nil {
447+
isPingAvailable = *configCopy.IsPingAvailable
448+
}
449+
monitor := NewClientHealthMonitor(m, id, DefaultHealthCheckInterval, isPingAvailable, m.logger)
450+
m.healthMonitorManager.StartMonitoring(monitor)
451+
452+
return fmt.Errorf("failed to connect MCP client '%s': %w", configCopy.Name, err)
453+
}
454+
455+
m.logger.Debug("%s MCP client '%s' enabled successfully", MCPLogPrefix, configCopy.Name)
456+
return nil
457+
}
458+
309459
// UpdateClient updates an existing MCP client's configuration and refreshes its tool list.
310460
// It updates the client's execution config with new settings and retrieves updated tools
311461
// from the MCP server if the client is connected.
@@ -371,6 +521,7 @@ func (m *MCPManager) UpdateClient(id string, updatedConfig *schemas.MCPClientCon
371521
IsPingAvailable: updatedConfig.IsPingAvailable,
372522
ToolSyncInterval: updatedConfig.ToolSyncInterval,
373523
AllowOnAllVirtualKeys: updatedConfig.AllowOnAllVirtualKeys,
524+
Disabled: updatedConfig.Disabled,
374525
}
375526

376527
// Atomically replace the config pointer

core/mcp/healthmonitor.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,11 @@ func (chm *ClientHealthMonitor) performHealthCheck() {
145145
// don't race with removeClientUnsafe zeroing it under the write lock.
146146
chm.manager.mu.RLock()
147147
clientState, exists := chm.manager.clientMap[chm.clientID]
148+
var isDisabled bool
148149
var conn *client.Client
149150
if exists && clientState != nil {
150151
conn = clientState.Conn
152+
isDisabled = clientState.State == schemas.MCPConnectionStateDisabled
151153
}
152154
chm.manager.mu.RUnlock()
153155

@@ -156,6 +158,13 @@ func (chm *ClientHealthMonitor) performHealthCheck() {
156158
return
157159
}
158160

161+
// Do not health-check intentionally disabled clients
162+
// Health monitoring is already stopped for disabled clients. This is just a sanity check.
163+
if isDisabled {
164+
chm.Stop()
165+
return
166+
}
167+
159168
var err error
160169
if conn == nil {
161170
// No active connection — treat as a health check failure
@@ -210,6 +219,17 @@ func (chm *ClientHealthMonitor) attemptReconnect() {
210219

211220
chm.logger.Debug("%s Attempting to reconnect MCP client %s...", MCPLogPrefix, chm.clientID)
212221

222+
// Do not attempt reconnect if the client has been intentionally disabled
223+
// Health monitoring is already stopped for disabled clients. This is just a sanity check.
224+
chm.manager.mu.RLock()
225+
clientState, exists := chm.manager.clientMap[chm.clientID]
226+
isDisabled := exists && clientState != nil && clientState.State == schemas.MCPConnectionStateDisabled
227+
chm.manager.mu.RUnlock()
228+
if isDisabled {
229+
chm.logger.Debug("%s Skipping reconnect for disabled MCP client %s", MCPLogPrefix, chm.clientID)
230+
return
231+
}
232+
213233
if err := chm.manager.ReconnectClient(chm.clientID); err != nil {
214234
chm.logger.Warn("%s Failed to reconnect MCP client %s: %v", MCPLogPrefix, chm.clientID, err)
215235
return

core/mcp/interface.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ type MCPManagerInterface interface {
6262
// ReconnectClient reconnects an MCP client by ID
6363
ReconnectClient(id string) error
6464

65+
// DisableClient shuts down a client's connection and workers without removing it
66+
DisableClient(id string) error
67+
68+
// EnableClient reconnects a disabled client and restarts its workers
69+
EnableClient(id string) error
70+
6571
// VerifyPerUserOAuthConnection creates a temporary MCP connection using a
6672
// test access token to verify connectivity and discover tools. The connection
6773
// is closed after verification.

core/mcp/utils.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ func (m *MCPManager) GetToolPerClient(ctx context.Context) map[string][]schemas.
7979

8080
m.logger.Debug("%s Evaluating client %s (ID: %s) for tools", MCPLogPrefix, clientName, clientID)
8181

82+
// Skip intentionally disabled clients
83+
if client.State == schemas.MCPConnectionStateDisabled {
84+
m.logger.Debug("%s Skipping disabled MCP client %s", MCPLogPrefix, clientName)
85+
continue
86+
}
87+
8288
// Apply client filtering logic - check both ID and Name for compatibility
8389
if !shouldIncludeClient(clientName, includeClients, m.logger) {
8490
m.logger.Debug("%s Skipping MCP client %s: not in include clients list", MCPLogPrefix, clientName)

core/schemas/mcp.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ type MCPClientConfig struct {
175175
IsPingAvailable *bool `json:"is_ping_available,omitempty"` // Whether the MCP server supports ping for health checks (nil/true = ping; false = listTools). Defaults to true.
176176
ToolSyncInterval time.Duration `json:"tool_sync_interval,omitempty"` // Per-client override for tool sync interval (0 = use global, negative = disabled)
177177
ToolPricing map[string]float64 `json:"tool_pricing,omitempty"` // Tool pricing for each tool (cost per execution)
178+
Disabled bool `json:"disabled"` // Whether the client is intentionally disabled (stops connection and workers)
178179
ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized)
179180
AllowOnAllVirtualKeys bool `json:"allow_on_all_virtual_keys"` // Whether to allow the MCP client to run on all virtual keys
180181

@@ -360,6 +361,7 @@ const (
360361
MCPConnectionStateDisconnected MCPConnectionState = "disconnected" // Client is not connected
361362
MCPConnectionStateError MCPConnectionState = "error" // Client is in an error state, and cannot be used
362363
MCPConnectionStatePendingTools MCPConnectionState = "pending_tools" // Connected but tools not yet populated
364+
MCPConnectionStateDisabled MCPConnectionState = "disabled" // Client is intentionally disabled by the user
363365
)
364366

365367
// MCPClientState represents a connected MCP client with its configuration and tools.

docs/mcp/connecting-to-servers.mdx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,44 @@ if err != nil {
921921

922922
---
923923

924+
## Disabling and Re-enabling Clients
925+
926+
You can temporarily disable an MCP client without removing it. When disabled, Bifrost shuts down the client's connection, health monitor, and tool syncer. The client entry is preserved and its tools are invisible to inference requests until it is re-enabled.
927+
928+
<Tabs>
929+
<Tab title="Web UI">
930+
931+
Use the **Enabled** toggle in the MCP Server Catalog table to disable or re-enable a client with a single click. The toggle shows a loading spinner while the API call is in flight and automatically reflects the updated state.
932+
933+
![MCP client enable/disable toggle in the server catalog table](/images/placeholder-mcp-disable-toggle.png)
934+
935+
</Tab>
936+
<Tab title="Gateway API">
937+
938+
```bash
939+
# Disable a client
940+
curl -X PUT http://localhost:8080/api/mcp/client/{id} \
941+
-H "Content-Type: application/json" \
942+
-d '{"disabled": true}'
943+
944+
# Re-enable a client (reconnects automatically)
945+
curl -X PUT http://localhost:8080/api/mcp/client/{id} \
946+
-H "Content-Type: application/json" \
947+
-d '{"disabled": false}'
948+
```
949+
950+
</Tab>
951+
<Tab title="config.json">
952+
953+
The `disabled` field is a runtime API state and **cannot** be set in `config.json`. Clients defined in `config.json` always start enabled. Use the Web UI or Gateway API to disable a client after it has been created.
954+
955+
</Tab>
956+
</Tabs>
957+
958+
The `disabled` state persists across restarts — a disabled client is loaded into memory on boot but its connection is not established until it is explicitly re-enabled. Config changes (name, tools, headers) sent in the same PUT request as a `disabled` change are applied before the connection is shut down or re-established.
959+
960+
---
961+
924962
## Naming Conventions
925963

926964
MCP client names have specific requirements:

docs/openapi/paths/management/mcp.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ client-by-id:
126126
Updates an existing MCP client's configuration.
127127
Unlike client creation, tool_pricing can be included to set per-tool execution costs since tools are already fetched.
128128
Optionally provide vk_configs to manage which virtual keys have access to this MCP server and with which tools. When provided, this fully replaces all existing VK assignments in a single atomic transaction.
129+
Set disabled: true to shut down the client's connection and workers without removing it. Set disabled: false to reconnect a previously disabled client.
129130
tags:
130131
- MCP
131132
parameters:

docs/openapi/schemas/management/mcp.yaml

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,14 @@ MCPConnectionType:
1717

1818
MCPConnectionState:
1919
type: string
20-
enum: [connected, disconnected, error]
21-
description: Connection state of an MCP client
20+
enum: [connected, disconnected, error, pending_tools, disabled]
21+
description: |
22+
Connection state of an MCP client:
23+
- connected: Client is connected and ready to use
24+
- disconnected: Client is not connected (will be auto-recovered by health monitor)
25+
- error: Client is in an unrecoverable error state
26+
- pending_tools: Connected but tools not yet populated (per-user OAuth clients)
27+
- disabled: Client has been intentionally disabled; no connection or workers are active
2228
2329
MCPStdioConfig:
2430
type: object
@@ -242,6 +248,13 @@ MCPClientUpdateRequest:
242248
When true, this MCP client's tools are accessible to all virtual keys without requiring
243249
explicit per-key assignment. All tools are allowed by default. If a virtual key has an
244250
explicit MCP config for this client, that config takes precedence and overrides this behaviour.
251+
disabled:
252+
type: boolean
253+
default: false
254+
description: |
255+
When true, the client's connection, health monitor, and tool syncer are shut down.
256+
The client entry is preserved so it can be re-enabled later by sending disabled: false.
257+
Disabled clients do not expose tools to inference requests.
245258
vk_configs:
246259
type: array
247260
items:
@@ -342,6 +355,12 @@ MCPClientConfig:
342355
When true, this MCP client's tools are accessible to all virtual keys without requiring
343356
explicit per-key assignment. All tools are allowed by default. If a virtual key has an
344357
explicit MCP config for this client, that config takes precedence and overrides this behaviour.
358+
disabled:
359+
type: boolean
360+
default: false
361+
description: |
362+
Whether the client is intentionally disabled.
363+
When true, the client has no active connection or workers and its tools are not available for inference.
345364
346365
ChatToolFunction:
347366
type: object

0 commit comments

Comments
 (0)