Skip to content

Commit 29cf86e

Browse files
Dumbrisclaude
andauthored
fix(sse): emit servers.changed event after tool discovery completes (#292)
* fix(sse): emit servers.changed event after tool discovery completes Fix race condition where tool counts in Web UI showed stale data after enabling/disabling servers. The issue was that SSE events were emitted before the asynchronous tool discovery completed, causing the frontend to fetch stats from StateView before it was updated. Changes: - Move emitServersChanged() inside the tool discovery goroutine in both HandleUpstreamServerChange() and RestartServer() - Add "reason": "tool_discovery_complete" to event payload for clarity - Change HandleUpstreamServerChange event type from "upstream_change" to "tools_indexed" to better reflect when it's emitted The frontend now receives the SSE event only after tool counts are accurate, ensuring consistent UI updates within ~3-5 seconds. Fixes #285 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(stats): TotalTools only counts enabled servers' tools (#285) Root cause fixes for stale tool counts: - TotalTools stat now excludes disabled servers' tool counts - DeleteServerTools called when server is disabled to remove from index Tests added: - Unit test: TotalTools excludes disabled servers (service_test.go) - Unit test: Disabled server removes tools from index (tool_invalidation_test.go) - E2E test: Full disable/enable cycle with search verification Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(ui): totalTools only counts enabled servers' tools (#285) The frontend was summing all servers' tool_count regardless of enabled status, causing Total Tools to show stale counts when servers are disabled. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent fa9ecf1 commit 29cf86e

6 files changed

Lines changed: 295 additions & 9 deletions

File tree

frontend/src/stores/servers.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,11 @@ export const useServersStore = defineStore('servers', () => {
2929
servers.value.filter(s => s.quarantined)
3030
)
3131

32+
// Only count tools from enabled servers (Issue #285 fix)
3233
const totalTools = computed(() =>
33-
servers.value.reduce((sum, server) => sum + server.tool_count, 0)
34+
servers.value
35+
.filter(s => s.enabled)
36+
.reduce((sum, server) => sum + server.tool_count, 0)
3437
)
3538

3639
// Helper: Smart merge servers to preserve object references and avoid full re-renders

internal/management/service.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,10 @@ func (s *service) ListServers(ctx context.Context) ([]*contracts.Server, *contra
283283
// Extract numeric fields
284284
if toolCount, ok := srvRaw["tool_count"].(int); ok {
285285
srv.ToolCount = toolCount
286-
stats.TotalTools += toolCount
286+
// Only count tools from enabled servers in the total
287+
if srv.Enabled {
288+
stats.TotalTools += toolCount
289+
}
287290
}
288291
if retryCount, ok := srvRaw["retry_count"].(int); ok {
289292
srv.ReconnectCount = retryCount

internal/management/service_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,61 @@ func TestListServers(t *testing.T) {
123123
assert.Equal(t, 1, stats.QuarantinedServers)
124124
})
125125

126+
// T094: Test that TotalTools only counts enabled servers' tools (Issue #285 fix)
127+
t.Run("TotalTools excludes disabled servers", func(t *testing.T) {
128+
runtime := newMockRuntime()
129+
runtime.servers = []map[string]interface{}{
130+
{
131+
"id": "server1",
132+
"name": "enabled-server",
133+
"enabled": true,
134+
"connected": true,
135+
"quarantined": false,
136+
"tool_count": 30,
137+
},
138+
{
139+
"id": "server2",
140+
"name": "disabled-server",
141+
"enabled": false,
142+
"connected": false,
143+
"quarantined": false,
144+
"tool_count": 20, // This should NOT be counted in TotalTools
145+
},
146+
{
147+
"id": "server3",
148+
"name": "another-enabled-server",
149+
"enabled": true,
150+
"connected": true,
151+
"quarantined": false,
152+
"tool_count": 10,
153+
},
154+
}
155+
156+
svc := NewService(runtime, cfg, emitter, nil, logger)
157+
servers, stats, err := svc.ListServers(context.Background())
158+
159+
require.NoError(t, err)
160+
assert.Len(t, servers, 3)
161+
assert.Equal(t, 3, stats.TotalServers)
162+
assert.Equal(t, 2, stats.ConnectedServers)
163+
164+
// CRITICAL: TotalTools should only count enabled servers (30 + 10 = 40)
165+
// If TotalTools is 60 (30 + 20 + 10), the fix is broken
166+
assert.Equal(t, 40, stats.TotalTools, "TotalTools should only count enabled servers' tools")
167+
168+
// Verify individual server tool counts are preserved
169+
for _, srv := range servers {
170+
switch srv.Name {
171+
case "enabled-server":
172+
assert.Equal(t, 30, srv.ToolCount)
173+
case "disabled-server":
174+
assert.Equal(t, 20, srv.ToolCount)
175+
case "another-enabled-server":
176+
assert.Equal(t, 10, srv.ToolCount)
177+
}
178+
}
179+
})
180+
126181
t.Run("runtime error", func(t *testing.T) {
127182
runtime := newMockRuntime()
128183
runtime.getAllError = fmt.Errorf("runtime error")

internal/runtime/lifecycle.go

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,19 @@ func (r *Runtime) EnableServer(serverName string, enabled bool) error {
906906
return fmt.Errorf("failed to reload configuration: %w", err)
907907
}
908908

909+
// When disabling a server, remove its tools from the search index
910+
// This ensures disabled server tools don't appear in search results
911+
if !enabled && r.indexManager != nil {
912+
if err := r.indexManager.DeleteServerTools(serverName); err != nil {
913+
r.logger.Warn("Failed to remove disabled server tools from index",
914+
zap.String("server", serverName),
915+
zap.Error(err))
916+
} else {
917+
r.logger.Info("Removed disabled server tools from search index",
918+
zap.String("server", serverName))
919+
}
920+
}
921+
909922
// Wait for the server to start connecting (LoadConfiguredServers spawns goroutines)
910923
// This ensures callers don't race with connection establishment
911924
// The goroutine needs time to spawn and then AddServer needs to initiate connection
@@ -1138,14 +1151,19 @@ func (r *Runtime) RestartServer(serverName string) error {
11381151

11391152
r.logger.Info("Successfully restarted server", zap.String("server", serverName))
11401153

1141-
// Trigger tool reindexing asynchronously
1154+
// Trigger tool reindexing asynchronously and emit SSE event AFTER completion
1155+
// to ensure frontend receives accurate tool counts (fixes stale stats race condition)
11421156
go func() {
11431157
if err := r.DiscoverAndIndexTools(r.AppContext()); err != nil {
11441158
r.logger.Error("Failed to reindex tools after restart", zap.Error(err))
11451159
}
1146-
}()
11471160

1148-
r.emitServersChanged("restart", map[string]any{"server": serverName})
1161+
// Emit event AFTER tool discovery completes so frontend gets fresh stats
1162+
r.emitServersChanged("restart", map[string]any{
1163+
"server": serverName,
1164+
"reason": "tool_discovery_complete",
1165+
})
1166+
}()
11491167

11501168
return nil
11511169
}
@@ -1182,16 +1200,26 @@ func (r *Runtime) HandleUpstreamServerChange(ctx context.Context) {
11821200
}
11831201

11841202
r.logger.Info("Upstream server configuration changed, triggering comprehensive update")
1203+
1204+
phase := r.CurrentStatus().Phase
1205+
r.UpdatePhase(phase, "Upstream servers updated")
1206+
1207+
// Tool discovery runs in background goroutine, and SSE event is emitted
1208+
// AFTER discovery completes to ensure frontend receives accurate tool counts.
1209+
// This fixes the race condition where stale stats were returned because
1210+
// the event was emitted before StateView was updated.
11851211
go func() {
11861212
if err := r.DiscoverAndIndexTools(ctx); err != nil {
11871213
r.logger.Error("Failed to update tool index after upstream change", zap.Error(err))
11881214
}
11891215
r.cleanupOrphanedIndexEntries()
1190-
}()
11911216

1192-
phase := r.CurrentStatus().Phase
1193-
r.UpdatePhase(phase, "Upstream servers updated")
1194-
r.emitServersChanged("upstream_change", map[string]any{"phase": phase})
1217+
// Emit event AFTER tool discovery completes so frontend gets fresh stats
1218+
r.emitServersChanged("tools_indexed", map[string]any{
1219+
"phase": phase,
1220+
"reason": "tool_discovery_complete",
1221+
})
1222+
}()
11951223
}
11961224

11971225
func (r *Runtime) cleanupOrphanedIndexEntries() {

internal/runtime/tool_invalidation_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,55 @@ func TestToolCacheInvalidation_MultipleServers(t *testing.T) {
388388
assert.Contains(t, names2, "tool_y")
389389
}
390390

391+
// TestToolCacheInvalidation_DisableServerRemovesTools tests that disabling a server
392+
// removes its tools from the search index (Issue #285 fix).
393+
// This ensures disabled servers' tools don't appear in search results.
394+
func TestToolCacheInvalidation_DisableServerRemovesTools(t *testing.T) {
395+
tempDir := t.TempDir()
396+
cfg := &config.Config{
397+
DataDir: tempDir,
398+
Listen: "127.0.0.1:0",
399+
ToolResponseLimit: 0,
400+
Servers: []*config.ServerConfig{},
401+
}
402+
403+
rt, err := New(cfg, "", zap.NewNop())
404+
require.NoError(t, err)
405+
defer func() {
406+
_ = rt.Close()
407+
}()
408+
409+
// Setup: Index tools for a server
410+
initialTools := []*config.ToolMetadata{
411+
{ServerName: "test-server", Name: "tool_a", Description: "Tool A", Hash: "hash_a"},
412+
{ServerName: "test-server", Name: "tool_b", Description: "Tool B", Hash: "hash_b"},
413+
{ServerName: "test-server", Name: "tool_c", Description: "Tool C", Hash: "hash_c"},
414+
}
415+
416+
err = rt.indexManager.BatchIndexTools(initialTools)
417+
require.NoError(t, err)
418+
419+
// Verify tools are indexed
420+
indexedTools, err := rt.indexManager.GetToolsByServer("test-server")
421+
require.NoError(t, err)
422+
assert.Len(t, indexedTools, 3, "Should have 3 tools before disable")
423+
424+
// Simulate disabling server by calling DeleteServerTools directly
425+
// (This is what EnableServer does when enabled=false)
426+
err = rt.indexManager.DeleteServerTools("test-server")
427+
require.NoError(t, err)
428+
429+
// Verify all tools are removed
430+
indexedToolsAfter, err := rt.indexManager.GetToolsByServer("test-server")
431+
require.NoError(t, err)
432+
assert.Len(t, indexedToolsAfter, 0, "All tools should be removed when server is disabled")
433+
434+
// Verify server is no longer in indexed servers list
435+
indexedServers, err := rt.indexManager.GetAllIndexedServerNames()
436+
require.NoError(t, err)
437+
assert.NotContains(t, indexedServers, "test-server", "Disabled server should not be in indexed list")
438+
}
439+
391440
// TestToolCacheInvalidation_OrphanCleanup tests that orphaned index entries are cleaned up
392441
// when a server is removed from the config but its tools remain in the index.
393442
func TestToolCacheInvalidation_OrphanCleanup(t *testing.T) {

internal/server/e2e_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2543,6 +2543,154 @@ func getToolResultText(result *mcp.CallToolResult) string {
25432543
return ""
25442544
}
25452545

2546+
// TestE2E_DisableServerRemovesToolsFromSearch validates Issue #285 fix:
2547+
// When a server is disabled:
2548+
// 1. Tools from that server should be removed from search results
2549+
// 2. TotalTools stat should only count enabled servers' tools
2550+
// When re-enabled:
2551+
// 3. Tools should be discoverable again after re-enable
2552+
func TestE2E_DisableServerRemovesToolsFromSearch(t *testing.T) {
2553+
env := NewTestEnvironment(t)
2554+
defer env.Cleanup()
2555+
2556+
ctx := context.Background()
2557+
2558+
// Create mock server with distinctive tools
2559+
uniqueTools := []mcp.Tool{
2560+
{
2561+
Name: "issue285_alpha_tool",
2562+
Description: "A unique tool for testing issue 285 disable scenario",
2563+
InputSchema: mcp.ToolInputSchema{Type: "object"},
2564+
},
2565+
{
2566+
Name: "issue285_beta_tool",
2567+
Description: "Another unique tool for testing issue 285 disable",
2568+
InputSchema: mcp.ToolInputSchema{Type: "object"},
2569+
},
2570+
}
2571+
2572+
const serverName = "issue285-test-server"
2573+
mockServer := env.CreateMockUpstreamServer(serverName, uniqueTools)
2574+
2575+
mcpClient := env.CreateProxyClient()
2576+
defer mcpClient.Close()
2577+
env.ConnectClient(mcpClient)
2578+
2579+
// Add server via upstream_servers tool
2580+
addRequest := mcp.CallToolRequest{}
2581+
addRequest.Params.Name = "upstream_servers"
2582+
addRequest.Params.Arguments = map[string]interface{}{
2583+
"operation": "add",
2584+
"name": serverName,
2585+
"url": mockServer.addr,
2586+
"protocol": "streamable-http",
2587+
"enabled": true,
2588+
}
2589+
2590+
addResult, err := mcpClient.CallTool(ctx, addRequest)
2591+
require.NoError(t, err)
2592+
require.False(t, addResult.IsError, "Add server should succeed: %v", getToolResultText(addResult))
2593+
2594+
// Unquarantine server
2595+
serverConfig, err := env.proxyServer.runtime.StorageManager().GetUpstreamServer(serverName)
2596+
require.NoError(t, err)
2597+
serverConfig.Quarantined = false
2598+
err = env.proxyServer.runtime.StorageManager().SaveUpstreamServer(serverConfig)
2599+
require.NoError(t, err)
2600+
2601+
// Reload configuration
2602+
servers, err := env.proxyServer.runtime.StorageManager().ListUpstreamServers()
2603+
require.NoError(t, err)
2604+
cfg := env.proxyServer.runtime.Config()
2605+
cfg.Servers = servers
2606+
err = env.proxyServer.runtime.LoadConfiguredServers(cfg)
2607+
require.NoError(t, err)
2608+
2609+
// Wait for connection and discovery
2610+
time.Sleep(4 * time.Second)
2611+
_ = env.proxyServer.runtime.DiscoverAndIndexTools(ctx)
2612+
time.Sleep(2 * time.Second)
2613+
2614+
// Step 1: Verify tools are searchable when server is enabled
2615+
searchRequest := mcp.CallToolRequest{}
2616+
searchRequest.Params.Name = "retrieve_tools"
2617+
searchRequest.Params.Arguments = map[string]interface{}{
2618+
"query": "issue285_alpha",
2619+
}
2620+
2621+
searchResult, err := mcpClient.CallTool(ctx, searchRequest)
2622+
require.NoError(t, err)
2623+
require.False(t, searchResult.IsError, "Search should succeed")
2624+
2625+
searchText := getToolResultText(searchResult)
2626+
assert.Contains(t, searchText, "issue285_alpha_tool", "Tool should be searchable when server is enabled")
2627+
t.Log("Step 1 PASSED: Tool is searchable when server is enabled")
2628+
2629+
// Step 2: Disable the server
2630+
disableRequest := mcp.CallToolRequest{}
2631+
disableRequest.Params.Name = "upstream_servers"
2632+
disableRequest.Params.Arguments = map[string]interface{}{
2633+
"operation": "patch",
2634+
"name": "issue285-test-server",
2635+
"enabled": false,
2636+
}
2637+
2638+
disableResult, err := mcpClient.CallTool(ctx, disableRequest)
2639+
require.NoError(t, err)
2640+
require.False(t, disableResult.IsError, "Disable operation should succeed: %v", getToolResultText(disableResult))
2641+
2642+
// Wait for async tool removal from index to complete
2643+
time.Sleep(2 * time.Second)
2644+
2645+
// Step 3: Verify tools are NOT searchable after disable
2646+
searchAfterDisable, err := mcpClient.CallTool(ctx, searchRequest)
2647+
require.NoError(t, err)
2648+
require.False(t, searchAfterDisable.IsError, "Search should succeed (returning empty results)")
2649+
2650+
searchAfterText := getToolResultText(searchAfterDisable)
2651+
// The search should NOT contain the disabled server's tools
2652+
assert.NotContains(t, searchAfterText, "issue285-test-server:issue285_alpha_tool",
2653+
"Disabled server's tools should NOT appear in search results")
2654+
t.Log("Step 3 PASSED: Tools are not searchable after server is disabled")
2655+
2656+
// Step 4: Re-enable the server
2657+
enableRequest := mcp.CallToolRequest{}
2658+
enableRequest.Params.Name = "upstream_servers"
2659+
enableRequest.Params.Arguments = map[string]interface{}{
2660+
"operation": "patch",
2661+
"name": "issue285-test-server",
2662+
"enabled": true,
2663+
}
2664+
2665+
enableResult, err := mcpClient.CallTool(ctx, enableRequest)
2666+
require.NoError(t, err)
2667+
require.False(t, enableResult.IsError, "Enable operation should succeed: %v", getToolResultText(enableResult))
2668+
2669+
// Wait for async tool discovery and indexing to complete
2670+
time.Sleep(3 * time.Second)
2671+
2672+
// Step 5: Verify tools are searchable again after re-enable
2673+
searchAfterEnable, err := mcpClient.CallTool(ctx, searchRequest)
2674+
require.NoError(t, err)
2675+
require.False(t, searchAfterEnable.IsError, "Search should succeed")
2676+
2677+
searchAfterEnableText := getToolResultText(searchAfterEnable)
2678+
assert.Contains(t, searchAfterEnableText, "issue285_alpha_tool",
2679+
"Tools should be searchable again after server is re-enabled")
2680+
t.Log("Step 5 PASSED: Tools are searchable again after re-enable")
2681+
2682+
// Cleanup
2683+
deleteRequest := mcp.CallToolRequest{}
2684+
deleteRequest.Params.Name = "upstream_servers"
2685+
deleteRequest.Params.Arguments = map[string]interface{}{
2686+
"operation": "remove",
2687+
"name": "issue285-test-server",
2688+
}
2689+
_, _ = mcpClient.CallTool(ctx, deleteRequest)
2690+
2691+
t.Log("✅ Issue #285 fix verified: disable removes tools from search, enable restores them")
2692+
}
2693+
25462694
// TestE2E_ServerDeleteReaddDifferentTools validates the full lifecycle of stale index cleanup:
25472695
// 1. Add server with Tool Set A, verify tools are searchable
25482696
// 2. Remove server, verify Tool Set A is no longer searchable

0 commit comments

Comments
 (0)