keystone-cli 0.1.1 → 0.3.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.
- package/README.md +69 -16
- package/package.json +14 -3
- package/src/cli.ts +183 -84
- package/src/db/workflow-db.ts +0 -7
- package/src/expression/evaluator.test.ts +46 -0
- package/src/expression/evaluator.ts +36 -0
- package/src/parser/agent-parser.test.ts +10 -0
- package/src/parser/agent-parser.ts +13 -5
- package/src/parser/config-schema.ts +24 -5
- package/src/parser/schema.ts +1 -1
- package/src/parser/workflow-parser.ts +5 -9
- package/src/runner/llm-adapter.test.ts +0 -8
- package/src/runner/llm-adapter.ts +33 -10
- package/src/runner/llm-executor.test.ts +230 -96
- package/src/runner/llm-executor.ts +9 -4
- package/src/runner/mcp-client.test.ts +204 -88
- package/src/runner/mcp-client.ts +349 -22
- package/src/runner/mcp-manager.test.ts +73 -15
- package/src/runner/mcp-manager.ts +84 -18
- package/src/runner/mcp-server.test.ts +4 -1
- package/src/runner/mcp-server.ts +25 -11
- package/src/runner/shell-executor.ts +3 -3
- package/src/runner/step-executor.test.ts +2 -2
- package/src/runner/step-executor.ts +31 -16
- package/src/runner/tool-integration.test.ts +21 -14
- package/src/runner/workflow-runner.ts +34 -7
- package/src/templates/agents/explore.md +54 -0
- package/src/templates/agents/general.md +8 -0
- package/src/templates/agents/keystone-architect.md +54 -0
- package/src/templates/agents/my-agent.md +3 -0
- package/src/templates/agents/summarizer.md +28 -0
- package/src/templates/agents/test-agent.md +10 -0
- package/src/templates/approval-process.yaml +36 -0
- package/src/templates/basic-inputs.yaml +19 -0
- package/src/templates/basic-shell.yaml +20 -0
- package/src/templates/batch-processor.yaml +43 -0
- package/src/templates/cleanup-finally.yaml +22 -0
- package/src/templates/composition-child.yaml +13 -0
- package/src/templates/composition-parent.yaml +14 -0
- package/src/templates/data-pipeline.yaml +38 -0
- package/src/templates/full-feature-demo.yaml +64 -0
- package/src/templates/human-interaction.yaml +12 -0
- package/src/templates/invalid.yaml +5 -0
- package/src/templates/llm-agent.yaml +8 -0
- package/src/templates/loop-parallel.yaml +37 -0
- package/src/templates/retry-policy.yaml +36 -0
- package/src/templates/scaffold-feature.yaml +48 -0
- package/src/templates/state.db +0 -0
- package/src/templates/state.db-shm +0 -0
- package/src/templates/state.db-wal +0 -0
- package/src/templates/stop-watch.yaml +17 -0
- package/src/templates/workflow.db +0 -0
- package/src/utils/auth-manager.test.ts +86 -0
- package/src/utils/auth-manager.ts +89 -0
- package/src/utils/config-loader.test.ts +32 -2
- package/src/utils/config-loader.ts +11 -1
- package/src/utils/mermaid.test.ts +27 -3
package/src/runner/mcp-client.ts
CHANGED
|
@@ -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
|
-
|
|
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,349 @@ export class MCPClient {
|
|
|
47
45
|
this.rl = createInterface({
|
|
48
46
|
input: this.process.stdout,
|
|
49
47
|
});
|
|
48
|
+
}
|
|
50
49
|
|
|
50
|
+
async send(message: unknown): Promise<void> {
|
|
51
|
+
this.process.stdin?.write(`${JSON.stringify(message)}\n`);
|
|
52
|
+
}
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
58
|
+
callback(response);
|
|
59
|
+
} catch (e) {
|
|
60
|
+
// Log non-JSON lines to stderr so they show up in the terminal
|
|
61
|
+
if (line.trim()) {
|
|
62
|
+
process.stderr.write(`[MCP Server Output] ${line}\n`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
close(): void {
|
|
69
|
+
this.rl.close();
|
|
70
|
+
this.process.kill();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
class SSETransport implements MCPTransport {
|
|
75
|
+
private url: string;
|
|
76
|
+
private headers: Record<string, string>;
|
|
77
|
+
private endpoint?: string;
|
|
78
|
+
private onMessageCallback?: (message: MCPResponse) => void;
|
|
79
|
+
private abortController: AbortController | null = null;
|
|
80
|
+
private sessionId?: string;
|
|
81
|
+
|
|
82
|
+
constructor(url: string, headers: Record<string, string> = {}) {
|
|
83
|
+
this.url = url;
|
|
84
|
+
this.headers = headers;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async connect(timeout = 60000): Promise<void> {
|
|
88
|
+
this.abortController = new AbortController();
|
|
89
|
+
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const timeoutId = setTimeout(() => {
|
|
92
|
+
this.close();
|
|
93
|
+
reject(new Error(`SSE connection timeout: ${this.url}`));
|
|
94
|
+
}, timeout);
|
|
95
|
+
|
|
96
|
+
(async () => {
|
|
97
|
+
try {
|
|
98
|
+
let response = await fetch(this.url, {
|
|
99
|
+
headers: {
|
|
100
|
+
Accept: 'application/json, text/event-stream',
|
|
101
|
+
...this.headers,
|
|
102
|
+
},
|
|
103
|
+
signal: this.abortController?.signal,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (response.status === 405) {
|
|
107
|
+
// Some MCP servers (like GitHub) require POST to start a session
|
|
108
|
+
response = await fetch(this.url, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: {
|
|
111
|
+
Accept: 'application/json, text/event-stream',
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
...this.headers,
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
jsonrpc: '2.0',
|
|
117
|
+
id: 'ping',
|
|
118
|
+
method: 'ping',
|
|
119
|
+
}),
|
|
120
|
+
signal: this.abortController?.signal,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
clearTimeout(timeoutId);
|
|
126
|
+
reject(new Error(`SSE connection failed: ${response.status} ${response.statusText}`));
|
|
127
|
+
return;
|
|
59
128
|
}
|
|
129
|
+
|
|
130
|
+
// Check for session ID in headers
|
|
131
|
+
this.sessionId =
|
|
132
|
+
response.headers.get('mcp-session-id') ||
|
|
133
|
+
response.headers.get('Mcp-Session-Id') ||
|
|
134
|
+
undefined;
|
|
135
|
+
|
|
136
|
+
const reader = response.body?.getReader();
|
|
137
|
+
if (!reader) {
|
|
138
|
+
clearTimeout(timeoutId);
|
|
139
|
+
reject(new Error('Failed to get response body reader'));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Process the stream in the background
|
|
144
|
+
(async () => {
|
|
145
|
+
let buffer = '';
|
|
146
|
+
const decoder = new TextDecoder();
|
|
147
|
+
let currentEvent: { event?: string; data?: string } = {};
|
|
148
|
+
let isResolved = false;
|
|
149
|
+
|
|
150
|
+
const dispatchEvent = () => {
|
|
151
|
+
if (currentEvent.data) {
|
|
152
|
+
if (currentEvent.event === 'endpoint') {
|
|
153
|
+
this.endpoint = currentEvent.data;
|
|
154
|
+
if (this.endpoint) {
|
|
155
|
+
this.endpoint = new URL(this.endpoint, this.url).href;
|
|
156
|
+
}
|
|
157
|
+
if (!isResolved) {
|
|
158
|
+
isResolved = true;
|
|
159
|
+
clearTimeout(timeoutId);
|
|
160
|
+
resolve();
|
|
161
|
+
}
|
|
162
|
+
} else if (!currentEvent.event || currentEvent.event === 'message') {
|
|
163
|
+
// If we get a message before an endpoint, assume the URL itself is the endpoint
|
|
164
|
+
// (Common in some MCP over SSE implementations like GitHub's)
|
|
165
|
+
if (!this.endpoint) {
|
|
166
|
+
this.endpoint = this.url;
|
|
167
|
+
if (!isResolved) {
|
|
168
|
+
isResolved = true;
|
|
169
|
+
clearTimeout(timeoutId);
|
|
170
|
+
resolve();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (this.onMessageCallback && currentEvent.data) {
|
|
175
|
+
try {
|
|
176
|
+
const message = JSON.parse(currentEvent.data) as MCPResponse;
|
|
177
|
+
this.onMessageCallback(message);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// Ignore parse errors
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
currentEvent = {};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
while (true) {
|
|
189
|
+
const { done, value } = await reader.read();
|
|
190
|
+
if (done) {
|
|
191
|
+
// Dispatch any remaining data
|
|
192
|
+
dispatchEvent();
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
buffer += decoder.decode(value, { stream: true });
|
|
197
|
+
const lines = buffer.split(/\r\n|\r|\n/);
|
|
198
|
+
buffer = lines.pop() || '';
|
|
199
|
+
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
if (line.trim() === '') {
|
|
202
|
+
dispatchEvent();
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (line.startsWith('event:')) {
|
|
207
|
+
currentEvent.event = line.substring(6).trim();
|
|
208
|
+
} else if (line.startsWith('data:')) {
|
|
209
|
+
const data = line.substring(5).trim();
|
|
210
|
+
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!isResolved) {
|
|
216
|
+
// If the stream ended before we resolved, but we have a session ID, we can try to resolve
|
|
217
|
+
if (this.sessionId && !this.endpoint) {
|
|
218
|
+
this.endpoint = this.url;
|
|
219
|
+
isResolved = true;
|
|
220
|
+
clearTimeout(timeoutId);
|
|
221
|
+
resolve();
|
|
222
|
+
} else {
|
|
223
|
+
clearTimeout(timeoutId);
|
|
224
|
+
reject(new Error('SSE stream ended before connection established'));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if ((err as Error).name !== 'AbortError' && !isResolved) {
|
|
229
|
+
clearTimeout(timeoutId);
|
|
230
|
+
reject(err);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
} catch (err) {
|
|
235
|
+
clearTimeout(timeoutId);
|
|
236
|
+
reject(err);
|
|
237
|
+
}
|
|
238
|
+
})();
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async send(message: unknown): Promise<void> {
|
|
243
|
+
if (!this.endpoint) {
|
|
244
|
+
throw new Error('SSE transport not connected or endpoint not received');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const headers = {
|
|
248
|
+
'Content-Type': 'application/json',
|
|
249
|
+
...this.headers,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
if (this.sessionId) {
|
|
253
|
+
headers['mcp-session-id'] = this.sessionId;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const response = await fetch(this.endpoint, {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers,
|
|
259
|
+
body: JSON.stringify(message),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
if (!response.ok) {
|
|
263
|
+
const text = await response.text();
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Failed to send message to MCP server: ${response.status} ${response.statusText}${
|
|
266
|
+
text ? ` - ${text}` : ''
|
|
267
|
+
}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Some MCP servers (like GitHub) send the response directly in the POST response as SSE
|
|
272
|
+
const contentType = response.headers.get('content-type');
|
|
273
|
+
if (contentType?.includes('text/event-stream')) {
|
|
274
|
+
const reader = response.body?.getReader();
|
|
275
|
+
if (reader) {
|
|
276
|
+
(async () => {
|
|
277
|
+
let buffer = '';
|
|
278
|
+
const decoder = new TextDecoder();
|
|
279
|
+
let currentEvent: { event?: string; data?: string } = {};
|
|
280
|
+
|
|
281
|
+
const dispatchEvent = () => {
|
|
282
|
+
if (
|
|
283
|
+
this.onMessageCallback &&
|
|
284
|
+
currentEvent.data &&
|
|
285
|
+
(!currentEvent.event || currentEvent.event === 'message')
|
|
286
|
+
) {
|
|
287
|
+
try {
|
|
288
|
+
const message = JSON.parse(currentEvent.data) as MCPResponse;
|
|
289
|
+
this.onMessageCallback(message);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
// Ignore parse errors
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
currentEvent = {};
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
while (true) {
|
|
299
|
+
const { done, value } = await reader.read();
|
|
300
|
+
if (done) {
|
|
301
|
+
dispatchEvent();
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
buffer += decoder.decode(value, { stream: true });
|
|
306
|
+
const lines = buffer.split(/\r\n|\r|\n/);
|
|
307
|
+
buffer = lines.pop() || '';
|
|
308
|
+
|
|
309
|
+
for (const line of lines) {
|
|
310
|
+
if (line.trim() === '') {
|
|
311
|
+
dispatchEvent();
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (line.startsWith('event:')) {
|
|
316
|
+
currentEvent.event = line.substring(6).trim();
|
|
317
|
+
} else if (line.startsWith('data:')) {
|
|
318
|
+
const data = line.substring(5).trim();
|
|
319
|
+
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
// Ignore stream errors
|
|
325
|
+
}
|
|
326
|
+
})();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
onMessage(callback: (message: MCPResponse) => void): void {
|
|
332
|
+
this.onMessageCallback = callback;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
close(): void {
|
|
336
|
+
this.abortController?.abort();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export class MCPClient {
|
|
341
|
+
private transport: MCPTransport;
|
|
342
|
+
private messageId = 0;
|
|
343
|
+
private pendingRequests = new Map<number, (response: MCPResponse) => void>();
|
|
344
|
+
private timeout: number;
|
|
345
|
+
|
|
346
|
+
constructor(
|
|
347
|
+
transportOrCommand: MCPTransport | string,
|
|
348
|
+
timeoutOrArgs: number | string[] = [],
|
|
349
|
+
env: Record<string, string> = {},
|
|
350
|
+
timeout = 60000
|
|
351
|
+
) {
|
|
352
|
+
if (typeof transportOrCommand === 'string') {
|
|
353
|
+
this.transport = new StdConfigTransport(transportOrCommand, timeoutOrArgs as string[], env);
|
|
354
|
+
this.timeout = timeout;
|
|
355
|
+
} else {
|
|
356
|
+
this.transport = transportOrCommand;
|
|
357
|
+
this.timeout = (timeoutOrArgs as number) || 60000;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
this.transport.onMessage((response) => {
|
|
361
|
+
if (response.id !== undefined && this.pendingRequests.has(response.id)) {
|
|
362
|
+
const resolve = this.pendingRequests.get(response.id);
|
|
363
|
+
if (resolve) {
|
|
364
|
+
this.pendingRequests.delete(response.id);
|
|
365
|
+
resolve(response);
|
|
60
366
|
}
|
|
61
|
-
} catch (e) {
|
|
62
|
-
// Ignore non-JSON lines
|
|
63
367
|
}
|
|
64
368
|
});
|
|
65
369
|
}
|
|
66
370
|
|
|
371
|
+
static async createLocal(
|
|
372
|
+
command: string,
|
|
373
|
+
args: string[] = [],
|
|
374
|
+
env: Record<string, string> = {},
|
|
375
|
+
timeout = 60000
|
|
376
|
+
): Promise<MCPClient> {
|
|
377
|
+
const transport = new StdConfigTransport(command, args, env);
|
|
378
|
+
return new MCPClient(transport, timeout);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
static async createRemote(
|
|
382
|
+
url: string,
|
|
383
|
+
headers: Record<string, string> = {},
|
|
384
|
+
timeout = 60000
|
|
385
|
+
): Promise<MCPClient> {
|
|
386
|
+
const transport = new SSETransport(url, headers);
|
|
387
|
+
await transport.connect(timeout);
|
|
388
|
+
return new MCPClient(transport, timeout);
|
|
389
|
+
}
|
|
390
|
+
|
|
67
391
|
private async request(
|
|
68
392
|
method: string,
|
|
69
393
|
params: Record<string, unknown> = {}
|
|
@@ -78,7 +402,10 @@ export class MCPClient {
|
|
|
78
402
|
|
|
79
403
|
return new Promise((resolve, reject) => {
|
|
80
404
|
this.pendingRequests.set(id, resolve);
|
|
81
|
-
this.
|
|
405
|
+
this.transport.send(message).catch((err) => {
|
|
406
|
+
this.pendingRequests.delete(id);
|
|
407
|
+
reject(err);
|
|
408
|
+
});
|
|
82
409
|
|
|
83
410
|
// Add a timeout
|
|
84
411
|
setTimeout(() => {
|
|
@@ -96,7 +423,7 @@ export class MCPClient {
|
|
|
96
423
|
capabilities: {},
|
|
97
424
|
clientInfo: {
|
|
98
425
|
name: 'keystone-cli',
|
|
99
|
-
version:
|
|
426
|
+
version: pkg.version,
|
|
100
427
|
},
|
|
101
428
|
});
|
|
102
429
|
}
|
|
@@ -118,6 +445,6 @@ export class MCPClient {
|
|
|
118
445
|
}
|
|
119
446
|
|
|
120
447
|
stop() {
|
|
121
|
-
this.
|
|
448
|
+
this.transport.close();
|
|
122
449
|
}
|
|
123
450
|
}
|
|
@@ -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
|
-
|
|
130
|
-
|
|
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
|
-
|
|
141
|
-
stopSpy.mockRestore();
|
|
199
|
+
createLocalSpy.mockRestore();
|
|
142
200
|
});
|
|
143
201
|
});
|