openclaw-cascade-plugin 1.0.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/PHASE1_SUMMARY.md +191 -0
- package/PHASE3_SUMMARY.md +195 -0
- package/README.md +43 -0
- package/dist/a2a-client.d.ts +17 -0
- package/dist/a2a-client.d.ts.map +1 -0
- package/dist/a2a-client.js +47 -0
- package/dist/a2a-client.js.map +1 -0
- package/dist/cascade-client.d.ts +53 -0
- package/dist/cascade-client.d.ts.map +1 -0
- package/dist/cascade-client.js +179 -0
- package/dist/cascade-client.js.map +1 -0
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +116 -0
- package/dist/config.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +136 -0
- package/dist/index.js.map +1 -0
- package/dist/python-manager.d.ts +59 -0
- package/dist/python-manager.d.ts.map +1 -0
- package/dist/python-manager.js +190 -0
- package/dist/python-manager.js.map +1 -0
- package/dist/test-utils/helpers.d.ts +20 -0
- package/dist/test-utils/helpers.d.ts.map +1 -0
- package/dist/test-utils/helpers.js +89 -0
- package/dist/test-utils/helpers.js.map +1 -0
- package/dist/test-utils/index.d.ts +3 -0
- package/dist/test-utils/index.d.ts.map +1 -0
- package/dist/test-utils/index.js +19 -0
- package/dist/test-utils/index.js.map +1 -0
- package/dist/test-utils/mocks.d.ts +51 -0
- package/dist/test-utils/mocks.d.ts.map +1 -0
- package/dist/test-utils/mocks.js +84 -0
- package/dist/test-utils/mocks.js.map +1 -0
- package/dist/tools/a2a-tools.d.ts +9 -0
- package/dist/tools/a2a-tools.d.ts.map +1 -0
- package/dist/tools/a2a-tools.js +147 -0
- package/dist/tools/a2a-tools.js.map +1 -0
- package/dist/tools/api-tools.d.ts +9 -0
- package/dist/tools/api-tools.d.ts.map +1 -0
- package/dist/tools/api-tools.js +102 -0
- package/dist/tools/api-tools.js.map +1 -0
- package/dist/tools/desktop-automation.d.ts +10 -0
- package/dist/tools/desktop-automation.d.ts.map +1 -0
- package/dist/tools/desktop-automation.js +330 -0
- package/dist/tools/desktop-automation.js.map +1 -0
- package/dist/tools/index.d.ts +12 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +35 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/response-helpers.d.ts +25 -0
- package/dist/tools/response-helpers.d.ts.map +1 -0
- package/dist/tools/response-helpers.js +71 -0
- package/dist/tools/response-helpers.js.map +1 -0
- package/dist/tools/sandbox-tools.d.ts +9 -0
- package/dist/tools/sandbox-tools.d.ts.map +1 -0
- package/dist/tools/sandbox-tools.js +79 -0
- package/dist/tools/sandbox-tools.js.map +1 -0
- package/dist/tools/tool-registry.d.ts +34 -0
- package/dist/tools/tool-registry.d.ts.map +1 -0
- package/dist/tools/tool-registry.js +50 -0
- package/dist/tools/tool-registry.js.map +1 -0
- package/dist/tools/web-automation.d.ts +9 -0
- package/dist/tools/web-automation.d.ts.map +1 -0
- package/dist/tools/web-automation.js +471 -0
- package/dist/tools/web-automation.js.map +1 -0
- package/dist/types/index.d.ts +111 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +38 -0
- package/dist/types/index.js.map +1 -0
- package/jest.setup.js +19 -0
- package/openclaw-cascade-plugin-1.0.0.tgz +0 -0
- package/openclaw.plugin.json +116 -0
- package/package.json +74 -0
- package/src/a2a-client.ts +66 -0
- package/src/cascade-client.test.ts +400 -0
- package/src/cascade-client.ts +198 -0
- package/src/config.test.ts +194 -0
- package/src/config.ts +135 -0
- package/src/index.ts +164 -0
- package/src/python-manager.test.ts +187 -0
- package/src/python-manager.ts +230 -0
- package/src/test-utils/helpers.ts +107 -0
- package/src/test-utils/index.ts +2 -0
- package/src/test-utils/mocks.ts +101 -0
- package/src/tools/a2a-tools.ts +162 -0
- package/src/tools/api-tools.ts +110 -0
- package/src/tools/desktop-automation.test.ts +305 -0
- package/src/tools/desktop-automation.ts +366 -0
- package/src/tools/index.ts +13 -0
- package/src/tools/response-helpers.ts +78 -0
- package/src/tools/sandbox-tools.ts +83 -0
- package/src/tools/tool-registry.ts +51 -0
- package/src/tools/web-automation.test.ts +177 -0
- package/src/tools/web-automation.ts +518 -0
- package/src/types/index.ts +132 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for CascadeMcpClient
|
|
3
|
+
*
|
|
4
|
+
* MCP (Model Context Protocol) client for communicating with Cascade
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { CascadeMcpClient } from './cascade-client';
|
|
8
|
+
import { createMockConfig } from './test-utils';
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
import { EventEmitter } from 'events';
|
|
11
|
+
|
|
12
|
+
// Mock child_process
|
|
13
|
+
jest.mock('child_process');
|
|
14
|
+
|
|
15
|
+
describe('CascadeMcpClient', () => {
|
|
16
|
+
let client: CascadeMcpClient;
|
|
17
|
+
let mockProcess: any;
|
|
18
|
+
let mockSpawn: jest.MockedFunction<typeof spawn>;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
|
|
23
|
+
// Create mock process
|
|
24
|
+
const stdout = new EventEmitter();
|
|
25
|
+
const stderr = new EventEmitter();
|
|
26
|
+
const stdin = { write: jest.fn() };
|
|
27
|
+
|
|
28
|
+
mockProcess = {
|
|
29
|
+
stdout,
|
|
30
|
+
stderr,
|
|
31
|
+
stdin,
|
|
32
|
+
kill: jest.fn(),
|
|
33
|
+
on: jest.fn(),
|
|
34
|
+
pid: 12345,
|
|
35
|
+
killed: false
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
mockSpawn = spawn as jest.MockedFunction<typeof spawn>;
|
|
39
|
+
mockSpawn.mockReturnValue(mockProcess as any);
|
|
40
|
+
|
|
41
|
+
const config = createMockConfig({
|
|
42
|
+
cascadePythonPath: '/usr/bin/python3',
|
|
43
|
+
cascadeGrpcEndpoint: 'localhost:50051'
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
client = new CascadeMcpClient(
|
|
47
|
+
config.cascadePythonPath!,
|
|
48
|
+
{
|
|
49
|
+
CASCADE_GRPC_ENDPOINT: config.cascadeGrpcEndpoint,
|
|
50
|
+
CASCADE_APP_ID: 'test-app',
|
|
51
|
+
CASCADE_USER_ID: 'test-user'
|
|
52
|
+
}
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('start', () => {
|
|
57
|
+
test('should spawn Python MCP server', async () => {
|
|
58
|
+
// Arrange
|
|
59
|
+
const startPromise = client.start();
|
|
60
|
+
|
|
61
|
+
// Emit initialize response
|
|
62
|
+
setTimeout(() => {
|
|
63
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
64
|
+
jsonrpc: '2.0',
|
|
65
|
+
id: 1,
|
|
66
|
+
result: {
|
|
67
|
+
protocolVersion: '2024-11-05',
|
|
68
|
+
capabilities: {},
|
|
69
|
+
serverInfo: { name: 'cascade-mcp', version: '1.0.0' }
|
|
70
|
+
}
|
|
71
|
+
}) + '\n');
|
|
72
|
+
}, 10);
|
|
73
|
+
|
|
74
|
+
// Act
|
|
75
|
+
await startPromise;
|
|
76
|
+
|
|
77
|
+
// Assert
|
|
78
|
+
expect(mockSpawn).toHaveBeenCalledWith(
|
|
79
|
+
'/usr/bin/python3',
|
|
80
|
+
['-m', 'mcp_server.cli'],
|
|
81
|
+
expect.objectContaining({
|
|
82
|
+
env: expect.objectContaining({
|
|
83
|
+
CASCADE_GRPC_ENDPOINT: 'localhost:50051',
|
|
84
|
+
CASCADE_APP_ID: 'test-app',
|
|
85
|
+
CASCADE_USER_ID: 'test-user'
|
|
86
|
+
}),
|
|
87
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
88
|
+
})
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('should send initialize request', async () => {
|
|
93
|
+
// Arrange
|
|
94
|
+
const startPromise = client.start();
|
|
95
|
+
|
|
96
|
+
// Emit initialize response
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
99
|
+
jsonrpc: '2.0',
|
|
100
|
+
id: 1,
|
|
101
|
+
result: {
|
|
102
|
+
protocolVersion: '2024-11-05',
|
|
103
|
+
capabilities: {},
|
|
104
|
+
serverInfo: { name: 'cascade-mcp', version: '1.0.0' }
|
|
105
|
+
}
|
|
106
|
+
}) + '\n');
|
|
107
|
+
}, 10);
|
|
108
|
+
|
|
109
|
+
// Act
|
|
110
|
+
await startPromise;
|
|
111
|
+
|
|
112
|
+
// Assert
|
|
113
|
+
const writeCalls = mockProcess.stdin.write.mock.calls;
|
|
114
|
+
const initRequest = JSON.parse(writeCalls[0][0]);
|
|
115
|
+
|
|
116
|
+
expect(initRequest).toMatchObject({
|
|
117
|
+
jsonrpc: '2.0',
|
|
118
|
+
id: 1,
|
|
119
|
+
method: 'initialize',
|
|
120
|
+
params: {
|
|
121
|
+
protocolVersion: '2024-11-05',
|
|
122
|
+
clientInfo: { name: 'openclaw-cascade', version: '1.0.0' }
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('should throw when spawn fails', async () => {
|
|
128
|
+
// Arrange
|
|
129
|
+
mockSpawn.mockImplementation(() => {
|
|
130
|
+
throw new Error('Spawn failed');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Act & Assert
|
|
134
|
+
await expect(client.start()).rejects.toThrow('Spawn failed');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('should timeout if no response', async () => {
|
|
138
|
+
// Arrange - don't emit any response
|
|
139
|
+
|
|
140
|
+
// Act & Assert
|
|
141
|
+
await expect(client.start()).rejects.toThrow('timed out');
|
|
142
|
+
}, 35000); // 35 second timeout for this test
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe('callTool', () => {
|
|
146
|
+
beforeEach(async () => {
|
|
147
|
+
// Start client first
|
|
148
|
+
const startPromise = client.start();
|
|
149
|
+
|
|
150
|
+
setTimeout(() => {
|
|
151
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
152
|
+
jsonrpc: '2.0',
|
|
153
|
+
id: 1,
|
|
154
|
+
result: {
|
|
155
|
+
protocolVersion: '2024-11-05',
|
|
156
|
+
capabilities: {},
|
|
157
|
+
serverInfo: { name: 'cascade-mcp', version: '1.0.0' }
|
|
158
|
+
}
|
|
159
|
+
}) + '\n');
|
|
160
|
+
}, 10);
|
|
161
|
+
|
|
162
|
+
await startPromise;
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('should send tool/call request', async () => {
|
|
166
|
+
// Arrange
|
|
167
|
+
const callPromise = client.callTool('click_element', { id: 'button1' });
|
|
168
|
+
|
|
169
|
+
setTimeout(() => {
|
|
170
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
171
|
+
jsonrpc: '2.0',
|
|
172
|
+
id: 2,
|
|
173
|
+
result: { success: true }
|
|
174
|
+
}) + '\n');
|
|
175
|
+
}, 10);
|
|
176
|
+
|
|
177
|
+
// Act
|
|
178
|
+
await callPromise;
|
|
179
|
+
|
|
180
|
+
// Assert
|
|
181
|
+
const writeCalls = mockProcess.stdin.write.mock.calls;
|
|
182
|
+
const lastCall = writeCalls[writeCalls.length - 1][0];
|
|
183
|
+
const request = JSON.parse(lastCall);
|
|
184
|
+
|
|
185
|
+
expect(request).toMatchObject({
|
|
186
|
+
jsonrpc: '2.0',
|
|
187
|
+
id: 2,
|
|
188
|
+
method: 'tools/call',
|
|
189
|
+
params: {
|
|
190
|
+
name: 'click_element',
|
|
191
|
+
arguments: { id: 'button1' }
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('should return tool result', async () => {
|
|
197
|
+
// Arrange
|
|
198
|
+
const callPromise = client.callTool('get_screenshot', {});
|
|
199
|
+
|
|
200
|
+
setTimeout(() => {
|
|
201
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
202
|
+
jsonrpc: '2.0',
|
|
203
|
+
id: 2,
|
|
204
|
+
result: { image: 'base64data', format: 'PNG' }
|
|
205
|
+
}) + '\n');
|
|
206
|
+
}, 10);
|
|
207
|
+
|
|
208
|
+
// Act
|
|
209
|
+
const result = await callPromise;
|
|
210
|
+
|
|
211
|
+
// Assert
|
|
212
|
+
expect(result).toEqual({ image: 'base64data', format: 'PNG' });
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test('should handle tool errors', async () => {
|
|
216
|
+
// Arrange
|
|
217
|
+
const callPromise = client.callTool('click_element', { id: 'nonexistent' });
|
|
218
|
+
|
|
219
|
+
setTimeout(() => {
|
|
220
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
221
|
+
jsonrpc: '2.0',
|
|
222
|
+
id: 2,
|
|
223
|
+
error: { code: -32602, message: 'Element not found' }
|
|
224
|
+
}) + '\n');
|
|
225
|
+
}, 10);
|
|
226
|
+
|
|
227
|
+
// Act & Assert
|
|
228
|
+
await expect(callPromise).rejects.toThrow('Element not found');
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test('should timeout after 30 seconds', async () => {
|
|
232
|
+
// Arrange - don't emit any response
|
|
233
|
+
|
|
234
|
+
// Act & Assert
|
|
235
|
+
await expect(client.callTool('click_element', {})).rejects.toThrow('timed out');
|
|
236
|
+
}, 35000); // 35 second timeout for this test
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('listTools', () => {
|
|
240
|
+
beforeEach(async () => {
|
|
241
|
+
const startPromise = client.start();
|
|
242
|
+
|
|
243
|
+
setTimeout(() => {
|
|
244
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
245
|
+
jsonrpc: '2.0',
|
|
246
|
+
id: 1,
|
|
247
|
+
result: {
|
|
248
|
+
protocolVersion: '2024-11-05',
|
|
249
|
+
capabilities: {},
|
|
250
|
+
serverInfo: { name: 'cascade-mcp', version: '1.0.0' }
|
|
251
|
+
}
|
|
252
|
+
}) + '\n');
|
|
253
|
+
}, 10);
|
|
254
|
+
|
|
255
|
+
await startPromise;
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test('should return list of available tools', async () => {
|
|
259
|
+
// Arrange
|
|
260
|
+
const listPromise = client.listTools();
|
|
261
|
+
|
|
262
|
+
setTimeout(() => {
|
|
263
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
264
|
+
jsonrpc: '2.0',
|
|
265
|
+
id: 2,
|
|
266
|
+
result: {
|
|
267
|
+
tools: [
|
|
268
|
+
{ name: 'click_element', description: 'Click element' },
|
|
269
|
+
{ name: 'get_screenshot', description: 'Get screenshot' }
|
|
270
|
+
]
|
|
271
|
+
}
|
|
272
|
+
}) + '\n');
|
|
273
|
+
}, 10);
|
|
274
|
+
|
|
275
|
+
// Act
|
|
276
|
+
const result = await listPromise;
|
|
277
|
+
|
|
278
|
+
// Assert
|
|
279
|
+
expect(result).toHaveLength(2);
|
|
280
|
+
expect(result[0].name).toBe('click_element');
|
|
281
|
+
expect(result[1].name).toBe('get_screenshot');
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test('should handle empty tool list', async () => {
|
|
285
|
+
// Arrange
|
|
286
|
+
const listPromise = client.listTools();
|
|
287
|
+
|
|
288
|
+
setTimeout(() => {
|
|
289
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
290
|
+
jsonrpc: '2.0',
|
|
291
|
+
id: 2,
|
|
292
|
+
result: { tools: [] }
|
|
293
|
+
}) + '\n');
|
|
294
|
+
}, 10);
|
|
295
|
+
|
|
296
|
+
// Act
|
|
297
|
+
const result = await listPromise;
|
|
298
|
+
|
|
299
|
+
// Assert
|
|
300
|
+
expect(result).toEqual([]);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe('error handling', () => {
|
|
305
|
+
test('should handle JSON parse errors gracefully', async () => {
|
|
306
|
+
// Arrange
|
|
307
|
+
const startPromise = client.start();
|
|
308
|
+
|
|
309
|
+
setTimeout(() => {
|
|
310
|
+
// Emit invalid JSON first
|
|
311
|
+
mockProcess.stdout.emit('data', 'not valid json\n');
|
|
312
|
+
|
|
313
|
+
// Then emit valid response
|
|
314
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
315
|
+
jsonrpc: '2.0',
|
|
316
|
+
id: 1,
|
|
317
|
+
result: {
|
|
318
|
+
protocolVersion: '2024-11-05',
|
|
319
|
+
capabilities: {},
|
|
320
|
+
serverInfo: { name: 'cascade-mcp', version: '1.0.0' }
|
|
321
|
+
}
|
|
322
|
+
}) + '\n');
|
|
323
|
+
}, 10);
|
|
324
|
+
|
|
325
|
+
// Act - should not throw
|
|
326
|
+
await expect(startPromise).resolves.not.toThrow();
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test('should handle process crash', async () => {
|
|
330
|
+
// Arrange
|
|
331
|
+
const startPromise = client.start();
|
|
332
|
+
|
|
333
|
+
setTimeout(() => {
|
|
334
|
+
const errorHandler = mockProcess.on.mock.calls.find(
|
|
335
|
+
(call: any) => call[0] === 'error'
|
|
336
|
+
)?.[1];
|
|
337
|
+
if (errorHandler) {
|
|
338
|
+
errorHandler(new Error('Process crashed'));
|
|
339
|
+
}
|
|
340
|
+
}, 10);
|
|
341
|
+
|
|
342
|
+
// Act & Assert
|
|
343
|
+
await expect(startPromise).rejects.toThrow('Process crashed');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
test('should reject all pending on error', async () => {
|
|
347
|
+
// Arrange
|
|
348
|
+
const startPromise = client.start();
|
|
349
|
+
|
|
350
|
+
setTimeout(() => {
|
|
351
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
352
|
+
jsonrpc: '2.0',
|
|
353
|
+
id: 1,
|
|
354
|
+
result: { protocolVersion: '2024-11-05' }
|
|
355
|
+
}) + '\n');
|
|
356
|
+
}, 10);
|
|
357
|
+
|
|
358
|
+
await startPromise;
|
|
359
|
+
|
|
360
|
+
// Start a tool call that will hang
|
|
361
|
+
const toolPromise = client.callTool('click_element', {});
|
|
362
|
+
|
|
363
|
+
// Emit error
|
|
364
|
+
setTimeout(() => {
|
|
365
|
+
const errorHandler = mockProcess.on.mock.calls.find(
|
|
366
|
+
(call: any) => call[0] === 'error'
|
|
367
|
+
)?.[1];
|
|
368
|
+
if (errorHandler) {
|
|
369
|
+
errorHandler(new Error('Connection lost'));
|
|
370
|
+
}
|
|
371
|
+
}, 10);
|
|
372
|
+
|
|
373
|
+
// Act & Assert
|
|
374
|
+
await expect(toolPromise).rejects.toThrow('Connection lost');
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe('stop', () => {
|
|
379
|
+
test('should kill the process', async () => {
|
|
380
|
+
// Arrange
|
|
381
|
+
const startPromise = client.start();
|
|
382
|
+
|
|
383
|
+
setTimeout(() => {
|
|
384
|
+
mockProcess.stdout.emit('data', JSON.stringify({
|
|
385
|
+
jsonrpc: '2.0',
|
|
386
|
+
id: 1,
|
|
387
|
+
result: { protocolVersion: '2024-11-05' }
|
|
388
|
+
}) + '\n');
|
|
389
|
+
}, 10);
|
|
390
|
+
|
|
391
|
+
await startPromise;
|
|
392
|
+
|
|
393
|
+
// Act
|
|
394
|
+
client.stop();
|
|
395
|
+
|
|
396
|
+
// Assert
|
|
397
|
+
expect(mockProcess.kill).toHaveBeenCalled();
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cascade MCP Client
|
|
3
|
+
*
|
|
4
|
+
* Handles communication with Cascade MCP server via stdio JSON-RPC
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawn, ChildProcess } from 'child_process';
|
|
8
|
+
|
|
9
|
+
export class CascadeMcpClient {
|
|
10
|
+
private process: ChildProcess | null = null;
|
|
11
|
+
private messageId = 0;
|
|
12
|
+
private pendingRequests = new Map<number, { resolve: Function; reject: Function }>();
|
|
13
|
+
private isInitialized = false;
|
|
14
|
+
private readonly REQUEST_TIMEOUT = 30000; // 30 seconds
|
|
15
|
+
private buffer = '';
|
|
16
|
+
|
|
17
|
+
constructor(
|
|
18
|
+
private pythonPath: string,
|
|
19
|
+
private env: NodeJS.ProcessEnv
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Start the MCP client and initialize connection
|
|
24
|
+
*/
|
|
25
|
+
async start(): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
// Spawn Cascade MCP server
|
|
28
|
+
this.process = spawn(this.pythonPath, ['-m', 'mcp_server.cli'], {
|
|
29
|
+
env: { ...process.env, ...this.env },
|
|
30
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Handle stdout for JSON-RPC responses
|
|
34
|
+
if (this.process.stdout) {
|
|
35
|
+
this.process.stdout.on('data', (data: Buffer) => {
|
|
36
|
+
this.handleData(data.toString());
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Handle stderr
|
|
41
|
+
if (this.process.stderr) {
|
|
42
|
+
this.process.stderr.on('data', (data: Buffer) => {
|
|
43
|
+
console.error('Cascade MCP stderr:', data.toString());
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Handle process errors
|
|
48
|
+
this.process.on('error', (error: Error) => {
|
|
49
|
+
console.error('Cascade MCP process error:', error);
|
|
50
|
+
this.rejectAllPending(error);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Initialize MCP connection
|
|
54
|
+
await this.sendRequest('initialize', {
|
|
55
|
+
protocolVersion: '2024-11-05',
|
|
56
|
+
capabilities: {},
|
|
57
|
+
clientInfo: { name: 'openclaw-cascade', version: '1.0.0' }
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
this.isInitialized = true;
|
|
61
|
+
} catch (error) {
|
|
62
|
+
this.stop();
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Call a tool by name with arguments
|
|
69
|
+
*/
|
|
70
|
+
async callTool(name: string, args: Record<string, any>): Promise<any> {
|
|
71
|
+
if (!this.isInitialized) {
|
|
72
|
+
throw new Error('MCP client not initialized. Call start() first.');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return this.sendRequest('tools/call', { name, arguments: args });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* List all available tools
|
|
80
|
+
*/
|
|
81
|
+
async listTools(): Promise<any[]> {
|
|
82
|
+
if (!this.isInitialized) {
|
|
83
|
+
throw new Error('MCP client not initialized. Call start() first.');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result = await this.sendRequest('tools/list', {});
|
|
87
|
+
return result.tools || [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Check if client is connected
|
|
92
|
+
*/
|
|
93
|
+
isConnected(): boolean {
|
|
94
|
+
return this.isInitialized && this.process !== null && !this.process.killed;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Stop the MCP client
|
|
99
|
+
*/
|
|
100
|
+
stop(): void {
|
|
101
|
+
this.rejectAllPending(new Error('Client stopped'));
|
|
102
|
+
|
|
103
|
+
if (this.process && !this.process.killed) {
|
|
104
|
+
this.process.kill();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
this.process = null;
|
|
108
|
+
this.isInitialized = false;
|
|
109
|
+
this.buffer = '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Handle incoming data from stdout
|
|
114
|
+
*/
|
|
115
|
+
private handleData(data: string): void {
|
|
116
|
+
this.buffer += data;
|
|
117
|
+
|
|
118
|
+
// Process complete lines
|
|
119
|
+
const lines = this.buffer.split('\n');
|
|
120
|
+
this.buffer = lines.pop() || ''; // Keep incomplete line in buffer
|
|
121
|
+
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
if (line.trim()) {
|
|
124
|
+
this.handleResponse(line);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Send a JSON-RPC request
|
|
131
|
+
*/
|
|
132
|
+
private async sendRequest(method: string, params: any): Promise<any> {
|
|
133
|
+
const id = ++this.messageId;
|
|
134
|
+
const request = { jsonrpc: '2.0', id, method, params };
|
|
135
|
+
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
// Set timeout
|
|
138
|
+
const timeoutId = setTimeout(() => {
|
|
139
|
+
this.pendingRequests.delete(id);
|
|
140
|
+
reject(new Error(`Request ${method} timed out after ${this.REQUEST_TIMEOUT}ms`));
|
|
141
|
+
}, this.REQUEST_TIMEOUT);
|
|
142
|
+
|
|
143
|
+
// Store pending request
|
|
144
|
+
this.pendingRequests.set(id, {
|
|
145
|
+
resolve: (result: any) => {
|
|
146
|
+
clearTimeout(timeoutId);
|
|
147
|
+
resolve(result);
|
|
148
|
+
},
|
|
149
|
+
reject: (error: Error) => {
|
|
150
|
+
clearTimeout(timeoutId);
|
|
151
|
+
reject(error);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Send request
|
|
156
|
+
if (this.process?.stdin) {
|
|
157
|
+
this.process.stdin.write(JSON.stringify(request) + '\n');
|
|
158
|
+
} else {
|
|
159
|
+
this.pendingRequests.delete(id);
|
|
160
|
+
clearTimeout(timeoutId);
|
|
161
|
+
reject(new Error('Process stdin not available'));
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Handle incoming JSON-RPC response
|
|
168
|
+
*/
|
|
169
|
+
private handleResponse(line: string): void {
|
|
170
|
+
try {
|
|
171
|
+
const response = JSON.parse(line);
|
|
172
|
+
|
|
173
|
+
if (response.id && this.pendingRequests.has(response.id)) {
|
|
174
|
+
const { resolve, reject } = this.pendingRequests.get(response.id)!;
|
|
175
|
+
this.pendingRequests.delete(response.id);
|
|
176
|
+
|
|
177
|
+
if (response.error) {
|
|
178
|
+
reject(new Error(response.error.message || 'Unknown error'));
|
|
179
|
+
} else {
|
|
180
|
+
resolve(response.result);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (e) {
|
|
184
|
+
// Ignore parse errors for non-JSON lines
|
|
185
|
+
console.debug('Failed to parse MCP response:', line.substring(0, 100));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Reject all pending requests
|
|
191
|
+
*/
|
|
192
|
+
private rejectAllPending(error: Error): void {
|
|
193
|
+
for (const [_id, { reject }] of this.pendingRequests) {
|
|
194
|
+
reject(error);
|
|
195
|
+
}
|
|
196
|
+
this.pendingRequests.clear();
|
|
197
|
+
}
|
|
198
|
+
}
|