Skip to content

Commit 3ec80c9

Browse files
fix(mcp): resync /mcp gateway tools/list on connect, reconnect, and tool-sync drift
The gateway's tools/list served a stale snapshot rebuilt only on admin config mutations (add/update/remove/enable/disable a client). Boot-time client dialing, manual/health-monitor reconnects, and periodic tool-sync drift all mutated a client's live ToolMap without ever triggering a resync, so /mcp could serve {"tools":[]} indefinitely after a clean restart until an admin toggled a client. Adds an onToolsUpdated hook on MCPManager, coalesced through a buffered channel so bursts of concurrent connects collapse into at most one extra resync, fired from connectToMCPClient and performSync only on their success paths. The transport wires this to SyncAllMCPServers in Bootstrap, and Bifrost retains the callback so it's re-applied to any MCPManager constructed later by a lazy-init path (AddMCPClient, VerifyPerUserOAuthConnection, VerifyHeadersConnection, SetMCPManager) for processes that start with no MCP configured. Fixes #4998
1 parent 1af7722 commit 3ec80c9

7 files changed

Lines changed: 261 additions & 0 deletions

File tree

core/bifrost.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ type Bifrost struct {
8989
MCPManager mcp.MCPManagerInterface // MCP integration manager (nil if MCP not configured)
9090
mcpCredStore schemas.MCPCredentialStore // Per-call credential resolver for MCP tool execution (wraps oauth2Provider for OAuth-flavored auth types)
9191
mcpInitOnce sync.Once // Ensures MCP manager is initialized only once
92+
mcpToolsUpdatedMu sync.RWMutex // Guards mcpToolsUpdatedFn
93+
mcpToolsUpdatedFn func() // Set by SetOnMCPToolsUpdated; applied to MCPManager at boot and re-applied to any manager created later by a lazy-init path (AddMCPClient, VerifyPerUserOAuthConnection, VerifyHeadersConnection), since those construct a fresh MCPManager that wouldn't otherwise see a hook set before it existed
9294
dropExcessRequests atomic.Bool // If true, in cases where the queue is full, requests will not wait for the queue to be empty and will be dropped instead.
9395
keySelector schemas.KeySelector // Custom key selector function
9496
keyPoolFilter schemas.KeyPoolFilter // optional hook to veto keys before selection (nil = all eligible)
@@ -340,6 +342,7 @@ func Init(ctx context.Context, config schemas.BifrostConfig) (*Bifrost, error) {
340342
}
341343
codeMode := starlark.NewStarlarkCodeMode(codeModeConfig, bifrost.logger)
342344
bifrost.MCPManager = mcp.NewMCPManager(bifrostCtx, mcpConfig, bifrost.mcpCredStore, bifrost.logger, codeMode)
345+
bifrost.applyMCPToolsUpdatedHook()
343346
bifrost.logger.Info("MCP integration initialized successfully")
344347
})
345348
}
@@ -3978,6 +3981,41 @@ func (bifrost *Bifrost) ConnectConfiguredMCPClients(ctx context.Context) {
39783981
}
39793982
}
39803983

3984+
// SetOnMCPToolsUpdated registers a callback invoked whenever a connected MCP client's live
3985+
// tool set changes (connect, reconnect, or periodic tool sync). Call before
3986+
// ConnectConfiguredMCPClients so boot-time discovery is covered.
3987+
//
3988+
// The callback is retained on the Bifrost instance (not just handed to the current
3989+
// MCPManager) and re-applied by applyMCPToolsUpdatedHook whenever a *new* MCPManager is
3990+
// constructed later — AddMCPClient, VerifyPerUserOAuthConnection, and
3991+
// VerifyHeadersConnection all lazily create one via mcpInitOnce if MCP wasn't configured
3992+
// at boot. Without retaining the callback here, a process that starts with no MCP config
3993+
// and has its first client added at runtime would end up with a manager whose
3994+
// onToolsUpdated hook was never set, silently reintroducing the gateway staleness bug
3995+
// this callback exists to fix.
3996+
func (bifrost *Bifrost) SetOnMCPToolsUpdated(fn func()) {
3997+
bifrost.mcpToolsUpdatedMu.Lock()
3998+
bifrost.mcpToolsUpdatedFn = fn
3999+
bifrost.mcpToolsUpdatedMu.Unlock()
4000+
if bifrost.MCPManager != nil {
4001+
bifrost.MCPManager.SetOnToolsUpdated(fn)
4002+
}
4003+
}
4004+
4005+
// applyMCPToolsUpdatedHook wires any previously-registered SetOnMCPToolsUpdated callback
4006+
// onto a freshly constructed MCPManager. Call this immediately after every
4007+
// mcp.NewMCPManager(...) assignment to bifrost.MCPManager, including lazy-init sites, so
4008+
// the hook is never silently missing on a manager created after boot. No-op if
4009+
// SetOnMCPToolsUpdated was never called (e.g. non-transport embedders of core).
4010+
func (bifrost *Bifrost) applyMCPToolsUpdatedHook() {
4011+
bifrost.mcpToolsUpdatedMu.RLock()
4012+
fn := bifrost.mcpToolsUpdatedFn
4013+
bifrost.mcpToolsUpdatedMu.RUnlock()
4014+
if fn != nil && bifrost.MCPManager != nil {
4015+
bifrost.MCPManager.SetOnToolsUpdated(fn)
4016+
}
4017+
}
4018+
39814019
func (bifrost *Bifrost) AddMCPClient(ctx context.Context, config *schemas.MCPClientConfig) error {
39824020
if bifrost.MCPManager == nil {
39834021
// Use sync.Once to ensure thread-safe initialization
@@ -3998,6 +4036,7 @@ func (bifrost *Bifrost) AddMCPClient(ctx context.Context, config *schemas.MCPCli
39984036
// Create Starlark CodeMode for code execution (with default config)
39994037
codeMode := starlark.NewStarlarkCodeMode(nil, bifrost.logger)
40004038
bifrost.MCPManager = mcp.NewMCPManager(bifrost.ctx, mcpConfig, bifrost.mcpCredStore, bifrost.logger, codeMode)
4039+
bifrost.applyMCPToolsUpdatedHook()
40014040
})
40024041
}
40034042

@@ -4059,6 +4098,10 @@ func (bifrost *Bifrost) SetMCPManager(manager mcp.MCPManagerInterface) {
40594098
},
40604099
)
40614100
}
4101+
// Wire any previously-registered SetOnMCPToolsUpdated callback onto this externally
4102+
// supplied manager too, for the same reason as the lazy-init sites: a callback set
4103+
// before this manager existed would otherwise be silently dropped.
4104+
bifrost.applyMCPToolsUpdatedHook()
40624105
}
40634106

40644107
// UpdateMCPClient updates the MCP client.
@@ -4148,6 +4191,7 @@ func (bifrost *Bifrost) VerifyPerUserOAuthConnection(ctx context.Context, config
41484191
}
41494192
codeMode := starlark.NewStarlarkCodeMode(nil, bifrost.logger)
41504193
bifrost.MCPManager = mcp.NewMCPManager(bifrost.ctx, mcpConfig, bifrost.mcpCredStore, bifrost.logger, codeMode)
4194+
bifrost.applyMCPToolsUpdatedHook()
41514195
})
41524196
}
41534197
if bifrost.MCPManager == nil {
@@ -4176,6 +4220,7 @@ func (bifrost *Bifrost) VerifyHeadersConnection(ctx context.Context, config *sch
41764220
}
41774221
codeMode := starlark.NewStarlarkCodeMode(nil, bifrost.logger)
41784222
bifrost.MCPManager = mcp.NewMCPManager(bifrost.ctx, mcpConfig, bifrost.mcpCredStore, bifrost.logger, codeMode)
4223+
bifrost.applyMCPToolsUpdatedHook()
41794224
})
41804225
}
41814226
if bifrost.MCPManager == nil {

core/mcp/clientmanager.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1606,6 +1606,11 @@ func (m *MCPManager) connectToMCPClient(requestCtx context.Context, config *sche
16061606
}
16071607
}
16081608

1609+
// Notify the transport (if wired) so gateway-facing tool registries (e.g. the /mcp
1610+
// tools/list snapshot) refresh from this client's freshly discovered ToolMap instead
1611+
// of waiting for an unrelated admin config mutation to trigger a resync.
1612+
m.notifyToolsUpdated()
1613+
16091614
return nil
16101615
}
16111616

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
// PreMCPConnectionHook sees the full plugin set.
6363
ConnectConfiguredClients(ctx context.Context)
6464

65+
// SetOnToolsUpdated registers a callback invoked whenever a client's live tool
66+
// set changes (connect, reconnect, or periodic tool sync), so callers can resync
67+
// any derived tool registry (e.g. a gateway's tools/list snapshot) without relying
68+
// on unrelated admin config mutations to trigger it.
69+
SetOnToolsUpdated(fn func())
70+
6571
// RemoveClient removes an MCP client by ID
6672
RemoveClient(id string) error
6773

core/mcp/mcp.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ type MCPManager struct {
5151
// existing execute-tool hooks.
5252
pluginPipelineProvider func() PluginPipeline
5353
releasePluginPipeline func(pipeline PluginPipeline)
54+
55+
// onToolsUpdatedMu guards onToolsUpdated so SetOnToolsUpdated (called once during
56+
// transport wiring) can never race with notifyToolsUpdated (called from connect/
57+
// reconnect/tool-sync goroutines throughout the process lifetime).
58+
onToolsUpdatedMu sync.RWMutex
59+
onToolsUpdated func() // Optional hook invoked whenever a client's live ToolMap changes
60+
61+
// toolsUpdatedCh coalesces bursts of notifyToolsUpdated calls (e.g. N clients
62+
// connecting concurrently at boot) into a single pending signal, so the hook fires
63+
// at most once more after any in-flight run finishes rather than once per caller.
64+
// Buffered at 1: a full channel means a sync is already pending or running and will
65+
// observe this change too (the hook re-reads live state each time it runs), so the
66+
// send is safely dropped instead of queued.
67+
toolsUpdatedCh chan struct{}
5468
}
5569

5670
// MCPToolFunction is a generic function type for handling tool calls with typed arguments.
@@ -103,7 +117,9 @@ func NewMCPManager(ctx context.Context, config schemas.MCPConfig, credStore sche
103117
healthMonitorManager: NewHealthMonitorManager(),
104118
toolSyncManager: NewToolSyncManager(config.ToolSyncInterval),
105119
credStore: credStore,
120+
toolsUpdatedCh: make(chan struct{}, 1),
106121
}
122+
go manager.runToolsUpdatedDispatcher()
107123
// Convert plugin pipeline provider functions to the interface expected by ToolsManager
108124
var pluginPipelineProvider func() PluginPipeline
109125
var releasePluginPipeline func(pipeline PluginPipeline)
@@ -205,6 +221,53 @@ func (m *MCPManager) connectConfiguredClients(ctx context.Context) {
205221
wg.Wait()
206222
}
207223

224+
// SetOnToolsUpdated registers a callback invoked after a client's live ToolMap changes —
225+
// on connect, reconnect (manual or health-monitor-driven), and periodic tool sync. Wire
226+
// this to the transport's gateway-registry resync (e.g. SyncAllMCPServers) so /mcp's
227+
// tools/list reflects live client state instead of a snapshot that only refreshes on
228+
// admin config mutations. Safe to call at most once during transport startup, before
229+
// ConnectConfiguredClients runs.
230+
func (m *MCPManager) SetOnToolsUpdated(fn func()) {
231+
m.onToolsUpdatedMu.Lock()
232+
defer m.onToolsUpdatedMu.Unlock()
233+
m.onToolsUpdated = fn
234+
}
235+
236+
// runToolsUpdatedDispatcher is the single consumer of toolsUpdatedCh, started once from
237+
// NewMCPManager. Running the hook from one dedicated goroutine (rather than inline in
238+
// every connect/reconnect/tool-sync caller) is what makes the channel's coalescing
239+
// effective: while a run is in flight, any number of new signals collapse into the one
240+
// already buffered, so a burst of concurrent connects triggers at most one extra run
241+
// after the current one finishes, not one run per caller.
242+
func (m *MCPManager) runToolsUpdatedDispatcher() {
243+
for {
244+
select {
245+
case <-m.ctx.Done():
246+
return
247+
case <-m.toolsUpdatedCh:
248+
m.onToolsUpdatedMu.RLock()
249+
fn := m.onToolsUpdated
250+
m.onToolsUpdatedMu.RUnlock()
251+
if fn != nil {
252+
fn()
253+
}
254+
}
255+
}
256+
}
257+
258+
// notifyToolsUpdated signals that a client's live ToolMap changed. It never blocks and
259+
// never calls the hook directly — it just pings the dispatcher, dropping the signal if
260+
// one is already pending/in-flight (a no-op early-return for the "already synced, or
261+
// about to be" case) since that pending run will read current state and cover this
262+
// change too. Callers must not hold m.mu (read or write) when calling this.
263+
func (m *MCPManager) notifyToolsUpdated() {
264+
select {
265+
case m.toolsUpdatedCh <- struct{}{}:
266+
default:
267+
// A sync is already queued or running; it will pick up this change as well.
268+
}
269+
}
270+
208271
// SetPluginPipeline updates the plugin pipeline provider and release function on the manager's
209272
// ToolsManager and CodeMode. Call this after attaching an externally-created MCPManager to a Bifrost
210273
// instance so that nested tool calls in code mode can run through Bifrost's plugin hooks.

core/mcp/toolsync.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,12 @@ func (cts *ClientToolSyncer) performSync() {
158158
} else {
159159
cts.logger.Debug("%s Tool sync completed for %s: %d tools (no change)", MCPLogPrefix, cts.clientID, newToolCount)
160160
}
161+
162+
// Notify the transport so gateway-facing tool registries pick up the change (or
163+
// confirm no drift) instead of only refreshing on the next unrelated admin mutation.
164+
// Fired unconditionally: the old/new count comparison above is only for logging and
165+
// can miss same-count renames, so don't gate the notify on it.
166+
cts.manager.notifyToolsUpdated()
161167
}
162168

163169
// ToolSyncManager manages all client tool syncers

core/mcp_lazy_hook_verify_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package bifrost
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"net/http"
8+
"net/http/httptest"
9+
"sync/atomic"
10+
"testing"
11+
"time"
12+
13+
"github.com/maximhq/bifrost/core/schemas"
14+
)
15+
16+
// startStubMCPServer spins up a minimal in-process JSON-RPC HTTP server that answers
17+
// initialize/tools/list/ping, enough for connectToMCPClient to succeed and populate a
18+
// real ToolMap (which is what triggers notifyToolsUpdated on the success path).
19+
func startStubMCPServer(t *testing.T) *httptest.Server {
20+
t.Helper()
21+
mux := http.NewServeMux()
22+
mux.HandleFunc("/mcp", func(w http.ResponseWriter, r *http.Request) {
23+
var req map[string]any
24+
body, _ := io.ReadAll(r.Body)
25+
_ = json.Unmarshal(body, &req)
26+
method, _ := req["method"].(string)
27+
id := req["id"]
28+
29+
w.Header().Set("Content-Type", "application/json")
30+
switch method {
31+
case "initialize":
32+
_ = json.NewEncoder(w).Encode(map[string]any{
33+
"jsonrpc": "2.0", "id": id,
34+
"result": map[string]any{
35+
"protocolVersion": "2025-03-26",
36+
"capabilities": map[string]any{"tools": map[string]any{}},
37+
"serverInfo": map[string]any{"name": "stub", "version": "0.1"},
38+
},
39+
})
40+
case "notifications/initialized":
41+
w.WriteHeader(http.StatusAccepted)
42+
case "tools/list":
43+
_ = json.NewEncoder(w).Encode(map[string]any{
44+
"jsonrpc": "2.0", "id": id,
45+
"result": map[string]any{
46+
"tools": []map[string]any{
47+
{"name": "echo", "description": "echo text", "inputSchema": map[string]any{"type": "object"}},
48+
},
49+
},
50+
})
51+
case "ping":
52+
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": id, "result": map[string]any{}})
53+
default:
54+
if id == nil {
55+
w.WriteHeader(http.StatusAccepted)
56+
return
57+
}
58+
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": id, "error": map[string]any{"code": -32601, "message": "nope"}})
59+
}
60+
})
61+
srv := httptest.NewServer(mux)
62+
t.Cleanup(srv.Close)
63+
return srv
64+
}
65+
66+
// TestLazyMCPManagerGetsToolsUpdatedHook covers the #4998 fix's lazy-init gap:
67+
// SetOnMCPToolsUpdated, called once at boot when MCPConfig is nil (so no MCPManager
68+
// exists yet), must still reach an MCPManager constructed later by a lazy-init path
69+
// (AddMCPClient here). Without applyMCPToolsUpdatedHook wired into that lazy path, the
70+
// hook would be permanently nil on the manager actually serving traffic, silently
71+
// reintroducing the gateway staleness bug for any deployment that starts with no MCP
72+
// config and adds clients dynamically afterward.
73+
func TestLazyMCPManagerGetsToolsUpdatedHook(t *testing.T) {
74+
stub := startStubMCPServer(t)
75+
76+
bf, err := Init(context.Background(), schemas.BifrostConfig{
77+
Account: NewMockAccount(),
78+
// MCPConfig intentionally nil: mirrors a process that boots with no MCP
79+
// configured, so MCPManager is nil at Init time.
80+
})
81+
if err != nil {
82+
t.Fatalf("Init failed: %v", err)
83+
}
84+
defer bf.Shutdown()
85+
86+
if bf.MCPManager != nil {
87+
t.Fatalf("expected MCPManager to be nil before any client is added")
88+
}
89+
90+
var hookCalls int32
91+
done := make(chan struct{}, 1)
92+
bf.SetOnMCPToolsUpdated(func() {
93+
atomic.AddInt32(&hookCalls, 1)
94+
select {
95+
case done <- struct{}{}:
96+
default:
97+
}
98+
})
99+
100+
// This is the lazy-init path under test: MCPManager is nil, so AddMCPClient
101+
// constructs a fresh one via mcpInitOnce. Before the fix, that fresh manager's
102+
// onToolsUpdated hook would be nil regardless of the SetOnMCPToolsUpdated call
103+
// above, since it ran before this manager existed.
104+
if err := bf.AddMCPClient(context.Background(), &schemas.MCPClientConfig{
105+
ID: "stub-1",
106+
Name: "stub",
107+
ConnectionType: schemas.MCPConnectionTypeHTTP,
108+
ConnectionString: schemas.NewSecretVar(stub.URL + "/mcp"),
109+
AuthType: schemas.MCPAuthTypeNone,
110+
}); err != nil {
111+
t.Fatalf("AddMCPClient failed: %v", err)
112+
}
113+
114+
if bf.MCPManager == nil {
115+
t.Fatalf("expected AddMCPClient to lazily construct MCPManager")
116+
}
117+
118+
select {
119+
case <-done:
120+
// Hook fired — the lazily-constructed manager was correctly wired.
121+
case <-time.After(2 * time.Second):
122+
t.Fatalf("onToolsUpdated hook never fired after AddMCPClient connected successfully; " +
123+
"lazily-constructed MCPManager did not have the hook wired (hookCalls=%d)", atomic.LoadInt32(&hookCalls))
124+
}
125+
}

transports/bifrost-http/server/server.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1874,6 +1874,17 @@ func (s *BifrostHTTPServer) Bootstrap(ctx context.Context) error {
18741874
}
18751875
return fmt.Errorf("failed to initialize inference routes: %v", err)
18761876
}
1877+
// Keep the /mcp gateway's tool registry in sync with live client tool state.
1878+
// Without this, the registry only refreshes on admin config mutations (add/update/
1879+
// remove/enable/disable), leaving boot-time discovery, reconnects, and periodic
1880+
// tool-sync drift unreflected in tools/list until an unrelated admin action happens
1881+
// to trigger a resync. Must be wired before ConnectConfiguredMCPClients below so
1882+
// the very first boot-time discovery is covered.
1883+
s.Client.SetOnMCPToolsUpdated(func() {
1884+
if err := s.MCPServerHandler.SyncAllMCPServers(context.Background()); err != nil {
1885+
logger.Warn("failed to sync MCP servers after tools update: %v", err)
1886+
}
1887+
})
18771888
// Dial configured MCP clients now that every plugin is registered in the core.
18781889
// Construction (bifrost.Init) no longer connects MCP, so connecting here ensures
18791890
// each client's PreMCPConnectionHook runs against the full plugin set rather than

0 commit comments

Comments
 (0)