diff --git a/.changeset/plan-call-request-timeout.md b/.changeset/plan-call-request-timeout.md new file mode 100644 index 0000000..4135000 --- /dev/null +++ b/.changeset/plan-call-request-timeout.md @@ -0,0 +1,6 @@ +--- +"@call-e/cli": patch +"@call-e/core": patch +--- + +Give `plan_call` its own request timeout ceiling so `calle call plan` does not fail under the shared 15 second default. `callMcpTool` accepts a per-call `timeoutSeconds` that covers the `tools/call` request only. The session handshake keeps the shared ceiling. An explicit `--timeout-seconds` still applies to every request, planning included. The timeout message now names the ceiling that ran out. A per-call value that cannot arm a timer, because it is not finite, not above zero or past Node's timer maximum, falls back to the shared session ceiling instead of aborting the request immediately. diff --git a/docs/install/troubleshooting.md b/docs/install/troubleshooting.md index 9508490..c3709d2 100644 --- a/docs/install/troubleshooting.md +++ b/docs/install/troubleshooting.md @@ -68,3 +68,55 @@ get_call_run If the same URL works from the user's terminal but fails only inside the Cursor agent shell, the issue is the Cursor sandbox policy rather than CALL-E service availability. + +## `calle call plan` fails with `MCP request timed out for tools/call` + +### Symptoms + +Planning fails while the rest of the CLI works: + +- `calle call plan` exits with: + + ```text + MCP request timed out for tools/call after 15s + ``` + +- `calle auth status` and `calle mcp tools` succeed on the same machine. +- The same plan completes when it is rerun with a longer `--timeout-seconds`. + +### Cause + +`plan_call` runs for about as long as the shared request ceiling allows, so a +normal plan can finish just after the ceiling every other request uses. This is a +client-side timeout rather than a server rejection, so the request may have been +accepted even though the CLI stopped waiting for it. + +Planning carries its own default ceiling of 120 seconds, so the shared 15 second +default no longer applies to it. An explicit `--timeout-seconds` is the ceiling +for every request, planning included, so a value shorter than planning needs +still times out. + +### Fix + +Run the plan without the flag to get the planning default: + +```bash +calle call plan --to-phone +15551234567 --goal "Confirm the appointment" +``` + +Raise the ceiling explicitly when planning needs longer than that: + +```bash +calle call plan --to-phone +15551234567 --goal "Confirm the appointment" --timeout-seconds 180 +``` + +### Verify + +The message names the ceiling that ran out, so it reports which value was in +effect: + +```text +MCP request timed out for tools/call after 15s +``` + +A plan that completes returns its payload on stdout instead. diff --git a/packages/cli/docs/cli-reference.md b/packages/cli/docs/cli-reference.md index b425168..06824c0 100644 --- a/packages/cli/docs/cli-reference.md +++ b/packages/cli/docs/cli-reference.md @@ -44,7 +44,7 @@ their network requests or output. | `--scope` | Text | `openid email profile` | Auth commands | No | No | OAuth scopes requested during brokered login. | `calle auth login --scope "openid email profile"` | | `--cache-root` | Path | `~/.calle-mcp/cli` | All commands | No | No | Directory for token, pending login, and telemetry cache files. `~` is expanded. | `calle auth status --cache-root ~/.calle-mcp/cli` | | `--min-ttl-seconds` | Number | `300` | Auth login/status, MCP and call token checks | No | No | Minimum remaining token lifetime for a cached token to count as usable. | `calle auth status --min-ttl-seconds 60` | -| `--timeout-seconds` | Number | `15` | Auth, MCP, and call network requests | No | No | Request timeout in seconds. | `calle mcp tools --timeout-seconds 30` | +| `--timeout-seconds` | Number | `15`, `120` for `plan_call` | Auth, MCP, and call network requests | No | No | Request timeout in seconds. Planning carries a longer default because `plan_call` runs for about as long as the shared ceiling allows. An explicit value is the ceiling for every request, planning included. | `calle mcp tools --timeout-seconds 30` | | `--poll-timeout-seconds` | Number | `300` | `auth login` | No | No | Maximum time to poll for brokered login completion. | `calle auth login --poll-timeout-seconds 600` | | `--server-name` | Text | `calle` | `mcp config` | No | No | MCP server key used in the generated client configuration. | `calle mcp config --server-name calle` | | `--json` | Boolean | `false` | All commands | No | No | Accepted for compatibility. Successful command stdout is already JSON except help. | `calle auth status --json` | diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index 753b9bf..acda73a 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -8,7 +8,7 @@ import { tokenCachePath, tokenIsUsable, } from "./cache.js"; -import { DEFAULT_BASE_URL, DEFAULT_CHANNEL, DEFAULT_CLIENT_NAME, DEFAULT_SCOPE, resolveRuntimeConfig } from "./config.js"; +import { DEFAULT_BASE_URL, DEFAULT_CHANNEL, DEFAULT_CLIENT_NAME, DEFAULT_PLAN_TIMEOUT_SECONDS, DEFAULT_SCOPE, DEFAULT_TIMEOUT_SECONDS, resolveRuntimeConfig } from "./config.js"; import { ensurePendingLogin, loginWithBroker } from "./broker-client.js"; import { AuthRequiredError, @@ -74,7 +74,7 @@ Common options: --client-name Default: ${DEFAULT_CLIENT_NAME} --scope Default: ${DEFAULT_SCOPE} --cache-root - --timeout-seconds + --timeout-seconds Default: ${DEFAULT_TIMEOUT_SECONDS}, ${DEFAULT_PLAN_TIMEOUT_SECONDS} for plan_call --poll-timeout-seconds --server-name Default: calle --force-login @@ -641,6 +641,14 @@ function mcpSuccessPayload({ config, toolName = null, result, method = null }) { }; } +// plan_call is the slow one. It regularly runs for about as long as the shared +// request ceiling allows, so planning gets the longer ceiling resolved in config. +// planTimeoutSeconds is the explicit --timeout-seconds whenever the flag was given, +// so an override still wins here. +function planRequestTimeoutSeconds(config) { + return config.planTimeoutSeconds ?? config.timeoutSeconds; +} + function buildPlanArguments(options) { const toPhones = optionValues(options.toPhone) .map((value) => String(value).trim()) @@ -777,12 +785,14 @@ async function handleMcpCommand({ command, positional, options, config, deps, st throw new InvalidArgumentsError("Usage: calle mcp call --args-json ''"); } const toolName = positional[0]; + const isPlanCall = toolName === "plan_call"; const toolArguments = parseJsonObject(options.argsJson, "--args-json"); const result = await callMcpTool({ config, toolName, toolArguments, - requestMeta: toolName === "plan_call" ? buildPlanRequestMeta(options, deps.env || process.env) : null, + requestMeta: isPlanCall ? buildPlanRequestMeta(options, deps.env || process.env) : null, + timeoutSeconds: isPlanCall ? planRequestTimeoutSeconds(config) : null, fetchImpl: deps.fetchImpl || globalThis.fetch, }); writeJson(stdout, mcpSuccessPayload({ config, toolName, result })); @@ -818,6 +828,7 @@ async function handleCallCommand({ command, positional, options, config, deps, s toolName, toolArguments: buildPlanArguments(options), requestMeta: buildPlanRequestMeta(options, deps.env || process.env), + timeoutSeconds: planRequestTimeoutSeconds(config), fetchImpl: deps.fetchImpl || globalThis.fetch, }); writeJson(stdout, mcpSuccessPayload({ config, toolName, result })); @@ -831,6 +842,7 @@ async function handleCallCommand({ command, positional, options, config, deps, s toolName: "plan_call", toolArguments: buildPlanArguments(options), requestMeta: buildPlanRequestMeta(options, deps.env || process.env), + timeoutSeconds: planRequestTimeoutSeconds(config), fetchImpl: deps.fetchImpl || globalThis.fetch, }); const structuredPlan = structuredPayload(planResult); diff --git a/packages/cli/lib/config.js b/packages/cli/lib/config.js index 8783739..1338c8b 100644 --- a/packages/cli/lib/config.js +++ b/packages/cli/lib/config.js @@ -9,6 +9,7 @@ import { DEFAULT_SCOPE, DEFAULT_TIMEOUT_SECONDS, INTEGRATION_HEADER, + MAX_TIMER_SECONDS, SESSION_SECRET_HEADER, } from "@call-e/core/constants"; import { @@ -38,6 +39,11 @@ export { export const DEFAULT_SERVER_NAME = "calle"; export const DEFAULT_POLL_TIMEOUT_SECONDS = 300; export const DEFAULT_TELEMETRY_TIMEOUT_SECONDS = 1.5; +// plan_call regularly runs for about as long as the shared DEFAULT_TIMEOUT_SECONDS +// ceiling allows, so the default sat exactly on the common case and planning failed +// with a timeout. Planning gets a longer ceiling of its own instead of raising the +// shared one, which would make every genuinely hung request wait two minutes. +export const DEFAULT_PLAN_TIMEOUT_SECONDS = 120; export const DEFAULT_CACHE_ROOT = path.join(os.homedir(), ".calle-mcp", "cli"); export const CLI_VERSION = "0.3.9"; @@ -45,20 +51,22 @@ function firstOptionValue(value) { return Array.isArray(value) ? value[0] : value; } -// Node turns any setTimeout delay above this into 1ms, which brings back the very -// immediate-abort failure this validator exists to prevent: --timeout-seconds 2147484 -// is 2,147,484,000ms, so the request would abort at once instead of waiting. -// https://nodejs.org/api/timers.html#settimeoutcallback-delay-args -const MAX_TIMER_DELAY_MS = 2_147_483_647; -const MAX_TIMER_SECONDS = Math.floor(MAX_TIMER_DELAY_MS / 1000); +// Absent rather than falsy: a numeric 0 is a value the user asked for on +// --min-ttl-seconds. An empty string is what an option with no value looks like. +function hasOptionValue(value) { + const provided = firstOptionValue(value); + return provided !== undefined && provided !== null && provided !== ""; +} // Zero is meaningful for --min-ttl-seconds (it disables the minimum remaining-lifetime // window) and meaningless for a timeout, so the bounds are per option rather than shared. +// MAX_TIMER_SECONDS comes from @call-e/core/constants, where the transport applies the +// same bound to its own per-call ceiling. Node turns any longer setTimeout delay into +// 1ms, which brings back the very immediate-abort failure this validator exists to +// prevent: --timeout-seconds 2147484 is 2,147,484,000ms, so the request would abort at +// once instead of waiting. The flag rejects such a value; the transport falls back. function secondsOption(value, fallback, flag, { allowZero = false, timerBacked = true } = {}) { - const provided = firstOptionValue(value); - // Not `value || fallback`: a numeric 0 is falsy, and for --min-ttl-seconds it is a - // value the user asked for, not an absent one. - const raw = provided === undefined || provided === null || provided === "" ? fallback : provided; + const raw = hasOptionValue(value) ? firstOptionValue(value) : fallback; const parsed = Number(raw); const wanted = allowZero ? "a non-negative" : "a positive"; @@ -160,6 +168,11 @@ export function resolveRuntimeConfig(options = {}, env = process.env) { const channel = options.channel || DEFAULT_CHANNEL; const serverUrl = resolveServerUrl({ serverUrl: options.serverUrl, baseUrl, channel }); const integrationContext = resolveIntegrationContext(env, CLI_VERSION); + const timeoutSeconds = secondsOption( + options.timeoutSeconds, + DEFAULT_TIMEOUT_SECONDS, + "--timeout-seconds", + ); return { cliVersion: CLI_VERSION, integrationContext, @@ -172,11 +185,12 @@ export function resolveRuntimeConfig(options = {}, env = process.env) { scope: options.scope || DEFAULT_SCOPE, clientName: options.clientName || DEFAULT_CLIENT_NAME, cacheRoot: expandHomePath(options.cacheRoot || DEFAULT_CACHE_ROOT), - timeoutSeconds: secondsOption( - options.timeoutSeconds, - DEFAULT_TIMEOUT_SECONDS, - "--timeout-seconds", - ), + timeoutSeconds, + // An explicit --timeout-seconds is the user's ceiling for every request, + // planning included. Only an absent flag gets the longer planning default. + planTimeoutSeconds: hasOptionValue(options.timeoutSeconds) + ? timeoutSeconds + : DEFAULT_PLAN_TIMEOUT_SECONDS, pollTimeoutSeconds: secondsOption( options.pollTimeoutSeconds, DEFAULT_POLL_TIMEOUT_SECONDS, diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index c7761ba..f3477b4 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -6,7 +6,12 @@ import assert from "node:assert/strict"; import { POST_AUTH_HELP_MESSAGE, preAuthHelpMessage, runCli } from "../lib/cli.js"; import { pendingCachePath, tokenCachePath, writePrivateJson } from "../lib/cache.js"; -import { CLI_VERSION, resolveRuntimeConfig } from "../lib/config.js"; +import { + CLI_VERSION, + DEFAULT_PLAN_TIMEOUT_SECONDS, + DEFAULT_TIMEOUT_SECONDS, + resolveRuntimeConfig, +} from "../lib/config.js"; const defaultIntegrationHeader = `cli/cli/${CLI_VERSION}`; @@ -1969,3 +1974,89 @@ test("resolveRuntimeConfig keeps accepting valid values and defaults", () => { defaults.timeoutSeconds, ); }); + +test("resolveRuntimeConfig gives planning its own request ceiling", () => { + // plan_call regularly runs for about as long as the shared ceiling allows, so the + // default sat exactly on the common case. Planning gets a longer one of its own + // and every other request keeps the short one. + const defaults = resolveRuntimeConfig({}, {}); + assert.equal(defaults.timeoutSeconds, DEFAULT_TIMEOUT_SECONDS); + assert.equal(defaults.planTimeoutSeconds, DEFAULT_PLAN_TIMEOUT_SECONDS); + assert.ok( + DEFAULT_PLAN_TIMEOUT_SECONDS > DEFAULT_TIMEOUT_SECONDS, + "planning needs more room than the shared ceiling, not less", + ); + + // An explicit flag is the user's ceiling for every request, in both directions. + assert.equal(resolveRuntimeConfig({ timeoutSeconds: "30" }, {}).planTimeoutSeconds, 30); + assert.equal(resolveRuntimeConfig({ timeoutSeconds: "5" }, {}).planTimeoutSeconds, 5); + + // Absent stays absent, the same way it does for timeoutSeconds itself. + assert.equal( + resolveRuntimeConfig({ timeoutSeconds: "" }, {}).planTimeoutSeconds, + DEFAULT_PLAN_TIMEOUT_SECONDS, + ); + assert.equal( + resolveRuntimeConfig({ timeoutSeconds: undefined }, {}).planTimeoutSeconds, + DEFAULT_PLAN_TIMEOUT_SECONDS, + ); +}); + +test("plan_call requests use the planning ceiling and other calls keep the short one", async () => { + const cacheRoot = makeTempRoot("calle-cli-plan-timeout"); + const serverUrl = "https://mcp.example/mcp/openagent_oauth"; + writeToken(cacheRoot, serverUrl); + + // The transport builds the timeout message from the same value it arms the + // AbortController with, so the reported ceiling is the effective one. + const fetchAbortingOnToolCall = async (_url, init) => { + const payload = JSON.parse(init.body); + if (payload.method === "initialize") { + return jsonRpcResponse( + { jsonrpc: "2.0", id: payload.id, result: {} }, + { headers: { "mcp-session-id": "sess-plan-timeout" } }, + ); + } + if (payload.method === "notifications/initialized") { + return jsonRpcResponse({}); + } + if (payload.method === "tools/call") { + const abortError = new Error("aborted"); + abortError.name = "AbortError"; + throw abortError; + } + throw new Error(`unexpected MCP method: ${payload.method}`); + }; + + async function timeoutMessageFor(argv) { + const result = await run([...argv, "--base-url", "https://mcp.example", "--cache-root", cacheRoot], { + fetchImpl: fetchAbortingOnToolCall, + }); + assert.equal(result.code, 1); + return JSON.parse(result.stdout).error.message; + } + + const planArgs = ["--to-phone", "+15551234567", "--goal", "Confirm appointment"]; + const planCeiling = `MCP request timed out for tools/call after ${DEFAULT_PLAN_TIMEOUT_SECONDS}s`; + const sharedCeiling = `MCP request timed out for tools/call after ${DEFAULT_TIMEOUT_SECONDS}s`; + + assert.equal(await timeoutMessageFor(["call", "plan", ...planArgs]), planCeiling); + assert.equal(await timeoutMessageFor(["call", "start", ...planArgs]), planCeiling); + assert.equal( + await timeoutMessageFor(["mcp", "call", "plan_call", "--args-json", '{"user_input":"Call Alex"}']), + planCeiling, + ); + + // An explicit --timeout-seconds still wins for planning. + assert.equal( + await timeoutMessageFor(["call", "plan", ...planArgs, "--timeout-seconds", "30"]), + "MCP request timed out for tools/call after 30s", + ); + + // Only planning is slow, so the other tool calls are unchanged. + assert.equal(await timeoutMessageFor(["call", "status", "--run-id", "run_123"]), sharedCeiling); + assert.equal( + await timeoutMessageFor(["mcp", "call", "get_call_run", "--args-json", '{"run_id":"run_123"}']), + sharedCeiling, + ); +}); diff --git a/packages/core/lib/constants.d.ts b/packages/core/lib/constants.d.ts index 2b42108..86eec42 100644 --- a/packages/core/lib/constants.d.ts +++ b/packages/core/lib/constants.d.ts @@ -7,5 +7,7 @@ export const DEFAULT_MIN_TTL_SECONDS: 300; export const DEFAULT_MCP_CLIENT_NAME: "calle"; export const DEFAULT_MCP_CLIENT_VERSION: "unknown"; export const MCP_PROTOCOL_VERSION: "2025-11-25"; +export const MAX_TIMER_DELAY_MS: 2147483647; +export const MAX_TIMER_SECONDS: 2147483; export const SESSION_SECRET_HEADER: "X-OpenAgent-Session-Secret"; export const INTEGRATION_HEADER: "X-Call-E-Integration"; diff --git a/packages/core/lib/constants.js b/packages/core/lib/constants.js index f2dfd2e..841b28b 100644 --- a/packages/core/lib/constants.js +++ b/packages/core/lib/constants.js @@ -7,5 +7,12 @@ export const DEFAULT_MIN_TTL_SECONDS = 300; export const DEFAULT_MCP_CLIENT_NAME = "calle"; export const DEFAULT_MCP_CLIENT_VERSION = "unknown"; export const MCP_PROTOCOL_VERSION = "2025-11-25"; +// Node keeps a setTimeout delay in a signed 32 bit int and turns anything it cannot +// store into a 1ms delay, so a request armed with a longer ceiling aborts at once +// instead of waiting. Every timer-backed duration in this repo is bounded by this, +// which is why the value is shared rather than declared per package. +// https://nodejs.org/api/timers.html#settimeoutcallback-delay-args +export const MAX_TIMER_DELAY_MS = 2_147_483_647; +export const MAX_TIMER_SECONDS = Math.floor(MAX_TIMER_DELAY_MS / 1000); export const SESSION_SECRET_HEADER = "X-OpenAgent-Session-Secret"; export const INTEGRATION_HEADER = "X-Call-E-Integration"; diff --git a/packages/core/lib/mcp-client.d.ts b/packages/core/lib/mcp-client.d.ts index dc8eac3..9997efd 100644 --- a/packages/core/lib/mcp-client.d.ts +++ b/packages/core/lib/mcp-client.d.ts @@ -38,6 +38,8 @@ export interface CallMcpToolOptions extends McpRequestOptions { toolName: string; toolArguments?: JsonObject; requestMeta?: JsonObject | null; + /** Ceiling for the tools/call request only. Defaults to config.timeoutSeconds. */ + timeoutSeconds?: number | null; } export class AuthRequiredError extends Error { diff --git a/packages/core/lib/mcp-client.js b/packages/core/lib/mcp-client.js index 6620bb9..25bd688 100644 --- a/packages/core/lib/mcp-client.js +++ b/packages/core/lib/mcp-client.js @@ -2,7 +2,9 @@ import { readJson, tokenCachePath, tokenIsUsable } from "./cache.js"; import { DEFAULT_MCP_CLIENT_NAME, DEFAULT_MCP_CLIENT_VERSION, + DEFAULT_TIMEOUT_SECONDS, INTEGRATION_HEADER, + MAX_TIMER_SECONDS, MCP_PROTOCOL_VERSION, } from "./constants.js"; @@ -50,6 +52,32 @@ function parseResponseBody(text) { return JSON.parse(text); } +// Milliseconds for a duration that can actually arm a timer. Null when it cannot. +// A ceiling is usable only when it is finite, above zero and inside MAX_TIMER_SECONDS, +// because setTimeout turns every other value into a 1ms delay: NaN from "120s", +// Infinity plus anything past the timer maximum all abort the request before it +// leaves. Null means unreadable rather than short, so callers fall back to a ceiling +// they already have instead of clamping a nonsense value into range. The CLI rejects +// the same values on --timeout-seconds; this repeats the bound because callMcpTool is +// public and its per-call override arrives straight from a caller. +function usableTimeoutMs(seconds) { + const parsed = Number(seconds); + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > MAX_TIMER_SECONDS) { + return null; + } + return Math.max(Math.ceil(parsed * 1000), 1000); +} + +// The shared session ceiling. An unreadable config value keeps the package default. +function timeoutMsFromSeconds(seconds) { + return usableTimeoutMs(seconds) ?? usableTimeoutMs(DEFAULT_TIMEOUT_SECONDS); +} + +// More than one ceiling is in play now, so the message says which one ran out. +function timeoutLabel(timeoutMs) { + return `${Number((timeoutMs / 1000).toFixed(3))}s`; +} + async function requestJsonRpc(fetchImpl, url, { headers, payload, timeoutMs }) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); @@ -94,7 +122,10 @@ async function requestJsonRpc(fetchImpl, url, { headers, payload, timeoutMs }) { return { body, headers: responseHeaders }; } catch (error) { if (error?.name === "AbortError") { - throw new McpHttpError(`MCP request timed out for ${payload.method}`, { code: "http_error" }); + throw new McpHttpError( + `MCP request timed out for ${payload.method} after ${timeoutLabel(timeoutMs)}`, + { code: "http_error" }, + ); } throw error; } finally { @@ -142,7 +173,7 @@ function accessTokenFromCache(config) { async function openMcpSession({ config, fetchImpl }) { requireFetch(fetchImpl); const accessToken = accessTokenFromCache(config); - const timeoutMs = Math.max(Math.ceil(Number(config.timeoutSeconds || 15) * 1000), 1000); + const timeoutMs = timeoutMsFromSeconds(config.timeoutSeconds); const commonHeaders = { Accept: "application/json, text/event-stream", "Content-Type": "application/json", @@ -199,6 +230,7 @@ export async function callMcpTool({ toolName, toolArguments = {}, requestMeta = null, + timeoutSeconds = null, fetchImpl = globalThis.fetch, } = {}) { const { rpcHeaders, timeoutMs } = await openMcpSession({ config, fetchImpl }); @@ -217,7 +249,11 @@ export async function callMcpTool({ method: "tools/call", params: toolCallParams, }), - timeoutMs, + // A slow tool can carry its own ceiling. An unusable override falls back to the + // shared ceiling this session already computed rather than to a fixed default, and + // the handshake above always keeps the shared one, so a server that never answers + // initialize still fails fast. + timeoutMs: usableTimeoutMs(timeoutSeconds) ?? timeoutMs, }); return response.body?.result ?? {}; } diff --git a/packages/core/test/tool-timeout.test.js b/packages/core/test/tool-timeout.test.js new file mode 100644 index 0000000..69fd8f6 --- /dev/null +++ b/packages/core/test/tool-timeout.test.js @@ -0,0 +1,256 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import assert from "node:assert/strict"; + +import { tokenCachePath, writePrivateJson } from "@call-e/core/cache"; +import { McpHttpError, callMcpTool, listMcpTools } from "@call-e/core/mcp-client"; + +// Every request in an MCP session shared one ceiling, so the only way to give a +// slow tool the time it needs was to give an unresponsive handshake the same time. +// callMcpTool now takes a per-call timeout that covers the tools/call request only. +// +// The transport builds the timeout message from the same value it arms the +// AbortController with, so the reported ceiling is the effective one and these +// tests can read it without waiting for a real timer. The one case that does wait +// is the overflow test at the bottom, because a collapsed delay is a wall clock +// fact rather than a string. + +const SERVER_URL = "https://example.test/mcp/openagent_oauth"; + +// Node keeps a timer delay in a signed 32 bit int, so this is the last whole second +// that can arm one. Computed here rather than imported so the test pins the number +// the transport is supposed to use. +const MAX_TIMEOUT_SECONDS = Math.floor(2_147_483_647 / 1000); // 2147483 + +function label(value) { + return typeof value === "string" ? `"${value}"` : String(value); +} + +function mcpConfig() { + const cacheRoot = fs.mkdtempSync(path.join(os.tmpdir(), "calle-core-tool-timeout-")); + writePrivateJson(tokenCachePath(cacheRoot, SERVER_URL), { + token: { access_token: "token-123" }, + expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(), + }); + return { + cacheRoot, + serverUrl: SERVER_URL, + minTtlSeconds: 300, + timeoutSeconds: 15, + }; +} + +function jsonRpcResponse(body, { headers = {} } = {}) { + return { + ok: true, + status: 200, + statusText: "OK", + headers: new Headers(headers), + async text() { + return JSON.stringify(body); + }, + }; +} + +// Fails the named JSON-RPC method the way fetch does when its signal aborts. +function fetchAbortingOn(method) { + return async (_url, init) => { + const payload = JSON.parse(init.body); + if (payload.method === method) { + const abortError = new Error("aborted"); + abortError.name = "AbortError"; + throw abortError; + } + if (payload.method === "initialize") { + return jsonRpcResponse({ result: {} }, { headers: { "mcp-session-id": "sess-1" } }); + } + return jsonRpcResponse({ result: {} }); + }; +} + +// Answers the handshake, then leaves tools/call open, so the only thing that ends the +// request is the transport's own timer. A ref'd interval stands in for the socket a +// real fetch would be holding, because the transport unrefs its timer. +function fetchHangingOnToolCall() { + return async (_url, init) => { + const payload = JSON.parse(init.body); + if (payload.method === "initialize") { + return jsonRpcResponse({ result: {} }, { headers: { "mcp-session-id": "sess-1" } }); + } + if (payload.method !== "tools/call") { + return jsonRpcResponse({ result: {} }); + } + return new Promise((_resolve, reject) => { + const keepAlive = setInterval(() => {}, 25); + init.signal.addEventListener("abort", () => { + clearInterval(keepAlive); + const abortError = new Error("aborted"); + abortError.name = "AbortError"; + reject(abortError); + }); + }); + }; +} + +async function timeoutMessage(request) { + try { + await request(); + } catch (error) { + assert.ok(error instanceof McpHttpError, `expected McpHttpError, got ${error}`); + assert.equal(error.code, "http_error"); + return error.message; + } + assert.fail("expected the request to time out"); +} + +test("a tool call can carry a longer ceiling than the session handshake", async () => { + const config = mcpConfig(); + + assert.equal( + await timeoutMessage(() => + callMcpTool({ + config, + toolName: "plan_call", + timeoutSeconds: 120, + fetchImpl: fetchAbortingOn("tools/call"), + }), + ), + "MCP request timed out for tools/call after 120s", + ); + + // The handshake is deliberately left out of the override, so a server that never + // answers initialize still fails at the shared ceiling rather than two minutes on. + assert.equal( + await timeoutMessage(() => + callMcpTool({ + config, + toolName: "plan_call", + timeoutSeconds: 120, + fetchImpl: fetchAbortingOn("initialize"), + }), + ), + "MCP request timed out for initialize after 15s", + ); +}); + +test("a tool call without an override keeps the shared ceiling", async () => { + const config = mcpConfig(); + + assert.equal( + await timeoutMessage(() => + callMcpTool({ + config, + toolName: "get_call_run", + fetchImpl: fetchAbortingOn("tools/call"), + }), + ), + "MCP request timed out for tools/call after 15s", + ); + + assert.equal( + await timeoutMessage(() => listMcpTools({ config, fetchImpl: fetchAbortingOn("tools/list") })), + "MCP request timed out for tools/list after 15s", + ); +}); + +test("an unusable per-call timeout falls back to the shared ceiling", async () => { + // setTimeout collapses a delay it cannot store into 1ms, so an unreadable override + // must never reach the timer: "120s" parses to NaN, Infinity and anything past + // MAX_TIMEOUT_SECONDS overflow the 32 bit delay. A negative value used to clamp to + // the one second floor instead of falling back. Zero is not a ceiling either. + const config = mcpConfig(); + const unusable = [ + "120s", + "", + NaN, + Infinity, + -Infinity, + -1, + -120, + 0, + MAX_TIMEOUT_SECONDS + 1, + Number.MAX_SAFE_INTEGER, + null, + undefined, + {}, + ]; + + for (const bad of unusable) { + assert.equal( + await timeoutMessage(() => + callMcpTool({ + config, + toolName: "plan_call", + timeoutSeconds: bad, + fetchImpl: fetchAbortingOn("tools/call"), + }), + ), + "MCP request timed out for tools/call after 15s", + `timeoutSeconds ${label(bad)} must fall back to the shared ceiling`, + ); + } +}); + +test("a readable per-call timeout is used as given, up to the timer maximum", async () => { + // The other half of the validator: the bound is inclusive and a usable value is not + // swallowed. The one second floor is the transport's, so a sub-second override still + // leaves the request a whole second. + const config = mcpConfig(); + const usable = [ + [MAX_TIMEOUT_SECONDS, `${MAX_TIMEOUT_SECONDS}s`], + [120, "120s"], + ["45", "45s"], + [0.25, "1s"], + ]; + + for (const [seconds, expected] of usable) { + assert.equal( + await timeoutMessage(() => + callMcpTool({ + config, + toolName: "plan_call", + timeoutSeconds: seconds, + fetchImpl: fetchAbortingOn("tools/call"), + }), + ), + `MCP request timed out for tools/call after ${expected}`, + `timeoutSeconds ${label(seconds)} must arm the timer as given`, + ); + } +}); + +test("an overflowing per-call timeout waits the shared ceiling instead of aborting at once", async () => { + // The message alone cannot show this half of the defect. Each of these armed a 1ms + // delay (the one second floor for the negative), so the request aborted long before + // the ceiling it reported. A shared ceiling of 1.5 seconds tells the three outcomes + // apart, which is why this is the one test that waits for a real timer. + const config = { ...mcpConfig(), timeoutSeconds: 1.5 }; + const measured = await Promise.all( + ["120s", -1, Infinity, MAX_TIMEOUT_SECONDS + 1].map(async (bad) => { + const startedAt = process.hrtime.bigint(); + const message = await timeoutMessage(() => + callMcpTool({ + config, + toolName: "plan_call", + timeoutSeconds: bad, + fetchImpl: fetchHangingOnToolCall(), + }), + ); + return { bad, message, elapsedMs: Number(process.hrtime.bigint() - startedAt) / 1e6 }; + }), + ); + + for (const { bad, message, elapsedMs } of measured) { + assert.equal( + message, + "MCP request timed out for tools/call after 1.5s", + `timeoutSeconds ${label(bad)} must report the shared ceiling`, + ); + assert.ok( + elapsedMs > 1200, + `timeoutSeconds ${label(bad)} aborted after ${elapsedMs.toFixed(0)}ms, so it did not wait the 1500ms shared ceiling`, + ); + } +});