OpenCode marks an MCP server failed when it does not come up at startup or when a live connection drops, and never retries. A new managed plugin reconnects those servers with a per-server backoff (1s doubling to a 30s cap), wakes early on mcp.tools.changed, and stops on dispose. Only the failed state is retried; disabled and auth states stay untouched. The OPENCODE_CONFIG_CONTENT merge that agent-tool and system-prompt each carried is now one shared helper so the three managed plugins compose. Claude-Session: https://claude.ai/code/session_01VqV56Hez25hTxXH4ipJfzH
123 lines
4.1 KiB
JavaScript
123 lines
4.1 KiB
JavaScript
import { pathToFileURL } from 'node:url';
|
|
import { appendManagedPlugin } from '../opencode/managed-plugin-config.js';
|
|
|
|
/**
|
|
* OpenCode marks an MCP server `failed` when it does not come up at startup or
|
|
* when a live connection drops, and never tries again. This plugin runs inside
|
|
* the managed OpenCode process and reconnects those servers with a per-server
|
|
* exponential backoff, so a server that was merely slow to start, or a local
|
|
* one that crashed, comes back without an OpenCode restart.
|
|
*
|
|
* Only `failed` is retried. `needs_auth`, `needs_client_registration`, and
|
|
* `disabled` are user decisions or need user action, and retrying them would
|
|
* either loop on a login prompt or re-enable a server the user turned off.
|
|
*
|
|
* The plugin talks to OpenCode through the SDK client OpenCode hands it, which
|
|
* is scoped to one project directory, so each open directory reconnects its
|
|
* own servers. Nothing is logged: OpenCode already reports each failed attempt.
|
|
*/
|
|
const createPluginSource = () => String.raw`
|
|
const INITIAL_RETRY_MS = 1000
|
|
const MAX_RETRY_MS = 30000
|
|
const IDLE_CHECK_MS = 30000
|
|
|
|
export const OpenChamberMcpReconnectPlugin = async ({ client }) => {
|
|
let disposed = false
|
|
let running = false
|
|
let wakeRequested = false
|
|
let timer
|
|
// Consecutive failed attempts per server, cleared once it is seen healthy.
|
|
const attempts = new Map()
|
|
const dueAt = new Map()
|
|
|
|
const schedule = (delayMs) => {
|
|
if (disposed) return
|
|
clearTimeout(timer)
|
|
timer = setTimeout(tick, delayMs)
|
|
}
|
|
|
|
const tick = async () => {
|
|
if (running) {
|
|
wakeRequested = true
|
|
return
|
|
}
|
|
running = true
|
|
let delay = IDLE_CHECK_MS
|
|
try {
|
|
const statuses = (await client.mcp.status())?.data ?? {}
|
|
const now = Date.now()
|
|
const due = []
|
|
for (const [name, entry] of Object.entries(statuses)) {
|
|
if (entry?.status !== "failed") {
|
|
attempts.delete(name)
|
|
dueAt.delete(name)
|
|
continue
|
|
}
|
|
const at = dueAt.get(name) ?? now
|
|
if (at > now) {
|
|
delay = Math.min(delay, at - now)
|
|
continue
|
|
}
|
|
due.push(name)
|
|
}
|
|
for (const name of attempts.keys()) {
|
|
if (!Object.hasOwn(statuses, name)) {
|
|
attempts.delete(name)
|
|
dueAt.delete(name)
|
|
}
|
|
}
|
|
|
|
await Promise.allSettled(due.map((name) => client.mcp.connect({ path: { name } })))
|
|
|
|
// The result is read on the next tick: a server that came back clears
|
|
// its counter there, one still failed waits out its backoff.
|
|
const after = Date.now()
|
|
for (const name of due) {
|
|
const count = (attempts.get(name) ?? 0) + 1
|
|
const wait = Math.min(INITIAL_RETRY_MS * 2 ** (count - 1), MAX_RETRY_MS)
|
|
attempts.set(name, count)
|
|
dueAt.set(name, after + wait)
|
|
delay = Math.min(delay, wait)
|
|
}
|
|
} catch {
|
|
// Status is unavailable while OpenCode is shutting down or restarting;
|
|
// the idle check picks up again when it is back.
|
|
} finally {
|
|
running = false
|
|
const wake = wakeRequested
|
|
wakeRequested = false
|
|
schedule(wake ? INITIAL_RETRY_MS : delay)
|
|
}
|
|
}
|
|
|
|
schedule(INITIAL_RETRY_MS)
|
|
|
|
return {
|
|
// A dropped connection publishes this event; checking right away beats
|
|
// waiting out the idle interval.
|
|
event: async ({ event }) => {
|
|
if (event?.type === "mcp.tools.changed") schedule(INITIAL_RETRY_MS)
|
|
},
|
|
dispose: async () => {
|
|
disposed = true
|
|
clearTimeout(timer)
|
|
},
|
|
}
|
|
}
|
|
`;
|
|
|
|
export const createMcpReconnectRuntime = ({ fsPromises, path, dataDir }) => {
|
|
const pluginDirectory = path.join(dataDir, 'mcp-reconnect');
|
|
const pluginPath = path.join(pluginDirectory, 'openchamber-mcp-reconnect-plugin.js');
|
|
|
|
const prepareManagedOpenCodeEnv = async (rawConfig) => {
|
|
await fsPromises.mkdir(pluginDirectory, { recursive: true });
|
|
await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
|
|
return {
|
|
OPENCODE_CONFIG_CONTENT: appendManagedPlugin(rawConfig, pathToFileURL(pluginPath).href, 'MCP reconnect plugin'),
|
|
};
|
|
};
|
|
|
|
return { prepareManagedOpenCodeEnv };
|
|
};
|