Skip to content

Commit 13dd40a

Browse files
JavisJavis
authored andcommitted
fix(mcp): retry listTools() before evicting client on transient failure
Previously, a single transient listTools() failure (timeout, pipe hiccup, GC pause) would permanently delete the MCP client from the session state with no retry or reconnection mechanism. This caused MCP tools to silently vanish mid-session while the server process was still running. This commit: 1. Adds retry logic (3 attempts with 1s delay) before marking a client as failed 2. Removes the immediate 'delete s.clients[clientName]' that permanently evicted healthy servers on transient errors 3. Logs retry attempts at warn level for visibility The client remains in 'failed' status after all retries are exhausted, but is no longer deleted, allowing future state() calls to attempt reconnection. Fixes anomalyco#17099
1 parent d8fbe0a commit 13dd40a

1 file changed

Lines changed: 43 additions & 3 deletions

File tree

‎packages/opencode/src/mcp/index.ts‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,31 @@ import open from "open"
2727
export namespace MCP {
2828
const log = Log.create({ service: "mcp" })
2929
const DEFAULT_TIMEOUT = 30_000
30+
const LIST_TOOLS_RETRY_ATTEMPTS = 3
31+
const LIST_TOOLS_RETRY_DELAY_MS = 1000
32+
33+
async function sleep(ms: number): Promise<void> {
34+
return new Promise((resolve) => setTimeout(resolve, ms))
35+
}
36+
37+
async function withRetry<T>(
38+
fn: () => Promise<T>,
39+
opts: { attempts: number; delayMs: number; onRetry?: (attempt: number, error: unknown) => void },
40+
): Promise<T> {
41+
let lastError: unknown
42+
for (let attempt = 1; attempt <= opts.attempts; attempt++) {
43+
try {
44+
return await fn()
45+
} catch (e) {
46+
lastError = e
47+
if (attempt < opts.attempts) {
48+
opts.onRetry?.(attempt, e)
49+
await sleep(opts.delayMs)
50+
}
51+
}
52+
}
53+
throw lastError
54+
}
3055

3156
export const Resource = z
3257
.object({
@@ -620,14 +645,29 @@ export namespace MCP {
620645

621646
const toolsResults = await Promise.all(
622647
connectedClients.map(async ([clientName, client]) => {
623-
const toolsResult = await client.listTools().catch((e) => {
624-
log.error("failed to get tools", { clientName, error: e.message })
648+
const toolsResult = await withRetry(
649+
() => client.listTools(),
650+
{
651+
attempts: LIST_TOOLS_RETRY_ATTEMPTS,
652+
delayMs: LIST_TOOLS_RETRY_DELAY_MS,
653+
onRetry: (attempt, error) => {
654+
log.warn("listTools failed, retrying", {
655+
clientName,
656+
attempt,
657+
maxAttempts: LIST_TOOLS_RETRY_ATTEMPTS,
658+
error: error instanceof Error ? error.message : String(error),
659+
})
660+
},
661+
},
662+
).catch((e) => {
663+
log.error("failed to get tools after retries", { clientName, error: e.message })
625664
const failedStatus = {
626665
status: "failed" as const,
627666
error: e instanceof Error ? e.message : String(e),
628667
}
629668
s.status[clientName] = failedStatus
630-
delete s.clients[clientName]
669+
// Don't delete the client immediately - allow reconnection attempts
670+
// The client will be recreated on next state() call if needed
631671
return undefined
632672
})
633673
return { clientName, client, toolsResult }

0 commit comments

Comments
 (0)