Files
waggle-os/packages/server/tests/local/mcp-hot-reload.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

183 lines
7.2 KiB
TypeScript

/**
* MCP hot-reload — refreshMcpIfChanged (steal #7).
*
* Reconciles a live McpRuntime with the on-disk `.mcp.json` without a restart:
* signature fast-path, 3-way add/remove/change diff, restart-only-if-running,
* and no-teardown on a corrupt file. Uses the same DI'd PassThrough mock-spawn
* the agent + mcps route tests use so "running" servers are real (stopped)
* state transitions, not fakes.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { PassThrough } from 'node:stream';
import { McpRuntime, type McpProcess, type SpawnFn } from '@waggle/agent';
import {
mcpConfigPath,
saveMcpServerEntry,
populateMcpRuntimeFromConfig,
refreshMcpIfChanged,
_resetMcpSignatureCache,
} from '../../src/local/mcp-config.js';
interface MockRpcRequest { id?: number | null; method?: string }
function createMockSpawn(): SpawnFn {
return () => {
const stdin = new PassThrough();
const stdout = new PassThrough();
const stderr = new PassThrough();
const proc: McpProcess = {
stdin, stdout, stderr, pid: 4242,
kill: () => true,
on: () => proc,
removeAllListeners: () => proc,
};
stdin.on('data', (chunk: Buffer) => {
for (const line of chunk.toString().split('\n')) {
if (!line.trim()) continue;
let req: MockRpcRequest;
try { req = JSON.parse(line); } catch { continue; }
if (req.id == null) continue;
const result = req.method === 'tools/list'
? { tools: [{ name: 'ping', description: 'Ping', inputSchema: { type: 'object' } }] }
: {};
setImmediate(() => stdout.write(JSON.stringify({ jsonrpc: '2.0', id: req.id, result }) + '\n'));
}
});
return proc;
};
}
/** Bump the config file's mtime forward so the signature fast-path sees the
* write as a genuine external edit (what a real editor does). */
function writeConfig(dir: string, servers: Record<string, unknown>, mtimeStep: number): void {
fs.writeFileSync(mcpConfigPath(dir), JSON.stringify({ mcpServers: servers }, null, 2), 'utf-8');
const t = new Date(Date.now() + mtimeStep * 1000);
fs.utimesSync(mcpConfigPath(dir), t, t);
}
describe('refreshMcpIfChanged (MCP hot-reload)', () => {
let tmpDir: string;
let runtime: McpRuntime;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-mcp-reload-'));
runtime = new McpRuntime({ spawn: createMockSpawn() });
_resetMcpSignatureCache();
});
afterEach(async () => {
await runtime.stopAll();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
it('no-change fast path: an unchanged file is a no-op', async () => {
saveMcpServerEntry(tmpDir, 'alpha', { command: 'node' });
populateMcpRuntimeFromConfig(runtime, tmpDir);
// First call establishes the signature; runtime already matches the file.
const first = await refreshMcpIfChanged(runtime, tmpDir);
expect(first.changed).toBe(false);
expect(first.added).toEqual([]);
expect(first.removed).toEqual([]);
// Second call short-circuits on the unchanged signature.
const second = await refreshMcpIfChanged(runtime, tmpDir);
expect(second).toEqual({ changed: false, added: [], removed: [], reregistered: [], restarted: [], skipped: [] });
});
it('added: a new server in the file is registered stopped', async () => {
writeConfig(tmpDir, { alpha: { command: 'node' } }, 1);
populateMcpRuntimeFromConfig(runtime, tmpDir);
await refreshMcpIfChanged(runtime, tmpDir); // prime the signature
writeConfig(tmpDir, { alpha: { command: 'node' }, beta: { command: 'uvx' } }, 2);
const result = await refreshMcpIfChanged(runtime, tmpDir);
expect(result.changed).toBe(true);
expect(result.added).toEqual(['beta']);
expect(runtime.getServer('beta')?.getState()).toBe('stopped');
});
it('removed: a server dropped from the file is removed from the runtime', async () => {
writeConfig(tmpDir, { alpha: { command: 'node' }, beta: { command: 'uvx' } }, 1);
populateMcpRuntimeFromConfig(runtime, tmpDir);
await refreshMcpIfChanged(runtime, tmpDir);
writeConfig(tmpDir, { alpha: { command: 'node' } }, 2);
const result = await refreshMcpIfChanged(runtime, tmpDir);
expect(result.removed).toEqual(['beta']);
expect(runtime.getServer('beta')).toBeUndefined();
expect(runtime.getServer('alpha')).toBeDefined();
});
it('changed (stopped server): re-registers with new config but does not start it', async () => {
writeConfig(tmpDir, { alpha: { command: 'node', args: ['a.js'] } }, 1);
populateMcpRuntimeFromConfig(runtime, tmpDir);
await refreshMcpIfChanged(runtime, tmpDir);
writeConfig(tmpDir, { alpha: { command: 'node', args: ['b.js'] } }, 2);
const result = await refreshMcpIfChanged(runtime, tmpDir);
expect(result.reregistered).toEqual(['alpha']);
expect(result.restarted).toEqual([]);
expect(runtime.getServer('alpha')?.config.args).toEqual(['b.js']);
expect(runtime.getServer('alpha')?.getState()).toBe('stopped');
});
it('changed (running server): re-registers AND restarts', async () => {
writeConfig(tmpDir, { alpha: { command: 'node', args: ['a.js'] } }, 1);
populateMcpRuntimeFromConfig(runtime, tmpDir);
await runtime.getServer('alpha')!.start();
expect(runtime.getServer('alpha')?.getState()).toBe('ready');
await refreshMcpIfChanged(runtime, tmpDir);
writeConfig(tmpDir, { alpha: { command: 'node', args: ['b.js'] } }, 2);
const result = await refreshMcpIfChanged(runtime, tmpDir);
expect(result.reregistered).toEqual(['alpha']);
expect(result.restarted).toEqual(['alpha']);
expect(runtime.getServer('alpha')?.config.args).toEqual(['b.js']);
expect(runtime.getServer('alpha')?.getState()).toBe('ready');
});
it('parse failure: warns and never tears down running servers', async () => {
writeConfig(tmpDir, { alpha: { command: 'node' } }, 1);
populateMcpRuntimeFromConfig(runtime, tmpDir);
await runtime.getServer('alpha')!.start();
await refreshMcpIfChanged(runtime, tmpDir);
// Corrupt the file, forward the mtime.
fs.writeFileSync(mcpConfigPath(tmpDir), '{ "mcpServers": { not json', 'utf-8');
const t = new Date(Date.now() + 5000);
fs.utimesSync(mcpConfigPath(tmpDir), t, t);
const warnings: string[] = [];
const result = await refreshMcpIfChanged(runtime, tmpDir, { warn: (m) => warnings.push(m) });
expect(result.error).toBeDefined();
expect(result.removed).toEqual([]);
expect(warnings).toHaveLength(1);
// The server survives the bad file, still running.
expect(runtime.getServer('alpha')?.getState()).toBe('ready');
});
it('skips invalid entries without registering them', async () => {
writeConfig(tmpDir, {}, 1);
populateMcpRuntimeFromConfig(runtime, tmpDir);
await refreshMcpIfChanged(runtime, tmpDir);
// Empty command fails validateMcpEntry.
writeConfig(tmpDir, { bad: { command: '' } }, 2);
const result = await refreshMcpIfChanged(runtime, tmpDir);
expect(result.added).toEqual([]);
expect(result.skipped.map((s) => s.name)).toContain('bad');
expect(runtime.getServer('bad')).toBeUndefined();
});
});