import { describe, it, expect, vi } from 'vitest'; import { PassThrough } from 'node:stream'; import { PersistentStdioClient } from '../src/services/transport/persistent-stdio.js'; import type { InteractiveExec, McpOrchestrator } from '../src/services/orchestrator.js'; function makeFakeExec(): { iexec: InteractiveExec; written: string[]; emit: (line: unknown) => void; } { const stdout = new PassThrough(); const written: string[] = []; const iexec: InteractiveExec = { stdout, write(data) { written.push(data); }, close() { stdout.destroy(); }, }; const emit = (msg: unknown) => { stdout.write(JSON.stringify(msg) + '\n'); }; return { iexec, written, emit }; } function makeOrchestrator(overrides: Partial = {}): McpOrchestrator { return { pullImage: vi.fn(), createContainer: vi.fn(), stopContainer: vi.fn(), removeContainer: vi.fn(), inspectContainer: vi.fn(), getContainerLogs: vi.fn(), execInContainer: vi.fn(), ping: vi.fn(), ...overrides, } as McpOrchestrator; } describe('PersistentStdioClient', () => { it('exec mode calls execInteractive with the command', async () => { const fake = makeFakeExec(); const execInteractive = vi.fn(async () => fake.iexec); const orch = makeOrchestrator({ execInteractive }); const client = new PersistentStdioClient( orch, 'container-1', { kind: 'exec', command: ['node', 'index.js'] }, ); // Drive the handshake: respond to the first init request (id=1) // then to the subsequent tools/list request (id=2). const sendPromise = client.send('tools/list'); await new Promise((r) => setTimeout(r, 10)); const init = JSON.parse(fake.written[0]!); expect(init.method).toBe('initialize'); fake.emit({ jsonrpc: '2.0', id: init.id, result: { capabilities: {} } }); await new Promise((r) => setTimeout(r, 150)); // Second written msg is notifications/initialized; third is tools/list const toolsReq = JSON.parse(fake.written[2]!); expect(toolsReq.method).toBe('tools/list'); fake.emit({ jsonrpc: '2.0', id: toolsReq.id, result: { tools: [] } }); const res = await sendPromise; expect(res.result).toEqual({ tools: [] }); expect(execInteractive).toHaveBeenCalledWith('container-1', ['node', 'index.js']); client.close(); }); it('attach mode calls attachInteractive and never execInteractive', async () => { const fake = makeFakeExec(); const attachInteractive = vi.fn(async () => fake.iexec); const execInteractive = vi.fn(); const orch = makeOrchestrator({ attachInteractive, execInteractive }); const client = new PersistentStdioClient( orch, 'container-gitea', { kind: 'attach' }, ); const sendPromise = client.send('tools/list'); await new Promise((r) => setTimeout(r, 10)); const init = JSON.parse(fake.written[0]!); fake.emit({ jsonrpc: '2.0', id: init.id, result: { capabilities: {} } }); await new Promise((r) => setTimeout(r, 150)); const req = JSON.parse(fake.written[2]!); fake.emit({ jsonrpc: '2.0', id: req.id, result: { tools: [{ name: 'list_repos' }] } }); const res = await sendPromise; expect((res.result as { tools: unknown[] }).tools).toHaveLength(1); expect(attachInteractive).toHaveBeenCalledWith('container-gitea'); expect(execInteractive).not.toHaveBeenCalled(); client.close(); }); it('attach mode throws if orchestrator does not support attach', async () => { const orch = makeOrchestrator({}); // no attachInteractive const client = new PersistentStdioClient(orch, 'c', { kind: 'attach' }); await expect(client.send('tools/list')).rejects.toThrow(/attach/i); }); it('exec mode throws if orchestrator does not support execInteractive', async () => { const orch = makeOrchestrator({}); // no execInteractive const client = new PersistentStdioClient(orch, 'c', { kind: 'exec', command: ['x'] }); await expect(client.send('tools/list')).rejects.toThrow(/interactive exec/i); }); });