Skip to content

Commit ab004f2

Browse files
committed
fix(cli): repair tools cache thrashing and add negative caching
The bare mcp-cli-ent invocation re-discovered tools from every server on each call because the cache gate required all enabled servers present. One failing server made the cache unusable, costing 20-30s per invocation. The discovery path now serves valid cache hits instantly and only contacts servers that are missing, expired, or past their negative-cache window. Failed servers are cached for five minutes and surfaced on stderr instead of vanishing silently. The cache map is merged in place rather than overwritten, so a transient failure on one server no longer evicts good entries for the rest. Each discovery worker gets its own timeout that prefers serverConfig.Timeout and falls back to the global flag. Bumps VERSION to 1.3.0.
1 parent dceb11e commit ab004f2

3 files changed

Lines changed: 83 additions & 56 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,20 @@
22

33
All notable changes to MCP CLI-Ent will be documented in this file.
44

5+
<!-- RELEASE:START 1.3.0 -->
6+
## [1.3.0] - 2026-07-08
7+
8+
### Fixed
9+
10+
- **Tools cache no longer thrashes on every invocation.** Bare `mcp-cli-ent` was re-discovering tools from all servers on every call (20-30s per invocation) because the cache gate required every enabled server to be present. A single failing server made the whole cache unusable. The discovery path now serves valid cache hits instantly and only contacts servers that are missing, expired, or past their negative-cache window.
11+
- **Negative caching with visibility.** Servers that fail to connect are cached as failed for 5 minutes so they stop forcing full re-discovery. They are no longer dropped silently: a stderr line (`<server>: (skipped: recently failed; retry in <duration> or use --refresh)`) reports their status instead of letting them vanish from the output.
12+
- **Cache is now merged, not overwritten.** Previously `SaveToolsToCache` wrote only the current run's successes, evicting previously-good entries on any transient failure (the cache shrank over time). The cache map is now updated in place, so a sibling failure never drops a good entry for another server.
13+
- **`--clear-cache` / `--refresh` no longer delete the cache file.** They force live discovery for enabled servers, whose fresh results overwrite only their own keys. Cached entries for dormant or disabled servers are preserved.
14+
- **Per-worker discovery timeout now honors server config.** Each discovery worker gets a `context.WithTimeout` that prefers `serverConfig.Timeout` and falls back to the global `--timeout` flag, so one hanging server can no longer stall the whole invocation up to the 30s transport default.
15+
- **Cache write failures surface under `--verbose`.** A failed `SaveToolsToCache` previously failed silently, causing mysterious perpetual slowness; it now logs a warning.
16+
17+
<!-- RELEASE:END 1.3.0 -->
18+
519
<!-- RELEASE:START 1.2.2 -->
620
## [1.2.2] - 2026-06-08
721

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.2.2
1+
1.3.0

internal/cli/root.go

Lines changed: 68 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,13 @@ var (
2929
searchQuery string
3030
)
3131

32-
// ToolsCacheEntry represents a cached tool listing for a server
32+
// ToolsCacheEntry represents a cached tool listing for a server.
33+
// Failed entries act as a short-lived negative cache so a broken server
34+
// does not force a full re-discovery on every invocation.
3335
type ToolsCacheEntry struct {
3436
Tools []mcp.Tool `json:"tools"`
3537
LastUpdate time.Time `json:"lastUpdate"`
38+
Failed bool `json:"failed,omitempty"`
3639
}
3740

3841
// ToolsCache represents the full cache structure
@@ -41,8 +44,9 @@ type ToolsCache struct {
4144
}
4245

4346
const (
44-
CacheFileName = "tools_cache.json"
45-
CacheTTL = 30 * 24 * time.Hour // Cache expires after 30 days
47+
CacheFileName = "tools_cache.json"
48+
CacheTTL = 30 * 24 * time.Hour // Successful entries expire after 30 days
49+
NegativeCacheTTL = 5 * time.Minute // Failed entries: retry the server after 5 min
4650
)
4751

4852
// rootCmd represents the base command when called without any subcommands
@@ -153,107 +157,116 @@ func showRootHelpWithServers(cmd *cobra.Command) error {
153157
return nil
154158
}
155159

156-
// If clearCache or refreshCache is set, clear cache file
157-
// These flags are aliases - both trigger cache refresh
160+
// --refresh / --clear-cache are aliases. They force live discovery for
161+
// enabled servers, whose fresh results overwrite only their own cache
162+
// keys. The file itself is never deleted, so dormant/disabled servers
163+
// keep their cached entries (true merge semantics).
158164
if clearCache || refreshCache {
159-
cachePath, err := GetCachePath()
160-
if err == nil {
161-
_ = os.Remove(cachePath)
162-
fmt.Println("Cache cleared.")
163-
}
164-
// Force refresh after clearing
165165
refreshCache = true
166166
clearCache = true
167167
}
168168

169+
// Load existing cache. We MERGE into it rather than overwrite, so a
170+
// transient failure on one server never evicts good entries for others.
169171
cache, err := LoadToolsFromCache()
170-
if err != nil {
171-
cache = nil
172+
if err != nil || cache == nil {
173+
cache = &ToolsCache{Servers: make(map[string]ToolsCacheEntry)}
174+
}
175+
if cache.Servers == nil {
176+
cache.Servers = make(map[string]ToolsCacheEntry)
172177
}
173-
useCache := cache != nil && !refreshCache && !clearCache
174178

175-
var totalTools int
176-
var toolsByServer map[string][]mcp.Tool
179+
toolsByServer := make(map[string][]mcp.Tool)
180+
var toDiscover []string
181+
now := time.Now()
177182

178-
if useCache {
179-
// Check if all servers are in cache
180-
toolsByServer = make(map[string][]mcp.Tool)
181-
allCached := true
182-
for serverName := range enabledServers {
183-
if entry, ok := cache.Servers[serverName]; ok {
184-
toolsByServer[serverName] = entry.Tools
185-
} else {
186-
allCached = false
187-
break
183+
// Partition enabled servers: serve valid cache hits immediately, queue
184+
// the rest (missing, expired, or failed-past-TTL) for live discovery.
185+
for serverName := range enabledServers {
186+
entry, cached := cache.Servers[serverName]
187+
if cached && !refreshCache && !clearCache {
188+
ttl := CacheTTL
189+
if entry.Failed {
190+
ttl = NegativeCacheTTL
188191
}
189-
}
190-
191-
if !allCached {
192-
useCache = false
193-
} else {
194-
// Count tools from cache
195-
for _, tools := range toolsByServer {
196-
totalTools += len(tools)
192+
if now.Sub(entry.LastUpdate) <= ttl {
193+
// Still valid. Negative entries yield no tools but are
194+
// surfaced so a failing server does not vanish silently.
195+
if entry.Failed {
196+
fmt.Fprintf(os.Stderr, "%s: (skipped: recently failed; retry in %s or use --refresh)\n",
197+
serverName, (ttl - now.Sub(entry.LastUpdate)).Round(time.Second))
198+
} else {
199+
toolsByServer[serverName] = entry.Tools
200+
}
201+
continue
197202
}
198203
}
204+
toDiscover = append(toDiscover, serverName)
199205
}
200206

201-
if !useCache {
202-
// Discover tools from all servers
207+
// Discover only what we could not serve from cache. Each worker gets its
208+
// own deadline so one hanging server cannot stall the whole invocation.
209+
if len(toDiscover) > 0 {
203210
factory, err := getSessionAwareClientFactory()
204211
if err != nil {
205212
fmt.Fprintf(os.Stderr, "Error: failed to create client factory: %v\n", err)
206213
fmt.Fprintln(os.Stderr, "Run 'mcp-cli-ent list-servers' to see available servers")
207214
return nil
208215
}
209216

210-
toolsByServer = make(map[string][]mcp.Tool)
211217
var wg sync.WaitGroup
212218
var mu sync.Mutex
213-
214-
// Start workers for parallel discovery
215-
for serverName := range enabledServers {
219+
for _, name := range toDiscover {
216220
wg.Add(1)
217221
go func(name string) {
218222
defer wg.Done()
219-
ctx := context.Background()
220223
serverConfig := enabledServers[name]
221224

225+
// Honor a server-specific timeout if set, else the global flag.
226+
serverTimeout := timeout
227+
if serverConfig.Timeout > 0 {
228+
serverTimeout = serverConfig.Timeout
229+
}
230+
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(serverTimeout)*time.Second)
231+
defer cancel()
232+
222233
mcpClient, err := factory.CreateClient(name, serverConfig)
223234
if err != nil {
224235
fmt.Fprintf(os.Stderr, "%s: (failed to connect: %v)\n", name, err)
236+
mu.Lock()
237+
cache.Servers[name] = ToolsCacheEntry{Failed: true, LastUpdate: time.Now()}
238+
mu.Unlock()
225239
return
226240
}
227241

228242
tools, err := mcpClient.ListTools(ctx)
229243
_ = mcpClient.Close()
230244
if err != nil {
231245
fmt.Fprintf(os.Stderr, "%s: (failed to list tools: %v)\n", name, err)
246+
mu.Lock()
247+
cache.Servers[name] = ToolsCacheEntry{Failed: true, LastUpdate: time.Now()}
248+
mu.Unlock()
232249
return
233250
}
234251

235252
mu.Lock()
236253
toolsByServer[name] = tools
254+
cache.Servers[name] = ToolsCacheEntry{Tools: tools, LastUpdate: time.Now()}
237255
mu.Unlock()
238-
}(serverName)
256+
}(name)
239257
}
240-
241-
// Wait for all workers to complete
242258
wg.Wait()
243259

244-
// Count total tools and build cache
245-
totalTools = 0
246-
newCache := &ToolsCache{Servers: make(map[string]ToolsCacheEntry)}
247-
for serverName, tools := range toolsByServer {
248-
totalTools += len(tools)
249-
newCache.Servers[serverName] = ToolsCacheEntry{
250-
Tools: tools,
251-
LastUpdate: time.Now(),
252-
}
260+
// Persist the merged cache: fresh successes + retained hits + new
261+
// negative entries. A sibling failure never drops a good entry.
262+
if err := SaveToolsToCache(cache); err != nil && verbose {
263+
fmt.Fprintf(os.Stderr, "Warning: failed to save tools cache: %v\n", err)
253264
}
265+
}
254266

255-
// Save to cache
256-
_ = SaveToolsToCache(newCache)
267+
totalTools := 0
268+
for _, tools := range toolsByServer {
269+
totalTools += len(tools)
257270
}
258271

259272
// Filter by search query if provided

0 commit comments

Comments
 (0)