keystone-cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +326 -59
  2. package/package.json +1 -1
  3. package/src/cli.ts +90 -81
  4. package/src/db/workflow-db.ts +0 -7
  5. package/src/expression/evaluator.test.ts +42 -0
  6. package/src/expression/evaluator.ts +28 -0
  7. package/src/parser/agent-parser.test.ts +10 -0
  8. package/src/parser/agent-parser.ts +2 -1
  9. package/src/parser/config-schema.ts +13 -5
  10. package/src/parser/workflow-parser.ts +0 -5
  11. package/src/runner/llm-adapter.test.ts +0 -8
  12. package/src/runner/llm-adapter.ts +33 -10
  13. package/src/runner/llm-executor.test.ts +59 -18
  14. package/src/runner/llm-executor.ts +1 -1
  15. package/src/runner/mcp-client.test.ts +166 -88
  16. package/src/runner/mcp-client.ts +156 -22
  17. package/src/runner/mcp-manager.test.ts +73 -15
  18. package/src/runner/mcp-manager.ts +44 -18
  19. package/src/runner/mcp-server.test.ts +4 -1
  20. package/src/runner/mcp-server.ts +25 -11
  21. package/src/runner/shell-executor.ts +3 -3
  22. package/src/runner/step-executor.ts +10 -9
  23. package/src/runner/tool-integration.test.ts +21 -14
  24. package/src/runner/workflow-runner.ts +25 -5
  25. package/src/templates/agents/explore.md +54 -0
  26. package/src/templates/agents/general.md +8 -0
  27. package/src/templates/agents/keystone-architect.md +54 -0
  28. package/src/templates/agents/my-agent.md +3 -0
  29. package/src/templates/agents/summarizer.md +28 -0
  30. package/src/templates/agents/test-agent.md +10 -0
  31. package/src/templates/approval-process.yaml +36 -0
  32. package/src/templates/basic-inputs.yaml +19 -0
  33. package/src/templates/basic-shell.yaml +20 -0
  34. package/src/templates/batch-processor.yaml +43 -0
  35. package/src/templates/cleanup-finally.yaml +22 -0
  36. package/src/templates/composition-child.yaml +13 -0
  37. package/src/templates/composition-parent.yaml +14 -0
  38. package/src/templates/data-pipeline.yaml +38 -0
  39. package/src/templates/full-feature-demo.yaml +64 -0
  40. package/src/templates/human-interaction.yaml +12 -0
  41. package/src/templates/invalid.yaml +5 -0
  42. package/src/templates/llm-agent.yaml +8 -0
  43. package/src/templates/loop-parallel.yaml +37 -0
  44. package/src/templates/retry-policy.yaml +36 -0
  45. package/src/templates/scaffold-feature.yaml +48 -0
  46. package/src/templates/state.db +0 -0
  47. package/src/templates/state.db-shm +0 -0
  48. package/src/templates/state.db-wal +0 -0
  49. package/src/templates/stop-watch.yaml +17 -0
  50. package/src/templates/workflow.db +0 -0
  51. package/src/utils/config-loader.test.ts +2 -2
@@ -1,8 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
- import { MCPClient } from './mcp-client';
3
2
  import * as child_process from 'node:child_process';
4
3
  import { EventEmitter } from 'node:events';
5
4
  import { Readable, Writable } from 'node:stream';
5
+ import { MCPClient } from './mcp-client';
6
6
 
7
7
  interface MockProcess extends EventEmitter {
8
8
  stdout: Readable;
@@ -11,112 +11,190 @@ interface MockProcess extends EventEmitter {
11
11
  }
12
12
 
13
13
  describe('MCPClient', () => {
14
- let mockProcess: MockProcess;
15
- let spawnSpy: ReturnType<typeof spyOn>;
14
+ describe('Local Transport', () => {
15
+ let mockProcess: MockProcess;
16
+ let spawnSpy: ReturnType<typeof spyOn>;
17
+
18
+ beforeEach(() => {
19
+ const emitter = new EventEmitter();
20
+ const stdout = new Readable({
21
+ read() {},
22
+ });
23
+ const stdin = new Writable({
24
+ write(_chunk, _encoding, callback) {
25
+ callback();
26
+ },
27
+ });
28
+ const kill = mock(() => {});
16
29
 
17
- beforeEach(() => {
18
- const emitter = new EventEmitter();
19
- const stdout = new Readable({
20
- read() {},
30
+ mockProcess = Object.assign(emitter, { stdout, stdin, kill }) as unknown as MockProcess;
31
+
32
+ spawnSpy = spyOn(child_process, 'spawn').mockReturnValue(
33
+ mockProcess as unknown as child_process.ChildProcess
34
+ );
21
35
  });
22
- const stdin = new Writable({
23
- write(_chunk, _encoding, callback) {
24
- callback();
25
- },
36
+
37
+ afterEach(() => {
38
+ spawnSpy.mockRestore();
26
39
  });
27
- const kill = mock(() => {});
28
40
 
29
- mockProcess = Object.assign(emitter, { stdout, stdin, kill }) as unknown as MockProcess;
41
+ it('should initialize correctly', async () => {
42
+ const client = await MCPClient.createLocal('node', ['server.js']);
43
+ const initPromise = client.initialize();
44
+
45
+ // Simulate server response
46
+ mockProcess.stdout.push(
47
+ `${JSON.stringify({
48
+ jsonrpc: '2.0',
49
+ id: 0,
50
+ result: { protocolVersion: '2024-11-05' },
51
+ })}\n`
52
+ );
53
+
54
+ const response = await initPromise;
55
+ expect(response.result?.protocolVersion).toBe('2024-11-05');
56
+ expect(spawnSpy).toHaveBeenCalledWith('node', ['server.js'], expect.any(Object));
57
+ });
30
58
 
31
- spawnSpy = spyOn(child_process, 'spawn').mockReturnValue(
32
- mockProcess as unknown as child_process.ChildProcess
33
- );
34
- });
59
+ it('should list tools', async () => {
60
+ const client = await MCPClient.createLocal('node');
61
+ const listPromise = client.listTools();
62
+
63
+ mockProcess.stdout.push(
64
+ `${JSON.stringify({
65
+ jsonrpc: '2.0',
66
+ id: 0,
67
+ result: {
68
+ tools: [{ name: 'test-tool', inputSchema: {} }],
69
+ },
70
+ })}\n`
71
+ );
72
+
73
+ const tools = await listPromise;
74
+ expect(tools).toHaveLength(1);
75
+ expect(tools[0].name).toBe('test-tool');
76
+ });
35
77
 
36
- afterEach(() => {
37
- spawnSpy.mockRestore();
38
- });
78
+ it('should call tool', async () => {
79
+ const client = await MCPClient.createLocal('node');
80
+ const callPromise = client.callTool('my-tool', { arg: 1 });
39
81
 
40
- it('should initialize correctly', async () => {
41
- const client = new MCPClient('node', ['server.js']);
42
- const initPromise = client.initialize();
43
-
44
- // Simulate server response
45
- mockProcess.stdout.push(
46
- `${JSON.stringify({
47
- jsonrpc: '2.0',
48
- id: 0,
49
- result: { protocolVersion: '2024-11-05' },
50
- })}\n`
51
- );
52
-
53
- const response = await initPromise;
54
- expect(response.result?.protocolVersion).toBe('2024-11-05');
55
- expect(spawnSpy).toHaveBeenCalledWith('node', ['server.js'], expect.any(Object));
56
- });
82
+ mockProcess.stdout.push(
83
+ `${JSON.stringify({
84
+ jsonrpc: '2.0',
85
+ id: 0,
86
+ result: { content: [{ type: 'text', text: 'success' }] },
87
+ })}\n`
88
+ );
89
+
90
+ const result = await callPromise;
91
+ expect((result as { content: Array<{ text: string }> }).content[0].text).toBe('success');
92
+ });
57
93
 
58
- it('should list tools', async () => {
59
- const client = new MCPClient('node');
60
- const listPromise = client.listTools();
94
+ it('should handle tool call error', async () => {
95
+ const client = await MCPClient.createLocal('node');
96
+ const callPromise = client.callTool('my-tool', {});
61
97
 
62
- mockProcess.stdout.push(
63
- `${JSON.stringify({
64
- jsonrpc: '2.0',
65
- id: 0,
66
- result: {
67
- tools: [{ name: 'test-tool', inputSchema: {} }],
68
- },
69
- })}\n`
70
- );
98
+ mockProcess.stdout.push(
99
+ `${JSON.stringify({
100
+ jsonrpc: '2.0',
101
+ id: 0,
102
+ error: { code: -32000, message: 'Tool error' },
103
+ })}\n`
104
+ );
71
105
 
72
- const tools = await listPromise;
73
- expect(tools).toHaveLength(1);
74
- expect(tools[0].name).toBe('test-tool');
75
- });
106
+ await expect(callPromise).rejects.toThrow(/MCP tool call failed/);
107
+ });
76
108
 
77
- it('should call tool', async () => {
78
- const client = new MCPClient('node');
79
- const callPromise = client.callTool('my-tool', { arg: 1 });
109
+ it('should timeout on request', async () => {
110
+ // Set a very short timeout for testing
111
+ const client = await MCPClient.createLocal('node', [], {}, 10);
112
+ const requestPromise = client.initialize();
80
113
 
81
- mockProcess.stdout.push(
82
- `${JSON.stringify({
83
- jsonrpc: '2.0',
84
- id: 0,
85
- result: { content: [{ type: 'text', text: 'success' }] },
86
- })}\n`
87
- );
114
+ // Don't push any response to stdout
115
+ await expect(requestPromise).rejects.toThrow(/MCP request timeout/);
116
+ });
88
117
 
89
- const result = await callPromise;
90
- expect((result as { content: Array<{ text: string }> }).content[0].text).toBe('success');
118
+ it('should stop the process', async () => {
119
+ const client = await MCPClient.createLocal('node');
120
+ client.stop();
121
+ expect(mockProcess.kill).toHaveBeenCalled();
122
+ });
91
123
  });
92
124
 
93
- it('should handle tool call error', async () => {
94
- const client = new MCPClient('node');
95
- const callPromise = client.callTool('my-tool', {});
125
+ describe('SSE Transport', () => {
126
+ it('should connect and receive endpoint', async () => {
127
+ const mockEventSource = new EventEmitter();
128
+ // @ts-ignore
129
+ mockEventSource.addEventListener = mockEventSource.on;
130
+ // @ts-ignore
131
+ mockEventSource.close = mock(() => {});
132
+ // @ts-ignore
133
+ global.EventSource = mock(() => mockEventSource);
134
+
135
+ const clientPromise = MCPClient.createRemote('http://localhost:8080/sse');
136
+
137
+ // Simulate endpoint event
138
+ mockEventSource.emit('endpoint', { data: '/endpoint' });
139
+
140
+ const client = await clientPromise;
141
+ expect(client).toBeDefined();
142
+
143
+ // Test sending a message
144
+ const fetchMock = spyOn(global, 'fetch').mockImplementation(() =>
145
+ Promise.resolve(new Response(JSON.stringify({ ok: true })))
146
+ );
147
+
148
+ const initPromise = client.initialize();
149
+
150
+ // Simulate message event (response from server)
151
+ mockEventSource.emit('message', {
152
+ data: JSON.stringify({
153
+ jsonrpc: '2.0',
154
+ id: 0,
155
+ result: { protocolVersion: '2024-11-05' },
156
+ }),
157
+ });
158
+
159
+ const response = await initPromise;
160
+ expect(response.result?.protocolVersion).toBe('2024-11-05');
161
+ expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/endpoint', expect.any(Object));
162
+
163
+ client.stop();
164
+ // @ts-ignore
165
+ expect(mockEventSource.close).toHaveBeenCalled();
166
+
167
+ fetchMock.mockRestore();
168
+ // @ts-ignore
169
+ global.EventSource = undefined;
170
+ });
96
171
 
97
- mockProcess.stdout.push(
98
- `${JSON.stringify({
99
- jsonrpc: '2.0',
100
- id: 0,
101
- error: { code: -32000, message: 'Tool error' },
102
- })}\n`
103
- );
172
+ it('should handle SSE connection failure', async () => {
173
+ const mockEventSource = {
174
+ addEventListener: mock((_event: string, callback: (arg: unknown) => void) => {
175
+ if (_event === 'error') {
176
+ // Store the callback to trigger it later
177
+ mockEventSource.onerror = callback;
178
+ }
179
+ }),
180
+ onerror: null as ((arg: unknown) => void) | null,
181
+ close: mock(() => {}),
182
+ };
104
183
 
105
- await expect(callPromise).rejects.toThrow(/MCP tool call failed/);
106
- });
184
+ // @ts-ignore
185
+ global.EventSource = mock(() => mockEventSource);
107
186
 
108
- it('should timeout on request', async () => {
109
- // Set a very short timeout for testing
110
- const client = new MCPClient('node', [], {}, 10);
111
- const requestPromise = client.initialize();
187
+ const clientPromise = MCPClient.createRemote('http://localhost:8080/sse');
112
188
 
113
- // Don't push any response to stdout
114
- await expect(requestPromise).rejects.toThrow(/MCP request timeout/);
115
- });
189
+ // Trigger the onerror callback
190
+ if (mockEventSource.onerror) {
191
+ mockEventSource.onerror({ message: 'Connection failed' });
192
+ }
116
193
 
117
- it('should stop the process', () => {
118
- const client = new MCPClient('node');
119
- client.stop();
120
- expect(mockProcess.kill).toHaveBeenCalled();
194
+ await expect(clientPromise).rejects.toThrow(/SSE connection failed/);
195
+
196
+ // @ts-ignore
197
+ global.EventSource = undefined;
198
+ });
121
199
  });
122
200
  });
@@ -1,5 +1,6 @@
1
1
  import { type ChildProcess, spawn } from 'node:child_process';
2
2
  import { type Interface, createInterface } from 'node:readline';
3
+ import pkg from '../../package.json' with { type: 'json' };
3
4
 
4
5
  interface MCPTool {
5
6
  name: string;
@@ -7,7 +8,7 @@ interface MCPTool {
7
8
  inputSchema: unknown;
8
9
  }
9
10
 
10
- interface MCPResponse {
11
+ export interface MCPResponse {
11
12
  id?: number;
12
13
  result?: {
13
14
  tools?: MCPTool[];
@@ -21,20 +22,17 @@ interface MCPResponse {
21
22
  };
22
23
  }
23
24
 
24
- export class MCPClient {
25
+ interface MCPTransport {
26
+ send(message: unknown): Promise<void>;
27
+ onMessage(callback: (message: MCPResponse) => void): void;
28
+ close(): void;
29
+ }
30
+
31
+ class StdConfigTransport implements MCPTransport {
25
32
  private process: ChildProcess;
26
33
  private rl: Interface;
27
- private messageId = 0;
28
- private pendingRequests = new Map<number, (response: MCPResponse) => void>();
29
- private timeout: number;
30
34
 
31
- constructor(
32
- command: string,
33
- args: string[] = [],
34
- env: Record<string, string> = {},
35
- timeout = 30000
36
- ) {
37
- this.timeout = timeout;
35
+ constructor(command: string, args: string[] = [], env: Record<string, string> = {}) {
38
36
  this.process = spawn(command, args, {
39
37
  env: { ...process.env, ...env },
40
38
  stdio: ['pipe', 'pipe', 'inherit'],
@@ -47,23 +45,156 @@ export class MCPClient {
47
45
  this.rl = createInterface({
48
46
  input: this.process.stdout,
49
47
  });
48
+ }
49
+
50
+ async send(message: unknown): Promise<void> {
51
+ this.process.stdin?.write(`${JSON.stringify(message)}\n`);
52
+ }
50
53
 
54
+ onMessage(callback: (message: MCPResponse) => void): void {
51
55
  this.rl.on('line', (line) => {
52
56
  try {
53
57
  const response = JSON.parse(line) as MCPResponse;
54
- if (response.id !== undefined && this.pendingRequests.has(response.id)) {
55
- const resolve = this.pendingRequests.get(response.id);
56
- if (resolve) {
57
- this.pendingRequests.delete(response.id);
58
- resolve(response);
59
- }
60
- }
58
+ callback(response);
61
59
  } catch (e) {
62
60
  // Ignore non-JSON lines
63
61
  }
64
62
  });
65
63
  }
66
64
 
65
+ close(): void {
66
+ this.process.kill();
67
+ }
68
+ }
69
+
70
+ class SSETransport implements MCPTransport {
71
+ private url: string;
72
+ private headers: Record<string, string>;
73
+ private eventSource: EventSource | null = null;
74
+ private endpoint?: string;
75
+ private onMessageCallback?: (message: MCPResponse) => void;
76
+
77
+ constructor(url: string, headers: Record<string, string> = {}) {
78
+ this.url = url;
79
+ this.headers = headers;
80
+ }
81
+
82
+ async connect(): Promise<void> {
83
+ return new Promise((resolve, reject) => {
84
+ // @ts-ignore - Bun supports EventSource
85
+ this.eventSource = new EventSource(this.url, { headers: this.headers });
86
+
87
+ if (!this.eventSource) {
88
+ reject(new Error('Failed to create EventSource'));
89
+ return;
90
+ }
91
+
92
+ this.eventSource.addEventListener('endpoint', (event: MessageEvent) => {
93
+ this.endpoint = event.data;
94
+ if (this.endpoint?.startsWith('/')) {
95
+ const urlObj = new URL(this.url);
96
+ this.endpoint = `${urlObj.origin}${this.endpoint}`;
97
+ }
98
+ resolve();
99
+ });
100
+
101
+ this.eventSource.addEventListener('message', (event: MessageEvent) => {
102
+ if (this.onMessageCallback) {
103
+ try {
104
+ const response = JSON.parse(event.data) as MCPResponse;
105
+ this.onMessageCallback(response);
106
+ } catch (e) {
107
+ // Ignore
108
+ }
109
+ }
110
+ });
111
+
112
+ this.eventSource.onerror = (err) => {
113
+ const error = err as ErrorEvent;
114
+ reject(new Error(`SSE connection failed: ${error?.message || 'Unknown error'}`));
115
+ };
116
+ });
117
+ }
118
+
119
+ async send(message: unknown): Promise<void> {
120
+ if (!this.endpoint) {
121
+ throw new Error('SSE transport not connected or endpoint not received');
122
+ }
123
+
124
+ const response = await fetch(this.endpoint, {
125
+ method: 'POST',
126
+ headers: {
127
+ 'Content-Type': 'application/json',
128
+ ...this.headers,
129
+ },
130
+ body: JSON.stringify(message),
131
+ });
132
+
133
+ if (!response.ok) {
134
+ throw new Error(`Failed to send message to MCP server: ${response.statusText}`);
135
+ }
136
+ }
137
+
138
+ onMessage(callback: (message: MCPResponse) => void): void {
139
+ this.onMessageCallback = callback;
140
+ }
141
+
142
+ close(): void {
143
+ this.eventSource?.close();
144
+ }
145
+ }
146
+
147
+ export class MCPClient {
148
+ private transport: MCPTransport;
149
+ private messageId = 0;
150
+ private pendingRequests = new Map<number, (response: MCPResponse) => void>();
151
+ private timeout: number;
152
+
153
+ constructor(
154
+ transportOrCommand: MCPTransport | string,
155
+ timeoutOrArgs: number | string[] = [],
156
+ env: Record<string, string> = {},
157
+ timeout = 30000
158
+ ) {
159
+ if (typeof transportOrCommand === 'string') {
160
+ this.transport = new StdConfigTransport(transportOrCommand, timeoutOrArgs as string[], env);
161
+ this.timeout = timeout;
162
+ } else {
163
+ this.transport = transportOrCommand;
164
+ this.timeout = (timeoutOrArgs as number) || 30000;
165
+ }
166
+
167
+ this.transport.onMessage((response) => {
168
+ if (response.id !== undefined && this.pendingRequests.has(response.id)) {
169
+ const resolve = this.pendingRequests.get(response.id);
170
+ if (resolve) {
171
+ this.pendingRequests.delete(response.id);
172
+ resolve(response);
173
+ }
174
+ }
175
+ });
176
+ }
177
+
178
+ static async createLocal(
179
+ command: string,
180
+ args: string[] = [],
181
+ env: Record<string, string> = {},
182
+ timeout = 30000
183
+ ): Promise<MCPClient> {
184
+ const transport = new StdConfigTransport(command, args, env);
185
+ return new MCPClient(transport, timeout);
186
+ }
187
+
188
+ static async createRemote(
189
+ url: string,
190
+ headers: Record<string, string> = {},
191
+ timeout = 30000
192
+ ): Promise<MCPClient> {
193
+ const transport = new SSETransport(url, headers);
194
+ await transport.connect();
195
+ return new MCPClient(transport, timeout);
196
+ }
197
+
67
198
  private async request(
68
199
  method: string,
69
200
  params: Record<string, unknown> = {}
@@ -78,7 +209,10 @@ export class MCPClient {
78
209
 
79
210
  return new Promise((resolve, reject) => {
80
211
  this.pendingRequests.set(id, resolve);
81
- this.process.stdin?.write(`${JSON.stringify(message)}\n`);
212
+ this.transport.send(message).catch((err) => {
213
+ this.pendingRequests.delete(id);
214
+ reject(err);
215
+ });
82
216
 
83
217
  // Add a timeout
84
218
  setTimeout(() => {
@@ -96,7 +230,7 @@ export class MCPClient {
96
230
  capabilities: {},
97
231
  clientInfo: {
98
232
  name: 'keystone-cli',
99
- version: '0.1.0',
233
+ version: pkg.version,
100
234
  },
101
235
  });
102
236
  }
@@ -118,6 +252,6 @@ export class MCPClient {
118
252
  }
119
253
 
120
254
  stop() {
121
- this.process.kill();
255
+ this.transport.close();
122
256
  }
123
257
  }
@@ -1,10 +1,12 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
- import { MCPManager } from './mcp-manager';
3
- import { MCPClient } from './mcp-client';
4
- import { ConfigLoader } from '../utils/config-loader';
5
2
  import * as child_process from 'node:child_process';
6
- import { Readable, Writable } from 'node:stream';
7
3
  import { EventEmitter } from 'node:events';
4
+ import { Readable, Writable } from 'node:stream';
5
+ import { ConfigLoader } from '../utils/config-loader';
6
+ import { MCPClient, type MCPResponse } from './mcp-client';
7
+ import { MCPManager } from './mcp-manager';
8
+
9
+ import type { Config } from '../parser/config-schema';
8
10
 
9
11
  describe('MCPManager', () => {
10
12
  let spawnSpy: ReturnType<typeof spyOn>;
@@ -15,7 +17,7 @@ describe('MCPManager', () => {
15
17
  const mockProcess = Object.assign(new EventEmitter(), {
16
18
  stdout: new Readable({ read() {} }),
17
19
  stdin: new Writable({
18
- write(_chunk, _encoding, cb) {
20
+ write(_chunk, _encoding, cb: (error?: Error | null) => void) {
19
21
  cb();
20
22
  },
21
23
  }),
@@ -35,6 +37,7 @@ describe('MCPManager', () => {
35
37
  ConfigLoader.setConfig({
36
38
  mcp_servers: {
37
39
  'test-server': {
40
+ type: 'local',
38
41
  command: 'node',
39
42
  args: ['test.js'],
40
43
  env: { FOO: 'bar' },
@@ -43,7 +46,9 @@ describe('MCPManager', () => {
43
46
  providers: {},
44
47
  model_mappings: {},
45
48
  default_provider: 'openai',
46
- });
49
+ storage: { retention_days: 30 },
50
+ workflows_directory: 'workflows',
51
+ } as unknown as Config);
47
52
 
48
53
  const manager = new MCPManager();
49
54
  const servers = manager.getGlobalServers();
@@ -56,13 +61,16 @@ describe('MCPManager', () => {
56
61
  ConfigLoader.setConfig({
57
62
  mcp_servers: {
58
63
  'test-server': {
64
+ type: 'local',
59
65
  command: 'node',
60
66
  },
61
67
  },
62
68
  providers: {},
63
69
  model_mappings: {},
64
70
  default_provider: 'openai',
65
- });
71
+ storage: { retention_days: 30 },
72
+ workflows_directory: 'workflows',
73
+ } as unknown as Config);
66
74
 
67
75
  const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue({
68
76
  result: { protocolVersion: '1.0' },
@@ -99,6 +107,7 @@ describe('MCPManager', () => {
99
107
  const manager = new MCPManager();
100
108
  const client = await manager.getClient({
101
109
  name: 'adhoc',
110
+ type: 'local',
102
111
  command: 'node',
103
112
  });
104
113
 
@@ -114,30 +123,79 @@ describe('MCPManager', () => {
114
123
  expect(client).toBeUndefined();
115
124
  });
116
125
 
126
+ it('should handle concurrent connection requests without double-spawning', async () => {
127
+ ConfigLoader.setConfig({
128
+ mcp_servers: {
129
+ 'concurrent-server': {
130
+ type: 'local',
131
+ command: 'node',
132
+ },
133
+ },
134
+ providers: {},
135
+ model_mappings: {},
136
+ default_provider: 'openai',
137
+ storage: { retention_days: 30 },
138
+ workflows_directory: 'workflows',
139
+ } as unknown as Config);
140
+
141
+ // Mock initialize to take some time
142
+ let initCalls = 0;
143
+ const initSpy = spyOn(MCPClient.prototype, 'initialize').mockImplementation(async () => {
144
+ initCalls++;
145
+ await new Promise((resolve) => setTimeout(resolve, 50));
146
+ return {
147
+ result: { protocolVersion: '1.0' },
148
+ jsonrpc: '2.0',
149
+ id: 0,
150
+ } as MCPResponse;
151
+ });
152
+
153
+ const manager = new MCPManager();
154
+
155
+ // Fire off multiple requests concurrently
156
+ const p1 = manager.getClient('concurrent-server');
157
+ const p2 = manager.getClient('concurrent-server');
158
+ const p3 = manager.getClient('concurrent-server');
159
+
160
+ const [c1, c2, c3] = await Promise.all([p1, p2, p3]);
161
+
162
+ expect(c1).toBeDefined();
163
+ expect(c1).toBe(c2);
164
+ expect(c1).toBe(c3);
165
+ expect(initCalls).toBe(1); // Crucial: only one initialization
166
+
167
+ initSpy.mockRestore();
168
+ });
169
+
117
170
  it('should handle connection failure', async () => {
118
171
  ConfigLoader.setConfig({
119
172
  mcp_servers: {
120
173
  'fail-server': {
174
+ type: 'local',
121
175
  command: 'fail',
122
176
  },
123
177
  },
124
178
  providers: {},
125
179
  model_mappings: {},
126
180
  default_provider: 'openai',
127
- });
128
-
129
- const initSpy = spyOn(MCPClient.prototype, 'initialize').mockRejectedValue(
130
- new Error('Connection failed')
181
+ storage: { retention_days: 30 },
182
+ workflows_directory: 'workflows',
183
+ } as unknown as Config);
184
+
185
+ const createLocalSpy = spyOn(MCPClient, 'createLocal').mockImplementation(
186
+ async (_cmd: string) => {
187
+ const client = Object.create(MCPClient.prototype);
188
+ spyOn(client, 'initialize').mockRejectedValue(new Error('Connection failed'));
189
+ spyOn(client, 'stop').mockReturnValue(undefined);
190
+ return client;
191
+ }
131
192
  );
132
- const stopSpy = spyOn(MCPClient.prototype, 'stop').mockReturnValue(undefined);
133
193
 
134
194
  const manager = new MCPManager();
135
195
  const client = await manager.getClient('fail-server');
136
196
 
137
197
  expect(client).toBeUndefined();
138
- expect(stopSpy).toHaveBeenCalled();
139
198
 
140
- initSpy.mockRestore();
141
- stopSpy.mockRestore();
199
+ createLocalSpy.mockRestore();
142
200
  });
143
201
  });