From 421a04f5fde2ff2923f98d7dc415571cf693d407 Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Fri, 2 Oct 2026 17:22:25 -0500
Subject: [PATCH] fix(core): retry transient MCP connect failures (#52614)
---
packages/core/src/mcp/client.ts | 67 ++++++++++++++++++++++++---------
packages/core/test/mcp.test.ts | 2 +-
2 files changed, 50 insertions(+), 19 deletions(-)
diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts
index bd294bbe1711..39a7085ec6b1 100644
--- a/packages/core/src/mcp/client.ts
+++ b/packages/core/src/mcp/client.ts
@@ -26,7 +26,7 @@ import {
type Transport,
type VersionNegotiationOptions,
} from "@modelcontextprotocol/client"
-import { Cause, Effect, Exit, Schema } from "effect"
+import { Cause, Clock, Duration, Effect, Exit, Schedule, Schema } from "effect"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import type { Session } from "@opencode/schema/session"
import { McpStdio } from "./stdio.js"
@@ -49,6 +49,14 @@ const describe = (error: unknown) => {
return detail && !error.message.includes(detail) ? `${error.message} (${detail})` : error.message
}
+// Overloaded or restarting servers and dropped connections usually recover within a second.
+// Fetch reports network failures as a TypeError carrying a system error code.
+// 501 means the server will never support the request.
+const isTransient = (error: unknown) =>
+ error instanceof SdkHttpError
+ ? error.status === 408 || error.status === 429 || (error.status >= 500 && error.status !== 501)
+ : error instanceof TypeError && "code" in error
+
export type { GetPromptResult, Prompt, ReadResourceResult, Resource, Tool }
export type ResourceTemplate = ResourceTemplateType
@@ -159,6 +167,20 @@ export const connect = Effect.fnUntraced(function* (
? `${describe(error)}: ${lastError}`
: describe(error)
+ // Two more attempts, 250 ms and 1 s later, while the failure is transient and the deadline has not passed.
+ const retrying = (what: string, effect: Effect.Effect, deadline = Infinity) =>
+ Effect.retry(
+ effect,
+ Schedule.max([Schedule.exponential("250 millis", 4), Schedule.recurs(2)]).pipe(
+ Schedule.setInputType(),
+ Schedule.while(({ input, now, duration }) =>
+ !isTransient(input) || now + Duration.toMillis(duration) >= deadline
+ ? Effect.succeed(false)
+ : Effect.logWarning(`retrying ${what}`, { server, error: describe(input) }).pipe(Effect.as(true)),
+ ),
+ ),
+ )
+
const initialize = Effect.fnUntraced(function* (transport: Transport) {
const runFork = Effect.runForkWith(yield* Effect.context())
const client = new Client(clientInfo, {
@@ -245,22 +267,28 @@ export const connect = Effect.fnUntraced(function* (
const url = new URL(config.url)
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
if (addedCodemode) url.searchParams.set("codemode", "false")
- const open = (url: URL) => {
+ const open = Effect.fnUntraced(function* (url: URL) {
session.transport = new StreamableHTTPClientTransport(url, {
requestInit: config.headers ? { headers: config.headers } : undefined,
authProvider,
fetch,
})
- return initialize(session.transport)
- }
+ return yield* initialize(session.transport)
+ })
- return yield* open(url).pipe(
- Effect.catch((error) => {
- if (!addedCodemode || !(error instanceof SdkHttpError) || (error.status !== 400 && error.status !== 404))
- return Effect.fail(error)
- // Servers that reject unknown query params get one retry at the configured URL.
- return open(new URL(config.url))
- }),
+ // Every attempt opens a fresh transport; no retry starts once the startup timeout has passed.
+ const deadline = (yield* Clock.currentTimeMillis) + (config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT)
+ return yield* retrying(
+ "MCP connect",
+ open(url).pipe(
+ Effect.catch((error) => {
+ if (!addedCodemode || !(error instanceof SdkHttpError) || (error.status !== 400 && error.status !== 404))
+ return Effect.fail(error)
+ // Servers that reject unknown query params get one retry at the configured URL.
+ return open(new URL(config.url))
+ }),
+ ),
+ deadline,
)
}).pipe(Effect.exit)
if (Exit.isSuccess(exit)) {
@@ -292,20 +320,23 @@ export const connect = Effect.fnUntraced(function* (
Effect.tryPromise({ try: run, catch: failure }).pipe(
Effect.tapError((error) => Effect.logWarning(`failed to ${what}`, { server, error: error.message })),
)
+ // Listing is read-only, so a transient failure is safe to retry.
+ const list = (what: string, run: () => Promise) =>
+ retrying(what, Effect.tryPromise({ try: run, catch: (error) => error })).pipe(
+ Effect.mapError(failure),
+ Effect.tapError((error) => Effect.logWarning(`failed to ${what}`, { server, error: error.message })),
+ )
return {
modern: client.getProtocolEra() === "modern",
instructions: client.getInstructions()?.trim() || undefined,
- tools: () =>
- request("list MCP tools", () => client.listTools(undefined, catalog)).pipe(Effect.map((r) => r.tools)),
+ tools: () => list("list MCP tools", () => client.listTools(undefined, catalog)).pipe(Effect.map((r) => r.tools)),
prompts: () =>
- request("list MCP prompts", () => client.listPrompts(undefined, catalog)).pipe(Effect.map((r) => r.prompts)),
+ list("list MCP prompts", () => client.listPrompts(undefined, catalog)).pipe(Effect.map((r) => r.prompts)),
resources: () =>
- request("list MCP resources", () => client.listResources(undefined, catalog)).pipe(
- Effect.map((r) => r.resources),
- ),
+ list("list MCP resources", () => client.listResources(undefined, catalog)).pipe(Effect.map((r) => r.resources)),
resourceTemplates: () =>
- request("list MCP resource templates", () => client.listResourceTemplates(undefined, catalog)).pipe(
+ list("list MCP resource templates", () => client.listResourceTemplates(undefined, catalog)).pipe(
Effect.map((r) => r.resourceTemplates),
),
readResource: (input) => {
diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts
index 81785d1d3698..314b81fcb8aa 100644
--- a/packages/core/test/mcp.test.ts
+++ b/packages/core/test/mcp.test.ts
@@ -1048,7 +1048,7 @@ for (const entry of [
{ name: "second 400", status: 400, query: "", codemode: undefined, attempts: 2 },
{ name: "401", status: 401, query: "", codemode: undefined, attempts: 1 },
{ name: "403", status: 403, query: "", codemode: undefined, attempts: 1 },
- { name: "500", status: 500, query: "", codemode: undefined, attempts: 1 },
+ { name: "501", status: 501, query: "", codemode: undefined, attempts: 1 },
{ name: "user codemode=true", status: 404, query: "?codemode=true", codemode: undefined, attempts: 1 },
{ name: "user codemode=false", status: 404, query: "?codemode=false", codemode: undefined, attempts: 1 },
{ name: "empty user codemode", status: 404, query: "?codemode=", codemode: undefined, attempts: 1 },