keystone-cli 0.1.1 → 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 +52 -15
  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
@@ -3,11 +3,18 @@ import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import type { ExpressionContext } from '../expression/evaluator';
5
5
  import type { LlmStep, Step } from '../parser/schema';
6
- import { AnthropicAdapter, CopilotAdapter, OpenAIAdapter } from './llm-adapter';
7
- import { MCPClient } from './mcp-client';
6
+ import { ConfigLoader } from '../utils/config-loader';
7
+ import {
8
+ AnthropicAdapter,
9
+ CopilotAdapter,
10
+ type LLMMessage,
11
+ type LLMResponse,
12
+ type LLMTool,
13
+ OpenAIAdapter,
14
+ } from './llm-adapter';
8
15
  import { executeLlmStep } from './llm-executor';
16
+ import { MCPClient, type MCPResponse } from './mcp-client';
9
17
  import { MCPManager } from './mcp-manager';
10
- import { ConfigLoader } from '../utils/config-loader';
11
18
  import type { StepResult } from './step-executor';
12
19
 
13
20
  // Mock adapters
@@ -302,9 +309,7 @@ You are a test agent.`;
302
309
  const context: ExpressionContext = { inputs: {}, steps: {} };
303
310
  const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
304
311
 
305
- const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue(
306
- {} as unknown as any
307
- );
312
+ const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue({} as MCPResponse);
308
313
  const listSpy = spyOn(MCPClient.prototype, 'listTools').mockResolvedValue([
309
314
  { name: 'mcp-tool', inputSchema: {} },
310
315
  ]);
@@ -317,7 +322,7 @@ You are a test agent.`;
317
322
  const originalAnthropicChatInner = AnthropicAdapter.prototype.chat;
318
323
  let toolErrorCaptured = false;
319
324
 
320
- const mockChat = mock(async (messages: any[]) => {
325
+ const mockChat = mock(async (messages: LLMMessage[]) => {
321
326
  const toolResultMessage = messages.find((m) => m.role === 'tool');
322
327
  if (toolResultMessage?.content?.includes('Error: Tool failed')) {
323
328
  toolErrorCaptured = true;
@@ -331,7 +336,7 @@ You are a test agent.`;
331
336
  ],
332
337
  },
333
338
  };
334
- }) as any;
339
+ }) as unknown as typeof originalOpenAIChat;
335
340
 
336
341
  OpenAIAdapter.prototype.chat = mockChat;
337
342
  CopilotAdapter.prototype.chat = mockChat;
@@ -377,21 +382,19 @@ You are a test agent.`;
377
382
  const context: ExpressionContext = { inputs: {}, steps: {} };
378
383
  const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
379
384
 
380
- const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue(
381
- {} as unknown as any
382
- );
385
+ const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue({} as MCPResponse);
383
386
  const listSpy = spyOn(MCPClient.prototype, 'listTools').mockResolvedValue([
384
387
  { name: 'global-tool', description: 'A global tool', inputSchema: {} },
385
388
  ]);
386
389
 
387
390
  let toolFound = false;
388
391
  const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
389
- const mockChat = mock(async (_messages: any[], options: any) => {
390
- if (options.tools?.some((t: any) => t.function.name === 'global-tool')) {
392
+ const mockChat = mock(async (_messages: LLMMessage[], options: { tools?: LLMTool[] }) => {
393
+ if (options.tools?.some((t: LLMTool) => t.function.name === 'global-tool')) {
391
394
  toolFound = true;
392
395
  }
393
396
  return { message: { role: 'assistant', content: 'hello' } };
394
- }) as any;
397
+ }) as unknown as typeof originalOpenAIChat;
395
398
 
396
399
  OpenAIAdapter.prototype.chat = mockChat;
397
400
 
@@ -499,15 +502,13 @@ You are a test agent.`;
499
502
  const context: ExpressionContext = { inputs: {}, steps: {} };
500
503
  const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
501
504
 
502
- const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue(
503
- {} as unknown as any
504
- );
505
+ const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue({} as MCPResponse);
505
506
  const listSpy = spyOn(MCPClient.prototype, 'listTools').mockResolvedValue([]);
506
507
 
507
508
  const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
508
509
  const mockChat = mock(async () => ({
509
510
  message: { role: 'assistant', content: 'hello' },
510
- })) as any;
511
+ })) as unknown as typeof originalOpenAIChat;
511
512
  OpenAIAdapter.prototype.chat = mockChat;
512
513
 
513
514
  const managerSpy = spyOn(manager, 'getGlobalServers');
@@ -534,4 +535,44 @@ You are a test agent.`;
534
535
  managerSpy.mockRestore();
535
536
  ConfigLoader.clear();
536
537
  });
538
+
539
+ it('should handle object prompts by stringifying them', async () => {
540
+ const step: LlmStep = {
541
+ id: 'l1',
542
+ type: 'llm',
543
+ agent: 'test-agent',
544
+ prompt: '${{ steps.prev.output }}' as unknown as string,
545
+ needs: [],
546
+ };
547
+ const context: ExpressionContext = {
548
+ inputs: {},
549
+ steps: {
550
+ prev: { output: { key: 'value' }, status: 'success' },
551
+ },
552
+ };
553
+
554
+ let capturedPrompt = '';
555
+ const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
556
+ const mockChat = mock(async (messages: LLMMessage[]) => {
557
+ // console.log('MESSAGES:', JSON.stringify(messages, null, 2));
558
+ capturedPrompt = messages.find((m) => m.role === 'user')?.content || '';
559
+ return { message: { role: 'assistant', content: 'Response' } };
560
+ }) as unknown as typeof originalOpenAIChat;
561
+ OpenAIAdapter.prototype.chat = mockChat;
562
+ CopilotAdapter.prototype.chat = mockChat;
563
+ AnthropicAdapter.prototype.chat = mockChat;
564
+
565
+ const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
566
+
567
+ await executeLlmStep(
568
+ step,
569
+ context,
570
+ executeStepFn as unknown as (step: Step, context: ExpressionContext) => Promise<StepResult>
571
+ );
572
+
573
+ expect(capturedPrompt).toContain('"key": "value"');
574
+ expect(capturedPrompt).not.toContain('[object Object]');
575
+
576
+ OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
577
+ });
537
578
  });
@@ -30,7 +30,7 @@ export async function executeLlmStep(
30
30
 
31
31
  const provider = step.provider || agent.provider;
32
32
  const model = step.model || agent.model || 'gpt-4o';
33
- const prompt = ExpressionEvaluator.evaluate(step.prompt, context) as string;
33
+ const prompt = ExpressionEvaluator.evaluateString(step.prompt, context);
34
34
 
35
35
  const fullModelString = provider ? `${provider}:${model}` : model;
36
36
  const { adapter, resolvedModel } = getAdapter(fullModelString);
@@ -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
  }