112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
|
|
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> = {}): 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);
|
||
|
|
});
|
||
|
|
});
|