Skip to content

Commit d9d516a

Browse files
Merge pull request #528 from corbitsdev/cl-6895-hung-mcp-tool-calls-stall-turns-forever-mcp-calls-need-their
Bound MCP tool calls with their own watchdog timeout
2 parents 04b767b + 4349392 commit d9d516a

7 files changed

Lines changed: 212 additions & 13 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,6 +378,8 @@ tool call
378378

379379
**Tool wall-clock budget vs. permission prompts.** Each tool `run()` is wrapped by an outer execution watchdog (`src/tui/tool-execution-watchdog.ts`). The watchdog arms only when Settings set `tools.timeoutMs` / `tools.maxTimeoutMs`, or when `run_shell` passes a positive timeout (requested plus slack, so this layer cannot beat shell-guard). The `task` tool is always exempt, regardless of Settings — a sub-agent run is bounded by its own limits (maxTurns, no-progress, thrash, opt-in deadlineMs), so the generic per-tool budget never aborts a healthy long-running worker; parent cancel, maxTurns, and eval `--agent-timeout-ms` still bound the run. By default (`tools.waitForApproval`, Settings → Tools, **On**), an armed budget freezes while the operator is deciding on a permission prompt, so a late approve still runs the tool and the agent waits for the decision instead of timing out under the modal. When **Off**, the budget keeps ticking during the prompt; if it expires first the tool is skipped and the permission modal is dismissed via the budget AbortSignal (auto-deny with a timeout message). The TUI permission queue (`src/tui/gate-wire.ts`, backed by `src/permission/queue.ts`) attaches that signal so ghost prompts cannot outlive an already-aborted tool.
380380

381+
`mcp__*` tool calls are the exception to "arms only when Settings set it": they arm unconditionally with a 5-minute default (`DEFAULT_MCP_TOOL_TIMEOUT_MS`), overridable via `mcp.timeoutMs` and still capped by `tools.maxTimeoutMs` (CL-6895). Nothing else bounds an MCP call — the stall watchdog treats an in-flight tool as activity by design, so a wedged MCP server previously hung a tool call, and the turn, forever. On expiry the call returns a normal tool-error result ("MCP tool `<name>` timed out after `<n>`s — the server may be wedged; retry or continue without it"); the turn is never aborted. The MCP client itself (`src/mcp/client.ts`, wrapping `@modelcontextprotocol/sdk`) multiplexes concurrent requests over one connection by JSON-RPC message id with no serial queue or mutex in our code or in the vendored SDK's `Protocol.request()` — so concurrent calls to the same server are not expected to deadlock each other. Live forensics for CL-6895 showed multi-minute MCP calls that eventually completed successfully, consistent with a slow server response rather than a client-side deadlock.
382+
381383
Approval scopes offered: Allow Once (persist nothing), Allow Always for a file or its directory (file tools), or a command shape (shell). There is intentionally no "all files" rung. Project-scoped Allow Always grants are confined to the session that minted them: they match the session root and its registered git worktrees (`cwdMatchesGrant` in `src/permission/authz-grants.ts` via `createWorktreeRootsProvider`), not bare process-cwd equality — so a grant at the repo root still covers a sub-agent running in a sibling worktree of the same project.
382384

383385
A queued gate's display-dependent timers (auto-deny timeout, tool-budget pause ceiling) arm when the request is actually shown to the operator, not when it is received — a request sitting behind others in the queue does not burn its timeout invisibly. `ask_operator` has the same abort/timeout safety net as the permission gate, so a queued operator question behind a stuck overlay cannot hang a run. Both live in `src/tui/gate-wire.ts`'s `onPermission`/`onOperator`.

‎docs/IMPLEMENTATION.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,16 @@ Provider and model configuration lives in JSON settings files. The global file h
233233
- `timeoutMs` / `maxTimeoutMs` — outer execution watchdog around each tool `run()`. Unset leaves the watchdog unarmed; set these to arm it. `maxTimeoutMs` clamps non-shell tools when set and does not cap a longer requested `run_shell`. The `task` tool is always exempt: a dispatched sub-agent is bounded by its own limits (maxTurns, no-progress, thrash, opt-in `deadlineMs`), not the generic per-tool budget.
234234
- `waitForApproval` (default **true** when unset) — freeze that budget while a permission prompt is open so a late approve still runs the tool. **Settings → Tools** toggles this live for the next tool call and persists it here. When **false**, the budget keeps ticking during the prompt; on expiry the tool is skipped and the modal is auto-dismissed. The freeze is bounded: after **30 minutes** with the prompt still unanswered the budget resumes ticking on its own, so a prompt that never becomes visible (overlay open, UI gone) cannot hang a tool run indefinitely.
235235

236+
Optional `mcp` block bounds MCP tool calls (`mcp__*` names) specifically — unlike `tools.*`, this arms **unconditionally** even with no settings at all, defaulting to **5 minutes**, since a wedged MCP server otherwise hangs a call forever with nothing to bound it (CL-6895):
237+
238+
```json
239+
"mcp": {
240+
"timeoutMs": 300000
241+
}
242+
```
243+
244+
On expiry the call returns a normal tool-error result ("MCP tool `<name>` timed out after `<n>`s — the server may be wedged; retry or continue without it") that the model can react to; the turn itself is never aborted. `tools.maxTimeoutMs`, if set, still caps `mcp.timeoutMs`.
245+
236246
Optional `subagentMaxTurns` (integer **1–100**, default **30**) sets the default inference-turn budget for dispatched workers (not the parent chat session limit). Per-dispatch `task(maxTurns)` and agent profile `maxTurns` override this default; values above **100** are rejected on `task` and clamped for profiles. Always applies — the primary session is always orchestrator-capable (CL-5814).
237247

238248
Optional `sessionMode` is **deprecated**. Legacy values (`single` | `orchestrator`) may still appear on disk and load without error; resolve always returns **orchestrator**. There is no first-run mode picker and no Settings row. Both the interactive TUI (`runTUI`) and the non-TUI product path (`runExec` / `corbits exec`) are orchestrator-only. Exec bootstrap is otherwise a forked copy of the TUI path (shared stack, intentional deltas documented under Architecture → Exec Runner).
@@ -253,6 +263,7 @@ All `tools.*` keys live in the global settings file only — there is no per-rep
253263
| `tools.timeoutMs` | unset (watchdog unarmed) | Outer wall-clock budget per tool `run()` when set |
254264
| `tools.maxTimeoutMs` | unset | Cap on the outer budget when set; does not cap a longer requested `run_shell` |
255265
| `tools.waitForApproval` | `true` | Freeze the budget while a permission prompt is open (freeze capped at 30 min); `false` keeps the clock ticking and auto-dismisses the prompt on expiry |
266+
| `mcp.timeoutMs` | **300000** (5 min) — armed even when unset | Outer wall-clock budget for `mcp__*` tool calls specifically; capped by `tools.maxTimeoutMs` when set |
256267

257268
The `waitForApproval` default is resolved once at the watchdog boundary (`resolveWaitForApproval`); toggling **Settings → Tools** updates the live config for the next tool call and persists the value here.
258269

‎src/config/settings.ts‎

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ export type Settings = {
135135
// the budget keeps ticking during the prompt; if it expires first the tool is
136136
// skipped and the prompt is dismissed.
137137
tools?: { timeoutMs?: number; maxTimeoutMs?: number; waitForApproval?: boolean };
138+
// Wall-clock budget for MCP tool calls specifically (mcp__* names). Unlike
139+
// the generic `tools` budget, this one is armed by default (see
140+
// DEFAULT_MCP_TOOL_TIMEOUT_MS) since a wedged MCP server otherwise hangs a
141+
// tool call forever with nothing to bound it.
142+
mcp?: { timeoutMs?: number };
138143
// Anonymous PostHog telemetry. Global only — never written to per-repo
139144
// local settings. `enabled` defaults to true (opt-out); `installationId`
140145
// is a random UUID generated once on first use; `noticeShown` stamps that
@@ -244,20 +249,23 @@ export function shellTimeoutFromSettings(
244249
};
245250
}
246251

247-
// Maps the settings tools block to the shape the tool-execution watchdog expects.
248-
// Returns undefined when nothing is configured so callers can skip the override.
252+
// Maps the settings tools/mcp blocks to the shape the tool-execution watchdog
253+
// expects. Returns undefined only when nothing at all is configured so callers
254+
// can skip the override; mcp.timeoutMs alone (with no tools.* set) still
255+
// produces a config, since MCP timeouts are armed unconditionally.
249256
export function toolWatchdogFromSettings(
250257
settings?: Settings | null,
251-
): { defaultMs?: number; maxMs?: number; waitForApproval?: boolean } | undefined {
258+
): { defaultMs?: number; maxMs?: number; waitForApproval?: boolean; mcpTimeoutMs?: number } | undefined {
252259
const tools = settings?.tools;
253-
if (tools === undefined) return undefined;
254-
const hasTimeout = tools.timeoutMs !== undefined || tools.maxTimeoutMs !== undefined;
255-
const hasWait = tools.waitForApproval !== undefined;
256-
if (!hasTimeout && !hasWait) return undefined;
260+
const mcpTimeoutMs = settings?.mcp?.timeoutMs;
261+
const hasTimeout = tools?.timeoutMs !== undefined || tools?.maxTimeoutMs !== undefined;
262+
const hasWait = tools?.waitForApproval !== undefined;
263+
if (!hasTimeout && !hasWait && mcpTimeoutMs === undefined) return undefined;
257264
return {
258-
...(tools.timeoutMs !== undefined ? { defaultMs: tools.timeoutMs } : {}),
259-
...(tools.maxTimeoutMs !== undefined ? { maxMs: tools.maxTimeoutMs } : {}),
260-
...(tools.waitForApproval !== undefined ? { waitForApproval: tools.waitForApproval } : {}),
265+
...(tools?.timeoutMs !== undefined ? { defaultMs: tools.timeoutMs } : {}),
266+
...(tools?.maxTimeoutMs !== undefined ? { maxMs: tools.maxTimeoutMs } : {}),
267+
...(tools?.waitForApproval !== undefined ? { waitForApproval: tools.waitForApproval } : {}),
268+
...(mcpTimeoutMs !== undefined ? { mcpTimeoutMs } : {}),
261269
};
262270
}
263271

@@ -464,6 +472,9 @@ const SettingsSchema = type({
464472
"maxTimeoutMs?": "number",
465473
"waitForApproval?": "boolean",
466474
}),
475+
"mcp?": type({
476+
"timeoutMs?": "number",
477+
}),
467478
"telemetry?": type({
468479
"enabled?": "boolean",
469480
"installationId?": "string",
@@ -771,6 +782,7 @@ export async function loadSettings(path: string): Promise<Settings | null> {
771782
: undefined,
772783
shell: s.shell as Settings["shell"] | undefined,
773784
tools: s.tools as Settings["tools"] | undefined,
785+
mcp: s.mcp as Settings["mcp"] | undefined,
774786
telemetry: s.telemetry as Settings["telemetry"] | undefined,
775787
otel: s.otel as Settings["otel"] | undefined,
776788
recentModels: s.recentModels as Settings["recentModels"] | undefined,

‎src/plugins/tool-time-budget.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,14 @@ export function formatToolExecutionTimeoutMessage(
3939
return `${trimmed}\n\n${notice}`;
4040
}
4141

42+
export function formatMcpToolTimeoutMessage(toolName: string, timeoutMs: number): string {
43+
const seconds = Math.round(timeoutMs / 1000);
44+
return (
45+
`MCP tool ${toolName} timed out after ${seconds}s — the server may be wedged; ` +
46+
`retry or continue without it.`
47+
);
48+
}
49+
4250
export function formatReadFileTimeoutMessage(
4351
path: string,
4452
partialResult?: string,

‎src/settings.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,22 @@ describe("loaders", () => {
738738
});
739739
expect(toolWatchdogFromSettings({ providers: {} })).toBeUndefined();
740740
});
741+
742+
test("toolWatchdogFromSettings maps mcp.timeoutMs alone (no tools.* set)", () => {
743+
expect(toolWatchdogFromSettings({ providers: {}, mcp: { timeoutMs: 45_000 } })).toEqual({
744+
mcpTimeoutMs: 45_000,
745+
});
746+
});
747+
748+
test("toolWatchdogFromSettings merges mcp.timeoutMs alongside tools.*", () => {
749+
expect(
750+
toolWatchdogFromSettings({
751+
providers: {},
752+
tools: { timeoutMs: 120_000, maxTimeoutMs: 600_000 },
753+
mcp: { timeoutMs: 45_000 },
754+
}),
755+
).toEqual({ defaultMs: 120_000, maxMs: 600_000, mcpTimeoutMs: 45_000 });
756+
});
741757
});
742758

743759
describe("persistSkipPermissionsDefault", () => {

‎src/tui/tool-execution-watchdog.test.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test";
22
import type { AgentTool } from "@intx/agent";
33
import { createDynamicToolRunner } from "./dynamic-tool-runner.js";
44
import {
5+
DEFAULT_MCP_TOOL_TIMEOUT_MS,
56
MAX_TOOL_EXECUTION_TIMEOUT_MS,
67
RUN_SHELL_WATCHDOG_SLACK_MS,
78
getToolApprovalBudget,
@@ -122,6 +123,44 @@ describe("tool execution watchdog", () => {
122123
expect(resolveToolExecutionTimeoutMs({ defaultMs: 9_999_999, maxMs: 100 }, call)).toBe(100);
123124
});
124125

126+
test("mcp tool calls are bounded by default even with no config (CL-6895)", () => {
127+
const call = { id: "1", name: "mcp__linear__get_issue", arguments: {} };
128+
expect(resolveToolExecutionTimeoutMs(undefined, call)).toBe(DEFAULT_MCP_TOOL_TIMEOUT_MS);
129+
expect(resolveToolExecutionTimeoutMs({}, call)).toBe(DEFAULT_MCP_TOOL_TIMEOUT_MS);
130+
});
131+
132+
test("mcp.timeoutMs overrides the mcp default", () => {
133+
const call = { id: "1", name: "mcp__linear__get_issue", arguments: {} };
134+
expect(resolveToolExecutionTimeoutMs({ mcpTimeoutMs: 45_000 }, call)).toBe(45_000);
135+
});
136+
137+
test("tools.defaultMs alone (no mcpTimeoutMs) does not affect the mcp default", () => {
138+
const call = { id: "1", name: "mcp__linear__get_issue", arguments: {} };
139+
expect(resolveToolExecutionTimeoutMs({ defaultMs: 5_000 }, call)).toBe(
140+
DEFAULT_MCP_TOOL_TIMEOUT_MS,
141+
);
142+
});
143+
144+
test("tools.maxTimeoutMs still caps a longer mcp.timeoutMs override", () => {
145+
const call = { id: "1", name: "mcp__linear__get_issue", arguments: {} };
146+
expect(
147+
resolveToolExecutionTimeoutMs({ mcpTimeoutMs: 9_999_999, maxMs: 100 }, call),
148+
).toBe(100);
149+
});
150+
151+
test("non-positive or non-finite mcp.timeoutMs falls back to the default instead of a 1ms timeout", () => {
152+
const call = { id: "1", name: "mcp__linear__get_issue", arguments: {} };
153+
expect(resolveToolExecutionTimeoutMs({ mcpTimeoutMs: 0 }, call)).toBe(
154+
DEFAULT_MCP_TOOL_TIMEOUT_MS,
155+
);
156+
expect(resolveToolExecutionTimeoutMs({ mcpTimeoutMs: -5 }, call)).toBe(
157+
DEFAULT_MCP_TOOL_TIMEOUT_MS,
158+
);
159+
expect(resolveToolExecutionTimeoutMs({ mcpTimeoutMs: NaN }, call)).toBe(
160+
DEFAULT_MCP_TOOL_TIMEOUT_MS,
161+
);
162+
});
163+
125164
test("withTimeout dispose clears timer without leaving hung state", async () => {
126165
const parent = new AbortController();
127166
const budget = withTimeout(parent.signal, 50);
@@ -158,6 +197,72 @@ describe("tool execution watchdog", () => {
158197
expect(result.content).toBe(formatToolExecutionTimeoutMessage("slow", 30));
159198
});
160199

200+
test(
201+
"mcp tool whose promise never resolves times out with a model-reactable error, turn continues",
202+
async () => {
203+
const runner = createDynamicToolRunner(
204+
[
205+
stringTool(
206+
"mcp__linear__get_issue",
207+
() => new Promise<string>(() => {}), // never resolves — wedged server
208+
),
209+
],
210+
{ mcpTimeoutMs: 30 },
211+
);
212+
const result = await runner.run(
213+
{ id: "1", name: "mcp__linear__get_issue", arguments: {} },
214+
new AbortController().signal,
215+
);
216+
expect(result.isError).toBe(true);
217+
expect(result.content).toContain("mcp__linear__get_issue timed out after 0s");
218+
expect(result.content).toContain("the server may be wedged");
219+
},
220+
10_000,
221+
);
222+
223+
test(
224+
"concurrent mcp tool calls each time out independently",
225+
async () => {
226+
const runner = createDynamicToolRunner(
227+
[
228+
stringTool("mcp__linear__get_issue", () => new Promise<string>(() => {})),
229+
stringTool("mcp__linear__list_issues", async () => "ok"),
230+
],
231+
{ mcpTimeoutMs: 30 },
232+
);
233+
const signal = new AbortController().signal;
234+
const [hung1, hung2, fast] = await Promise.all([
235+
runner.run({ id: "1", name: "mcp__linear__get_issue", arguments: {} }, signal),
236+
runner.run({ id: "2", name: "mcp__linear__get_issue", arguments: {} }, signal),
237+
runner.run({ id: "3", name: "mcp__linear__list_issues", arguments: {} }, signal),
238+
]);
239+
expect(hung1.isError).toBe(true);
240+
expect(hung1.content).toContain("mcp__linear__get_issue timed out");
241+
expect(hung2.isError).toBe(true);
242+
expect(hung2.content).toContain("mcp__linear__get_issue timed out");
243+
expect(fast.content).toBe("ok");
244+
expect(fast.isError).toBeUndefined();
245+
},
246+
10_000,
247+
);
248+
249+
test(
250+
"mcp.timeoutMs: 0 does not instantly time out an mcp tool call (falls back to the default)",
251+
async () => {
252+
const runner = createDynamicToolRunner(
253+
[stringTool("mcp__linear__get_issue", async () => "ok")],
254+
{ mcpTimeoutMs: 0 },
255+
);
256+
const result = await runner.run(
257+
{ id: "1", name: "mcp__linear__get_issue", arguments: {} },
258+
new AbortController().signal,
259+
);
260+
expect(result.isError).toBeUndefined();
261+
expect(result.content).toBe("ok");
262+
},
263+
10_000,
264+
);
265+
161266
test("parent cancel prefers execute salvage body over synthetic aborted", async () => {
162267
const parent = new AbortController();
163268
const salvage = {

0 commit comments

Comments
 (0)