keystone-cli 0.2.0 → 0.3.1
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 +30 -12
- package/package.json +20 -4
- package/src/cli.ts +171 -27
- package/src/expression/evaluator.test.ts +4 -0
- package/src/expression/evaluator.ts +9 -1
- package/src/parser/agent-parser.ts +11 -4
- package/src/parser/config-schema.ts +11 -0
- package/src/parser/schema.ts +20 -10
- package/src/parser/workflow-parser.ts +5 -4
- package/src/runner/llm-executor.test.ts +174 -81
- package/src/runner/llm-executor.ts +8 -3
- package/src/runner/mcp-client.test.ts +85 -47
- package/src/runner/mcp-client.ts +235 -42
- package/src/runner/mcp-manager.ts +42 -2
- package/src/runner/mcp-server.test.ts +22 -15
- package/src/runner/mcp-server.ts +21 -4
- package/src/runner/step-executor.test.ts +51 -8
- package/src/runner/step-executor.ts +69 -7
- package/src/runner/workflow-runner.ts +65 -24
- package/src/utils/auth-manager.test.ts +86 -0
- package/src/utils/auth-manager.ts +89 -0
- package/src/utils/config-loader.test.ts +30 -0
- package/src/utils/config-loader.ts +11 -1
- package/src/utils/mermaid.test.ts +18 -18
- package/src/utils/mermaid.ts +154 -20
- package/src/utils/redactor.test.ts +6 -0
- package/src/utils/redactor.ts +10 -1
- package/src/utils/sandbox.test.ts +29 -0
- package/src/utils/sandbox.ts +61 -0
|
@@ -1,6 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
afterEach,
|
|
4
|
+
beforeAll,
|
|
5
|
+
beforeEach,
|
|
6
|
+
describe,
|
|
7
|
+
expect,
|
|
8
|
+
it,
|
|
9
|
+
mock,
|
|
10
|
+
spyOn,
|
|
11
|
+
} from 'bun:test';
|
|
12
|
+
import * as child_process from 'node:child_process';
|
|
13
|
+
import { EventEmitter } from 'node:events';
|
|
2
14
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
3
15
|
import { join } from 'node:path';
|
|
16
|
+
import { Readable, Writable } from 'node:stream';
|
|
4
17
|
import type { ExpressionContext } from '../expression/evaluator';
|
|
5
18
|
import type { LlmStep, Step } from '../parser/schema';
|
|
6
19
|
import { ConfigLoader } from '../utils/config-loader';
|
|
@@ -24,8 +37,113 @@ const originalAnthropicChat = AnthropicAdapter.prototype.chat;
|
|
|
24
37
|
|
|
25
38
|
describe('llm-executor', () => {
|
|
26
39
|
const agentsDir = join(process.cwd(), '.keystone', 'workflows', 'agents');
|
|
40
|
+
let spawnSpy: ReturnType<typeof spyOn>;
|
|
41
|
+
let initSpy: ReturnType<typeof spyOn>;
|
|
42
|
+
let listToolsSpy: ReturnType<typeof spyOn>;
|
|
43
|
+
let stopSpy: ReturnType<typeof spyOn>;
|
|
44
|
+
|
|
45
|
+
const mockChat = async (messages: unknown[], _options?: unknown) => {
|
|
46
|
+
const msgs = messages as LLMMessage[];
|
|
47
|
+
const lastMessage = msgs[msgs.length - 1];
|
|
48
|
+
const systemMessage = msgs.find((m) => m.role === 'system');
|
|
49
|
+
|
|
50
|
+
// If there's any tool message, just respond with final message
|
|
51
|
+
if (msgs.some((m) => m.role === 'tool')) {
|
|
52
|
+
return {
|
|
53
|
+
message: { role: 'assistant', content: 'LLM Response' },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (systemMessage?.content?.includes('IMPORTANT: You must output valid JSON')) {
|
|
58
|
+
return {
|
|
59
|
+
message: { role: 'assistant', content: '```json\n{"foo": "bar"}\n```' },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (lastMessage.role === 'user' && lastMessage.content?.includes('trigger tool')) {
|
|
64
|
+
return {
|
|
65
|
+
message: {
|
|
66
|
+
role: 'assistant',
|
|
67
|
+
content: null,
|
|
68
|
+
tool_calls: [
|
|
69
|
+
{
|
|
70
|
+
id: 'call-1',
|
|
71
|
+
type: 'function',
|
|
72
|
+
function: { name: 'test-tool', arguments: '{"val": 123}' },
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (lastMessage.role === 'user' && lastMessage.content?.includes('trigger adhoc tool')) {
|
|
80
|
+
return {
|
|
81
|
+
message: {
|
|
82
|
+
role: 'assistant',
|
|
83
|
+
content: null,
|
|
84
|
+
tool_calls: [
|
|
85
|
+
{
|
|
86
|
+
id: 'call-adhoc',
|
|
87
|
+
type: 'function',
|
|
88
|
+
function: { name: 'adhoc-tool', arguments: '{}' },
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (lastMessage.role === 'user' && lastMessage.content?.includes('trigger unknown tool')) {
|
|
96
|
+
return {
|
|
97
|
+
message: {
|
|
98
|
+
role: 'assistant',
|
|
99
|
+
content: null,
|
|
100
|
+
tool_calls: [
|
|
101
|
+
{
|
|
102
|
+
id: 'call-unknown',
|
|
103
|
+
type: 'function',
|
|
104
|
+
function: { name: 'unknown-tool', arguments: '{}' },
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (lastMessage.role === 'user' && lastMessage.content?.includes('trigger mcp tool')) {
|
|
112
|
+
return {
|
|
113
|
+
message: {
|
|
114
|
+
role: 'assistant',
|
|
115
|
+
content: null,
|
|
116
|
+
tool_calls: [
|
|
117
|
+
{
|
|
118
|
+
id: 'call-mcp',
|
|
119
|
+
type: 'function',
|
|
120
|
+
function: { name: 'mcp-tool', arguments: '{}' },
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
message: { role: 'assistant', content: 'LLM Response' },
|
|
129
|
+
};
|
|
130
|
+
};
|
|
27
131
|
|
|
28
132
|
beforeAll(() => {
|
|
133
|
+
// Mock spawn to avoid actual process creation
|
|
134
|
+
const mockProcess = Object.assign(new EventEmitter(), {
|
|
135
|
+
stdout: new Readable({ read() {} }),
|
|
136
|
+
stdin: new Writable({
|
|
137
|
+
write(_chunk, _encoding, cb: (error?: Error | null) => void) {
|
|
138
|
+
cb();
|
|
139
|
+
},
|
|
140
|
+
}),
|
|
141
|
+
kill: mock(() => {}),
|
|
142
|
+
});
|
|
143
|
+
spawnSpy = spyOn(child_process, 'spawn').mockReturnValue(
|
|
144
|
+
mockProcess as unknown as child_process.ChildProcess
|
|
145
|
+
);
|
|
146
|
+
|
|
29
147
|
try {
|
|
30
148
|
mkdirSync(agentsDir, { recursive: true });
|
|
31
149
|
} catch (e) {}
|
|
@@ -40,68 +158,35 @@ tools:
|
|
|
40
158
|
---
|
|
41
159
|
You are a test agent.`;
|
|
42
160
|
writeFileSync(join(agentsDir, 'test-agent.md'), agentContent);
|
|
161
|
+
});
|
|
43
162
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
message: { role: 'assistant', content: '```json\n{"foo": "bar"}\n```' },
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
if (lastMessage?.content?.includes('trigger tool')) {
|
|
61
|
-
return {
|
|
62
|
-
message: {
|
|
63
|
-
role: 'assistant',
|
|
64
|
-
content: null,
|
|
65
|
-
tool_calls: [
|
|
66
|
-
{
|
|
67
|
-
id: 'call-1',
|
|
68
|
-
type: 'function',
|
|
69
|
-
function: { name: 'test-tool', arguments: '{"val": 123}' },
|
|
70
|
-
},
|
|
71
|
-
],
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (lastMessage?.content?.includes('trigger adhoc tool')) {
|
|
77
|
-
return {
|
|
78
|
-
message: {
|
|
79
|
-
role: 'assistant',
|
|
80
|
-
content: null,
|
|
81
|
-
tool_calls: [
|
|
82
|
-
{
|
|
83
|
-
id: 'call-adhoc',
|
|
84
|
-
type: 'function',
|
|
85
|
-
function: { name: 'adhoc-tool', arguments: '{}' },
|
|
86
|
-
},
|
|
87
|
-
],
|
|
88
|
-
},
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
return {
|
|
92
|
-
message: { role: 'assistant', content: 'LLM Response' },
|
|
93
|
-
};
|
|
94
|
-
};
|
|
95
|
-
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
// Global MCP mocks to avoid hangs
|
|
165
|
+
initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue({
|
|
166
|
+
jsonrpc: '2.0',
|
|
167
|
+
id: 0,
|
|
168
|
+
result: { protocolVersion: '2024-11-05' },
|
|
169
|
+
} as MCPResponse);
|
|
170
|
+
listToolsSpy = spyOn(MCPClient.prototype, 'listTools').mockResolvedValue([]);
|
|
171
|
+
stopSpy = spyOn(MCPClient.prototype, 'stop').mockReturnValue(undefined);
|
|
172
|
+
|
|
173
|
+
// Set adapters to global mock
|
|
96
174
|
OpenAIAdapter.prototype.chat = mock(mockChat) as unknown as typeof originalOpenAIChat;
|
|
97
175
|
CopilotAdapter.prototype.chat = mock(mockChat) as unknown as typeof originalCopilotChat;
|
|
98
176
|
AnthropicAdapter.prototype.chat = mock(mockChat) as unknown as typeof originalAnthropicChat;
|
|
99
177
|
});
|
|
100
178
|
|
|
179
|
+
afterEach(() => {
|
|
180
|
+
initSpy.mockRestore();
|
|
181
|
+
listToolsSpy.mockRestore();
|
|
182
|
+
stopSpy.mockRestore();
|
|
183
|
+
});
|
|
184
|
+
|
|
101
185
|
afterAll(() => {
|
|
102
186
|
OpenAIAdapter.prototype.chat = originalOpenAIChat;
|
|
103
187
|
CopilotAdapter.prototype.chat = originalCopilotChat;
|
|
104
188
|
AnthropicAdapter.prototype.chat = originalAnthropicChat;
|
|
189
|
+
spawnSpy.mockRestore();
|
|
105
190
|
});
|
|
106
191
|
|
|
107
192
|
it('should execute a simple LLM step', async () => {
|
|
@@ -279,9 +364,12 @@ You are a test agent.`;
|
|
|
279
364
|
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
280
365
|
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
281
366
|
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
367
|
+
const createLocalSpy = spyOn(MCPClient, 'createLocal').mockImplementation(async () => {
|
|
368
|
+
const client = Object.create(MCPClient.prototype);
|
|
369
|
+
spyOn(client, 'initialize').mockRejectedValue(new Error('Connect failed'));
|
|
370
|
+
spyOn(client, 'stop').mockReturnValue(undefined);
|
|
371
|
+
return client;
|
|
372
|
+
});
|
|
285
373
|
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
286
374
|
|
|
287
375
|
await executeLlmStep(
|
|
@@ -293,7 +381,7 @@ You are a test agent.`;
|
|
|
293
381
|
expect(consoleSpy).toHaveBeenCalledWith(
|
|
294
382
|
expect.stringContaining('Failed to connect to MCP server fail-mcp')
|
|
295
383
|
);
|
|
296
|
-
|
|
384
|
+
createLocalSpy.mockRestore();
|
|
297
385
|
consoleSpy.mockRestore();
|
|
298
386
|
});
|
|
299
387
|
|
|
@@ -309,13 +397,14 @@ You are a test agent.`;
|
|
|
309
397
|
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
310
398
|
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
311
399
|
|
|
312
|
-
const
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
400
|
+
const createLocalSpy = spyOn(MCPClient, 'createLocal').mockImplementation(async () => {
|
|
401
|
+
const client = Object.create(MCPClient.prototype);
|
|
402
|
+
spyOn(client, 'initialize').mockResolvedValue({} as MCPResponse);
|
|
403
|
+
spyOn(client, 'listTools').mockResolvedValue([{ name: 'mcp-tool', inputSchema: {} }]);
|
|
404
|
+
spyOn(client, 'callTool').mockRejectedValue(new Error('Tool failed'));
|
|
405
|
+
spyOn(client, 'stop').mockReturnValue(undefined);
|
|
406
|
+
return client;
|
|
407
|
+
});
|
|
319
408
|
|
|
320
409
|
const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
|
|
321
410
|
const originalCopilotChatInner = CopilotAdapter.prototype.chat;
|
|
@@ -351,11 +440,7 @@ You are a test agent.`;
|
|
|
351
440
|
expect(toolErrorCaptured).toBe(true);
|
|
352
441
|
|
|
353
442
|
OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
|
|
354
|
-
|
|
355
|
-
AnthropicAdapter.prototype.chat = originalAnthropicChatInner;
|
|
356
|
-
initSpy.mockRestore();
|
|
357
|
-
listSpy.mockRestore();
|
|
358
|
-
callSpy.mockRestore();
|
|
443
|
+
createLocalSpy.mockRestore();
|
|
359
444
|
});
|
|
360
445
|
|
|
361
446
|
it('should use global MCP servers when useGlobalMcp is true', async () => {
|
|
@@ -382,10 +467,15 @@ You are a test agent.`;
|
|
|
382
467
|
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
383
468
|
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
384
469
|
|
|
385
|
-
const
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
470
|
+
const createLocalSpy = spyOn(MCPClient, 'createLocal').mockImplementation(async () => {
|
|
471
|
+
const client = Object.create(MCPClient.prototype);
|
|
472
|
+
spyOn(client, 'initialize').mockResolvedValue({} as MCPResponse);
|
|
473
|
+
spyOn(client, 'listTools').mockResolvedValue([
|
|
474
|
+
{ name: 'global-tool', description: 'A global tool', inputSchema: {} },
|
|
475
|
+
]);
|
|
476
|
+
spyOn(client, 'stop').mockReturnValue(undefined);
|
|
477
|
+
return client;
|
|
478
|
+
});
|
|
389
479
|
|
|
390
480
|
let toolFound = false;
|
|
391
481
|
const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
|
|
@@ -409,8 +499,7 @@ You are a test agent.`;
|
|
|
409
499
|
expect(toolFound).toBe(true);
|
|
410
500
|
|
|
411
501
|
OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
|
|
412
|
-
|
|
413
|
-
listSpy.mockRestore();
|
|
502
|
+
createLocalSpy.mockRestore();
|
|
414
503
|
ConfigLoader.clear();
|
|
415
504
|
});
|
|
416
505
|
|
|
@@ -502,8 +591,13 @@ You are a test agent.`;
|
|
|
502
591
|
const context: ExpressionContext = { inputs: {}, steps: {} };
|
|
503
592
|
const executeStepFn = mock(async () => ({ status: 'success' as const, output: 'ok' }));
|
|
504
593
|
|
|
505
|
-
const
|
|
506
|
-
|
|
594
|
+
const createLocalSpy = spyOn(MCPClient, 'createLocal').mockImplementation(async () => {
|
|
595
|
+
const client = Object.create(MCPClient.prototype);
|
|
596
|
+
spyOn(client, 'initialize').mockResolvedValue({} as MCPResponse);
|
|
597
|
+
spyOn(client, 'listTools').mockResolvedValue([]);
|
|
598
|
+
spyOn(client, 'stop').mockReturnValue(undefined);
|
|
599
|
+
return client;
|
|
600
|
+
});
|
|
507
601
|
|
|
508
602
|
const originalOpenAIChatInner = OpenAIAdapter.prototype.chat;
|
|
509
603
|
const mockChat = mock(async () => ({
|
|
@@ -526,12 +620,11 @@ You are a test agent.`;
|
|
|
526
620
|
// We can check this by seeing how many times initialize was called if they were different,
|
|
527
621
|
// but here we just want to ensure it didn't push the global one again.
|
|
528
622
|
|
|
529
|
-
// Actually,
|
|
530
|
-
expect(
|
|
623
|
+
// Actually, createLocal will be called for 'test-mcp' (explicitly listed)
|
|
624
|
+
expect(createLocalSpy).toHaveBeenCalledTimes(1);
|
|
531
625
|
|
|
532
626
|
OpenAIAdapter.prototype.chat = originalOpenAIChatInner;
|
|
533
|
-
|
|
534
|
-
listSpy.mockRestore();
|
|
627
|
+
createLocalSpy.mockRestore();
|
|
535
628
|
managerSpy.mockRestore();
|
|
536
629
|
ConfigLoader.clear();
|
|
537
630
|
});
|
|
@@ -23,9 +23,10 @@ export async function executeLlmStep(
|
|
|
23
23
|
context: ExpressionContext,
|
|
24
24
|
executeStepFn: (step: Step, context: ExpressionContext) => Promise<StepResult>,
|
|
25
25
|
logger: Logger = console,
|
|
26
|
-
mcpManager?: MCPManager
|
|
26
|
+
mcpManager?: MCPManager,
|
|
27
|
+
workflowDir?: string
|
|
27
28
|
): Promise<StepResult> {
|
|
28
|
-
const agentPath = resolveAgentPath(step.agent);
|
|
29
|
+
const agentPath = resolveAgentPath(step.agent, workflowDir);
|
|
29
30
|
const agent = parseAgent(agentPath);
|
|
30
31
|
|
|
31
32
|
const provider = step.provider || agent.provider;
|
|
@@ -110,8 +111,12 @@ export async function executeLlmStep(
|
|
|
110
111
|
continue;
|
|
111
112
|
}
|
|
112
113
|
logger.log(` 🔌 Connecting to MCP server: ${server.name}`);
|
|
113
|
-
client = new MCPClient(server.command, server.args, server.env);
|
|
114
114
|
try {
|
|
115
|
+
client = await MCPClient.createLocal(
|
|
116
|
+
server.command,
|
|
117
|
+
server.args || [],
|
|
118
|
+
server.env || {}
|
|
119
|
+
);
|
|
115
120
|
await client.initialize();
|
|
116
121
|
localMcpClients.push(client);
|
|
117
122
|
} catch (error) {
|
|
@@ -124,77 +124,115 @@ describe('MCPClient', () => {
|
|
|
124
124
|
|
|
125
125
|
describe('SSE Transport', () => {
|
|
126
126
|
it('should connect and receive endpoint', async () => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
127
|
+
let controller: ReadableStreamDefaultController;
|
|
128
|
+
const stream = new ReadableStream({
|
|
129
|
+
start(c) {
|
|
130
|
+
controller = c;
|
|
131
|
+
controller.enqueue(new TextEncoder().encode('event: endpoint\ndata: /endpoint\n\n'));
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
134
|
|
|
135
|
-
const
|
|
135
|
+
const fetchMock = spyOn(global, 'fetch').mockImplementation((url) => {
|
|
136
|
+
if (url === 'http://localhost:8080/sse') {
|
|
137
|
+
return Promise.resolve(new Response(stream));
|
|
138
|
+
}
|
|
139
|
+
return Promise.resolve(new Response(JSON.stringify({ ok: true })));
|
|
140
|
+
});
|
|
136
141
|
|
|
137
|
-
|
|
138
|
-
mockEventSource.emit('endpoint', { data: '/endpoint' });
|
|
142
|
+
const clientPromise = MCPClient.createRemote('http://localhost:8080/sse');
|
|
139
143
|
|
|
140
144
|
const client = await clientPromise;
|
|
141
145
|
expect(client).toBeDefined();
|
|
142
146
|
|
|
143
|
-
// Test sending a message
|
|
144
|
-
const fetchMock = spyOn(global, 'fetch').mockImplementation(() =>
|
|
145
|
-
Promise.resolve(new Response(JSON.stringify({ ok: true })))
|
|
146
|
-
);
|
|
147
|
-
|
|
148
147
|
const initPromise = client.initialize();
|
|
149
148
|
|
|
150
149
|
// Simulate message event (response from server)
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
150
|
+
if (controller) {
|
|
151
|
+
controller.enqueue(
|
|
152
|
+
new TextEncoder().encode(
|
|
153
|
+
`data: ${JSON.stringify({
|
|
154
|
+
jsonrpc: '2.0',
|
|
155
|
+
id: 0,
|
|
156
|
+
result: { protocolVersion: '2024-11-05' },
|
|
157
|
+
})}\n\n`
|
|
158
|
+
)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
158
161
|
|
|
159
162
|
const response = await initPromise;
|
|
160
163
|
expect(response.result?.protocolVersion).toBe('2024-11-05');
|
|
161
164
|
expect(fetchMock).toHaveBeenCalledWith('http://localhost:8080/endpoint', expect.any(Object));
|
|
162
165
|
|
|
163
166
|
client.stop();
|
|
164
|
-
|
|
165
|
-
|
|
167
|
+
fetchMock.mockRestore();
|
|
168
|
+
});
|
|
166
169
|
|
|
170
|
+
it('should handle SSE with multiple events and chunked data', async () => {
|
|
171
|
+
let controller: ReadableStreamDefaultController;
|
|
172
|
+
const stream = new ReadableStream({
|
|
173
|
+
start(c) {
|
|
174
|
+
controller = c;
|
|
175
|
+
// Send endpoint event
|
|
176
|
+
controller.enqueue(new TextEncoder().encode('event: endpoint\n'));
|
|
177
|
+
controller.enqueue(new TextEncoder().encode('data: /endpoint\n\n'));
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
const fetchMock = spyOn(global, 'fetch').mockImplementation((url) => {
|
|
182
|
+
if (url === 'http://localhost:8080/sse') {
|
|
183
|
+
return Promise.resolve(new Response(stream));
|
|
184
|
+
}
|
|
185
|
+
return Promise.resolve(new Response(JSON.stringify({ ok: true })));
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const client = await MCPClient.createRemote('http://localhost:8080/sse');
|
|
189
|
+
|
|
190
|
+
// We can't easily hook into onMessage without reaching into internals
|
|
191
|
+
// Instead, we'll test that initialize resolves correctly when the response arrives
|
|
192
|
+
const initPromise = client.initialize();
|
|
193
|
+
|
|
194
|
+
// Enqueue data in chunks
|
|
195
|
+
if (controller) {
|
|
196
|
+
controller.enqueue(new TextEncoder().encode('data: {"jsonrpc":"2.0","id":0,'));
|
|
197
|
+
controller.enqueue(
|
|
198
|
+
new TextEncoder().encode('"result":{"protocolVersion":"2024-11-05"}}\n\n')
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// Send another event (just to test dispatching doesn't crash)
|
|
202
|
+
controller.enqueue(
|
|
203
|
+
new TextEncoder().encode(
|
|
204
|
+
'event: message\ndata: {"jsonrpc":"2.0","id":99,"result":"ignored"}\n\n'
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Send empty line
|
|
209
|
+
controller.enqueue(new TextEncoder().encode('\n'));
|
|
210
|
+
|
|
211
|
+
controller.close();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const response = await initPromise;
|
|
215
|
+
expect(response.result?.protocolVersion).toBe('2024-11-05');
|
|
216
|
+
|
|
217
|
+
client.stop();
|
|
167
218
|
fetchMock.mockRestore();
|
|
168
|
-
// @ts-ignore
|
|
169
|
-
global.EventSource = undefined;
|
|
170
219
|
});
|
|
171
220
|
|
|
172
221
|
it('should handle SSE connection failure', async () => {
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
close: mock(() => {}),
|
|
182
|
-
};
|
|
183
|
-
|
|
184
|
-
// @ts-ignore
|
|
185
|
-
global.EventSource = mock(() => mockEventSource);
|
|
222
|
+
const fetchMock = spyOn(global, 'fetch').mockImplementation(() =>
|
|
223
|
+
Promise.resolve(
|
|
224
|
+
new Response(null, {
|
|
225
|
+
status: 500,
|
|
226
|
+
statusText: 'Internal Server Error',
|
|
227
|
+
})
|
|
228
|
+
)
|
|
229
|
+
);
|
|
186
230
|
|
|
187
231
|
const clientPromise = MCPClient.createRemote('http://localhost:8080/sse');
|
|
188
232
|
|
|
189
|
-
|
|
190
|
-
if (mockEventSource.onerror) {
|
|
191
|
-
mockEventSource.onerror({ message: 'Connection failed' });
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
await expect(clientPromise).rejects.toThrow(/SSE connection failed/);
|
|
233
|
+
await expect(clientPromise).rejects.toThrow(/SSE connection failed: 500/);
|
|
195
234
|
|
|
196
|
-
|
|
197
|
-
global.EventSource = undefined;
|
|
235
|
+
fetchMock.mockRestore();
|
|
198
236
|
});
|
|
199
237
|
});
|
|
200
238
|
});
|