From ac7835fef7cd7622178a52b2d64817590c0236fb Mon Sep 17 00:00:00 2001 From: "Victor A." <52110451+cs50victor@users.noreply.github.com> Date: Sun, 11 Jan 2026 07:44:25 -0800 Subject: [PATCH 1/2] feat: add daemon mode for persistent MCP connections --- src/commands/call.ts | 31 ++++ src/daemon.ts | 357 +++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 44 +++++- 3 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 src/daemon.ts diff --git a/src/commands/call.ts b/src/commands/call.ts index 7317ce7..ca4138d 100644 --- a/src/commands/call.ts +++ b/src/commands/call.ts @@ -22,6 +22,7 @@ import { getServerConfig, loadConfig, } from '../config.js'; +import { callViaDaemon, isDaemonRunning } from '../daemon.js'; import { ErrorCode, formatCliError, @@ -148,6 +149,36 @@ export async function callCommand(options: CallOptions): Promise { process.exit(ErrorCode.CLIENT_ERROR); } + if (await isDaemonRunning()) { + debug(`routing call through daemon: ${serverName}/${toolName}`); + try { + const result = await callViaDaemon( + serverName, + serverConfig, + toolName, + args, + ); + if (options.json) { + console.log(formatJson(result)); + } else { + console.log(formatToolResult(result)); + } + return; + } catch (error) { + const errMsg = (error as Error).message; + if (errMsg.includes('not found') || errMsg.includes('unknown tool')) { + console.error( + formatCliError(toolNotFoundError(toolName, serverName, undefined)), + ); + } else { + console.error( + formatCliError(toolExecutionError(toolName, serverName, errMsg)), + ); + } + process.exit(ErrorCode.SERVER_ERROR); + } + } + let client: Client; let close: () => Promise = async () => {}; // Initialize to noop to prevent undefined access diff --git a/src/daemon.ts b/src/daemon.ts new file mode 100644 index 0000000..6ed5070 --- /dev/null +++ b/src/daemon.ts @@ -0,0 +1,357 @@ +/** + * Persistent connection daemon for MCP servers + * + * Keeps stdio server processes alive across CLI invocations, + * enabling stateful workflows without reconnection overhead. + * + * Architecture: + * CLI invocation -> Unix socket -> Daemon -> MCP Server Pool + * + * @env MCP_DAEMON_SOCKET - Socket path (default: ~/.mcp-cli/daemon.sock) + * @env MCP_DAEMON_IDLE_MS - Idle timeout in ms (default: 300000 = 5 min) + */ + +import { existsSync, mkdirSync, unlinkSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { type ConnectedClient, connectToServer, safeClose } from './client.js'; +import { type ServerConfig, debug } from './config.js'; +import { ErrorCode } from './errors.js'; + +const DEFAULT_SOCKET_PATH = join(homedir(), '.mcp-cli', 'daemon.sock'); +const DEFAULT_IDLE_MS = 300000; // 5 minutes + +function getSocketPath(): string { + return process.env.MCP_DAEMON_SOCKET || DEFAULT_SOCKET_PATH; +} + +function getIdleTimeoutMs(): number { + const env = process.env.MCP_DAEMON_IDLE_MS; + if (env) { + const ms = Number.parseInt(env, 10); + if (!Number.isNaN(ms) && ms > 0) return ms; + } + return DEFAULT_IDLE_MS; +} + +interface PoolEntry { + connection: ConnectedClient; + config: ServerConfig; + lastUsed: number; +} + +class ConnectionPool { + private pool = new Map(); + private idleTimer: ReturnType | undefined; + + constructor(private idleTimeoutMs: number) { + this.startIdleCheck(); + } + + async acquire( + serverName: string, + config: ServerConfig, + ): Promise { + const existing = this.pool.get(serverName); + if (existing) { + existing.lastUsed = Date.now(); + debug(`daemon: reusing connection for ${serverName}`); + return existing.connection; + } + + debug(`daemon: creating new connection for ${serverName}`); + const connection = await connectToServer(serverName, config); + this.pool.set(serverName, { + connection, + config, + lastUsed: Date.now(), + }); + return connection; + } + + async release(serverName: string): Promise { + const entry = this.pool.get(serverName); + if (entry) { + debug(`daemon: releasing connection for ${serverName}`); + await safeClose(entry.connection.close); + this.pool.delete(serverName); + } + } + + async releaseAll(): Promise { + debug(`daemon: releasing all connections (${this.pool.size} active)`); + const closes = [...this.pool.entries()].map(async ([name, entry]) => { + debug(`daemon: closing ${name}`); + await safeClose(entry.connection.close); + }); + await Promise.all(closes); + this.pool.clear(); + this.stopIdleCheck(); + } + + list(): string[] { + return [...this.pool.keys()]; + } + + private startIdleCheck(): void { + this.idleTimer = setInterval(() => { + const now = Date.now(); + for (const [name, entry] of this.pool.entries()) { + if (now - entry.lastUsed > this.idleTimeoutMs) { + debug(`daemon: idle timeout for ${name}`); + this.release(name); + } + } + }, 60000); // Check every minute + } + + private stopIdleCheck(): void { + if (this.idleTimer) { + clearInterval(this.idleTimer); + this.idleTimer = undefined; + } + } +} + +interface DaemonRequest { + method: 'call' | 'connect' | 'disconnect' | 'list' | 'shutdown'; + params?: { + server?: string; + config?: ServerConfig; + tool?: string; + args?: Record; + }; +} + +interface DaemonResponse { + ok?: boolean; + result?: unknown; + servers?: string[]; + error?: string; +} + +export async function startDaemon(): Promise { + const socketPath = getSocketPath(); + const idleMs = getIdleTimeoutMs(); + + const socketDir = dirname(socketPath); + if (!existsSync(socketDir)) { + mkdirSync(socketDir, { recursive: true }); + } + + if (existsSync(socketPath)) { + unlinkSync(socketPath); + } + + const pool = new ConnectionPool(idleMs); + + const server = Bun.serve({ + unix: socketPath, + async fetch(req): Promise { + let request: DaemonRequest; + try { + request = (await req.json()) as DaemonRequest; + } catch { + return Response.json({ error: 'invalid JSON' } as DaemonResponse, { + status: 400, + }); + } + + try { + switch (request.method) { + case 'connect': { + const { server: serverName, config } = request.params ?? {}; + if (!serverName || !config) { + return Response.json( + { error: 'missing server or config' } as DaemonResponse, + { status: 400 }, + ); + } + await pool.acquire(serverName, config); + return Response.json({ ok: true } as DaemonResponse); + } + + case 'call': { + const { + server: serverName, + config, + tool, + args, + } = request.params ?? {}; + if (!serverName || !config || !tool) { + return Response.json( + { error: 'missing server, config, or tool' } as DaemonResponse, + { status: 400 }, + ); + } + const { client } = await pool.acquire(serverName, config); + const result = await client.callTool({ + name: tool, + arguments: args ?? {}, + }); + return Response.json({ result } as DaemonResponse); + } + + case 'disconnect': { + const { server: serverName } = request.params ?? {}; + if (!serverName) { + return Response.json( + { error: 'missing server' } as DaemonResponse, + { status: 400 }, + ); + } + await pool.release(serverName); + return Response.json({ ok: true } as DaemonResponse); + } + + case 'list': { + return Response.json({ servers: pool.list() } as DaemonResponse); + } + + case 'shutdown': { + await pool.releaseAll(); + // Schedule shutdown after response + setTimeout(() => { + server.stop(); + process.exit(0); + }, 100); + return Response.json({ ok: true } as DaemonResponse); + } + + default: + return Response.json( + { error: `unknown method: ${request.method}` } as DaemonResponse, + { status: 400 }, + ); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return Response.json({ error: message } as DaemonResponse, { + status: 500, + }); + } + }, + }); + + const shutdown = async () => { + debug('daemon: shutting down'); + await pool.releaseAll(); + server.stop(); + if (existsSync(socketPath)) { + unlinkSync(socketPath); + } + process.exit(0); + }; + + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + + console.log('mcp-cli daemon started'); + console.log(` Socket: ${socketPath}`); + console.log(` Idle timeout: ${idleMs}ms`); + console.log(` PID: ${process.pid}`); +} + +export async function isDaemonRunning(): Promise { + const socketPath = getSocketPath(); + if (!existsSync(socketPath)) { + return false; + } + + try { + const res = await fetch('http://localhost/', { + unix: socketPath, + method: 'POST', + body: JSON.stringify({ method: 'list' }), + }); + return res.ok; + } catch { + return false; + } +} + +export async function callViaDaemon( + serverName: string, + config: ServerConfig, + toolName: string, + args: Record, +): Promise { + const socketPath = getSocketPath(); + const request: DaemonRequest = { + method: 'call', + params: { + server: serverName, + config, + tool: toolName, + args, + }, + }; + + const res = await fetch('http://localhost/', { + unix: socketPath, + method: 'POST', + body: JSON.stringify(request), + }); + + const response = (await res.json()) as DaemonResponse; + + if (response.error) { + throw new Error(response.error); + } + + return response.result; +} + +export async function listDaemonServers(): Promise { + const socketPath = getSocketPath(); + const res = await fetch('http://localhost/', { + unix: socketPath, + method: 'POST', + body: JSON.stringify({ method: 'list' }), + }); + const response = (await res.json()) as DaemonResponse; + return response.servers ?? []; +} + +export async function stopDaemon(): Promise { + const socketPath = getSocketPath(); + if (!existsSync(socketPath)) { + console.error('Daemon is not running'); + process.exit(ErrorCode.CLIENT_ERROR); + } + + try { + await fetch('http://localhost/', { + unix: socketPath, + method: 'POST', + body: JSON.stringify({ method: 'shutdown' }), + }); + console.log('Daemon stopped'); + } catch { + console.error('Failed to stop daemon'); + process.exit(ErrorCode.NETWORK_ERROR); + } +} + +export async function daemonStatus(): Promise { + const socketPath = getSocketPath(); + const running = await isDaemonRunning(); + + if (!running) { + console.log('Daemon: not running'); + console.log(`Socket: ${socketPath}`); + return; + } + + const servers = await listDaemonServers(); + console.log('Daemon: running'); + console.log(`Socket: ${socketPath}`); + console.log(`Active connections: ${servers.length}`); + if (servers.length > 0) { + console.log(` ${servers.join('\n ')}`); + } +} + +export function getDaemonSocketPath(): string { + return getSocketPath(); +} diff --git a/src/index.ts b/src/index.ts index e634437..891e4b8 100755 --- a/src/index.ts +++ b/src/index.ts @@ -20,6 +20,12 @@ import { DEFAULT_RETRY_DELAY_MS, DEFAULT_TIMEOUT_SECONDS, } from './config.js'; +import { + daemonStatus, + getDaemonSocketPath, + startDaemon, + stopDaemon, +} from './daemon.js'; import { ErrorCode, formatCliError, @@ -29,13 +35,14 @@ import { import { VERSION } from './version.js'; interface ParsedArgs { - command: 'list' | 'grep' | 'info' | 'call' | 'help' | 'version'; + command: 'list' | 'grep' | 'info' | 'call' | 'help' | 'version' | 'daemon'; target?: string; pattern?: string; args?: string; json: boolean; withDescriptions: boolean; configPath?: string; + daemonAction?: 'start' | 'stop' | 'status'; } /** @@ -92,6 +99,16 @@ function parseArgs(args: string[]): ParsedArgs { // Determine command from positional arguments if (positional.length === 0) { result.command = 'list'; + } else if (positional[0] === 'daemon') { + result.command = 'daemon'; + const action = positional[1] as 'start' | 'stop' | 'status' | undefined; + if (!action || !['start', 'stop', 'status'].includes(action)) { + console.error( + formatCliError(missingArgumentError('daemon', 'start|stop|status')), + ); + process.exit(ErrorCode.CLIENT_ERROR); + } + result.daemonAction = action; } else if (positional[0] === 'grep') { result.command = 'grep'; result.pattern = positional[1]; @@ -123,6 +140,7 @@ function parseArgs(args: string[]): ParsedArgs { * Print help message */ function printHelp(): void { + const socketPath = getDaemonSocketPath(); console.log(` mcp-cli v${VERSION} - A lightweight CLI for MCP servers @@ -132,6 +150,7 @@ Usage: mcp-cli [options] Show server tools and parameters mcp-cli [options] / Show tool schema and description mcp-cli [options] / Call tool with arguments + mcp-cli daemon Manage persistent connection daemon Options: -h, --help Show this help message @@ -152,6 +171,8 @@ Environment Variables: MCP_MAX_RETRIES Max retry attempts for transient errors (default: ${DEFAULT_MAX_RETRIES}) MCP_RETRY_DELAY Base retry delay in milliseconds (default: ${DEFAULT_RETRY_DELAY_MS}) MCP_STRICT_ENV Set to "false" to warn on missing env vars (default: true) + MCP_DAEMON_SOCKET Daemon socket path (default: ${socketPath}) + MCP_DAEMON_IDLE_MS Daemon idle timeout in ms (default: 300000) Examples: mcp-cli # List all servers @@ -162,6 +183,13 @@ Examples: mcp-cli filesystem/read_file '{"path":"./README.md"}' # Call tool echo '{"path":"./file"}' | mcp-cli server/tool - # Read JSON from stdin +Daemon Mode: + mcp-cli daemon start # Start persistent daemon + mcp-cli daemon status # Show daemon status + mcp-cli daemon stop # Stop daemon + + When daemon is running, tool calls reuse connections for faster execution. + Config File: The CLI looks for mcp_servers.json in: 1. Path specified by MCP_CONFIG_PATH or -c/--config @@ -220,6 +248,20 @@ async function main(): Promise { configPath: args.configPath, }); break; + + case 'daemon': + switch (args.daemonAction) { + case 'start': + await startDaemon(); + break; + case 'stop': + await stopDaemon(); + break; + case 'status': + await daemonStatus(); + break; + } + break; } } From b3ed39694d006959eb306fdfb7afde85f3f1105a Mon Sep 17 00:00:00 2001 From: "Victor A." <52110451+cs50victor@users.noreply.github.com> Date: Mon, 12 Jan 2026 12:25:32 -0800 Subject: [PATCH 2/2] docs: add daemon mode to README and SKILL --- README.md | 6 ++++++ SKILL.md | 1 + 2 files changed, 7 insertions(+) diff --git a/README.md b/README.md index 1ab6ba5..f918d37 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,8 @@ The CLI searches for configuration in this order: | `MCP_MAX_RETRIES` | Retry attempts for transient errors (0 = disable) | `3` | | `MCP_RETRY_DELAY` | Base retry delay (milliseconds) | `1000` | | `MCP_STRICT_ENV` | Error on missing `${VAR}` in config | `true` | +| `MCP_DAEMON_SOCKET` | Daemon socket path | `~/.mcp-cli/daemon.sock` | +| `MCP_DAEMON_IDLE_MS` | Daemon idle timeout (ms) | `300000` | ## Using with AI Agents @@ -303,8 +305,12 @@ mcp-cli # Show server tools and parameters mcp-cli / # Get tool JSON schema and descriptions mcp-cli / '' # Call tool with JSON arguments mcp-cli grep "" # Search tools by name (glob pattern) +mcp-cli daemon start # Start persistent connection daemon +mcp-cli daemon stop # Stop daemon ``` +For stateful MCP servers (browser automation, database sessions), start the daemon first. Connections persist across calls. + **Add `-d` to include tool descriptions** (e.g., `mcp-cli -d`) Workflow: diff --git a/SKILL.md b/SKILL.md index 734b72d..818b47d 100644 --- a/SKILL.md +++ b/SKILL.md @@ -16,6 +16,7 @@ Access MCP servers through the command line. MCP enables interaction with extern | `mcp-cli /` | Get tool JSON schema | | `mcp-cli / ''` | Call tool with arguments | | `mcp-cli grep ""` | Search tools by name | +| `mcp-cli daemon start\|stop` | Manage persistent connections (for stateful servers) | **Add `-d` to include descriptions** (e.g., `mcp-cli filesystem -d`)