Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/plan-call-request-timeout.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions docs/install/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion packages/cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
18 changes: 15 additions & 3 deletions packages/cli/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -74,7 +74,7 @@ Common options:
--client-name <name> Default: ${DEFAULT_CLIENT_NAME}
--scope <scope> Default: ${DEFAULT_SCOPE}
--cache-root <path>
--timeout-seconds <seconds>
--timeout-seconds <seconds> Default: ${DEFAULT_TIMEOUT_SECONDS}, ${DEFAULT_PLAN_TIMEOUT_SECONDS} for plan_call
--poll-timeout-seconds <seconds>
--server-name <name> Default: calle
--force-login
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -777,12 +785,14 @@ async function handleMcpCommand({ command, positional, options, config, deps, st
throw new InvalidArgumentsError("Usage: calle mcp call <tool-name> --args-json '<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 }));
Expand Down Expand Up @@ -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 }));
Expand All @@ -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);
Expand Down
44 changes: 29 additions & 15 deletions packages/cli/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
DEFAULT_SCOPE,
DEFAULT_TIMEOUT_SECONDS,
INTEGRATION_HEADER,
MAX_TIMER_SECONDS,
SESSION_SECRET_HEADER,
} from "@call-e/core/constants";
import {
Expand Down Expand Up @@ -38,27 +39,34 @@ 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";

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";

Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
93 changes: 92 additions & 1 deletion packages/cli/test/cli.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`;

Expand Down Expand Up @@ -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,
);
});
2 changes: 2 additions & 0 deletions packages/core/lib/constants.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
7 changes: 7 additions & 0 deletions packages/core/lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
2 changes: 2 additions & 0 deletions packages/core/lib/mcp-client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading