keystone-cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +326 -59
- 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
|
@@ -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
|
}
|
|
@@ -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
|
});
|