feat(mcp): reconnect failed MCP servers in managed OpenCode

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
This commit is contained in:
Bohdan Triapitsyn
2026-09-04 22:56:27 +03:00
parent caac24ac3d
commit ce5c0c068e
8 changed files with 375 additions and 45 deletions
+15 -4
View File
@@ -111,6 +111,7 @@ import { createDevServerScanner } from './lib/dev-servers/routes.js';
import { createDevTunnelRuntime } from './lib/dev-tunnel/runtime.js';
import { registerBrowserControlRoutes } from './lib/browser-control/routes.js';
import { createSystemPromptRuntime } from './lib/system-prompt/runtime.js';
import { createMcpReconnectRuntime } from './lib/mcp-reconnect/runtime.js';
import { createOpenChamberSessionService } from './lib/openchamber-sessions/routes.js';
import { createScheduledTaskService } from './lib/scheduled-tasks/service.js';
import { createOpenChamberControlService } from './lib/openchamber-control/service.js';
@@ -285,6 +286,7 @@ const readCustomThemesFromDisk = (...args) => themeRuntime.readCustomThemesFromD
let notificationTemplateRuntime = null;
let agentToolRuntime = null;
let systemPromptRuntime = null;
let mcpReconnectRuntime = null;
const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args);
const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args);
@@ -1222,11 +1224,15 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({
const managedEnv = includeControl || includeWeb || includeMemory
? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {})
: {};
if (settings?.optimizeSystemPrompt !== true) return managedEnv;
const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
const systemPromptEnv = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent);
return { ...managedEnv, ...systemPromptEnv };
// Each managed plugin appends itself to the config the previous one produced.
let configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
if (settings?.optimizeSystemPrompt === true) {
({ OPENCODE_CONFIG_CONTENT: configContent } = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent));
}
// Always on for managed OpenCode: it only retries servers OpenCode gave up on.
const mcpReconnectEnv = await mcpReconnectRuntime.prepareManagedOpenCodeEnv(configContent);
return { ...managedEnv, ...mcpReconnectEnv };
},
});
@@ -1500,6 +1506,11 @@ async function main(options = {}) {
path,
dataDir: OPENCHAMBER_DATA_DIR,
});
mcpReconnectRuntime = createMcpReconnectRuntime({
fsPromises,
path,
dataDir: OPENCHAMBER_DATA_DIR,
});
// Pairing transports advertised to the create-device dialog. LAN reachability is
// derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP;
+2 -19
View File
@@ -1,5 +1,5 @@
import { parse as parseJsonc } from 'jsonc-parser';
import { pathToFileURL } from 'node:url';
import { appendManagedPlugin } from '../opencode/managed-plugin-config.js';
import {
OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS,
OPENCHAMBER_AGENT_TOOL_ACTIONS,
@@ -252,23 +252,6 @@ ${entries.join('')} },
`;
};
const mergePluginConfig = (rawConfig, pluginUrl) => {
const errors = [];
const parsed = asNonEmptyString(rawConfig) ? parseJsonc(rawConfig, errors, { allowTrailingComma: true }) : {};
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its managed tool');
}
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
throw new Error('OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its managed tool');
}
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
parsed.plugin = [
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
pluginUrl,
];
return JSON.stringify(parsed);
};
export const createAgentToolRuntime = (dependencies) => {
const {
crypto,
@@ -296,7 +279,7 @@ export const createAgentToolRuntime = (dependencies) => {
activeToken = crypto.randomBytes(32).toString('base64url');
const pluginUrl = pathToFileURL(pluginPath).href;
return {
OPENCODE_CONFIG_CONTENT: mergePluginConfig(env.OPENCODE_CONFIG_CONTENT, pluginUrl),
OPENCODE_CONFIG_CONTENT: appendManagedPlugin(env.OPENCODE_CONFIG_CONTENT, pluginUrl, 'managed tool'),
OPENCHAMBER_AGENT_TOOL_URL: `http://127.0.0.1:${port}/api/openchamber/agent-tool`,
OPENCHAMBER_AGENT_TOOL_TOKEN: activeToken,
};
@@ -0,0 +1,55 @@
# Managed MCP Reconnect
## Purpose
OpenCode connects each configured MCP server once, when a project directory is
first used. A server that does not come up then is marked `failed` and never
retried; a server whose live connection later drops is marked `failed` too and
stays that way until OpenCode restarts. This module injects a small plugin into
the OpenCode process OpenChamber launches that reconnects those servers, so a
server that was slow to start or crashed mid-session comes back on its own.
## Runtime flow
1. `prepareManagedOpenCodeEnv(configContent)` materializes the plugin under
`<openchamber-data-dir>/mcp-reconnect/` and appends its `file://` URL to
`OPENCODE_CONFIG_CONTENT` through the shared merge in
`packages/web/server/lib/opencode/managed-plugin-config.js`.
2. It is always on for managed OpenCode. There is no setting, because it only
acts on servers OpenCode has already given up on.
3. OpenCode loads the plugin once per project directory with an SDK client
scoped to that directory, so each directory reconnects its own servers.
4. The plugin reads MCP status one second after load, calls connect for every
server in the `failed` state, then re-reads status after a per-server delay
that doubles from one second to a cap of thirty. A server seen in any other
state resets its counter. While nothing is failed it checks every thirty
seconds.
5. A dropped connection publishes `mcp.tools.changed`, which the plugin uses to
check right away instead of waiting out the idle interval.
6. OpenCode calls the plugin's `dispose` hook when it tears the directory down,
which stops the loop.
## Invariants
- Only `failed` is retried. `disabled` is the user's choice, and `needs_auth`
or `needs_client_registration` need the user to act; retrying those would
either re-enable a server the user turned off or loop on a login prompt.
- The plugin logs nothing and swallows every error. OpenCode already logs each
failed attempt, and a status call failing during an OpenCode restart is not
news.
- One check runs at a time; a wake-up arriving during a check is honored once
it finishes rather than starting a second loop.
## What the UI sees
OpenCode publishes no event when a reconnect succeeds. The chat picks the
server up on the next prompt because tools are resolved from live state, but
the MCP page reads status on bootstrap and refresh, so it can show `failed` for
a while after the server is back.
## Runtime parity
- Web and Desktop managed OpenCode: injected automatically.
- External OpenCode (`OPENCODE_HOST` or skip-start) and VS Code's separate
OpenCode lifecycle: not injected, because OpenChamber does not control that
process environment.
@@ -0,0 +1,122 @@
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 };
};
@@ -0,0 +1,140 @@
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createMcpReconnectRuntime } from './runtime.js';
const temporaryDirectories = [];
const disposers = [];
afterEach(async () => {
await Promise.all(disposers.splice(0).map((dispose) => dispose()));
vi.useRealTimers();
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
});
const materialize = async (rawConfig = '{}') => {
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-mcp-reconnect-'));
temporaryDirectories.push(dataDir);
const runtime = createMcpReconnectRuntime({ fsPromises: fs, path, dataDir });
const prepared = await runtime.prepareManagedOpenCodeEnv(rawConfig);
const pluginPath = path.join(dataDir, 'mcp-reconnect', 'openchamber-mcp-reconnect-plugin.js');
const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}-${Math.random()}`);
return { prepared, pluginPath, plugin: pluginModule.OpenChamberMcpReconnectPlugin };
};
/**
* A stand-in for the SDK client OpenCode hands to plugins. `statuses` is live
* state the test mutates; `connect` records when each attempt happened.
*/
const createClient = (statuses, { onConnect } = {}) => {
const attempts = [];
return {
attempts,
statuses,
mcp: {
status: vi.fn(async () => ({ data: { ...statuses } })),
connect: vi.fn(async ({ path: { name } }) => {
attempts.push({ name, at: Date.now() });
onConnect?.(name);
return { data: true };
}),
},
};
};
const start = async (plugin, client) => {
const hooks = await plugin({ client });
disposers.push(hooks.dispose);
return hooks;
};
describe('managed MCP reconnect runtime', () => {
it('materializes the plugin and preserves existing plugin entries', async () => {
const { prepared, pluginPath } = await materialize('{ "plugin": ["file:///existing.js"], "model": "test/model" }');
const config = JSON.parse(prepared.OPENCODE_CONFIG_CONTENT);
expect(config.model).toBe('test/model');
expect(config.plugin).toEqual(['file:///existing.js', pathToFileURL(pluginPath).href]);
});
it('reconnects only servers in the failed state', async () => {
vi.useFakeTimers();
const { plugin } = await materialize();
const client = createClient({
broken: { status: 'failed', error: 'spawn ENOENT' },
healthy: { status: 'connected' },
off: { status: 'disabled' },
login: { status: 'needs_auth' },
registration: { status: 'needs_client_registration', error: 'no client id' },
});
await start(plugin, client);
await vi.advanceTimersByTimeAsync(1000);
expect(client.attempts.map((attempt) => attempt.name)).toEqual(['broken']);
});
it('backs off per server up to a cap while it keeps failing', async () => {
vi.useFakeTimers();
const { plugin } = await materialize();
const client = createClient({ broken: { status: 'failed', error: 'refused' } });
const started = Date.now();
await start(plugin, client);
await vi.advanceTimersByTimeAsync(92_000);
expect(client.attempts.map((attempt) => attempt.at - started)).toEqual([
1000, 2000, 4000, 8000, 16_000, 32_000, 62_000, 92_000,
]);
});
it('stops retrying once a server is back and starts fresh when it drops again', async () => {
vi.useFakeTimers();
const { plugin } = await materialize();
const statuses = { flaky: { status: 'failed', error: 'refused' } };
const client = createClient(statuses, {
onConnect: () => { statuses.flaky = { status: 'connected' }; },
});
const hooks = await start(plugin, client);
await vi.advanceTimersByTimeAsync(60_000);
expect(client.attempts).toHaveLength(1);
statuses.flaky = { status: 'failed', error: 'Connection closed' };
await hooks.event({ event: { type: 'mcp.tools.changed', properties: { server: 'flaky' } } });
await vi.advanceTimersByTimeAsync(1000);
expect(client.attempts).toHaveLength(2);
expect(client.attempts[1].at - client.attempts[0].at).toBeGreaterThan(30_000);
});
it('keeps going when status is temporarily unavailable', async () => {
vi.useFakeTimers();
const { plugin } = await materialize();
const client = createClient({ broken: { status: 'failed', error: 'refused' } });
client.mcp.status.mockRejectedValueOnce(new Error('ECONNREFUSED'));
await start(plugin, client);
await vi.advanceTimersByTimeAsync(1000);
expect(client.attempts).toHaveLength(0);
await vi.advanceTimersByTimeAsync(30_000);
expect(client.attempts).toHaveLength(1);
});
it('does nothing after dispose', async () => {
vi.useFakeTimers();
const { plugin } = await materialize();
const client = createClient({ broken: { status: 'failed', error: 'refused' } });
const hooks = await plugin({ client });
await hooks.dispose();
await hooks.event({ event: { type: 'mcp.tools.changed', properties: { server: 'broken' } } });
await vi.advanceTimersByTimeAsync(120_000);
expect(client.mcp.status).not.toHaveBeenCalled();
expect(client.attempts).toHaveLength(0);
});
});
@@ -34,6 +34,8 @@ This module provides OpenCode server integration utilities for the web server ru
- `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists.
- `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch.
- `packages/web/server/lib/system-prompt/runtime.js`: opt-in managed OpenCode system-prompt optimizer materialization and plugin injection.
- `packages/web/server/lib/mcp-reconnect/runtime.js`: always-on managed OpenCode plugin that reconnects MCP servers OpenCode marked `failed`, with per-server backoff.
- `packages/web/server/lib/opencode/managed-plugin-config.js`: the one `OPENCODE_CONFIG_CONTENT` merge every managed plugin (agent tools, system prompt optimizer, MCP reconnect) appends itself through.
- `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers.
- `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration.
- `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching.
@@ -130,7 +132,8 @@ The runtime maintains active-session count incrementally from idempotent activit
- `killProcessOnPort(port)`
Managed OpenCode launch also merges the environment returned by the agent-tool
runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
runtime, the opt-in system prompt optimizer, and the always-on MCP reconnect
plugin, each appending its `file://` entry to the previous one's config. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot
be replaced by injected values. External OpenCode processes receive no
OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before
spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage
@@ -0,0 +1,35 @@
import { parse as parseJsonc } from 'jsonc-parser';
const isJsonObject = (value) => value !== null && value !== undefined && Object.getPrototypeOf(value) === Object.prototype;
/**
* Append a managed OpenChamber plugin to the `plugin` list of an
* `OPENCODE_CONFIG_CONTENT` value.
*
* Existing entries are preserved; an earlier entry for the same URL is dropped
* so a restart never registers the same plugin twice. Every managed plugin
* (agent tools, system prompt optimizer, MCP reconnect) goes through this one
* merge so they compose in any order.
*
* @param {string | undefined} rawConfig the current `OPENCODE_CONFIG_CONTENT`, JSONC or unset
* @param {string} pluginUrl `file://` URL of the materialized plugin
* @param {string} purpose short noun phrase for the error message, e.g. "managed tool"
* @returns {string} the merged config as JSON
*/
export const appendManagedPlugin = (rawConfig, pluginUrl, purpose) => {
const errors = [];
const text = (rawConfig ?? '').trim();
const parsed = text ? parseJsonc(text, errors, { allowTrailingComma: true }) : {};
if (errors.length > 0 || !isJsonObject(parsed)) {
throw new Error(`OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its ${purpose}`);
}
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
throw new Error(`OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its ${purpose}`);
}
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
parsed.plugin = [
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
pluginUrl,
];
return JSON.stringify(parsed);
};
@@ -1,5 +1,5 @@
import { parse as parseJsonc } from 'jsonc-parser';
import { pathToFileURL } from 'node:url';
import { appendManagedPlugin } from '../opencode/managed-plugin-config.js';
const PROVIDER_PROMPT_BOUNDARY = 'You are powered by the model named';
const MINIMAL_IDENTITY = 'You are OpenCode, a coding agent.';
@@ -33,25 +33,6 @@ export const OpenChamberSystemPromptPlugin = async () => ({
})
`;
const mergePluginConfig = (rawConfig, pluginUrl) => {
const errors = [];
const parsed = typeof rawConfig === 'string' && rawConfig.trim()
? parseJsonc(rawConfig, errors, { allowTrailingComma: true })
: {};
if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its system prompt optimizer');
}
if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) {
throw new Error('OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its system prompt optimizer');
}
const configured = Array.isArray(parsed.plugin) ? parsed.plugin : [];
parsed.plugin = [
...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)),
pluginUrl,
];
return JSON.stringify(parsed);
};
export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => {
const pluginDirectory = path.join(dataDir, 'system-prompt');
const pluginPath = path.join(pluginDirectory, 'openchamber-system-prompt-plugin.js');
@@ -60,7 +41,7 @@ export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => {
await fsPromises.mkdir(pluginDirectory, { recursive: true });
await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 });
return {
OPENCODE_CONFIG_CONTENT: mergePluginConfig(rawConfig, pathToFileURL(pluginPath).href),
OPENCODE_CONFIG_CONTENT: appendManagedPlugin(rawConfig, pathToFileURL(pluginPath).href, 'system prompt optimizer'),
};
};