fix(mcp): force-close local client subprocesses

This commit is contained in:
Dax Raad 2026-03-03 19:31:14 -05:00
commit 159164ae8e

View file

@ -27,6 +27,7 @@ import open from "open"
export namespace MCP { export namespace MCP {
const log = Log.create({ service: "mcp" }) const log = Log.create({ service: "mcp" })
const DEFAULT_TIMEOUT = 30_000 const DEFAULT_TIMEOUT = 30_000
const CLOSE_TIMEOUT = 5_000
export const Resource = z export const Resource = z
.object({ .object({
@ -182,6 +183,52 @@ export namespace MCP {
return pids return pids
} }
function transportPID(client: MCPClient) {
const pid = (client.transport as any)?.pid
if (typeof pid === "number") return pid
return undefined
}
function signal(pid: number, kind: NodeJS.Signals) {
try {
process.kill(pid, kind)
return true
} catch {
return false
}
}
async function signalDescendants(pid: number, kind: NodeJS.Signals) {
const pids = await descendants(pid)
pids.forEach((child) => {
signal(child, kind)
})
}
async function closeClient(client: MCPClient, key: string, inputPID?: number) {
const pid = inputPID ?? transportPID(client)
if (pid !== undefined) {
await signalDescendants(pid, "SIGTERM")
}
const closed = await withTimeout(client.close(), CLOSE_TIMEOUT)
.then(() => true)
.catch((error) => {
log.error("Failed to close MCP client", { key, error })
return false
})
if (closed || pid === undefined) return
signal(pid, "SIGTERM")
await signalDescendants(pid, "SIGTERM")
if (process.platform === "win32") return
await Bun.sleep(200)
signal(pid, "SIGKILL")
await signalDescendants(pid, "SIGKILL")
}
const state = Instance.state( const state = Instance.state(
async () => { async () => {
const cfg = await Config.get() const cfg = await Config.get()
@ -218,30 +265,7 @@ export namespace MCP {
} }
}, },
async (state) => { async (state) => {
// The MCP SDK only signals the direct child process on close. await Promise.all(Object.entries(state.clients).map(([key, client]) => closeClient(client, key)))
// Servers like chrome-devtools-mcp spawn grandchild processes
// (e.g. Chrome) that the SDK never reaches, leaving them orphaned.
// Kill the full descendant tree first so the server exits promptly
// and no processes are left behind.
for (const client of Object.values(state.clients)) {
const pid = (client.transport as any)?.pid
if (typeof pid !== "number") continue
for (const dpid of await descendants(pid)) {
try {
process.kill(dpid, "SIGTERM")
} catch {}
}
}
await Promise.all(
Object.values(state.clients).map((client) =>
client.close().catch((error) => {
log.error("Failed to close MCP client", {
error,
})
}),
),
)
pendingOAuthTransports.clear() pendingOAuthTransports.clear()
}, },
) )
@ -313,9 +337,7 @@ export namespace MCP {
// Close existing client if present to prevent memory leaks // Close existing client if present to prevent memory leaks
const existingClient = s.clients[name] const existingClient = s.clients[name]
if (existingClient) { if (existingClient) {
await existingClient.close().catch((error) => { await closeClient(existingClient, name)
log.error("Failed to close existing MCP client", { name, error })
})
} }
s.clients[name] = result.mcpClient s.clients[name] = result.mcpClient
s.status[name] = result.status s.status[name] = result.status
@ -461,11 +483,11 @@ export namespace MCP {
}) })
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
const client = new Client({
name: "opencode",
version: Installation.VERSION,
})
try { try {
const client = new Client({
name: "opencode",
version: Installation.VERSION,
})
await withTimeout(client.connect(transport), connectTimeout) await withTimeout(client.connect(transport), connectTimeout)
registerNotificationHandlers(client, key) registerNotificationHandlers(client, key)
mcpClient = client mcpClient = client
@ -473,6 +495,7 @@ export namespace MCP {
status: "connected", status: "connected",
} }
} catch (error) { } catch (error) {
await closeClient(client, key, (transport as any)?.pid)
log.error("local mcp startup failed", { log.error("local mcp startup failed", {
key, key,
command: mcp.command, command: mcp.command,
@ -505,11 +528,7 @@ export namespace MCP {
return undefined return undefined
}) })
if (!result) { if (!result) {
await mcpClient.close().catch((error) => { await closeClient(mcpClient, key)
log.error("Failed to close MCP client", {
error,
})
})
status = { status = {
status: "failed", status: "failed",
error: "Failed to get tools", error: "Failed to get tools",
@ -580,9 +599,7 @@ export namespace MCP {
// Close existing client if present to prevent memory leaks // Close existing client if present to prevent memory leaks
const existingClient = s.clients[name] const existingClient = s.clients[name]
if (existingClient) { if (existingClient) {
await existingClient.close().catch((error) => { await closeClient(existingClient, name)
log.error("Failed to close existing MCP client", { name, error })
})
} }
s.clients[name] = result.mcpClient s.clients[name] = result.mcpClient
} }
@ -592,9 +609,7 @@ export namespace MCP {
const s = await state() const s = await state()
const client = s.clients[name] const client = s.clients[name]
if (client) { if (client) {
await client.close().catch((error) => { await closeClient(client, name)
log.error("Failed to close MCP client", { name, error })
})
delete s.clients[name] delete s.clients[name]
} }
s.status[name] = { status: "disabled" } s.status[name] = { status: "disabled" }