diff --git a/CHANGELOG.md b/CHANGELOG.md index e7a02bc..fb09773 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Recover cleanly from renamed OAuth server entries, invalid refresh tokens, and stale dynamic client registrations without reusing unrelated same-URL credentials. +### CLI + +- Reconcile keep-alive daemon metadata with the responding process and serialize daemon startup across parallel clients, preventing duplicate orphaned daemons. (Issue #191, thanks @dtmsyi) + ## [0.11.3] - 2026-05-21 - Fall back to `~/.mcporter/mcporter.json[c]` when `XDG_CONFIG_HOME` points at an empty mcporter config directory, preventing embedders from accidentally hiding the user server registry. (Issue #184, thanks @ChrisBot2026) diff --git a/src/daemon/client.ts b/src/daemon/client.ts index 08ec7ac..642e5d0 100644 --- a/src/daemon/client.ts +++ b/src/daemon/client.ts @@ -3,6 +3,7 @@ import fs from 'node:fs/promises'; import net from 'node:net'; import path from 'node:path'; import { listConfigLayerPaths } from '../config/path-discovery.js'; +import { withFileLock } from '../fs-json.js'; import { getDaemonMetadataPath, getDaemonSocketPath } from './paths.js'; import type { CallToolParams, @@ -23,6 +24,7 @@ export interface DaemonClientOptions { } const DEFAULT_DAEMON_TIMEOUT_MS = 30_000; +const MIN_DAEMON_STATUS_TIMEOUT_MS = 1_000; export interface DaemonPaths { readonly key: string; @@ -83,14 +85,7 @@ export class DaemonClient { } async status(): Promise { - try { - return (await this.sendRequest('status', {})) as StatusResult; - } catch (error) { - if (isTransportError(error)) { - return null; - } - throw error; - } + return await this.readVerifiedStatus(); } async stop(): Promise { @@ -105,7 +100,7 @@ export class DaemonClient { } private async invoke(method: DaemonRequestMethod, params: unknown, timeoutMs?: number): Promise { - await this.ensureDaemon(); + await this.ensureDaemon(timeoutMs); try { return (await this.sendRequest(method, params, timeoutMs)) as T; } catch (error) { @@ -117,47 +112,87 @@ export class DaemonClient { } } - private async ensureDaemon(): Promise { - const configState = await this.checkConfigState(); + private async ensureDaemon(timeoutMs?: number): Promise { + const statusTimeoutMs = resolveDaemonStatusTimeout(timeoutMs); + const metadata = await readDaemonMetadata(this.metadataPath); + const configState = await this.checkConfigState(metadata); if (configState === 'stale') { - await this.stop().catch(() => {}); - await this.restartDaemon(); + await this.restartDaemon({ reason: 'stale-config', expectedPid: metadata?.pid }); return; } if (configState === 'fresh') { - return; + if (await this.isResponsive(statusTimeoutMs)) { + return; + } } - await this.startDaemon(); - await this.waitForReady(); + await this.startDaemon({ preflightTimeoutMs: statusTimeoutMs }); } - private async restartDaemon(): Promise { - await this.startDaemon(); - await this.waitForReady(); + private async restartDaemon(options: { reason?: 'stale-config'; expectedPid?: number } = {}): Promise { + await this.startingWithLock(async () => { + const currentStatus = await this.readVerifiedStatus(); + if ( + currentStatus && + options.expectedPid !== undefined && + currentStatus.pid !== options.expectedPid && + (await this.checkConfigState()) === 'fresh' + ) { + return; + } + if (options.reason === 'stale-config' && currentStatus && (await this.checkConfigState()) === 'fresh') { + return; + } + await this.stop().catch(() => {}); + await this.waitForStopped(); + await this.launchDaemonAndWait(); + }); } - private async startDaemon(): Promise { + private async startDaemon(options: { preflightTimeoutMs?: number } = {}): Promise { + await this.startingWithLock(async () => { + if (await this.isResponsive(options.preflightTimeoutMs)) { + return; + } + await this.launchDaemonAndWait(); + }); + } + + private async startingWithLock(task: () => Promise): Promise { if (this.startingPromise) { await this.startingPromise; return; } - this.startingPromise = Promise.resolve() - .then(async () => { - const { launchDaemonDetached } = await import('./launch.js'); - launchDaemonDetached({ - configPath: this.options.configPath, - configExplicit: this.options.configExplicit, - rootDir: this.options.rootDir, - metadataPath: this.metadataPath, - socketPath: this.socketPath, - }); - }) - .finally(() => { - this.startingPromise = null; - }); + this.startingPromise = withFileLock(this.metadataPath, async () => { + await task(); + }).finally(() => { + this.startingPromise = null; + }); await this.startingPromise; } + private async launchDaemonAndWait(): Promise { + const { launchDaemonDetached } = await import('./launch.js'); + launchDaemonDetached({ + configPath: this.options.configPath, + configExplicit: this.options.configExplicit, + rootDir: this.options.rootDir, + metadataPath: this.metadataPath, + socketPath: this.socketPath, + }); + await this.waitForReady(); + } + + private async waitForStopped(): Promise { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (!(await this.isResponsive())) { + return; + } + await delay(100); + } + throw new Error('Daemon did not stop before restart could begin.'); + } + private async waitForReady(): Promise { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { @@ -169,20 +204,31 @@ export class DaemonClient { throw new Error('Timeout while waiting for MCPorter daemon to start.'); } - private async isResponsive(): Promise { + private async isResponsive(timeoutMs?: number): Promise { + return (await this.readVerifiedStatus(timeoutMs)) !== null; + } + + private async readVerifiedStatus(timeoutMs?: number): Promise { + const metadata = await readDaemonMetadata(this.metadataPath); + if (!metadata || metadata.socketPath !== this.socketPath || !isProcessRunning(metadata.pid)) { + return null; + } try { - await this.sendRequest('status', {}); - return true; + const status = (await this.sendRequest('status', {}, timeoutMs)) as StatusResult; + if (status.pid !== metadata.pid || status.socketPath !== metadata.socketPath) { + return null; + } + return status; } catch (error) { if (isTransportError(error)) { - return false; + return null; } throw error; } } - private async checkConfigState(): Promise { - const metadata = await readDaemonMetadata(this.metadataPath); + private async checkConfigState(metadata?: DaemonMetadata | null): Promise { + metadata ??= await readDaemonMetadata(this.metadataPath); if (!metadata) { return 'missing'; } @@ -290,6 +336,18 @@ function isTransportError(error: unknown): boolean { return code === 'ECONNREFUSED' || code === 'ENOENT' || code === 'ETIMEDOUT' || code === 'ECONNRESET'; } +function isProcessRunning(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) { + return false; + } + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + function resolveDaemonTimeout(override?: number): number { if (typeof override === 'number' && Number.isFinite(override) && override > 0) { return override; @@ -305,6 +363,13 @@ function resolveDaemonTimeout(override?: number): number { return parsed; } +function resolveDaemonStatusTimeout(override?: number): number | undefined { + if (typeof override !== 'number' || !Number.isFinite(override) || override <= 0) { + return undefined; + } + return Math.max(override, MIN_DAEMON_STATUS_TIMEOUT_MS); +} + async function statConfigMtime(configPath: string): Promise { try { const stats = await fs.stat(configPath); diff --git a/tests/daemon-client-config-stale.test.ts b/tests/daemon-client-config-stale.test.ts index d0dc3d2..0dc06d0 100644 --- a/tests/daemon-client-config-stale.test.ts +++ b/tests/daemon-client-config-stale.test.ts @@ -40,7 +40,7 @@ function buildResponse(method: string, id: string) { id, ok: true, result: { - pid: 123, + pid: activeStatusPid, startedAt: Date.now(), configPath: activeConfigPath, configMtimeMs: activeConfigMtime, @@ -59,6 +59,7 @@ function buildResponse(method: string, id: string) { let activeConfigPath: string; let activeConfigMtime: number | null = null; +let activeStatusPid = process.pid; let activeSocketPath: string; let previousDaemonDir: string | undefined; let activeLayers: Array<{ path: string; mtimeMs: number | null }> = []; @@ -84,6 +85,28 @@ describe('DaemonClient config freshness', () => { previousDaemonDir = process.env.MCPORTER_DAEMON_DIR; activeLayers = []; launchDaemonDetached.mockClear(); + launchDaemonDetached.mockImplementation( + (options: { metadataPath: string; socketPath: string; configPath: string }) => { + activeStatusPid = process.pid; + void fs.writeFile( + options.metadataPath, + JSON.stringify( + { + pid: process.pid, + socketPath: options.socketPath, + configPath: options.configPath, + startedAt: Date.now(), + logPath: null, + configMtimeMs: activeConfigMtime, + configLayers: activeLayers, + }, + null, + 2 + ), + 'utf8' + ); + } + ); }); afterEach(async () => { @@ -102,10 +125,12 @@ describe('DaemonClient config freshness', () => { await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }), 'utf8'); const stat = await fs.stat(configPath); const oldMtime = stat.mtimeMs - 1000; + const deadPid = findNonRunningPid(); const { metadataPath, socketPath } = resolveDaemonPaths(configPath); activeConfigPath = configPath; activeSocketPath = socketPath; activeConfigMtime = stat.mtimeMs; + activeStatusPid = deadPid; activeLayers = [{ path: configPath, mtimeMs: stat.mtimeMs }]; await fs.mkdir(path.dirname(metadataPath), { recursive: true }); @@ -113,7 +138,7 @@ describe('DaemonClient config freshness', () => { metadataPath, JSON.stringify( { - pid: 1111, + pid: deadPid, socketPath, configPath, startedAt: Date.now() - 10_000, @@ -143,10 +168,12 @@ describe('DaemonClient config freshness', () => { const configPath = path.join(tmpDir, 'config.json'); await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }), 'utf8'); const stat = await fs.stat(configPath); + const deadPid = findNonRunningPid(); const { metadataPath, socketPath } = resolveDaemonPaths(configPath); activeConfigPath = configPath; activeSocketPath = socketPath; activeConfigMtime = stat.mtimeMs; + activeStatusPid = deadPid; activeLayers = [{ path: configPath, mtimeMs: stat.mtimeMs }]; await fs.mkdir(path.dirname(metadataPath), { recursive: true }); @@ -154,7 +181,7 @@ describe('DaemonClient config freshness', () => { metadataPath, JSON.stringify( { - pid: 1111, + pid: deadPid, socketPath, configPath, startedAt: Date.now() - 10_000, @@ -189,6 +216,7 @@ describe('DaemonClient config freshness', () => { activeConfigPath = configPath; activeSocketPath = socketPath; activeConfigMtime = stat.mtimeMs; + activeStatusPid = process.pid; activeLayers = [{ path: configPath, mtimeMs: stat.mtimeMs }]; await fs.mkdir(path.dirname(metadataPath), { recursive: true }); @@ -196,7 +224,7 @@ describe('DaemonClient config freshness', () => { metadataPath, JSON.stringify( { - pid: 1111, + pid: process.pid, socketPath, configPath, startedAt: Date.now() - 10_000, @@ -213,8 +241,21 @@ describe('DaemonClient config freshness', () => { const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir }); await client.listTools({ server: 'playwright' }); - expect(sentMethods).toEqual(['listTools']); + expect(sentMethods).toEqual(['status', 'listTools']); expect(sentMethods).not.toContain('stop'); expect(launchDaemonDetached).not.toHaveBeenCalled(); }); }); + +function findNonRunningPid(): number { + for (let pid = process.pid + 100_000; pid < process.pid + 101_000; pid += 1) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + return pid; + } + } + } + throw new Error('Unable to find a non-running pid for daemon tests.'); +} diff --git a/tests/daemon-client-lifecycle.test.ts b/tests/daemon-client-lifecycle.test.ts new file mode 100644 index 0000000..81cf280 --- /dev/null +++ b/tests/daemon-client-lifecycle.test.ts @@ -0,0 +1,230 @@ +import fs from 'node:fs/promises'; +import net from 'node:net'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeShortTempDir } from './fixtures/test-helpers.js'; + +const launchDaemonDetached = vi.hoisted(() => vi.fn()); + +vi.mock('../src/daemon/launch.js', () => ({ + launchDaemonDetached, +})); + +const { DaemonClient, resolveDaemonPaths } = await import('../src/daemon/client.js'); + +interface MockDaemonOptions { + readonly configPath: string; + readonly socketPath: string; + readonly metadataPath: string; +} + +const servers: net.Server[] = []; +let previousDaemonDir: string | undefined; + +describe('DaemonClient lifecycle reconciliation', () => { + beforeEach(() => { + previousDaemonDir = process.env.MCPORTER_DAEMON_DIR; + launchDaemonDetached.mockReset(); + launchDaemonDetached.mockImplementation((options: MockDaemonOptions) => { + void startMockDaemon(options, process.pid); + }); + }); + + afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => closeServer(server))); + if (previousDaemonDir === undefined) { + delete process.env.MCPORTER_DAEMON_DIR; + } else { + process.env.MCPORTER_DAEMON_DIR = previousDaemonDir; + } + }); + + it('serializes concurrent daemon starts with a filesystem lock', async () => { + const tmpDir = await makeShortTempDir('daemon-lock'); + process.env.MCPORTER_DAEMON_DIR = tmpDir; + const configPath = path.join(tmpDir, 'config.json'); + await fs.writeFile( + configPath, + JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }), + 'utf8' + ); + + const firstClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir }); + const secondClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir }); + + await Promise.all([firstClient.listTools({ server: 'warm' }), secondClient.listTools({ server: 'warm' })]); + + expect(launchDaemonDetached).toHaveBeenCalledTimes(1); + }); + + it('rejects socket responders that do not match metadata pid', async () => { + const tmpDir = await makeShortTempDir('daemon-pid'); + process.env.MCPORTER_DAEMON_DIR = tmpDir; + const configPath = path.join(tmpDir, 'config.json'); + await fs.writeFile(configPath, JSON.stringify({ mcpServers: {} }), 'utf8'); + const { socketPath, metadataPath } = resolveDaemonPaths(configPath); + await fs.mkdir(path.dirname(metadataPath), { recursive: true }); + await fs.writeFile( + metadataPath, + JSON.stringify({ + pid: process.pid, + socketPath, + configPath, + configLayers: [{ path: configPath, mtimeMs: (await fs.stat(configPath)).mtimeMs }], + startedAt: Date.now(), + }), + 'utf8' + ); + await startStatusServer(socketPath, process.pid + 10_000, configPath); + + const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir }); + + await expect(client.status()).resolves.toBeNull(); + }); + + it('forces a new daemon after a request transport failure even when status still responds', async () => { + const tmpDir = await makeShortTempDir('daemon-restart'); + process.env.MCPORTER_DAEMON_DIR = tmpDir; + const configPath = path.join(tmpDir, 'config.json'); + await fs.writeFile( + configPath, + JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }), + 'utf8' + ); + const paths = resolveDaemonPaths(configPath); + await startMockDaemon({ ...paths, configPath }, process.pid, { failCallTool: true }); + + const client = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir }); + const result = await client.callTool({ server: 'warm', tool: 'list' }); + + expect(result).toEqual({ ok: true }); + expect(launchDaemonDetached).toHaveBeenCalledTimes(1); + }); + + it('deduplicates concurrent stale-config restarts after the first replacement wins', async () => { + const tmpDir = await makeShortTempDir('daemon-stale-lock'); + process.env.MCPORTER_DAEMON_DIR = tmpDir; + const configPath = path.join(tmpDir, 'config.json'); + await fs.writeFile( + configPath, + JSON.stringify({ mcpServers: { warm: { command: 'node', args: ['server.js'], lifecycle: 'keep-alive' } } }), + 'utf8' + ); + const stat = await fs.stat(configPath); + const deadPid = findNonRunningPid(); + const paths = resolveDaemonPaths(configPath); + await fs.mkdir(path.dirname(paths.metadataPath), { recursive: true }); + await fs.writeFile( + paths.metadataPath, + JSON.stringify({ + pid: deadPid, + socketPath: paths.socketPath, + configPath, + configLayers: [{ path: configPath, mtimeMs: stat.mtimeMs - 1000 }], + startedAt: Date.now() - 10_000, + }), + 'utf8' + ); + + const firstClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir }); + const secondClient = new DaemonClient({ configPath, configExplicit: true, rootDir: tmpDir }); + + await Promise.all([firstClient.listTools({ server: 'warm' }), secondClient.listTools({ server: 'warm' })]); + + expect(launchDaemonDetached).toHaveBeenCalledTimes(1); + }); +}); + +async function startMockDaemon( + options: MockDaemonOptions, + pid: number, + behavior: { failCallTool?: boolean } = {} +): Promise { + const stat = await fs.stat(options.configPath); + await startStatusServer(options.socketPath, pid, options.configPath, options.metadataPath, behavior); + await fs.mkdir(path.dirname(options.metadataPath), { recursive: true }); + await fs.writeFile( + options.metadataPath, + JSON.stringify({ + pid, + socketPath: options.socketPath, + configPath: options.configPath, + configLayers: [{ path: options.configPath, mtimeMs: stat.mtimeMs }], + startedAt: Date.now(), + }), + 'utf8' + ); +} + +async function startStatusServer( + socketPath: string, + pid: number, + configPath: string, + metadataPath?: string, + behavior: { failCallTool?: boolean } = {} +): Promise { + await fs.mkdir(path.dirname(socketPath), { recursive: true }); + await fs.unlink(socketPath).catch(() => {}); + const server = net.createServer((socket) => { + let buffer = ''; + socket.setEncoding('utf8'); + socket.on('data', (chunk) => { + buffer += chunk; + const request = JSON.parse(buffer) as { id: string; method: string }; + if (request.method === 'callTool' && behavior.failCallTool) { + behavior.failCallTool = false; + socket.destroy(); + return; + } + if (request.method === 'stop') { + socket.end(JSON.stringify({ id: request.id, ok: true, result: true }), () => { + server.close(() => {}); + if (metadataPath) { + void fs.unlink(metadataPath).catch(() => {}); + } + }); + return; + } + const result = + request.method === 'status' + ? { + pid, + startedAt: Date.now(), + configPath, + socketPath, + servers: [], + } + : request.method === 'callTool' + ? { ok: true } + : { tools: [] }; + socket.end(JSON.stringify({ id: request.id, ok: true, result })); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.off('error', reject); + servers.push(server); + resolve(); + }); + }); +} + +async function closeServer(server: net.Server): Promise { + await new Promise((resolve) => { + server.close(() => resolve()).on('error', () => resolve()); + }); +} + +function findNonRunningPid(): number { + for (let pid = process.pid + 100_000; pid < process.pid + 101_000; pid += 1) { + try { + process.kill(pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + return pid; + } + } + } + throw new Error('Unable to find a non-running pid for daemon tests.'); +} diff --git a/tests/daemon-client-timeout.test.ts b/tests/daemon-client-timeout.test.ts index 3119661..f34d6a0 100644 --- a/tests/daemon-client-timeout.test.ts +++ b/tests/daemon-client-timeout.test.ts @@ -1,5 +1,8 @@ import { EventEmitter } from 'node:events'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { makeShortTempDir } from './fixtures/test-helpers.js'; const timeoutRecords: Array<{ method: string; timeout: number }> = []; @@ -34,6 +37,8 @@ class MockSocket extends EventEmitter { } let responseDelayMs = 5; +let activeConfigPath = path.resolve('mcporter.config.json'); +let activeSocketPath = ''; const createConnection = vi.fn(() => { const socket = new MockSocket(); setTimeout(() => socket.emit('connect'), 0); @@ -41,6 +46,8 @@ const createConnection = vi.fn(() => { }); let previousDaemonTimeout: string | undefined; +let previousDaemonDir: string | undefined; +let tmpDaemonDir: string | undefined; vi.mock('node:net', () => ({ createConnection, @@ -51,7 +58,7 @@ vi.mock('../src/daemon/launch.js', () => ({ launchDaemonDetached: vi.fn(), })); -const { DaemonClient } = await import('../src/daemon/client.js'); +const { DaemonClient, resolveDaemonPaths } = await import('../src/daemon/client.js'); function buildResponse(method: string, id: string) { if (method === 'status') { @@ -59,10 +66,10 @@ function buildResponse(method: string, id: string) { id, ok: true, result: { - pid: 123, + pid: process.pid, startedAt: Date.now(), - configPath: 'test', - socketPath: '/tmp/socket', + configPath: activeConfigPath, + socketPath: activeSocketPath, servers: [], }, }; @@ -75,40 +82,92 @@ function buildResponse(method: string, id: string) { } describe('DaemonClient timeouts', () => { - beforeEach(() => { + beforeEach(async () => { timeoutRecords.length = 0; responseDelayMs = 5; previousDaemonTimeout = process.env.MCPORTER_DAEMON_TIMEOUT_MS; + previousDaemonDir = process.env.MCPORTER_DAEMON_DIR; + tmpDaemonDir = await makeShortTempDir('daemon-timeout'); + process.env.MCPORTER_DAEMON_DIR = tmpDaemonDir; delete process.env.MCPORTER_DAEMON_TIMEOUT_MS; }); - afterEach(() => { + afterEach(async () => { if (previousDaemonTimeout === undefined) { delete process.env.MCPORTER_DAEMON_TIMEOUT_MS; } else { process.env.MCPORTER_DAEMON_TIMEOUT_MS = previousDaemonTimeout; } + if (previousDaemonDir === undefined) { + delete process.env.MCPORTER_DAEMON_DIR; + } else { + process.env.MCPORTER_DAEMON_DIR = previousDaemonDir; + } + if (tmpDaemonDir) { + await fs.rm(tmpDaemonDir, { recursive: true, force: true }); + } }); it('defaults to 30s per request', async () => { - const client = new DaemonClient({ configPath: 'mcporter.config.json' }); + const configPath = 'mcporter.config.json'; + await writeFreshMetadata(configPath); + const client = new DaemonClient({ configPath, configExplicit: true }); await client.callTool({ server: 'foo', tool: 'bar' }); + const statusRecord = timeoutRecords.find((entry) => entry.method === 'status'); const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool'); + expect(statusRecord?.timeout).toBe(30_000); expect(callRecord?.timeout).toBe(30_000); }); it('honors MCPORTER_DAEMON_TIMEOUT_MS override', async () => { process.env.MCPORTER_DAEMON_TIMEOUT_MS = '4500'; - const client = new DaemonClient({ configPath: 'mcporter.config.json' }); + const configPath = 'mcporter.config.json'; + await writeFreshMetadata(configPath); + const client = new DaemonClient({ configPath, configExplicit: true }); await client.callTool({ server: 'foo', tool: 'bar' }); + const statusRecord = timeoutRecords.find((entry) => entry.method === 'status'); const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool'); + expect(statusRecord?.timeout).toBe(4_500); expect(callRecord?.timeout).toBe(4_500); }); it('honors per-call timeout overrides', async () => { - const client = new DaemonClient({ configPath: 'mcporter.config.json' }); + const configPath = 'mcporter.config.json'; + await writeFreshMetadata(configPath); + const client = new DaemonClient({ configPath, configExplicit: true }); await client.callTool({ server: 'foo', tool: 'bar', timeoutMs: 12_345 }); + const statusRecord = timeoutRecords.find((entry) => entry.method === 'status'); const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool'); + expect(statusRecord?.timeout).toBe(12_345); expect(callRecord?.timeout).toBe(12_345); }); + + it('clamps daemon status preflight timeout for tiny per-call timeouts', async () => { + const configPath = 'mcporter.config.json'; + await writeFreshMetadata(configPath); + const client = new DaemonClient({ configPath, configExplicit: true }); + await client.callTool({ server: 'foo', tool: 'bar', timeoutMs: 1 }); + const statusRecord = timeoutRecords.find((entry) => entry.method === 'status'); + const callRecord = timeoutRecords.find((entry) => entry.method === 'callTool'); + expect(statusRecord?.timeout).toBe(1_000); + expect(callRecord?.timeout).toBe(1); + }); }); + +async function writeFreshMetadata(configPath: string): Promise { + activeConfigPath = path.resolve(configPath); + const paths = resolveDaemonPaths(configPath); + activeSocketPath = paths.socketPath; + await fs.mkdir(path.dirname(paths.metadataPath), { recursive: true }); + await fs.writeFile( + paths.metadataPath, + JSON.stringify({ + pid: process.pid, + socketPath: paths.socketPath, + configPath, + configLayers: [{ path: activeConfigPath, mtimeMs: null }], + startedAt: Date.now(), + }), + 'utf8' + ); +} diff --git a/tests/daemon-client.test.ts b/tests/daemon-client.test.ts index 8e379f8..2c5fc77 100644 --- a/tests/daemon-client.test.ts +++ b/tests/daemon-client.test.ts @@ -90,7 +90,7 @@ describe('daemon client', () => { } }); - it('skips status preflight when daemon metadata is fresh', async () => { + it('verifies daemon pid before trusting fresh metadata', async () => { const tmpDir = await makeShortTempDir('mcpd-fresh'); const originalDir = process.env.MCPORTER_DAEMON_DIR; process.env.MCPORTER_DAEMON_DIR = tmpDir; @@ -121,7 +121,18 @@ describe('daemon client', () => { buffer += chunk; const request = JSON.parse(buffer) as { id: string; method: string }; methods.push(request.method); - socket.end(JSON.stringify({ id: request.id, ok: true, result: { tools: [] } })); + const result = + request.method === 'status' + ? { + pid: process.pid, + startedAt: Date.now(), + configPath, + configLayers: [{ path: configPath, mtimeMs: configStats.mtimeMs }], + socketPath, + servers: [], + } + : { tools: [] }; + socket.end(JSON.stringify({ id: request.id, ok: true, result })); }); }); await new Promise((resolve, reject) => { @@ -134,7 +145,7 @@ describe('daemon client', () => { try { const client = new DaemonClient({ configPath, configExplicit: true }); await client.listTools({ server: 'warm' }); - expect(methods).toEqual(['listTools']); + expect(methods).toEqual(['status', 'listTools']); } finally { await new Promise((resolve) => server.close(() => resolve())); await fs.unlink(socketPath).catch(() => {});