Skip to content

Commit 862cd7c

Browse files
committed
fix: bound sidecar control-channel fetches with timeouts
controlRequest had no abort signal: a stale port (something accepting but never responding, e.g. a hung opencode holding the port after the bridge closed) would hang Cursor's MCP discovery on tools/list, and tools/call could block forever. tools/list gets 5s (loopback list is instant; discovery degrades to an empty tool list on timeout) and tools/call gets 5 minutes (plugin tools can legitimately run long). New test spawns the sidecar against a never-responding server and asserts tools/list answers [] fast.
1 parent 8505258 commit 862cd7c

2 files changed

Lines changed: 59 additions & 3 deletions

File tree

src/sidecar/plugin-tools-mcp.mjs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,18 @@ function logErr(message, extra) {
3636
}
3737
}
3838

39-
async function controlRequest(path, body) {
39+
async function controlRequest(path, body, timeoutMs) {
4040
const res = await fetch(`http://127.0.0.1:${CONTROL_PORT}${path}`, {
4141
method: body ? "POST" : "GET",
4242
headers: {
4343
"content-type": "application/json",
4444
authorization: `Bearer ${TOKEN}`,
4545
},
4646
body: body ? JSON.stringify(body) : undefined,
47+
// A stale/hung port must not hang Cursor's MCP discovery (tools/list) or
48+
// block a tool call forever. Loopback list is instant; calls get a
49+
// generous ceiling because plugin tools can legitimately run for minutes.
50+
signal: AbortSignal.timeout(timeoutMs),
4751
});
4852
const text = await res.text();
4953
let json;
@@ -61,7 +65,7 @@ async function controlRequest(path, body) {
6165

6266
async function listTools() {
6367
try {
64-
const data = await controlRequest("/tools");
68+
const data = await controlRequest("/tools", undefined, 5_000);
6569
return data?.tools ?? [];
6670
} catch (err) {
6771
logErr("tools/list failed", { error: String(err) });
@@ -71,7 +75,7 @@ async function listTools() {
7175

7276
async function callTool(name, args) {
7377
try {
74-
const data = await controlRequest("/call", { id: name, args: args ?? {} });
78+
const data = await controlRequest("/call", { id: name, args: args ?? {} }, 300_000);
7579
if (data?.ok === false) {
7680
return {
7781
isError: true,

test/plugin-tools-bridge.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,58 @@ describe("plugin-tools bridge", () => {
442442
await bridge.close();
443443
}
444444
});
445+
446+
it("tools/list fails fast against a hung control port", { timeout: 20_000 }, async () => {
447+
// A control port that accepts connections but never responds (stale
448+
// server) must not hang Cursor's MCP discovery — listTools aborts after
449+
// its 5s budget and degrades to an empty tool list.
450+
const { createServer } = await import("node:http");
451+
const hung = createServer(() => {
452+
// never respond
453+
});
454+
await new Promise<void>((resolve) =>
455+
hung.listen(0, "127.0.0.1", () => resolve()),
456+
);
457+
const address = hung.address();
458+
const port = typeof address === "object" && address ? address.port : 0;
459+
try {
460+
const child = spawn(
461+
process.execPath,
462+
["src/sidecar/plugin-tools-mcp.mjs"],
463+
{
464+
env: {
465+
...process.env,
466+
OPENCODE_PLUGIN_TOOLS_PORT: String(port),
467+
OPENCODE_PLUGIN_TOOLS_TOKEN: "t",
468+
},
469+
stdio: ["pipe", "pipe", "pipe"],
470+
},
471+
);
472+
const started = Date.now();
473+
const reply = await new Promise<string>((resolve, reject) => {
474+
let buf = "";
475+
child.stdout!.on("data", (chunk: Buffer) => {
476+
buf += chunk.toString();
477+
const line = buf.split("\n").find((l) => l.trim());
478+
if (line) resolve(line);
479+
});
480+
child.once("error", reject);
481+
child.once("exit", () => reject(new Error("child exited")));
482+
child.stdin!.write(
483+
JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }) + "\n",
484+
);
485+
});
486+
const elapsed = Date.now() - started;
487+
const parsed = JSON.parse(reply) as { result?: { tools?: unknown[] } };
488+
expect(parsed.result?.tools).toEqual([]);
489+
// 5s budget + slack — anything under 10s proves we did not hang.
490+
expect(elapsed).toBeLessThan(10_000);
491+
child.kill();
492+
} finally {
493+
await new Promise<void>((resolve) => hung.close(() => resolve()));
494+
hung.closeAllConnections?.();
495+
}
496+
});
445497
});
446498

447499
// --- full plugin wiring: config hook merges the bridge into mcpServers ---

0 commit comments

Comments
 (0)