fix: reconcile daemon lifecycle starts

This commit is contained in:
Peter Steinberger 2026-05-28 16:45:31 +01:00
parent 552fcb1f60
commit f4f209317f
No known key found for this signature in database
6 changed files with 467 additions and 57 deletions

View File

@ -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)

View File

@ -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<StatusResult | null> {
try {
return (await this.sendRequest<StatusResult>('status', {})) as StatusResult;
} catch (error) {
if (isTransportError(error)) {
return null;
}
throw error;
}
return await this.readVerifiedStatus();
}
async stop(): Promise<void> {
@ -105,7 +100,7 @@ export class DaemonClient {
}
private async invoke<T = unknown>(method: DaemonRequestMethod, params: unknown, timeoutMs?: number): Promise<T> {
await this.ensureDaemon();
await this.ensureDaemon(timeoutMs);
try {
return (await this.sendRequest<T>(method, params, timeoutMs)) as T;
} catch (error) {
@ -117,47 +112,87 @@ export class DaemonClient {
}
}
private async ensureDaemon(): Promise<void> {
const configState = await this.checkConfigState();
private async ensureDaemon(timeoutMs?: number): Promise<void> {
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<void> {
await this.startDaemon();
await this.waitForReady();
private async restartDaemon(options: { reason?: 'stale-config'; expectedPid?: number } = {}): Promise<void> {
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<void> {
private async startDaemon(options: { preflightTimeoutMs?: number } = {}): Promise<void> {
await this.startingWithLock(async () => {
if (await this.isResponsive(options.preflightTimeoutMs)) {
return;
}
await this.launchDaemonAndWait();
});
}
private async startingWithLock(task: () => Promise<void>): Promise<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
private async isResponsive(timeoutMs?: number): Promise<boolean> {
return (await this.readVerifiedStatus(timeoutMs)) !== null;
}
private async readVerifiedStatus(timeoutMs?: number): Promise<StatusResult | null> {
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<StatusResult>('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<DaemonConfigState> {
const metadata = await readDaemonMetadata(this.metadataPath);
private async checkConfigState(metadata?: DaemonMetadata | null): Promise<DaemonConfigState> {
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<number | null> {
try {
const stats = await fs.stat(configPath);

View File

@ -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.');
}

View File

@ -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<void> {
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<void> {
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<void>((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, () => {
server.off('error', reject);
servers.push(server);
resolve();
});
});
}
async function closeServer(server: net.Server): Promise<void> {
await new Promise<void>((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.');
}

View File

@ -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<void> {
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'
);
}

View File

@ -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<void>((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<void>((resolve) => server.close(() => resolve()));
await fs.unlink(socketPath).catch(() => {});