Skip to content
Merged
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,10 @@ 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_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 |
| `MCP2CLI_LOG_DIR` | `~/.cache/mcp2cli/logs` | Directory for stderr capture logs |
| `MCP2CLI_NO_DAEMON` | (unset) | If set, bypass the daemon and connect directly |
Expand Down
12 changes: 12 additions & 0 deletions deploy/env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,17 @@ 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 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

# 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
30 changes: 24 additions & 6 deletions src/process/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<DaemonResponse> {
const remote = getRemoteConfig()!;
Expand All @@ -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)
Expand All @@ -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));
}
Expand Down Expand Up @@ -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;
Expand Down
181 changes: 181 additions & 0 deletions tests/process/client-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<typeof Bun.serve>;
let testDir: string;
let configPath: string;
const disabledAuthProvider: AuthProvider = {
enabled: false,
authenticate: () => null,
};

beforeEach(async () => {
clearRemoteServiceCache();
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -374,6 +427,134 @@ 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 } });
}
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");
}
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 () => {
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: {
Expand Down