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.
Files changed (57) hide show
  1. package/README.md +69 -16
  2. package/package.json +14 -3
  3. package/src/cli.ts +183 -84
  4. package/src/db/workflow-db.ts +0 -7
  5. package/src/expression/evaluator.test.ts +46 -0
  6. package/src/expression/evaluator.ts +36 -0
  7. package/src/parser/agent-parser.test.ts +10 -0
  8. package/src/parser/agent-parser.ts +13 -5
  9. package/src/parser/config-schema.ts +24 -5
  10. package/src/parser/schema.ts +1 -1
  11. package/src/parser/workflow-parser.ts +5 -9
  12. package/src/runner/llm-adapter.test.ts +0 -8
  13. package/src/runner/llm-adapter.ts +33 -10
  14. package/src/runner/llm-executor.test.ts +230 -96
  15. package/src/runner/llm-executor.ts +9 -4
  16. package/src/runner/mcp-client.test.ts +204 -88
  17. package/src/runner/mcp-client.ts +349 -22
  18. package/src/runner/mcp-manager.test.ts +73 -15
  19. package/src/runner/mcp-manager.ts +84 -18
  20. package/src/runner/mcp-server.test.ts +4 -1
  21. package/src/runner/mcp-server.ts +25 -11
  22. package/src/runner/shell-executor.ts +3 -3
  23. package/src/runner/step-executor.test.ts +2 -2
  24. package/src/runner/step-executor.ts +31 -16
  25. package/src/runner/tool-integration.test.ts +21 -14
  26. package/src/runner/workflow-runner.ts +34 -7
  27. package/src/templates/agents/explore.md +54 -0
  28. package/src/templates/agents/general.md +8 -0
  29. package/src/templates/agents/keystone-architect.md +54 -0
  30. package/src/templates/agents/my-agent.md +3 -0
  31. package/src/templates/agents/summarizer.md +28 -0
  32. package/src/templates/agents/test-agent.md +10 -0
  33. package/src/templates/approval-process.yaml +36 -0
  34. package/src/templates/basic-inputs.yaml +19 -0
  35. package/src/templates/basic-shell.yaml +20 -0
  36. package/src/templates/batch-processor.yaml +43 -0
  37. package/src/templates/cleanup-finally.yaml +22 -0
  38. package/src/templates/composition-child.yaml +13 -0
  39. package/src/templates/composition-parent.yaml +14 -0
  40. package/src/templates/data-pipeline.yaml +38 -0
  41. package/src/templates/full-feature-demo.yaml +64 -0
  42. package/src/templates/human-interaction.yaml +12 -0
  43. package/src/templates/invalid.yaml +5 -0
  44. package/src/templates/llm-agent.yaml +8 -0
  45. package/src/templates/loop-parallel.yaml +37 -0
  46. package/src/templates/retry-policy.yaml +36 -0
  47. package/src/templates/scaffold-feature.yaml +48 -0
  48. package/src/templates/state.db +0 -0
  49. package/src/templates/state.db-shm +0 -0
  50. package/src/templates/state.db-wal +0 -0
  51. package/src/templates/stop-watch.yaml +17 -0
  52. package/src/templates/workflow.db +0 -0
  53. package/src/utils/auth-manager.test.ts +86 -0
  54. package/src/utils/auth-manager.ts +89 -0
  55. package/src/utils/config-loader.test.ts +32 -2
  56. package/src/utils/config-loader.ts +11 -1
  57. package/src/utils/mermaid.test.ts +27 -3
@@ -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,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
- 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);
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.process.stdin?.write(`${JSON.stringify(message)}\n`);
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: '0.1.0',
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.process.kill();
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
- 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
  });