fix(mcp): force-close local client subprocesses
This commit is contained in:
parent
570038ac3c
commit
159164ae8e
1 changed files with 57 additions and 42 deletions
|
|
@ -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" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue