keystone-cli 0.5.0 → 0.6.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 +55 -8
- package/package.json +5 -3
- package/src/cli.ts +33 -192
- package/src/db/memory-db.test.ts +54 -0
- package/src/db/memory-db.ts +122 -0
- package/src/db/sqlite-setup.ts +49 -0
- package/src/db/workflow-db.test.ts +41 -10
- package/src/db/workflow-db.ts +84 -28
- package/src/expression/evaluator.test.ts +19 -0
- package/src/expression/evaluator.ts +134 -39
- package/src/parser/schema.ts +41 -0
- package/src/runner/audit-verification.test.ts +23 -0
- package/src/runner/auto-heal.test.ts +64 -0
- package/src/runner/debug-repl.test.ts +74 -0
- package/src/runner/debug-repl.ts +225 -0
- package/src/runner/foreach-executor.ts +327 -0
- package/src/runner/llm-adapter.test.ts +27 -14
- package/src/runner/llm-adapter.ts +90 -112
- package/src/runner/llm-executor.test.ts +91 -6
- package/src/runner/llm-executor.ts +26 -6
- package/src/runner/mcp-client.audit.test.ts +69 -0
- package/src/runner/mcp-client.test.ts +12 -3
- package/src/runner/mcp-client.ts +199 -19
- package/src/runner/mcp-manager.ts +19 -8
- package/src/runner/mcp-server.test.ts +8 -5
- package/src/runner/mcp-server.ts +31 -17
- package/src/runner/optimization-runner.ts +305 -0
- package/src/runner/reflexion.test.ts +87 -0
- package/src/runner/shell-executor.test.ts +12 -0
- package/src/runner/shell-executor.ts +9 -6
- package/src/runner/step-executor.test.ts +46 -1
- package/src/runner/step-executor.ts +154 -60
- package/src/runner/stream-utils.test.ts +65 -0
- package/src/runner/stream-utils.ts +186 -0
- package/src/runner/workflow-runner.test.ts +4 -4
- package/src/runner/workflow-runner.ts +436 -251
- package/src/templates/agents/keystone-architect.md +6 -4
- package/src/templates/full-feature-demo.yaml +4 -4
- package/src/types/assets.d.ts +14 -0
- package/src/types/status.ts +1 -1
- package/src/ui/dashboard.tsx +38 -26
- package/src/utils/auth-manager.ts +3 -1
- package/src/utils/logger.test.ts +76 -0
- package/src/utils/logger.ts +39 -0
- package/src/utils/prompt.ts +75 -0
- package/src/utils/redactor.test.ts +86 -4
- package/src/utils/redactor.ts +48 -13
package/src/runner/mcp-client.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { type ChildProcess, spawn } from 'node:child_process';
|
|
2
|
+
import { lookup } from 'node:dns/promises';
|
|
3
|
+
import { isIP } from 'node:net';
|
|
2
4
|
import { type Interface, createInterface } from 'node:readline';
|
|
3
5
|
import pkg from '../../package.json' with { type: 'json' };
|
|
6
|
+
import { ConsoleLogger, type Logger } from '../utils/logger.ts';
|
|
4
7
|
|
|
5
8
|
// MCP Protocol version - update when upgrading to newer MCP spec
|
|
6
9
|
export const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
@@ -8,6 +11,142 @@ export const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
|
8
11
|
// Maximum buffer size for incoming messages (10MB) to prevent memory exhaustion
|
|
9
12
|
const MAX_BUFFER_SIZE = 10 * 1024 * 1024;
|
|
10
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Validate a URL to prevent SSRF attacks
|
|
16
|
+
* Blocks private IP ranges, localhost, and other internal addresses
|
|
17
|
+
* @param url The URL to validate
|
|
18
|
+
* @param options.allowInsecure If true, skips all security checks (use only for development/testing)
|
|
19
|
+
* @throws Error if the URL is potentially dangerous
|
|
20
|
+
*/
|
|
21
|
+
function isPrivateIpAddress(address: string): boolean {
|
|
22
|
+
const normalized = address.toLowerCase();
|
|
23
|
+
const parseMappedIpv4 = (mapped: string): string | null => {
|
|
24
|
+
const rest = mapped.replace(/^::ffff:/i, '');
|
|
25
|
+
if (rest.includes('.')) {
|
|
26
|
+
return rest;
|
|
27
|
+
}
|
|
28
|
+
const parts = rest.split(':');
|
|
29
|
+
if (parts.length !== 2) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const high = Number.parseInt(parts[0], 16);
|
|
33
|
+
const low = Number.parseInt(parts[1], 16);
|
|
34
|
+
if (Number.isNaN(high) || Number.isNaN(low)) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
const a = (high >> 8) & 0xff;
|
|
38
|
+
const b = high & 0xff;
|
|
39
|
+
const c = (low >> 8) & 0xff;
|
|
40
|
+
const d = low & 0xff;
|
|
41
|
+
return `${a}.${b}.${c}.${d}`;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// IPv4 checks
|
|
45
|
+
const ipv4Match = normalized.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
46
|
+
if (ipv4Match) {
|
|
47
|
+
const [, a, b] = ipv4Match.map(Number);
|
|
48
|
+
return (
|
|
49
|
+
a === 10 || // 10.0.0.0/8
|
|
50
|
+
(a === 172 && b >= 16 && b <= 31) || // 172.16.0.0/12
|
|
51
|
+
(a === 192 && b === 168) || // 192.168.0.0/16
|
|
52
|
+
(a === 169 && b === 254) || // 169.254.0.0/16 (link-local)
|
|
53
|
+
a === 127 // 127.0.0.0/8
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// IPv6-mapped IPv4 (::ffff:127.0.0.1 or ::ffff:7f00:1)
|
|
58
|
+
if (normalized.startsWith('::ffff:')) {
|
|
59
|
+
const mappedIpv4 = parseMappedIpv4(normalized);
|
|
60
|
+
if (mappedIpv4) {
|
|
61
|
+
return isPrivateIpAddress(mappedIpv4);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// IPv6 checks (best-effort, without full CIDR parsing)
|
|
66
|
+
const ipv6 = normalized.replace(/^\[|\]$/g, '');
|
|
67
|
+
return (
|
|
68
|
+
ipv6 === '::1' || // Loopback
|
|
69
|
+
ipv6.startsWith('fe80:') || // Link-local
|
|
70
|
+
ipv6.startsWith('fc') || // Unique local (fc00::/7)
|
|
71
|
+
ipv6.startsWith('fd') // Unique local (fc00::/7)
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function validateRemoteUrl(
|
|
76
|
+
url: string,
|
|
77
|
+
options: { allowInsecure?: boolean } = {}
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
let parsed: URL;
|
|
80
|
+
try {
|
|
81
|
+
parsed = new URL(url);
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error(`Invalid MCP server URL: ${url}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Skip all security checks if allowInsecure is set (for development/testing)
|
|
87
|
+
if (options.allowInsecure) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Require HTTPS in production
|
|
92
|
+
if (parsed.protocol !== 'https:') {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`SSRF Protection: MCP remote URL must use HTTPS. Got: ${parsed.protocol}. Set allowInsecure option to true if you trust this server.`
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
99
|
+
|
|
100
|
+
// Block localhost variants
|
|
101
|
+
if (
|
|
102
|
+
hostname === 'localhost' ||
|
|
103
|
+
hostname === '127.0.0.1' ||
|
|
104
|
+
hostname === '::1' ||
|
|
105
|
+
hostname === '0.0.0.0' ||
|
|
106
|
+
hostname.endsWith('.localhost')
|
|
107
|
+
) {
|
|
108
|
+
throw new Error(`SSRF Protection: Cannot connect to localhost/loopback address: ${hostname}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Block private IP ranges (IPv4/IPv6) for literal IPs
|
|
112
|
+
if (isIP(hostname)) {
|
|
113
|
+
if (isPrivateIpAddress(hostname)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`SSRF Protection: Cannot connect to private/internal IP address: ${hostname}`
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Block cloud metadata endpoints
|
|
121
|
+
if (
|
|
122
|
+
hostname === '169.254.169.254' || // AWS/GCP/Azure metadata
|
|
123
|
+
hostname === 'metadata.google.internal' ||
|
|
124
|
+
hostname.endsWith('.internal')
|
|
125
|
+
) {
|
|
126
|
+
throw new Error(`SSRF Protection: Cannot connect to cloud metadata endpoint: ${hostname}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Resolve DNS to prevent hostnames that map to private IPs (DNS rebinding)
|
|
130
|
+
if (!isIP(hostname)) {
|
|
131
|
+
try {
|
|
132
|
+
const resolved = await lookup(hostname, { all: true });
|
|
133
|
+
for (const record of resolved) {
|
|
134
|
+
if (isPrivateIpAddress(record.address)) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`SSRF Protection: Hostname "${hostname}" resolves to private/internal address: ${record.address}`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`SSRF Protection: Failed to resolve hostname "${hostname}": ${
|
|
143
|
+
error instanceof Error ? error.message : String(error)
|
|
144
|
+
}`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
11
150
|
interface MCPTool {
|
|
12
151
|
name: string;
|
|
13
152
|
description?: string;
|
|
@@ -32,17 +171,20 @@ interface MCPTransport {
|
|
|
32
171
|
send(message: unknown): Promise<void>;
|
|
33
172
|
onMessage(callback: (message: MCPResponse) => void): void;
|
|
34
173
|
close(): void;
|
|
174
|
+
setLogger(logger: Logger): void;
|
|
35
175
|
}
|
|
36
176
|
|
|
37
177
|
class StdConfigTransport implements MCPTransport {
|
|
38
178
|
private process: ChildProcess;
|
|
39
179
|
private rl: Interface;
|
|
180
|
+
private logger: Logger = new ConsoleLogger();
|
|
40
181
|
|
|
41
182
|
constructor(command: string, args: string[] = [], env: Record<string, string> = {}) {
|
|
42
183
|
// Filter out sensitive environment variables from the host process
|
|
43
184
|
// unless they are explicitly provided in the 'env' argument
|
|
44
185
|
const safeEnv: Record<string, string> = {};
|
|
45
|
-
const sensitivePattern =
|
|
186
|
+
const sensitivePattern =
|
|
187
|
+
/(?:key|token|secret|password|credential|auth|private|cookie|session|signature)/i;
|
|
46
188
|
|
|
47
189
|
for (const [key, value] of Object.entries(process.env)) {
|
|
48
190
|
if (value && !sensitivePattern.test(key)) {
|
|
@@ -64,6 +206,10 @@ class StdConfigTransport implements MCPTransport {
|
|
|
64
206
|
});
|
|
65
207
|
}
|
|
66
208
|
|
|
209
|
+
setLogger(logger: Logger): void {
|
|
210
|
+
this.logger = logger;
|
|
211
|
+
}
|
|
212
|
+
|
|
67
213
|
async send(message: unknown): Promise<void> {
|
|
68
214
|
this.process.stdin?.write(`${JSON.stringify(message)}\n`);
|
|
69
215
|
}
|
|
@@ -72,8 +218,8 @@ class StdConfigTransport implements MCPTransport {
|
|
|
72
218
|
this.rl.on('line', (line) => {
|
|
73
219
|
// Safety check for extremely long lines that might have bypassed readline's internal limits
|
|
74
220
|
if (line.length > MAX_BUFFER_SIZE) {
|
|
75
|
-
|
|
76
|
-
`[MCP Error] Received line exceeding maximum size (${line.length} bytes), ignoring
|
|
221
|
+
this.logger.error(
|
|
222
|
+
`[MCP Error] Received line exceeding maximum size (${line.length} bytes), ignoring.`
|
|
77
223
|
);
|
|
78
224
|
return;
|
|
79
225
|
}
|
|
@@ -84,7 +230,7 @@ class StdConfigTransport implements MCPTransport {
|
|
|
84
230
|
} catch (e) {
|
|
85
231
|
// Log non-JSON lines to stderr so they show up in the terminal
|
|
86
232
|
if (line.trim()) {
|
|
87
|
-
|
|
233
|
+
this.logger.log(`[MCP Server Output] ${line}`);
|
|
88
234
|
}
|
|
89
235
|
}
|
|
90
236
|
});
|
|
@@ -104,12 +250,17 @@ class SSETransport implements MCPTransport {
|
|
|
104
250
|
private abortController: AbortController | null = null;
|
|
105
251
|
private sessionId?: string;
|
|
106
252
|
private activeReaders: Set<ReadableStreamDefaultReader<Uint8Array>> = new Set();
|
|
253
|
+
private logger: Logger = new ConsoleLogger();
|
|
107
254
|
|
|
108
255
|
constructor(url: string, headers: Record<string, string> = {}) {
|
|
109
256
|
this.url = url;
|
|
110
257
|
this.headers = headers;
|
|
111
258
|
}
|
|
112
259
|
|
|
260
|
+
setLogger(logger: Logger): void {
|
|
261
|
+
this.logger = logger;
|
|
262
|
+
}
|
|
263
|
+
|
|
113
264
|
async connect(timeout = 60000): Promise<void> {
|
|
114
265
|
this.abortController = new AbortController();
|
|
115
266
|
|
|
@@ -179,6 +330,18 @@ class SSETransport implements MCPTransport {
|
|
|
179
330
|
const dispatchEvent = () => {
|
|
180
331
|
if (currentEvent.data) {
|
|
181
332
|
if (currentEvent.event === 'endpoint') {
|
|
333
|
+
// Validate endpoint to prevent SSRF - only allow relative paths
|
|
334
|
+
const endpointValue = currentEvent.data;
|
|
335
|
+
if (
|
|
336
|
+
endpointValue &&
|
|
337
|
+
(endpointValue.startsWith('http://') ||
|
|
338
|
+
endpointValue.startsWith('https://') ||
|
|
339
|
+
endpointValue.startsWith('//'))
|
|
340
|
+
) {
|
|
341
|
+
throw new Error(
|
|
342
|
+
`SSE endpoint must be a relative path, got absolute URL: ${endpointValue.substring(0, 50)}`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
182
345
|
this.endpoint = currentEvent.data;
|
|
183
346
|
if (this.endpoint) {
|
|
184
347
|
this.endpoint = new URL(this.endpoint, this.url).href;
|
|
@@ -414,15 +577,25 @@ class SSETransport implements MCPTransport {
|
|
|
414
577
|
export class MCPClient {
|
|
415
578
|
private transport: MCPTransport;
|
|
416
579
|
private messageId = 0;
|
|
417
|
-
private pendingRequests = new Map<
|
|
580
|
+
private pendingRequests = new Map<
|
|
581
|
+
number,
|
|
582
|
+
{
|
|
583
|
+
resolve: (response: MCPResponse) => void;
|
|
584
|
+
reject: (error: Error) => void;
|
|
585
|
+
timeoutId: ReturnType<typeof setTimeout>;
|
|
586
|
+
}
|
|
587
|
+
>();
|
|
418
588
|
private timeout: number;
|
|
589
|
+
private logger: Logger;
|
|
419
590
|
|
|
420
591
|
constructor(
|
|
421
592
|
transportOrCommand: MCPTransport | string,
|
|
422
593
|
timeoutOrArgs: number | string[] = [],
|
|
423
594
|
env: Record<string, string> = {},
|
|
424
|
-
timeout = 60000
|
|
595
|
+
timeout = 60000,
|
|
596
|
+
logger: Logger = new ConsoleLogger()
|
|
425
597
|
) {
|
|
598
|
+
this.logger = logger;
|
|
426
599
|
if (typeof transportOrCommand === 'string') {
|
|
427
600
|
this.transport = new StdConfigTransport(transportOrCommand, timeoutOrArgs as string[], env);
|
|
428
601
|
this.timeout = timeout;
|
|
@@ -430,13 +603,15 @@ export class MCPClient {
|
|
|
430
603
|
this.transport = transportOrCommand;
|
|
431
604
|
this.timeout = timeoutOrArgs as number;
|
|
432
605
|
}
|
|
606
|
+
this.transport.setLogger(this.logger);
|
|
433
607
|
|
|
434
608
|
this.transport.onMessage((response) => {
|
|
435
609
|
if (response.id !== undefined && this.pendingRequests.has(response.id)) {
|
|
436
|
-
const
|
|
437
|
-
if (
|
|
610
|
+
const pending = this.pendingRequests.get(response.id);
|
|
611
|
+
if (pending) {
|
|
438
612
|
this.pendingRequests.delete(response.id);
|
|
439
|
-
|
|
613
|
+
clearTimeout(pending.timeoutId);
|
|
614
|
+
pending.resolve(response);
|
|
440
615
|
}
|
|
441
616
|
}
|
|
442
617
|
});
|
|
@@ -446,20 +621,27 @@ export class MCPClient {
|
|
|
446
621
|
command: string,
|
|
447
622
|
args: string[] = [],
|
|
448
623
|
env: Record<string, string> = {},
|
|
449
|
-
timeout = 60000
|
|
624
|
+
timeout = 60000,
|
|
625
|
+
logger: Logger = new ConsoleLogger()
|
|
450
626
|
): Promise<MCPClient> {
|
|
451
627
|
const transport = new StdConfigTransport(command, args, env);
|
|
452
|
-
return new MCPClient(transport, timeout);
|
|
628
|
+
return new MCPClient(transport, timeout, {}, 0, logger);
|
|
453
629
|
}
|
|
454
630
|
|
|
455
631
|
static async createRemote(
|
|
456
632
|
url: string,
|
|
457
633
|
headers: Record<string, string> = {},
|
|
458
|
-
timeout = 60000
|
|
634
|
+
timeout = 60000,
|
|
635
|
+
options: { allowInsecure?: boolean; logger?: Logger } = {}
|
|
459
636
|
): Promise<MCPClient> {
|
|
637
|
+
// Validate URL to prevent SSRF attacks
|
|
638
|
+
await validateRemoteUrl(url, options);
|
|
639
|
+
|
|
640
|
+
const logger = options.logger || new ConsoleLogger();
|
|
460
641
|
const transport = new SSETransport(url, headers);
|
|
642
|
+
transport.setLogger(logger);
|
|
461
643
|
await transport.connect(timeout);
|
|
462
|
-
return new MCPClient(transport, timeout);
|
|
644
|
+
return new MCPClient(transport, timeout, {}, 0, logger);
|
|
463
645
|
}
|
|
464
646
|
|
|
465
647
|
private async request(
|
|
@@ -482,10 +664,7 @@ export class MCPClient {
|
|
|
482
664
|
}
|
|
483
665
|
}, this.timeout);
|
|
484
666
|
|
|
485
|
-
this.pendingRequests.set(id,
|
|
486
|
-
clearTimeout(timeoutId);
|
|
487
|
-
resolve(response);
|
|
488
|
-
});
|
|
667
|
+
this.pendingRequests.set(id, { resolve, reject, timeoutId });
|
|
489
668
|
|
|
490
669
|
this.transport.send(message).catch((err) => {
|
|
491
670
|
clearTimeout(timeoutId);
|
|
@@ -524,8 +703,9 @@ export class MCPClient {
|
|
|
524
703
|
|
|
525
704
|
stop() {
|
|
526
705
|
// Reject all pending requests to prevent hanging callers
|
|
527
|
-
for (const [
|
|
528
|
-
|
|
706
|
+
for (const [, pending] of this.pendingRequests) {
|
|
707
|
+
clearTimeout(pending.timeoutId);
|
|
708
|
+
pending.reject(new Error('MCP client stopped'));
|
|
529
709
|
}
|
|
530
710
|
this.pendingRequests.clear();
|
|
531
711
|
this.transport.close();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { ConfigLoader } from '../utils/config-loader';
|
|
2
|
+
import { ConsoleLogger, type Logger } from '../utils/logger.ts';
|
|
2
3
|
import { MCPClient } from './mcp-client';
|
|
3
|
-
import type { Logger } from './workflow-runner';
|
|
4
4
|
|
|
5
5
|
export interface MCPServerConfig {
|
|
6
6
|
name: string;
|
|
@@ -20,9 +20,16 @@ export class MCPManager {
|
|
|
20
20
|
private clients: Map<string, MCPClient> = new Map();
|
|
21
21
|
private connectionPromises: Map<string, Promise<MCPClient | undefined>> = new Map();
|
|
22
22
|
private sharedServers: Map<string, MCPServerConfig> = new Map();
|
|
23
|
+
private logger: Logger;
|
|
23
24
|
|
|
24
|
-
constructor() {
|
|
25
|
+
constructor(logger: Logger = new ConsoleLogger()) {
|
|
26
|
+
this.logger = logger;
|
|
25
27
|
this.loadGlobalConfig();
|
|
28
|
+
|
|
29
|
+
// Ensure cleanup on process exit
|
|
30
|
+
process.on('exit', () => {
|
|
31
|
+
this.stopAll();
|
|
32
|
+
});
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
private loadGlobalConfig() {
|
|
@@ -39,14 +46,15 @@ export class MCPManager {
|
|
|
39
46
|
|
|
40
47
|
async getClient(
|
|
41
48
|
serverRef: string | MCPServerConfig,
|
|
42
|
-
logger
|
|
49
|
+
logger?: Logger
|
|
43
50
|
): Promise<MCPClient | undefined> {
|
|
51
|
+
const activeLogger = logger || this.logger;
|
|
44
52
|
let config: MCPServerConfig;
|
|
45
53
|
|
|
46
54
|
if (typeof serverRef === 'string') {
|
|
47
55
|
const shared = this.sharedServers.get(serverRef);
|
|
48
56
|
if (!shared) {
|
|
49
|
-
|
|
57
|
+
activeLogger.error(` ✗ Global MCP server not found: ${serverRef}`);
|
|
50
58
|
return undefined;
|
|
51
59
|
}
|
|
52
60
|
config = shared;
|
|
@@ -68,7 +76,7 @@ export class MCPManager {
|
|
|
68
76
|
|
|
69
77
|
// Start a new connection and cache the promise
|
|
70
78
|
const connectionPromise = (async () => {
|
|
71
|
-
|
|
79
|
+
activeLogger.log(` 🔌 Connecting to MCP server: ${config.name} (${config.type || 'local'})`);
|
|
72
80
|
|
|
73
81
|
let client: MCPClient;
|
|
74
82
|
try {
|
|
@@ -91,7 +99,9 @@ export class MCPManager {
|
|
|
91
99
|
headers.Authorization = `Bearer ${token}`;
|
|
92
100
|
}
|
|
93
101
|
|
|
94
|
-
client = await MCPClient.createRemote(config.url, headers, config.timeout
|
|
102
|
+
client = await MCPClient.createRemote(config.url, headers, config.timeout, {
|
|
103
|
+
logger: activeLogger,
|
|
104
|
+
});
|
|
95
105
|
} else {
|
|
96
106
|
if (!config.command) throw new Error('Local MCP server missing command');
|
|
97
107
|
|
|
@@ -118,7 +128,8 @@ export class MCPManager {
|
|
|
118
128
|
config.command,
|
|
119
129
|
config.args || [],
|
|
120
130
|
env,
|
|
121
|
-
config.timeout
|
|
131
|
+
config.timeout,
|
|
132
|
+
activeLogger
|
|
122
133
|
);
|
|
123
134
|
}
|
|
124
135
|
|
|
@@ -126,7 +137,7 @@ export class MCPManager {
|
|
|
126
137
|
this.clients.set(key, client);
|
|
127
138
|
return client;
|
|
128
139
|
} catch (error) {
|
|
129
|
-
|
|
140
|
+
activeLogger.error(
|
|
130
141
|
` ✗ Failed to connect to MCP server ${config.name}: ${error instanceof Error ? error.message : String(error)}`
|
|
131
142
|
);
|
|
132
143
|
return undefined;
|
|
@@ -166,7 +166,7 @@ describe('MCPServer', () => {
|
|
|
166
166
|
expect(JSON.parse(response?.result?.content?.[0]?.text || '{}').status).toBe('success');
|
|
167
167
|
|
|
168
168
|
// Verify DB was updated
|
|
169
|
-
const steps = db.getStepsByRun(runId);
|
|
169
|
+
const steps = await db.getStepsByRun(runId);
|
|
170
170
|
expect(steps[0].status).toBe('success');
|
|
171
171
|
expect(steps[0].output).toBeDefined();
|
|
172
172
|
if (steps[0].output) {
|
|
@@ -301,10 +301,13 @@ describe('MCPServer', () => {
|
|
|
301
301
|
expect(status.hint).toContain('still running');
|
|
302
302
|
});
|
|
303
303
|
|
|
304
|
-
it('should call get_run_status tool for
|
|
305
|
-
const runId = '
|
|
304
|
+
it('should call get_run_status tool for success workflow', async () => {
|
|
305
|
+
const runId = 'success-test-run';
|
|
306
306
|
await db.createRun(runId, 'test-wf', {});
|
|
307
|
-
await db.updateRunStatus(runId, '
|
|
307
|
+
await db.updateRunStatus(runId, 'success', { output: 'done' });
|
|
308
|
+
|
|
309
|
+
// Wait for the async run to finish and update DB
|
|
310
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
308
311
|
|
|
309
312
|
const response = await handleMessage({
|
|
310
313
|
jsonrpc: '2.0',
|
|
@@ -314,7 +317,7 @@ describe('MCPServer', () => {
|
|
|
314
317
|
});
|
|
315
318
|
|
|
316
319
|
const status = JSON.parse(response?.result?.content?.[0]?.text || '{}');
|
|
317
|
-
expect(status.status).toBe('
|
|
320
|
+
expect(status.status).toBe('success');
|
|
318
321
|
expect(status.outputs).toEqual({ output: 'done' });
|
|
319
322
|
expect(status.hint).toBeUndefined();
|
|
320
323
|
});
|
package/src/runner/mcp-server.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Readable, Writable } from 'node:stream';
|
|
|
3
3
|
import pkg from '../../package.json' with { type: 'json' };
|
|
4
4
|
import { WorkflowDb } from '../db/workflow-db';
|
|
5
5
|
import { WorkflowParser } from '../parser/workflow-parser';
|
|
6
|
+
import { ConsoleLogger, type Logger } from '../utils/logger';
|
|
6
7
|
import { generateMermaidGraph } from '../utils/mermaid';
|
|
7
8
|
import { WorkflowRegistry } from '../utils/workflow-registry';
|
|
8
9
|
import { WorkflowSuspendedError } from './step-executor';
|
|
@@ -19,11 +20,18 @@ export class MCPServer {
|
|
|
19
20
|
private db: WorkflowDb;
|
|
20
21
|
private input: Readable;
|
|
21
22
|
private output: Writable;
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
private logger: Logger;
|
|
24
|
+
|
|
25
|
+
constructor(
|
|
26
|
+
db?: WorkflowDb,
|
|
27
|
+
input: Readable = process.stdin,
|
|
28
|
+
output: Writable = process.stdout,
|
|
29
|
+
logger: Logger = new ConsoleLogger()
|
|
30
|
+
) {
|
|
24
31
|
this.db = db || new WorkflowDb();
|
|
25
32
|
this.input = input;
|
|
26
33
|
this.output = output;
|
|
34
|
+
this.logger = logger;
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
async start() {
|
|
@@ -43,7 +51,7 @@ export class MCPServer {
|
|
|
43
51
|
this.output.write(`${JSON.stringify(response)}\n`);
|
|
44
52
|
}
|
|
45
53
|
} catch (error) {
|
|
46
|
-
|
|
54
|
+
this.logger.error(`Error handling MCP message: ${error}`);
|
|
47
55
|
}
|
|
48
56
|
});
|
|
49
57
|
|
|
@@ -54,7 +62,7 @@ export class MCPServer {
|
|
|
54
62
|
|
|
55
63
|
// Handle stream errors
|
|
56
64
|
this.input.on('error', (err: Error) => {
|
|
57
|
-
|
|
65
|
+
this.logger.error(`stdin error: ${err}`);
|
|
58
66
|
});
|
|
59
67
|
});
|
|
60
68
|
}
|
|
@@ -223,6 +231,8 @@ export class MCPServer {
|
|
|
223
231
|
log: (msg: string) => logs.push(msg),
|
|
224
232
|
error: (msg: string) => logs.push(`ERROR: ${msg}`),
|
|
225
233
|
warn: (msg: string) => logs.push(`WARN: ${msg}`),
|
|
234
|
+
info: (msg: string) => logs.push(`INFO: ${msg}`),
|
|
235
|
+
debug: (msg: string) => logs.push(`DEBUG: ${msg}`),
|
|
226
236
|
};
|
|
227
237
|
|
|
228
238
|
const runner = new WorkflowRunner(workflow, {
|
|
@@ -307,13 +317,13 @@ export class MCPServer {
|
|
|
307
317
|
// --- Tool: get_run_logs ---
|
|
308
318
|
if (toolParams.name === 'get_run_logs') {
|
|
309
319
|
const { run_id } = toolParams.arguments as { run_id: string };
|
|
310
|
-
const run = this.db.getRun(run_id);
|
|
320
|
+
const run = await this.db.getRun(run_id);
|
|
311
321
|
|
|
312
322
|
if (!run) {
|
|
313
323
|
throw new Error(`Run ID ${run_id} not found`);
|
|
314
324
|
}
|
|
315
325
|
|
|
316
|
-
const steps = this.db.getStepsByRun(run_id);
|
|
326
|
+
const steps = await this.db.getStepsByRun(run_id);
|
|
317
327
|
const summary = {
|
|
318
328
|
workflow: run.workflow_name,
|
|
319
329
|
status: run.status,
|
|
@@ -360,7 +370,7 @@ export class MCPServer {
|
|
|
360
370
|
// --- Tool: answer_human_input ---
|
|
361
371
|
if (toolParams.name === 'answer_human_input') {
|
|
362
372
|
const { run_id, input } = toolParams.arguments as { run_id: string; input: string };
|
|
363
|
-
const run = this.db.getRun(run_id);
|
|
373
|
+
const run = await this.db.getRun(run_id);
|
|
364
374
|
if (!run) {
|
|
365
375
|
throw new Error(`Run ID ${run_id} not found`);
|
|
366
376
|
}
|
|
@@ -370,7 +380,7 @@ export class MCPServer {
|
|
|
370
380
|
}
|
|
371
381
|
|
|
372
382
|
// Find the pending or suspended step
|
|
373
|
-
const steps = this.db.getStepsByRun(run_id);
|
|
383
|
+
const steps = await this.db.getStepsByRun(run_id);
|
|
374
384
|
const pendingStep = steps.find(
|
|
375
385
|
(s) => s.status === 'pending' || s.status === 'suspended'
|
|
376
386
|
);
|
|
@@ -403,6 +413,8 @@ export class MCPServer {
|
|
|
403
413
|
log: (msg: string) => logs.push(msg),
|
|
404
414
|
error: (msg: string) => logs.push(`ERROR: ${msg}`),
|
|
405
415
|
warn: (msg: string) => logs.push(`WARN: ${msg}`),
|
|
416
|
+
info: (msg: string) => logs.push(`INFO: ${msg}`),
|
|
417
|
+
debug: (msg: string) => logs.push(`DEBUG: ${msg}`),
|
|
406
418
|
};
|
|
407
419
|
|
|
408
420
|
const runner = new WorkflowRunner(workflow, {
|
|
@@ -497,6 +509,8 @@ export class MCPServer {
|
|
|
497
509
|
log: () => {},
|
|
498
510
|
error: () => {},
|
|
499
511
|
warn: () => {},
|
|
512
|
+
info: () => {},
|
|
513
|
+
debug: () => {},
|
|
500
514
|
};
|
|
501
515
|
|
|
502
516
|
const runner = new WorkflowRunner(workflow, {
|
|
@@ -507,18 +521,18 @@ export class MCPServer {
|
|
|
507
521
|
|
|
508
522
|
const runId = runner.getRunId();
|
|
509
523
|
|
|
510
|
-
// Start the workflow asynchronously
|
|
524
|
+
// Start the workflow asynchronously
|
|
511
525
|
runner.run().then(
|
|
512
|
-
(outputs) => {
|
|
513
|
-
// Update DB with success on completion
|
|
514
|
-
this.db.updateRunStatus(runId, '
|
|
526
|
+
async (outputs) => {
|
|
527
|
+
// Update DB with success on completion
|
|
528
|
+
await this.db.updateRunStatus(runId, 'success', outputs);
|
|
515
529
|
},
|
|
516
|
-
(error) => {
|
|
530
|
+
async (error) => {
|
|
517
531
|
// Update DB with failure
|
|
518
532
|
if (error instanceof WorkflowSuspendedError) {
|
|
519
|
-
this.db.updateRunStatus(runId, 'paused');
|
|
533
|
+
await this.db.updateRunStatus(runId, 'paused');
|
|
520
534
|
} else {
|
|
521
|
-
this.db.updateRunStatus(
|
|
535
|
+
await this.db.updateRunStatus(
|
|
522
536
|
runId,
|
|
523
537
|
'failed',
|
|
524
538
|
undefined,
|
|
@@ -554,7 +568,7 @@ export class MCPServer {
|
|
|
554
568
|
// --- Tool: get_run_status ---
|
|
555
569
|
if (toolParams.name === 'get_run_status') {
|
|
556
570
|
const { run_id } = toolParams.arguments as { run_id: string };
|
|
557
|
-
const run = this.db.getRun(run_id);
|
|
571
|
+
const run = await this.db.getRun(run_id);
|
|
558
572
|
|
|
559
573
|
if (!run) {
|
|
560
574
|
throw new Error(`Run ID ${run_id} not found`);
|
|
@@ -567,7 +581,7 @@ export class MCPServer {
|
|
|
567
581
|
};
|
|
568
582
|
|
|
569
583
|
// Include outputs if completed successfully
|
|
570
|
-
if (run.status === '
|
|
584
|
+
if (run.status === 'success' && run.outputs) {
|
|
571
585
|
response.outputs = JSON.parse(run.outputs);
|
|
572
586
|
}
|
|
573
587
|
|