From 7e2c97790bfd10678013d46d0370dcdefc13ec19 Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Mon, 29 Jun 2026 22:09:14 -0400 Subject: [PATCH 1/2] fix: allow slower remote tool calls --- README.md | 3 + deploy/env.example | 9 ++ src/process/client.ts | 30 ++++- tests/process/client-remote.test.ts | 176 ++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 57a7ebd..ea5191b 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,9 @@ fi | `MCP2CLI_IDLE_TIMEOUT` | `60` | Daemon idle timeout in seconds | | `MCP2CLI_STARTUP_TIMEOUT` | `10000` | CLI wait time for daemon startup readiness in milliseconds | | `MCP2CLI_TOOL_TIMEOUT` | `30000` | Tool call timeout in milliseconds | +| `MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS` | `60000` | CLI HTTP request timeout for explicit remote daemon calls in milliseconds | +| `MCP2CLI_REMOTE_FALLBACK_TIMEOUT_MS` | `10000` | CLI HTTP timeout for each `remote-local` probe before falling back to the local daemon | +| `MCP2CLI_REMOTE_FALLBACK_RETRIES` | `1` | Remote probe attempts before `remote-local` calls fall back to the local daemon | | `MCP2CLI_POOL_MAX` | `50` | Max concurrent MCP connections in the pool | | `MCP2CLI_LOG_DIR` | `~/.cache/mcp2cli/logs` | Directory for stderr capture logs | | `MCP2CLI_NO_DAEMON` | (unset) | If set, bypass the daemon and connect directly | diff --git a/deploy/env.example b/deploy/env.example index 9ee27df..2da1efb 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -34,5 +34,14 @@ MCP2CLI_LOG_LEVEL=info # Per-tool invocation timeout in seconds # MCP2CLI_TOOL_TIMEOUT=30 +# CLI-side timeout for explicit remote daemon requests in milliseconds +# MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS=60000 + +# CLI-side timeout before remote-local requests fall back to local in milliseconds +# MCP2CLI_REMOTE_FALLBACK_TIMEOUT_MS=10000 + +# CLI-side remote attempts before remote-local requests fall back to local +# MCP2CLI_REMOTE_FALLBACK_RETRIES=1 + # Directory for schema and response caching # MCP2CLI_CACHE_DIR=/var/lib/mcp2cli/cache diff --git a/src/process/client.ts b/src/process/client.ts index 4c0846b..2f5585d 100644 --- a/src/process/client.ts +++ b/src/process/client.ts @@ -32,9 +32,18 @@ const STARTUP_TIMEOUT_MS = readPositiveIntEnv( ); const STARTUP_POLL_MS = 50; const REQUEST_TIMEOUT_MS = 60_000; -const REMOTE_CONNECT_TIMEOUT_MS = 10_000; const STALE_LOCK_THRESHOLD_MS = 30_000; +function remoteRequestTimeoutMs(source: ServiceSource): number { + if (source === "remote-local") { + return readPositiveIntEnv("MCP2CLI_REMOTE_FALLBACK_TIMEOUT_MS", 10_000); + } + return readPositiveIntEnv( + "MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS", + REQUEST_TIMEOUT_MS, + ); +} + /** Cached local token to avoid re-reading tokens.json on every request. */ let cachedLocalToken: string | undefined; let cachedLocalTokenExpiresAt: string | undefined; @@ -377,8 +386,16 @@ const REMOTE_BACKOFF_BASE_MS = parseInt( 10, ); +function remoteRetries(source: ServiceSource): number { + if (source === "remote-local") { + return readPositiveIntEnv("MCP2CLI_REMOTE_FALLBACK_RETRIES", 1); + } + return REMOTE_RETRIES; +} + async function fetchRemote( path: string, + source: ServiceSource, body?: unknown, ): Promise { const remote = getRemoteConfig()!; @@ -391,13 +408,14 @@ async function fetchRemote( } let lastError: unknown; - for (let attempt = 0; attempt < REMOTE_RETRIES; attempt++) { + const maxAttempts = remoteRetries(source); + for (let attempt = 0; attempt < maxAttempts; attempt++) { try { const response = await fetch(url, { method: "POST", headers, body: body !== undefined ? JSON.stringify(body) : undefined, - signal: AbortSignal.timeout(REMOTE_CONNECT_TIMEOUT_MS), + signal: AbortSignal.timeout(remoteRequestTimeoutMs(source)), }); // Auth errors are permanent -- don't retry (wrong token won't become right) @@ -422,7 +440,7 @@ async function fetchRemote( throw err; } lastError = err; - if (attempt < REMOTE_RETRIES - 1) { + if (attempt < maxAttempts - 1) { const delay = REMOTE_BACKOFF_BASE_MS * Math.pow(2, attempt); await new Promise((r) => setTimeout(r, delay)); } @@ -458,12 +476,12 @@ async function fetchDaemon( } if (source === "remote") { - return await fetchRemote(path, body); + return await fetchRemote(path, source, body); } // "remote-local": try remote, fall back to local try { - return await fetchRemote(path, body); + return await fetchRemote(path, source, body); } catch (err) { if (isRemoteAuthError(err)) { throw err; diff --git a/tests/process/client-remote.test.ts b/tests/process/client-remote.test.ts index 9570d86..0c86114 100644 --- a/tests/process/client-remote.test.ts +++ b/tests/process/client-remote.test.ts @@ -9,11 +9,17 @@ import { import { callViaDaemon, clearClientConfigCache, + clearLocalTokenCache, resolveSource, } from "../../src/process/client.ts"; +import { createDaemonServer } from "../../src/daemon/server.ts"; +import { ConnectionPool } from "../../src/daemon/pool.ts"; +import { IdleTimer } from "../../src/daemon/idle.ts"; +import { MetricsCollector } from "../../src/daemon/metrics.ts"; import { join } from "node:path"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; +import type { AuthProvider } from "../../src/daemon/auth-provider.ts"; describe("getRemoteConfig", () => { const originalUrl = process.env.MCP2CLI_REMOTE_URL; @@ -234,9 +240,20 @@ describe("remote-aware source resolution", () => { const originalUrl = process.env.MCP2CLI_REMOTE_URL; const originalConfig = process.env.MCP2CLI_CONFIG; const originalTtl = process.env.MCP2CLI_REMOTE_SERVICE_CACHE_TTL_MS; + const originalRemoteRequestTimeout = process.env.MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS; + const originalRemoteFallbackTimeout = process.env.MCP2CLI_REMOTE_FALLBACK_TIMEOUT_MS; + const originalRemoteFallbackRetries = process.env.MCP2CLI_REMOTE_FALLBACK_RETRIES; + const originalPidFile = process.env.MCP2CLI_PID_FILE; + const originalSocketPath = process.env.MCP2CLI_SOCKET_PATH; + const originalAuthToken = process.env.MCP2CLI_AUTH_TOKEN; + const originalMcpToken = process.env.MCP_TOKEN; let server: ReturnType; let testDir: string; let configPath: string; + const disabledAuthProvider: AuthProvider = { + enabled: false, + authenticate: () => null, + }; beforeEach(async () => { clearRemoteServiceCache(); @@ -281,6 +298,42 @@ describe("remote-aware source resolution", () => { } else { delete process.env.MCP2CLI_REMOTE_SERVICE_CACHE_TTL_MS; } + if (originalRemoteRequestTimeout !== undefined) { + process.env.MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS = originalRemoteRequestTimeout; + } else { + delete process.env.MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS; + } + if (originalRemoteFallbackTimeout !== undefined) { + process.env.MCP2CLI_REMOTE_FALLBACK_TIMEOUT_MS = originalRemoteFallbackTimeout; + } else { + delete process.env.MCP2CLI_REMOTE_FALLBACK_TIMEOUT_MS; + } + if (originalRemoteFallbackRetries !== undefined) { + process.env.MCP2CLI_REMOTE_FALLBACK_RETRIES = originalRemoteFallbackRetries; + } else { + delete process.env.MCP2CLI_REMOTE_FALLBACK_RETRIES; + } + if (originalPidFile !== undefined) { + process.env.MCP2CLI_PID_FILE = originalPidFile; + } else { + delete process.env.MCP2CLI_PID_FILE; + } + if (originalSocketPath !== undefined) { + process.env.MCP2CLI_SOCKET_PATH = originalSocketPath; + } else { + delete process.env.MCP2CLI_SOCKET_PATH; + } + if (originalAuthToken !== undefined) { + process.env.MCP2CLI_AUTH_TOKEN = originalAuthToken; + } else { + delete process.env.MCP2CLI_AUTH_TOKEN; + } + if (originalMcpToken !== undefined) { + process.env.MCP_TOKEN = originalMcpToken; + } else { + delete process.env.MCP_TOKEN; + } + clearLocalTokenCache(); }); async function writeServices( @@ -374,6 +427,129 @@ describe("remote-aware source resolution", () => { } }); + test("honors remote request timeout override for hosted tool calls", async () => { + server.stop(true); + server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/call") { + await new Promise((resolve) => setTimeout(resolve, 50)); + return Response.json({ success: true, result: { ok: true } }); + } + if (url.pathname === "/api/services/discovery") { + return Response.json({ + success: true, + configuredServices: ["slow-remote"], + }); + } + return new Response("Not Found", { status: 404 }); + }, + }); + process.env.MCP2CLI_REMOTE_URL = `http://localhost:${server.port}`; + await writeServices({ + "slow-remote": { + backend: "stdio", + command: "echo", + source: "remote", + }, + }); + + process.env.MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS = "10"; + const timedOut = await callViaDaemon({ + service: "slow-remote", + tool: "wait", + params: {}, + }); + expect(timedOut.success).toBe(false); + if (!timedOut.success) { + expect(timedOut.error.code).toBe("CONNECTION_ERROR"); + expect(timedOut.error.message).toContain("timed out"); + } + + process.env.MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS = "250"; + const succeeded = await callViaDaemon({ + service: "slow-remote", + tool: "wait", + params: {}, + }); + expect(succeeded).toEqual({ success: true, result: { ok: true } }); + }); + + test("uses short fallback timeout for remote-local services", async () => { + server.stop(true); + const localSocketPath = join(testDir, "local-daemon.sock"); + const localPidFile = join(testDir, "local-daemon.pid"); + await Bun.write(localPidFile, String(process.pid)); + process.env.MCP2CLI_PID_FILE = localPidFile; + process.env.MCP2CLI_SOCKET_PATH = localSocketPath; + delete process.env.MCP2CLI_AUTH_TOKEN; + delete process.env.MCP_TOKEN; + clearLocalTokenCache(); + + const localPool = new ConnectionPool(); + const localServer = createDaemonServer({ + listenConfig: { mode: "unix", socketPath: localSocketPath }, + pool: localPool, + config: { services: {} }, + idleTimer: new IdleTimer(60000, () => {}), + onShutdown: () => {}, + authProvider: disabledAuthProvider, + metrics: new MetricsCollector(), + }); + + let remoteCalls = 0; + server = Bun.serve({ + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (url.pathname === "/call") { + remoteCalls += 1; + await new Promise((resolve) => setTimeout(resolve, 50)); + return Response.json({ success: true, result: { ok: true } }); + } + if (url.pathname === "/api/services/discovery") { + return Response.json({ + success: true, + configuredServices: ["fallback-sensitive"], + }); + } + return new Response("Not Found", { status: 404 }); + }, + }); + process.env.MCP2CLI_REMOTE_URL = `http://localhost:${server.port}`; + await writeServices({ + "fallback-sensitive": { + backend: "http", + url: "http://local-fallback.example/mcp", + source: "remote-local", + }, + }); + + try { + process.env.MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS = "250"; + process.env.MCP2CLI_REMOTE_FALLBACK_TIMEOUT_MS = "10"; + delete process.env.MCP2CLI_REMOTE_FALLBACK_RETRIES; + const result = await callViaDaemon({ + service: "fallback-sensitive", + tool: "wait", + params: {}, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.code).toBe("CONNECTION_ERROR"); + expect(result.error.message).toContain( + "Service not found in config: fallback-sensitive", + ); + expect(result.error.message).not.toContain("remote daemon"); + } + expect(remoteCalls).toBe(1); + } finally { + localServer.stop(true); + await localPool.closeAll(); + } + }); + test("platforms prefer local when current OS is allowed", async () => { await writeServices({ local: { From 04f65f3b07ab5416c2077206bcfc7923bb914948 Mon Sep 17 00:00:00 2001 From: Rodaddy Date: Mon, 29 Jun 2026 22:42:06 -0400 Subject: [PATCH 2/2] test: document remote retry split --- README.md | 1 + deploy/env.example | 3 +++ tests/process/client-remote.test.ts | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/README.md b/README.md index ea5191b..8dd365b 100644 --- a/README.md +++ b/README.md @@ -218,6 +218,7 @@ fi | `MCP2CLI_STARTUP_TIMEOUT` | `10000` | CLI wait time for daemon startup readiness in milliseconds | | `MCP2CLI_TOOL_TIMEOUT` | `30000` | Tool call timeout in milliseconds | | `MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS` | `60000` | CLI HTTP request timeout for explicit remote daemon calls in milliseconds | +| `MCP2CLI_REMOTE_RETRIES` | `3` | Remote request attempts for explicit remote daemon calls | | `MCP2CLI_REMOTE_FALLBACK_TIMEOUT_MS` | `10000` | CLI HTTP timeout for each `remote-local` probe before falling back to the local daemon | | `MCP2CLI_REMOTE_FALLBACK_RETRIES` | `1` | Remote probe attempts before `remote-local` calls fall back to the local daemon | | `MCP2CLI_POOL_MAX` | `50` | Max concurrent MCP connections in the pool | diff --git a/deploy/env.example b/deploy/env.example index 2da1efb..147d85a 100644 --- a/deploy/env.example +++ b/deploy/env.example @@ -37,6 +37,9 @@ MCP2CLI_LOG_LEVEL=info # CLI-side timeout for explicit remote daemon requests in milliseconds # MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS=60000 +# CLI-side remote attempts for explicit remote daemon requests +# MCP2CLI_REMOTE_RETRIES=3 + # CLI-side timeout before remote-local requests fall back to local in milliseconds # MCP2CLI_REMOTE_FALLBACK_TIMEOUT_MS=10000 diff --git a/tests/process/client-remote.test.ts b/tests/process/client-remote.test.ts index 0c86114..51d87fe 100644 --- a/tests/process/client-remote.test.ts +++ b/tests/process/client-remote.test.ts @@ -429,11 +429,13 @@ describe("remote-aware source resolution", () => { test("honors remote request timeout override for hosted tool calls", async () => { server.stop(true); + let remoteCalls = 0; server = Bun.serve({ port: 0, async fetch(req) { const url = new URL(req.url); if (url.pathname === "/call") { + remoteCalls += 1; await new Promise((resolve) => setTimeout(resolve, 50)); return Response.json({ success: true, result: { ok: true } }); } @@ -466,14 +468,17 @@ describe("remote-aware source resolution", () => { expect(timedOut.error.code).toBe("CONNECTION_ERROR"); expect(timedOut.error.message).toContain("timed out"); } + expect(remoteCalls).toBe(3); process.env.MCP2CLI_REMOTE_REQUEST_TIMEOUT_MS = "250"; + remoteCalls = 0; const succeeded = await callViaDaemon({ service: "slow-remote", tool: "wait", params: {}, }); expect(succeeded).toEqual({ success: true, result: { ok: true } }); + expect(remoteCalls).toBe(1); }); test("uses short fallback timeout for remote-local services", async () => {