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
package/src/runner/mcp-client.ts
CHANGED
|
@@ -57,12 +57,16 @@ class StdConfigTransport implements MCPTransport {
|
|
|
57
57
|
const response = JSON.parse(line) as MCPResponse;
|
|
58
58
|
callback(response);
|
|
59
59
|
} catch (e) {
|
|
60
|
-
//
|
|
60
|
+
// Log non-JSON lines to stderr so they show up in the terminal
|
|
61
|
+
if (line.trim()) {
|
|
62
|
+
process.stderr.write(`[MCP Server Output] ${line}\n`);
|
|
63
|
+
}
|
|
61
64
|
}
|
|
62
65
|
});
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
close(): void {
|
|
69
|
+
this.rl.close();
|
|
66
70
|
this.process.kill();
|
|
67
71
|
}
|
|
68
72
|
}
|
|
@@ -70,49 +74,168 @@ class StdConfigTransport implements MCPTransport {
|
|
|
70
74
|
class SSETransport implements MCPTransport {
|
|
71
75
|
private url: string;
|
|
72
76
|
private headers: Record<string, string>;
|
|
73
|
-
private eventSource: EventSource | null = null;
|
|
74
77
|
private endpoint?: string;
|
|
75
78
|
private onMessageCallback?: (message: MCPResponse) => void;
|
|
79
|
+
private abortController: AbortController | null = null;
|
|
80
|
+
private sessionId?: string;
|
|
76
81
|
|
|
77
82
|
constructor(url: string, headers: Record<string, string> = {}) {
|
|
78
83
|
this.url = url;
|
|
79
84
|
this.headers = headers;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
async connect(): Promise<void> {
|
|
83
|
-
|
|
84
|
-
// @ts-ignore - Bun supports EventSource
|
|
85
|
-
this.eventSource = new EventSource(this.url, { headers: this.headers });
|
|
87
|
+
async connect(timeout = 60000): Promise<void> {
|
|
88
|
+
this.abortController = new AbortController();
|
|
86
89
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
const timeoutId = setTimeout(() => {
|
|
92
|
+
this.close();
|
|
93
|
+
reject(new Error(`SSE connection timeout: ${this.url}`));
|
|
94
|
+
}, timeout);
|
|
95
|
+
|
|
96
|
+
(async () => {
|
|
97
|
+
try {
|
|
98
|
+
let response = await fetch(this.url, {
|
|
99
|
+
headers: {
|
|
100
|
+
Accept: 'application/json, text/event-stream',
|
|
101
|
+
...this.headers,
|
|
102
|
+
},
|
|
103
|
+
signal: this.abortController?.signal,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (response.status === 405) {
|
|
107
|
+
// Some MCP servers (like GitHub) require POST to start a session
|
|
108
|
+
response = await fetch(this.url, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: {
|
|
111
|
+
Accept: 'application/json, text/event-stream',
|
|
112
|
+
'Content-Type': 'application/json',
|
|
113
|
+
...this.headers,
|
|
114
|
+
},
|
|
115
|
+
body: JSON.stringify({
|
|
116
|
+
jsonrpc: '2.0',
|
|
117
|
+
id: 'ping',
|
|
118
|
+
method: 'ping',
|
|
119
|
+
}),
|
|
120
|
+
signal: this.abortController?.signal,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
91
123
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
resolve();
|
|
99
|
-
});
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
clearTimeout(timeoutId);
|
|
126
|
+
reject(new Error(`SSE connection failed: ${response.status} ${response.statusText}`));
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
100
129
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
130
|
+
// Check for session ID in headers
|
|
131
|
+
this.sessionId =
|
|
132
|
+
response.headers.get('mcp-session-id') ||
|
|
133
|
+
response.headers.get('Mcp-Session-Id') ||
|
|
134
|
+
undefined;
|
|
135
|
+
|
|
136
|
+
const reader = response.body?.getReader();
|
|
137
|
+
if (!reader) {
|
|
138
|
+
clearTimeout(timeoutId);
|
|
139
|
+
reject(new Error('Failed to get response body reader'));
|
|
140
|
+
return;
|
|
108
141
|
}
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
142
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
143
|
+
// Process the stream in the background
|
|
144
|
+
(async () => {
|
|
145
|
+
let buffer = '';
|
|
146
|
+
const decoder = new TextDecoder();
|
|
147
|
+
let currentEvent: { event?: string; data?: string } = {};
|
|
148
|
+
let isResolved = false;
|
|
149
|
+
|
|
150
|
+
const dispatchEvent = () => {
|
|
151
|
+
if (currentEvent.data) {
|
|
152
|
+
if (currentEvent.event === 'endpoint') {
|
|
153
|
+
this.endpoint = currentEvent.data;
|
|
154
|
+
if (this.endpoint) {
|
|
155
|
+
this.endpoint = new URL(this.endpoint, this.url).href;
|
|
156
|
+
}
|
|
157
|
+
if (!isResolved) {
|
|
158
|
+
isResolved = true;
|
|
159
|
+
clearTimeout(timeoutId);
|
|
160
|
+
resolve();
|
|
161
|
+
}
|
|
162
|
+
} else if (!currentEvent.event || currentEvent.event === 'message') {
|
|
163
|
+
// If we get a message before an endpoint, assume the URL itself is the endpoint
|
|
164
|
+
// (Common in some MCP over SSE implementations like GitHub's)
|
|
165
|
+
if (!this.endpoint) {
|
|
166
|
+
this.endpoint = this.url;
|
|
167
|
+
if (!isResolved) {
|
|
168
|
+
isResolved = true;
|
|
169
|
+
clearTimeout(timeoutId);
|
|
170
|
+
resolve();
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (this.onMessageCallback && currentEvent.data) {
|
|
175
|
+
try {
|
|
176
|
+
const message = JSON.parse(currentEvent.data) as MCPResponse;
|
|
177
|
+
this.onMessageCallback(message);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
// Ignore parse errors
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
currentEvent = {};
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
while (true) {
|
|
189
|
+
const { done, value } = await reader.read();
|
|
190
|
+
if (done) {
|
|
191
|
+
// Dispatch any remaining data
|
|
192
|
+
dispatchEvent();
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
buffer += decoder.decode(value, { stream: true });
|
|
197
|
+
const lines = buffer.split(/\r\n|\r|\n/);
|
|
198
|
+
buffer = lines.pop() || '';
|
|
199
|
+
|
|
200
|
+
for (const line of lines) {
|
|
201
|
+
if (line.trim() === '') {
|
|
202
|
+
dispatchEvent();
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (line.startsWith('event:')) {
|
|
207
|
+
currentEvent.event = line.substring(6).trim();
|
|
208
|
+
} else if (line.startsWith('data:')) {
|
|
209
|
+
const data = line.substring(5).trim();
|
|
210
|
+
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!isResolved) {
|
|
216
|
+
// If the stream ended before we resolved, but we have a session ID, we can try to resolve
|
|
217
|
+
if (this.sessionId && !this.endpoint) {
|
|
218
|
+
this.endpoint = this.url;
|
|
219
|
+
isResolved = true;
|
|
220
|
+
clearTimeout(timeoutId);
|
|
221
|
+
resolve();
|
|
222
|
+
} else {
|
|
223
|
+
clearTimeout(timeoutId);
|
|
224
|
+
reject(new Error('SSE stream ended before connection established'));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
} catch (err) {
|
|
228
|
+
if ((err as Error).name !== 'AbortError' && !isResolved) {
|
|
229
|
+
clearTimeout(timeoutId);
|
|
230
|
+
reject(err);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
})();
|
|
234
|
+
} catch (err) {
|
|
235
|
+
clearTimeout(timeoutId);
|
|
236
|
+
reject(err);
|
|
237
|
+
}
|
|
238
|
+
})();
|
|
116
239
|
});
|
|
117
240
|
}
|
|
118
241
|
|
|
@@ -121,17 +244,87 @@ class SSETransport implements MCPTransport {
|
|
|
121
244
|
throw new Error('SSE transport not connected or endpoint not received');
|
|
122
245
|
}
|
|
123
246
|
|
|
247
|
+
const headers = {
|
|
248
|
+
'Content-Type': 'application/json',
|
|
249
|
+
...this.headers,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
if (this.sessionId) {
|
|
253
|
+
headers['mcp-session-id'] = this.sessionId;
|
|
254
|
+
}
|
|
255
|
+
|
|
124
256
|
const response = await fetch(this.endpoint, {
|
|
125
257
|
method: 'POST',
|
|
126
|
-
headers
|
|
127
|
-
'Content-Type': 'application/json',
|
|
128
|
-
...this.headers,
|
|
129
|
-
},
|
|
258
|
+
headers,
|
|
130
259
|
body: JSON.stringify(message),
|
|
131
260
|
});
|
|
132
261
|
|
|
133
262
|
if (!response.ok) {
|
|
134
|
-
|
|
263
|
+
const text = await response.text();
|
|
264
|
+
throw new Error(
|
|
265
|
+
`Failed to send message to MCP server: ${response.status} ${response.statusText}${
|
|
266
|
+
text ? ` - ${text}` : ''
|
|
267
|
+
}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Some MCP servers (like GitHub) send the response directly in the POST response as SSE
|
|
272
|
+
const contentType = response.headers.get('content-type');
|
|
273
|
+
if (contentType?.includes('text/event-stream')) {
|
|
274
|
+
const reader = response.body?.getReader();
|
|
275
|
+
if (reader) {
|
|
276
|
+
(async () => {
|
|
277
|
+
let buffer = '';
|
|
278
|
+
const decoder = new TextDecoder();
|
|
279
|
+
let currentEvent: { event?: string; data?: string } = {};
|
|
280
|
+
|
|
281
|
+
const dispatchEvent = () => {
|
|
282
|
+
if (
|
|
283
|
+
this.onMessageCallback &&
|
|
284
|
+
currentEvent.data &&
|
|
285
|
+
(!currentEvent.event || currentEvent.event === 'message')
|
|
286
|
+
) {
|
|
287
|
+
try {
|
|
288
|
+
const message = JSON.parse(currentEvent.data) as MCPResponse;
|
|
289
|
+
this.onMessageCallback(message);
|
|
290
|
+
} catch (e) {
|
|
291
|
+
// Ignore parse errors
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
currentEvent = {};
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
while (true) {
|
|
299
|
+
const { done, value } = await reader.read();
|
|
300
|
+
if (done) {
|
|
301
|
+
dispatchEvent();
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
buffer += decoder.decode(value, { stream: true });
|
|
306
|
+
const lines = buffer.split(/\r\n|\r|\n/);
|
|
307
|
+
buffer = lines.pop() || '';
|
|
308
|
+
|
|
309
|
+
for (const line of lines) {
|
|
310
|
+
if (line.trim() === '') {
|
|
311
|
+
dispatchEvent();
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
if (line.startsWith('event:')) {
|
|
316
|
+
currentEvent.event = line.substring(6).trim();
|
|
317
|
+
} else if (line.startsWith('data:')) {
|
|
318
|
+
const data = line.substring(5).trim();
|
|
319
|
+
currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {
|
|
324
|
+
// Ignore stream errors
|
|
325
|
+
}
|
|
326
|
+
})();
|
|
327
|
+
}
|
|
135
328
|
}
|
|
136
329
|
}
|
|
137
330
|
|
|
@@ -140,7 +333,7 @@ class SSETransport implements MCPTransport {
|
|
|
140
333
|
}
|
|
141
334
|
|
|
142
335
|
close(): void {
|
|
143
|
-
this.
|
|
336
|
+
this.abortController?.abort();
|
|
144
337
|
}
|
|
145
338
|
}
|
|
146
339
|
|
|
@@ -154,14 +347,14 @@ export class MCPClient {
|
|
|
154
347
|
transportOrCommand: MCPTransport | string,
|
|
155
348
|
timeoutOrArgs: number | string[] = [],
|
|
156
349
|
env: Record<string, string> = {},
|
|
157
|
-
timeout =
|
|
350
|
+
timeout = 60000
|
|
158
351
|
) {
|
|
159
352
|
if (typeof transportOrCommand === 'string') {
|
|
160
353
|
this.transport = new StdConfigTransport(transportOrCommand, timeoutOrArgs as string[], env);
|
|
161
354
|
this.timeout = timeout;
|
|
162
355
|
} else {
|
|
163
356
|
this.transport = transportOrCommand;
|
|
164
|
-
this.timeout = (timeoutOrArgs as number) ||
|
|
357
|
+
this.timeout = (timeoutOrArgs as number) || 60000;
|
|
165
358
|
}
|
|
166
359
|
|
|
167
360
|
this.transport.onMessage((response) => {
|
|
@@ -179,7 +372,7 @@ export class MCPClient {
|
|
|
179
372
|
command: string,
|
|
180
373
|
args: string[] = [],
|
|
181
374
|
env: Record<string, string> = {},
|
|
182
|
-
timeout =
|
|
375
|
+
timeout = 60000
|
|
183
376
|
): Promise<MCPClient> {
|
|
184
377
|
const transport = new StdConfigTransport(command, args, env);
|
|
185
378
|
return new MCPClient(transport, timeout);
|
|
@@ -188,10 +381,10 @@ export class MCPClient {
|
|
|
188
381
|
static async createRemote(
|
|
189
382
|
url: string,
|
|
190
383
|
headers: Record<string, string> = {},
|
|
191
|
-
timeout =
|
|
384
|
+
timeout = 60000
|
|
192
385
|
): Promise<MCPClient> {
|
|
193
386
|
const transport = new SSETransport(url, headers);
|
|
194
|
-
await transport.connect();
|
|
387
|
+
await transport.connect(timeout);
|
|
195
388
|
return new MCPClient(transport, timeout);
|
|
196
389
|
}
|
|
197
390
|
|
|
@@ -10,6 +10,9 @@ export interface MCPServerConfig {
|
|
|
10
10
|
env?: Record<string, string>;
|
|
11
11
|
url?: string;
|
|
12
12
|
headers?: Record<string, string>;
|
|
13
|
+
oauth?: {
|
|
14
|
+
scope?: string;
|
|
15
|
+
};
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
export class MCPManager {
|
|
@@ -70,10 +73,47 @@ export class MCPManager {
|
|
|
70
73
|
try {
|
|
71
74
|
if (config.type === 'remote') {
|
|
72
75
|
if (!config.url) throw new Error('Remote MCP server missing URL');
|
|
73
|
-
|
|
76
|
+
|
|
77
|
+
const headers = { ...(config.headers || {}) };
|
|
78
|
+
|
|
79
|
+
if (config.oauth) {
|
|
80
|
+
const { AuthManager } = await import('../utils/auth-manager');
|
|
81
|
+
const auth = AuthManager.load();
|
|
82
|
+
const token = auth.mcp_tokens?.[config.name]?.access_token;
|
|
83
|
+
|
|
84
|
+
if (!token) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`MCP server ${config.name} requires OAuth. Please run "keystone mcp login ${config.name}" first.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
headers.Authorization = `Bearer ${token}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
client = await MCPClient.createRemote(config.url, headers);
|
|
74
94
|
} else {
|
|
75
95
|
if (!config.command) throw new Error('Local MCP server missing command');
|
|
76
|
-
|
|
96
|
+
|
|
97
|
+
const env = { ...(config.env || {}) };
|
|
98
|
+
|
|
99
|
+
if (config.oauth) {
|
|
100
|
+
const { AuthManager } = await import('../utils/auth-manager');
|
|
101
|
+
const auth = AuthManager.load();
|
|
102
|
+
const token = auth.mcp_tokens?.[config.name]?.access_token;
|
|
103
|
+
|
|
104
|
+
if (!token) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`MCP server ${config.name} requires OAuth. Please run "keystone mcp login ${config.name}" first.`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Pass token to the local proxy via environment variables
|
|
111
|
+
// Most proxies expect AUTHORIZATION or MCP_TOKEN
|
|
112
|
+
env.AUTHORIZATION = `Bearer ${token}`;
|
|
113
|
+
env.MCP_TOKEN = token;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
client = await MCPClient.createLocal(config.command, config.args || [], env);
|
|
77
117
|
}
|
|
78
118
|
|
|
79
119
|
await client.initialize();
|
|
@@ -28,7 +28,7 @@ describe('MCPServer', () => {
|
|
|
28
28
|
method: 'initialize',
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
expect(response
|
|
31
|
+
expect(response?.result?.serverInfo?.name).toBe('keystone-mcp');
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
it('should list tools', async () => {
|
|
@@ -38,9 +38,9 @@ describe('MCPServer', () => {
|
|
|
38
38
|
method: 'tools/list',
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
expect(response
|
|
41
|
+
expect(response?.result?.tools).toHaveLength(5);
|
|
42
42
|
// @ts-ignore
|
|
43
|
-
expect(response
|
|
43
|
+
expect(response?.result?.tools?.map((t) => t.name)).toContain('run_workflow');
|
|
44
44
|
});
|
|
45
45
|
|
|
46
46
|
it('should call list_workflows tool', async () => {
|
|
@@ -55,7 +55,7 @@ describe('MCPServer', () => {
|
|
|
55
55
|
params: { name: 'list_workflows', arguments: {} },
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
expect(response
|
|
58
|
+
expect(response?.result?.content?.[0]?.text).toContain('test-wf');
|
|
59
59
|
});
|
|
60
60
|
|
|
61
61
|
it('should call run_workflow tool successfully', async () => {
|
|
@@ -104,8 +104,8 @@ describe('MCPServer', () => {
|
|
|
104
104
|
},
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
expect(response
|
|
108
|
-
expect(response
|
|
107
|
+
expect(response?.result?.isError).toBe(true);
|
|
108
|
+
expect(response?.result?.content?.[0]?.text).toContain('Workflow failed');
|
|
109
109
|
});
|
|
110
110
|
|
|
111
111
|
it('should handle workflow suspension in run_workflow', async () => {
|
|
@@ -130,7 +130,7 @@ describe('MCPServer', () => {
|
|
|
130
130
|
},
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
-
const result = JSON.parse(response
|
|
133
|
+
const result = JSON.parse(response?.result?.content?.[0]?.text);
|
|
134
134
|
expect(result.status).toBe('paused');
|
|
135
135
|
expect(result.run_id).toBe('run123');
|
|
136
136
|
expect(result.message).toBe('Input needed');
|
|
@@ -187,7 +187,7 @@ describe('MCPServer', () => {
|
|
|
187
187
|
params: { name: 'get_run_logs', arguments: { run_id: runId } },
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
-
const summary = JSON.parse(response
|
|
190
|
+
const summary = JSON.parse(response?.result?.content?.[0]?.text);
|
|
191
191
|
expect(summary.workflow).toBe('test-wf');
|
|
192
192
|
expect(summary.steps).toHaveLength(1);
|
|
193
193
|
expect(summary.steps[0].step).toBe('s1');
|
|
@@ -202,7 +202,7 @@ describe('MCPServer', () => {
|
|
|
202
202
|
params: { name: 'unknown_tool', arguments: {} },
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
-
expect(response
|
|
205
|
+
expect(response?.error?.message).toContain('Unknown tool');
|
|
206
206
|
});
|
|
207
207
|
|
|
208
208
|
it('should handle unknown method', async () => {
|
|
@@ -212,14 +212,21 @@ describe('MCPServer', () => {
|
|
|
212
212
|
method: 'unknown_method',
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
-
expect(response
|
|
215
|
+
expect(response?.error?.message).toContain('Method not found');
|
|
216
216
|
});
|
|
217
217
|
|
|
218
218
|
it('should start and handle messages from stdin', async () => {
|
|
219
|
-
const
|
|
219
|
+
const { PassThrough } = await import('node:stream');
|
|
220
|
+
const input = new PassThrough();
|
|
221
|
+
const outputStream = new PassThrough();
|
|
222
|
+
|
|
223
|
+
// Create a new server for this test to use the streams
|
|
224
|
+
const testServer = new MCPServer(db, input, outputStream);
|
|
225
|
+
|
|
226
|
+
const writeSpy = spyOn(outputStream, 'write').mockImplementation(() => true);
|
|
220
227
|
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
221
228
|
|
|
222
|
-
const startPromise =
|
|
229
|
+
const startPromise = testServer.start();
|
|
223
230
|
|
|
224
231
|
// Simulate stdin data
|
|
225
232
|
const message = {
|
|
@@ -227,16 +234,16 @@ describe('MCPServer', () => {
|
|
|
227
234
|
id: 9,
|
|
228
235
|
method: 'initialize',
|
|
229
236
|
};
|
|
230
|
-
|
|
237
|
+
input.write(`${JSON.stringify(message)}\n`);
|
|
231
238
|
|
|
232
239
|
// Wait for async processing
|
|
233
|
-
await new Promise((resolve) => setTimeout(resolve,
|
|
240
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
234
241
|
|
|
235
242
|
expect(writeSpy).toHaveBeenCalled();
|
|
236
243
|
const output = JSON.parse(writeSpy.mock.calls[0][0] as string);
|
|
237
244
|
expect(output.id).toBe(9);
|
|
238
245
|
|
|
239
|
-
|
|
246
|
+
input.end();
|
|
240
247
|
await startPromise;
|
|
241
248
|
|
|
242
249
|
writeSpy.mockRestore();
|
package/src/runner/mcp-server.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as readline from 'node:readline';
|
|
2
|
+
import type { Readable, Writable } from 'node:stream';
|
|
2
3
|
import pkg from '../../package.json' with { type: 'json' };
|
|
3
4
|
import { WorkflowDb } from '../db/workflow-db';
|
|
4
5
|
import { WorkflowParser } from '../parser/workflow-parser';
|
|
@@ -16,14 +17,18 @@ interface MCPMessage {
|
|
|
16
17
|
|
|
17
18
|
export class MCPServer {
|
|
18
19
|
private db: WorkflowDb;
|
|
20
|
+
private input: Readable;
|
|
21
|
+
private output: Writable;
|
|
19
22
|
|
|
20
|
-
constructor(db?: WorkflowDb) {
|
|
23
|
+
constructor(db?: WorkflowDb, input: Readable = process.stdin, output: Writable = process.stdout) {
|
|
21
24
|
this.db = db || new WorkflowDb();
|
|
25
|
+
this.input = input;
|
|
26
|
+
this.output = output;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
async start() {
|
|
25
30
|
const rl = readline.createInterface({
|
|
26
|
-
input:
|
|
31
|
+
input: this.input,
|
|
27
32
|
terminal: false,
|
|
28
33
|
});
|
|
29
34
|
|
|
@@ -35,7 +40,7 @@ export class MCPServer {
|
|
|
35
40
|
const message = JSON.parse(line) as MCPMessage;
|
|
36
41
|
const response = await this.handleMessage(message);
|
|
37
42
|
if (response) {
|
|
38
|
-
|
|
43
|
+
this.output.write(`${JSON.stringify(response)}\n`);
|
|
39
44
|
}
|
|
40
45
|
} catch (error) {
|
|
41
46
|
console.error('Error handling MCP message:', error);
|
|
@@ -46,6 +51,11 @@ export class MCPServer {
|
|
|
46
51
|
this.stop();
|
|
47
52
|
resolve();
|
|
48
53
|
});
|
|
54
|
+
|
|
55
|
+
// Handle stream errors
|
|
56
|
+
this.input.on('error', (err: Error) => {
|
|
57
|
+
console.error('stdin error:', err);
|
|
58
|
+
});
|
|
49
59
|
});
|
|
50
60
|
}
|
|
51
61
|
|
|
@@ -333,7 +343,14 @@ export class MCPServer {
|
|
|
333
343
|
}
|
|
334
344
|
|
|
335
345
|
// Fulfill the step in the DB
|
|
336
|
-
|
|
346
|
+
let output: unknown = input;
|
|
347
|
+
const lowerInput = input.trim().toLowerCase();
|
|
348
|
+
if (lowerInput === 'confirm' || lowerInput === 'y' || lowerInput === 'yes' || lowerInput === '') {
|
|
349
|
+
output = true;
|
|
350
|
+
} else if (lowerInput === 'n' || lowerInput === 'no') {
|
|
351
|
+
output = false;
|
|
352
|
+
}
|
|
353
|
+
|
|
337
354
|
await this.db.completeStep(pendingStep.id, 'success', output);
|
|
338
355
|
|
|
339
356
|
// Resume the workflow
|