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.
- package/README.md +52 -15
- package/package.json +1 -1
- package/src/cli.ts +90 -81
- package/src/db/workflow-db.ts +0 -7
- package/src/expression/evaluator.test.ts +42 -0
- package/src/expression/evaluator.ts +28 -0
- package/src/parser/agent-parser.test.ts +10 -0
- package/src/parser/agent-parser.ts +2 -1
- package/src/parser/config-schema.ts +13 -5
- package/src/parser/workflow-parser.ts +0 -5
- package/src/runner/llm-adapter.test.ts +0 -8
- package/src/runner/llm-adapter.ts +33 -10
- package/src/runner/llm-executor.test.ts +59 -18
- package/src/runner/llm-executor.ts +1 -1
- package/src/runner/mcp-client.test.ts +166 -88
- package/src/runner/mcp-client.ts +156 -22
- package/src/runner/mcp-manager.test.ts +73 -15
- package/src/runner/mcp-manager.ts +44 -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.ts +10 -9
- package/src/runner/tool-integration.test.ts +21 -14
- package/src/runner/workflow-runner.ts +25 -5
- 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/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 {
|
|
7
|
-
import {
|
|
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:
|
|
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
|
|
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:
|
|
390
|
-
if (options.tools?.some((t:
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
},
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
spawnSpy.mockRestore();
|
|
26
39
|
});
|
|
27
|
-
const kill = mock(() => {});
|
|
28
40
|
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
expect(tools[0].name).toBe('test-tool');
|
|
75
|
-
});
|
|
106
|
+
await expect(callPromise).rejects.toThrow(/MCP tool call failed/);
|
|
107
|
+
});
|
|
76
108
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
184
|
+
// @ts-ignore
|
|
185
|
+
global.EventSource = mock(() => mockEventSource);
|
|
107
186
|
|
|
108
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
189
|
+
// Trigger the onerror callback
|
|
190
|
+
if (mockEventSource.onerror) {
|
|
191
|
+
mockEventSource.onerror({ message: 'Connection failed' });
|
|
192
|
+
}
|
|
116
193
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
194
|
+
await expect(clientPromise).rejects.toThrow(/SSE connection failed/);
|
|
195
|
+
|
|
196
|
+
// @ts-ignore
|
|
197
|
+
global.EventSource = undefined;
|
|
198
|
+
});
|
|
121
199
|
});
|
|
122
200
|
});
|
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,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
|
-
|
|
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.
|
|
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:
|
|
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.
|
|
255
|
+
this.transport.close();
|
|
122
256
|
}
|
|
123
257
|
}
|