@kairos-sdk/core 0.4.5 → 0.5.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 +28 -11
- package/dist/{chunk-4TS6GW6O.js → chunk-GVZKMS53.js} +27 -16
- package/dist/chunk-GVZKMS53.js.map +1 -0
- package/dist/{chunk-6CLI43FI.js → chunk-MYAGTDQ2.js} +109 -13
- package/dist/chunk-MYAGTDQ2.js.map +1 -0
- package/dist/{chunk-CR2NHLOH.js → chunk-V2IZBZGB.js} +57 -11
- package/dist/chunk-V2IZBZGB.js.map +1 -0
- package/dist/{chunk-6IXW3WCC.js → chunk-VPPWTMRJ.js} +533 -77
- package/dist/chunk-VPPWTMRJ.js.map +1 -0
- package/dist/cli.cjs +790 -145
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +54 -16
- package/dist/cli.js.map +1 -1
- package/dist/index.cjs +740 -133
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -3
- package/dist/index.d.ts +2 -3
- package/dist/index.js +5 -5
- package/dist/mcp-server.cjs +960 -347
- package/dist/mcp-server.cjs.map +1 -1
- package/dist/mcp-server.js +374 -261
- package/dist/mcp-server.js.map +1 -1
- package/dist/{reader-CpUcHhKW.d.ts → reader-B5mV20H6.d.cts} +34 -4
- package/dist/{reader-CpUcHhKW.d.cts → reader-B5mV20H6.d.ts} +34 -4
- package/dist/standalone.cjs +603 -85
- package/dist/standalone.cjs.map +1 -1
- package/dist/standalone.d.cts +2 -1
- package/dist/standalone.d.ts +2 -1
- package/dist/standalone.js +3 -3
- package/package.json +15 -5
- package/dist/chunk-4TS6GW6O.js.map +0 -1
- package/dist/chunk-6CLI43FI.js.map +0 -1
- package/dist/chunk-6IXW3WCC.js.map +0 -1
- package/dist/chunk-CR2NHLOH.js.map +0 -1
package/dist/mcp-server.cjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
// src/mcp-server.ts
|
|
5
5
|
var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
|
|
6
6
|
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
7
|
+
var import_streamableHttp = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
8
|
+
var import_node_http = require("http");
|
|
7
9
|
var import_zod = require("zod");
|
|
8
10
|
|
|
9
11
|
// src/library/file-library.ts
|
|
@@ -13,7 +15,13 @@ var import_node_os = require("os");
|
|
|
13
15
|
|
|
14
16
|
// src/utils/uuid.ts
|
|
15
17
|
function generateUUID() {
|
|
16
|
-
|
|
18
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
19
|
+
return crypto.randomUUID();
|
|
20
|
+
}
|
|
21
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
|
|
22
|
+
const r = Math.random() * 16 | 0;
|
|
23
|
+
return (c === "x" ? r : r & 3 | 8).toString(16);
|
|
24
|
+
});
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
// src/utils/thresholds.ts
|
|
@@ -26,12 +34,32 @@ function scoreToMode(score) {
|
|
|
26
34
|
}
|
|
27
35
|
|
|
28
36
|
// src/library/scorer.ts
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
37
|
+
function loadWeights() {
|
|
38
|
+
const raw = {
|
|
39
|
+
tfidf: parseFloat(process.env["KAIROS_WEIGHT_TFIDF"] ?? ""),
|
|
40
|
+
nodeFingerprint: parseFloat(process.env["KAIROS_WEIGHT_JACCARD"] ?? ""),
|
|
41
|
+
outcome: parseFloat(process.env["KAIROS_WEIGHT_OUTCOME"] ?? ""),
|
|
42
|
+
deploy: parseFloat(process.env["KAIROS_WEIGHT_DEPLOY"] ?? "")
|
|
43
|
+
};
|
|
44
|
+
const defaults = { tfidf: 0.35, nodeFingerprint: 0.3, outcome: 0.2, deploy: 0.15 };
|
|
45
|
+
const anySet = Object.values(raw).some((v) => !isNaN(v) && v >= 0);
|
|
46
|
+
if (!anySet) return defaults;
|
|
47
|
+
const w = {
|
|
48
|
+
tfidf: !isNaN(raw.tfidf) && raw.tfidf >= 0 ? raw.tfidf : defaults.tfidf,
|
|
49
|
+
nodeFingerprint: !isNaN(raw.nodeFingerprint) && raw.nodeFingerprint >= 0 ? raw.nodeFingerprint : defaults.nodeFingerprint,
|
|
50
|
+
outcome: !isNaN(raw.outcome) && raw.outcome >= 0 ? raw.outcome : defaults.outcome,
|
|
51
|
+
deploy: !isNaN(raw.deploy) && raw.deploy >= 0 ? raw.deploy : defaults.deploy
|
|
52
|
+
};
|
|
53
|
+
const total = w.tfidf + w.nodeFingerprint + w.outcome + w.deploy;
|
|
54
|
+
if (total <= 0) return defaults;
|
|
55
|
+
return {
|
|
56
|
+
tfidf: w.tfidf / total,
|
|
57
|
+
nodeFingerprint: w.nodeFingerprint / total,
|
|
58
|
+
outcome: w.outcome / total,
|
|
59
|
+
deploy: w.deploy / total
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
var WEIGHTS = loadWeights();
|
|
35
63
|
var NODE_KEYWORDS = {
|
|
36
64
|
slack: ["slack", "slackApi"],
|
|
37
65
|
email: ["gmail", "sendEmail", "emailSend", "emailReadImap"],
|
|
@@ -216,6 +244,8 @@ function clusterWorkflows(workflows) {
|
|
|
216
244
|
}
|
|
217
245
|
return clusters.sort((a, b) => b.members.length - a.members.length);
|
|
218
246
|
}
|
|
247
|
+
var NOVELTY_BOOST = 0.05;
|
|
248
|
+
var NOVELTY_PENALTY = 0.03;
|
|
219
249
|
function rerank(candidates, clusters) {
|
|
220
250
|
const clusterMap = /* @__PURE__ */ new Map();
|
|
221
251
|
for (const cluster of clusters) {
|
|
@@ -223,7 +253,7 @@ function rerank(candidates, clusters) {
|
|
|
223
253
|
clusterMap.set(member.id, cluster);
|
|
224
254
|
}
|
|
225
255
|
}
|
|
226
|
-
|
|
256
|
+
const pass1 = candidates.map((c) => {
|
|
227
257
|
const cluster = clusterMap.get(c.workflow.id);
|
|
228
258
|
let boost = 0;
|
|
229
259
|
if (cluster && cluster.avgFirstTryPassRate > 0) {
|
|
@@ -235,7 +265,25 @@ function rerank(candidates, clusters) {
|
|
|
235
265
|
return {
|
|
236
266
|
workflow: c.workflow,
|
|
237
267
|
score: Math.max(0, Math.min(1, c.score + boost)),
|
|
238
|
-
|
|
268
|
+
cluster
|
|
269
|
+
};
|
|
270
|
+
}).sort((a, b) => b.score - a.score);
|
|
271
|
+
const seenFingerprints = /* @__PURE__ */ new Set();
|
|
272
|
+
return pass1.map((c) => {
|
|
273
|
+
const fpKey = c.cluster ? fingerprintKey(c.cluster.fingerprint) : null;
|
|
274
|
+
let noveltyAdjust = 0;
|
|
275
|
+
if (fpKey !== null) {
|
|
276
|
+
if (!seenFingerprints.has(fpKey)) {
|
|
277
|
+
seenFingerprints.add(fpKey);
|
|
278
|
+
noveltyAdjust = NOVELTY_BOOST;
|
|
279
|
+
} else {
|
|
280
|
+
noveltyAdjust = -NOVELTY_PENALTY;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
workflow: c.workflow,
|
|
285
|
+
score: Math.max(0, Math.min(1, c.score + noveltyAdjust)),
|
|
286
|
+
...c.cluster ? { clusterPattern: c.cluster.pattern } : {}
|
|
239
287
|
};
|
|
240
288
|
}).sort((a, b) => b.score - a.score);
|
|
241
289
|
}
|
|
@@ -252,7 +300,11 @@ function buildSearchCorpus(w) {
|
|
|
252
300
|
});
|
|
253
301
|
return `${w.description} ${w.workflow.name} ${w.tags.join(" ")} ${nodeTokens.join(" ")}`;
|
|
254
302
|
}
|
|
255
|
-
var
|
|
303
|
+
var _rawSize = parseInt(process.env["KAIROS_LIBRARY_SIZE"] ?? "500", 10);
|
|
304
|
+
var MAX_LIBRARY_SIZE = Number.isFinite(_rawSize) && _rawSize >= 10 ? _rawSize : 500;
|
|
305
|
+
function evictionScore(m) {
|
|
306
|
+
return (m.deployCount ?? 0) * 3 + (m.timesRetrieved ?? 0) + (m.outcomeStats?.totalUses ?? 0);
|
|
307
|
+
}
|
|
256
308
|
function isValidMeta(item) {
|
|
257
309
|
return typeof item === "object" && item !== null && typeof item.id === "string" && typeof item.description === "string" && typeof item.workflowName === "string" && Array.isArray(item.cachedNodeTypes);
|
|
258
310
|
}
|
|
@@ -300,6 +352,7 @@ var FileLibrary = class {
|
|
|
300
352
|
} catch {
|
|
301
353
|
this.meta = [];
|
|
302
354
|
}
|
|
355
|
+
await this.scanForOrphansAndCleanup();
|
|
303
356
|
} else {
|
|
304
357
|
try {
|
|
305
358
|
const raw = await (0, import_promises.readFile)(indexPath, "utf-8");
|
|
@@ -314,6 +367,31 @@ var FileLibrary = class {
|
|
|
314
367
|
await (0, import_promises.mkdir)(this.workflowsDir, { recursive: true });
|
|
315
368
|
}
|
|
316
369
|
}
|
|
370
|
+
async scanForOrphansAndCleanup() {
|
|
371
|
+
let entries;
|
|
372
|
+
try {
|
|
373
|
+
entries = await (0, import_promises.readdir)(this.workflowsDir);
|
|
374
|
+
} catch {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const indexedIds = new Set(this.meta.map((m) => m.id));
|
|
378
|
+
const orphanIds = [];
|
|
379
|
+
for (const filename of entries) {
|
|
380
|
+
if (filename.endsWith(".tmp")) {
|
|
381
|
+
await (0, import_promises.unlink)((0, import_node_path.join)(this.workflowsDir, filename)).catch(() => {
|
|
382
|
+
});
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (!filename.endsWith(".json")) continue;
|
|
386
|
+
const id = filename.slice(0, -5);
|
|
387
|
+
if (!indexedIds.has(id)) {
|
|
388
|
+
orphanIds.push(id);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (orphanIds.length > 0) {
|
|
392
|
+
console.warn(`[FileLibrary] Found ${orphanIds.length} orphaned workflow file(s) not in index: ${orphanIds.join(", ")}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
317
395
|
/**
|
|
318
396
|
* One-time transparent migration from v0.4.x monolithic index.json.
|
|
319
397
|
* Splits each stored workflow into a per-file workflow JSON and a lightweight
|
|
@@ -384,10 +462,12 @@ var FileLibrary = class {
|
|
|
384
462
|
const docTokenSets = docTokenArrays.map((tokens) => new Set(tokens));
|
|
385
463
|
const docCount = shells.length;
|
|
386
464
|
const idf = /* @__PURE__ */ new Map();
|
|
465
|
+
const idfCeiling = Math.log(docCount + 1) + 1;
|
|
387
466
|
const allTokens = new Set(queryTokens);
|
|
388
467
|
for (const token of allTokens) {
|
|
389
468
|
const docsWithToken = docTokenSets.filter((d) => d.has(token)).length;
|
|
390
|
-
|
|
469
|
+
const rawIdf = Math.log((docCount + 1) / (docsWithToken + 1)) + 1;
|
|
470
|
+
idf.set(token, rawIdf / idfCeiling);
|
|
391
471
|
}
|
|
392
472
|
const scored = hybridScore(queryTokens, description, shells, docTokenArrays, idf).filter((m) => m.score > 0).sort((a, b) => b.score - a.score);
|
|
393
473
|
const clusters = clusterWorkflows(shells);
|
|
@@ -413,6 +493,27 @@ var FileLibrary = class {
|
|
|
413
493
|
return results.filter((r) => r !== null);
|
|
414
494
|
}
|
|
415
495
|
async save(workflow, metadata) {
|
|
496
|
+
const existingByN8nId = metadata.n8nWorkflowId ? this.meta.find((m) => m.n8nWorkflowId === metadata.n8nWorkflowId) : void 0;
|
|
497
|
+
const normalizedDesc = metadata.description.trim().toLowerCase();
|
|
498
|
+
const existing = existingByN8nId ?? this.meta.find((m) => m.description.trim().toLowerCase() === normalizedDesc);
|
|
499
|
+
if (existing) {
|
|
500
|
+
existing.description = metadata.description;
|
|
501
|
+
existing.workflowName = workflow.name;
|
|
502
|
+
existing.cachedNodeTypes = workflow.nodes.map((n) => n.type);
|
|
503
|
+
if (metadata.n8nWorkflowId) existing.n8nWorkflowId = metadata.n8nWorkflowId;
|
|
504
|
+
if (metadata.generationAttempts != null) {
|
|
505
|
+
existing.generationAttempts = metadata.generationAttempts;
|
|
506
|
+
}
|
|
507
|
+
if (metadata.failurePatterns?.length) {
|
|
508
|
+
existing.failurePatterns = this.deduplicateFailurePatterns(metadata.failurePatterns);
|
|
509
|
+
}
|
|
510
|
+
if (metadata.tags?.length) {
|
|
511
|
+
existing.tags = [.../* @__PURE__ */ new Set([...existing.tags, ...metadata.tags])];
|
|
512
|
+
}
|
|
513
|
+
await this.writeWorkflowFile(existing.id, workflow);
|
|
514
|
+
await this.persist();
|
|
515
|
+
return existing.id;
|
|
516
|
+
}
|
|
416
517
|
const id = generateUUID();
|
|
417
518
|
await this.writeWorkflowFile(id, workflow);
|
|
418
519
|
const failurePatterns = this.deduplicateFailurePatterns(metadata.failurePatterns);
|
|
@@ -434,25 +535,27 @@ var FileLibrary = class {
|
|
|
434
535
|
...metadata.sourceKind ? { sourceKind: metadata.sourceKind } : {},
|
|
435
536
|
...metadata.sourceId ? { sourceId: metadata.sourceId } : {},
|
|
436
537
|
...metadata.sourceUrl ? { sourceUrl: metadata.sourceUrl } : {},
|
|
437
|
-
...metadata.trustLevel ? { trustLevel: metadata.trustLevel } : {}
|
|
538
|
+
...metadata.trustLevel ? { trustLevel: metadata.trustLevel } : {},
|
|
539
|
+
...metadata.n8nWorkflowId ? { n8nWorkflowId: metadata.n8nWorkflowId } : {}
|
|
438
540
|
};
|
|
439
541
|
this.meta.push(meta);
|
|
440
542
|
if (this.meta.length > MAX_LIBRARY_SIZE) {
|
|
441
543
|
this.meta.sort((a, b) => {
|
|
442
544
|
if (a.id === id) return -1;
|
|
443
545
|
if (b.id === id) return 1;
|
|
444
|
-
return (b
|
|
546
|
+
return evictionScore(b) - evictionScore(a);
|
|
445
547
|
});
|
|
446
548
|
this.meta = this.meta.slice(0, MAX_LIBRARY_SIZE);
|
|
447
549
|
}
|
|
448
550
|
await this.persist();
|
|
449
551
|
return id;
|
|
450
552
|
}
|
|
451
|
-
async recordDeployment(id) {
|
|
553
|
+
async recordDeployment(id, n8nWorkflowId) {
|
|
452
554
|
const m = this.meta.find((m2) => m2.id === id);
|
|
453
555
|
if (m) {
|
|
454
556
|
m.deployCount++;
|
|
455
557
|
m.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
558
|
+
if (n8nWorkflowId) m.n8nWorkflowId = n8nWorkflowId;
|
|
456
559
|
await this.persist();
|
|
457
560
|
}
|
|
458
561
|
}
|
|
@@ -515,37 +618,98 @@ var FileLibrary = class {
|
|
|
515
618
|
}
|
|
516
619
|
return [...map.values()];
|
|
517
620
|
}
|
|
621
|
+
// ── Cross-process file locking ────────────────────────────────────────────
|
|
622
|
+
// Uses O_EXCL (exclusive create) which is atomic on POSIX and Windows NTFS.
|
|
623
|
+
// Protects the read-modify-write cycle in persist() from concurrent writers
|
|
624
|
+
// in separate OS processes (e.g. MCP server + CLI running simultaneously).
|
|
625
|
+
get lockPath() {
|
|
626
|
+
return (0, import_node_path.join)(this.dir, ".index.lock");
|
|
627
|
+
}
|
|
628
|
+
async acquireLock(timeoutMs = 3e3) {
|
|
629
|
+
const deadline = Date.now() + timeoutMs;
|
|
630
|
+
let delayMs = 10;
|
|
631
|
+
while (true) {
|
|
632
|
+
try {
|
|
633
|
+
const fh = await (0, import_promises.open)(this.lockPath, "wx");
|
|
634
|
+
await fh.writeFile(String(process.pid));
|
|
635
|
+
await fh.close();
|
|
636
|
+
return async () => {
|
|
637
|
+
await (0, import_promises.unlink)(this.lockPath).catch(() => {
|
|
638
|
+
});
|
|
639
|
+
};
|
|
640
|
+
} catch {
|
|
641
|
+
try {
|
|
642
|
+
const content = await (0, import_promises.readFile)(this.lockPath, "utf-8");
|
|
643
|
+
const lockPid = parseInt(content.trim(), 10);
|
|
644
|
+
const fileStat = await (0, import_promises.stat)(this.lockPath);
|
|
645
|
+
const ageMs = Date.now() - fileStat.mtimeMs;
|
|
646
|
+
if (ageMs > 1e4) {
|
|
647
|
+
await (0, import_promises.unlink)(this.lockPath).catch(() => {
|
|
648
|
+
});
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
if (!isNaN(lockPid)) {
|
|
652
|
+
try {
|
|
653
|
+
process.kill(lockPid, 0);
|
|
654
|
+
} catch {
|
|
655
|
+
await (0, import_promises.unlink)(this.lockPath).catch(() => {
|
|
656
|
+
});
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
} catch {
|
|
661
|
+
continue;
|
|
662
|
+
}
|
|
663
|
+
if (Date.now() > deadline) {
|
|
664
|
+
return async () => {
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
668
|
+
delayMs = Math.min(delayMs * 1.5, 200);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
518
672
|
/**
|
|
519
673
|
* Direct write used only during migration (before writeQueue is needed).
|
|
520
674
|
*/
|
|
521
675
|
async persistNow() {
|
|
522
|
-
const
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
676
|
+
const releaseLock = await this.acquireLock();
|
|
677
|
+
try {
|
|
678
|
+
const indexPath = (0, import_node_path.join)(this.dir, "index.json");
|
|
679
|
+
const tmpPath = `${indexPath}.tmp`;
|
|
680
|
+
await (0, import_promises.writeFile)(tmpPath, JSON.stringify(this.meta, null, 2), "utf-8");
|
|
681
|
+
await (0, import_promises.rename)(tmpPath, indexPath);
|
|
682
|
+
} finally {
|
|
683
|
+
await releaseLock();
|
|
684
|
+
}
|
|
526
685
|
}
|
|
527
686
|
persist() {
|
|
528
687
|
this.writeQueue = this.writeQueue.then(async () => {
|
|
529
|
-
const
|
|
530
|
-
let onDisk = [];
|
|
688
|
+
const releaseLock = await this.acquireLock();
|
|
531
689
|
try {
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
690
|
+
const indexPath = (0, import_node_path.join)(this.dir, "index.json");
|
|
691
|
+
let onDisk = [];
|
|
692
|
+
try {
|
|
693
|
+
const raw = await (0, import_promises.readFile)(indexPath, "utf-8");
|
|
694
|
+
const parsed = JSON.parse(raw);
|
|
695
|
+
if (Array.isArray(parsed)) {
|
|
696
|
+
onDisk = parsed.filter(isValidMeta);
|
|
697
|
+
}
|
|
698
|
+
} catch {
|
|
536
699
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
700
|
+
const ourIds = new Set(this.meta.map((m) => m.id));
|
|
701
|
+
const external = onDisk.filter((m) => !ourIds.has(m.id));
|
|
702
|
+
let merged = [...this.meta, ...external];
|
|
703
|
+
if (merged.length > MAX_LIBRARY_SIZE) {
|
|
704
|
+
merged.sort((a, b) => evictionScore(b) - evictionScore(a));
|
|
705
|
+
merged = merged.slice(0, MAX_LIBRARY_SIZE);
|
|
706
|
+
}
|
|
707
|
+
const tmpPath = `${indexPath}.tmp`;
|
|
708
|
+
await (0, import_promises.writeFile)(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
709
|
+
await (0, import_promises.rename)(tmpPath, indexPath);
|
|
710
|
+
} finally {
|
|
711
|
+
await releaseLock();
|
|
545
712
|
}
|
|
546
|
-
const tmpPath = `${indexPath}.tmp`;
|
|
547
|
-
await (0, import_promises.writeFile)(tmpPath, JSON.stringify(merged, null, 2), "utf-8");
|
|
548
|
-
await (0, import_promises.rename)(tmpPath, indexPath);
|
|
549
713
|
});
|
|
550
714
|
return this.writeQueue;
|
|
551
715
|
}
|
|
@@ -651,6 +815,14 @@ var NodeRegistry = class {
|
|
|
651
815
|
if (!def) return true;
|
|
652
816
|
return def.safeTypeVersions.includes(version);
|
|
653
817
|
}
|
|
818
|
+
// Returns true when the version is a positive integer greater than the highest
|
|
819
|
+
// known safe version — indicates a newer release rather than a bad value.
|
|
820
|
+
isVersionNewer(type, version) {
|
|
821
|
+
const def = this.byType.get(type);
|
|
822
|
+
if (!def || def.safeTypeVersions.length === 0) return false;
|
|
823
|
+
const max = Math.max(...def.safeTypeVersions);
|
|
824
|
+
return Number.isInteger(version) && version > max;
|
|
825
|
+
}
|
|
654
826
|
getRequiredParams(type) {
|
|
655
827
|
return this.byType.get(type)?.requiredParams ?? [];
|
|
656
828
|
}
|
|
@@ -721,6 +893,14 @@ var N8nValidator = class {
|
|
|
721
893
|
this.checkRule24(workflow, issues);
|
|
722
894
|
this.checkRule25(workflow, issues);
|
|
723
895
|
this.checkRule26(workflow, issues);
|
|
896
|
+
this.checkRule27(workflow, issues);
|
|
897
|
+
this.checkRule28(workflow, issues);
|
|
898
|
+
this.checkRule29(workflow, issues);
|
|
899
|
+
this.checkRule30(workflow, issues);
|
|
900
|
+
this.checkRule31(workflow, issues);
|
|
901
|
+
this.checkRule32(workflow, issues);
|
|
902
|
+
this.checkRule33(workflow, issues);
|
|
903
|
+
this.checkRule34(workflow, issues);
|
|
724
904
|
if (Array.isArray(workflow.nodes)) {
|
|
725
905
|
const nodeById = new Map(workflow.nodes.map((n) => [n.id, n.type]));
|
|
726
906
|
for (const issue of issues) {
|
|
@@ -972,19 +1152,22 @@ var N8nValidator = class {
|
|
|
972
1152
|
}
|
|
973
1153
|
}
|
|
974
1154
|
}
|
|
975
|
-
// Rule 19 (WARN): typeVersion is within known safe range for registered node types
|
|
1155
|
+
// Rule 19 (WARN): typeVersion is within known safe range for registered node types.
|
|
1156
|
+
// In lenient mode (KAIROS_REGISTRY_STRICT != 'true'), versions higher than the known
|
|
1157
|
+
// max are allowed — they likely represent newer n8n releases Kairos hasn't catalogued yet.
|
|
976
1158
|
checkRule19(w, issues) {
|
|
977
1159
|
if (!Array.isArray(w.nodes)) return;
|
|
1160
|
+
const strict = process.env["KAIROS_REGISTRY_STRICT"] === "true";
|
|
978
1161
|
for (const node of w.nodes) {
|
|
979
1162
|
if (typeof node.type !== "string" || typeof node.typeVersion !== "number") continue;
|
|
980
|
-
if (
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1163
|
+
if (this.registry.isVersionSafe(node.type, node.typeVersion)) continue;
|
|
1164
|
+
if (!strict && this.registry.isVersionNewer(node.type, node.typeVersion)) continue;
|
|
1165
|
+
this.warn(
|
|
1166
|
+
issues,
|
|
1167
|
+
19,
|
|
1168
|
+
`Node "${node.name}" uses typeVersion ${node.typeVersion} for type "${node.type}" which is not in the known safe list`,
|
|
1169
|
+
node.id
|
|
1170
|
+
);
|
|
988
1171
|
}
|
|
989
1172
|
}
|
|
990
1173
|
// Rule 20 (WARN): cycle detection — no node should be reachable from itself
|
|
@@ -1033,6 +1216,27 @@ var N8nValidator = class {
|
|
|
1033
1216
|
}
|
|
1034
1217
|
}
|
|
1035
1218
|
}
|
|
1219
|
+
// Rule 21 (WARN): webhook with responseMode="responseNode" must have respondToWebhook node
|
|
1220
|
+
checkRule21(w, issues) {
|
|
1221
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1222
|
+
const webhooksNeedingResponse = w.nodes.filter((n) => {
|
|
1223
|
+
if (!n.type.includes("webhook")) return false;
|
|
1224
|
+
const params = n.parameters;
|
|
1225
|
+
return params?.responseMode === "responseNode";
|
|
1226
|
+
});
|
|
1227
|
+
if (webhooksNeedingResponse.length === 0) return;
|
|
1228
|
+
const hasRespondNode = w.nodes.some((n) => n.type.includes("respondToWebhook"));
|
|
1229
|
+
if (!hasRespondNode) {
|
|
1230
|
+
for (const wh of webhooksNeedingResponse) {
|
|
1231
|
+
this.warn(
|
|
1232
|
+
issues,
|
|
1233
|
+
21,
|
|
1234
|
+
`Webhook "${wh.name}" uses responseMode "responseNode" but no respondToWebhook node exists in the workflow`,
|
|
1235
|
+
wh.id
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1036
1240
|
// Rule 22 (WARN): check requiredParams from registry
|
|
1037
1241
|
checkRule22(w, issues) {
|
|
1038
1242
|
if (!Array.isArray(w.nodes)) return;
|
|
@@ -1141,23 +1345,162 @@ var N8nValidator = class {
|
|
|
1141
1345
|
walk(params);
|
|
1142
1346
|
return expressions;
|
|
1143
1347
|
}
|
|
1144
|
-
// Rule
|
|
1145
|
-
|
|
1348
|
+
// Rule 27 (WARN): httpRequest URL is a placeholder
|
|
1349
|
+
checkRule27(w, issues) {
|
|
1146
1350
|
if (!Array.isArray(w.nodes)) return;
|
|
1147
|
-
const
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1351
|
+
const PLACEHOLDER_RE = [
|
|
1352
|
+
/^https?:\/\/example\.com/i,
|
|
1353
|
+
/your[-_]?(api[-_]?)?url/i,
|
|
1354
|
+
/^https?:\/\/$/,
|
|
1355
|
+
/^<.+>$/,
|
|
1356
|
+
/placeholder/i
|
|
1357
|
+
];
|
|
1358
|
+
for (const node of w.nodes) {
|
|
1359
|
+
if (node.type !== "n8n-nodes-base.httpRequest") continue;
|
|
1360
|
+
const params = node.parameters;
|
|
1361
|
+
const url = params?.["url"];
|
|
1362
|
+
if (typeof url !== "string" || url.trim() === "") continue;
|
|
1363
|
+
if (PLACEHOLDER_RE.some((re) => re.test(url.trim()))) {
|
|
1156
1364
|
this.warn(
|
|
1157
1365
|
issues,
|
|
1158
|
-
|
|
1159
|
-
`
|
|
1160
|
-
|
|
1366
|
+
27,
|
|
1367
|
+
`Node "${node.name}" httpRequest URL appears to be a placeholder: "${url}" \u2014 replace with your actual endpoint`,
|
|
1368
|
+
node.id
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
// Rule 28 (WARN): code node with empty or comment-only code
|
|
1374
|
+
checkRule28(w, issues) {
|
|
1375
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1376
|
+
for (const node of w.nodes) {
|
|
1377
|
+
if (node.type !== "n8n-nodes-base.code") continue;
|
|
1378
|
+
const params = node.parameters;
|
|
1379
|
+
const jsCode = typeof params?.["jsCode"] === "string" ? params["jsCode"] : "";
|
|
1380
|
+
const pythonCode = typeof params?.["pythonCode"] === "string" ? params["pythonCode"] : "";
|
|
1381
|
+
const code = jsCode || pythonCode;
|
|
1382
|
+
const stripped = code.replace(/\/\/[^\n]*/g, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/#[^\n]*/g, "").trim();
|
|
1383
|
+
if (!stripped) {
|
|
1384
|
+
this.warn(issues, 28, `Node "${node.name}" code node has no executable code`, node.id);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
// Rule 29 (WARN): slack node message operation missing channel
|
|
1389
|
+
checkRule29(w, issues) {
|
|
1390
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1391
|
+
for (const node of w.nodes) {
|
|
1392
|
+
if (node.type !== "n8n-nodes-base.slack") continue;
|
|
1393
|
+
const params = node.parameters;
|
|
1394
|
+
const resource = params?.["resource"];
|
|
1395
|
+
const operation = params?.["operation"];
|
|
1396
|
+
const isMessageOp = resource === "message" || operation === "sendMessage" || operation === "post";
|
|
1397
|
+
if (!isMessageOp) continue;
|
|
1398
|
+
const channel = params?.["channel"] ?? params?.["channelId"];
|
|
1399
|
+
const rlValue = typeof channel === "object" && channel !== null ? channel["value"] : void 0;
|
|
1400
|
+
const isEmpty = channel === void 0 || channel === null || typeof channel === "string" && channel.trim() === "" || typeof channel === "object" && (!rlValue || typeof rlValue === "string" && rlValue.trim() === "");
|
|
1401
|
+
if (isEmpty) {
|
|
1402
|
+
this.warn(issues, 29, `Node "${node.name}" Slack message has no channel specified`, node.id);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
// Rule 30 (WARN): gmail node send operation missing recipient
|
|
1407
|
+
checkRule30(w, issues) {
|
|
1408
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1409
|
+
for (const node of w.nodes) {
|
|
1410
|
+
if (node.type !== "n8n-nodes-base.gmail") continue;
|
|
1411
|
+
const params = node.parameters;
|
|
1412
|
+
const operation = params?.["operation"];
|
|
1413
|
+
if (operation !== "send") continue;
|
|
1414
|
+
const to = params?.["to"] ?? params?.["toList"];
|
|
1415
|
+
const isEmpty = to === void 0 || to === null || typeof to === "string" && to.trim() === "" || Array.isArray(to) && to.length === 0;
|
|
1416
|
+
if (isEmpty) {
|
|
1417
|
+
this.warn(issues, 30, `Node "${node.name}" gmail send has no recipient (to) specified`, node.id);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
// Rule 31 (WARN): if node with empty conditions
|
|
1422
|
+
checkRule31(w, issues) {
|
|
1423
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1424
|
+
for (const node of w.nodes) {
|
|
1425
|
+
if (node.type !== "n8n-nodes-base.if") continue;
|
|
1426
|
+
const params = node.parameters;
|
|
1427
|
+
const conditions = params?.["conditions"];
|
|
1428
|
+
if (conditions === void 0 || conditions === null) {
|
|
1429
|
+
this.warn(issues, 31, `Node "${node.name}" if node has no conditions defined`, node.id);
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
if (typeof conditions === "object" && !Array.isArray(conditions)) {
|
|
1433
|
+
const conds = conditions["conditions"];
|
|
1434
|
+
if (!Array.isArray(conds) || conds.length === 0) {
|
|
1435
|
+
this.warn(issues, 31, `Node "${node.name}" if node conditions array is empty`, node.id);
|
|
1436
|
+
}
|
|
1437
|
+
} else if (Array.isArray(conditions) && conditions.length === 0) {
|
|
1438
|
+
this.warn(issues, 31, `Node "${node.name}" if node conditions array is empty`, node.id);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
// Rule 32 (WARN): set node with no assignments
|
|
1443
|
+
checkRule32(w, issues) {
|
|
1444
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1445
|
+
for (const node of w.nodes) {
|
|
1446
|
+
if (node.type !== "n8n-nodes-base.set") continue;
|
|
1447
|
+
const params = node.parameters;
|
|
1448
|
+
const assignmentsObj = params?.["assignments"];
|
|
1449
|
+
const assignmentsArr = assignmentsObj?.["assignments"];
|
|
1450
|
+
const valuesObj = params?.["values"];
|
|
1451
|
+
const hasV1 = valuesObj && Object.values(valuesObj).some((v) => Array.isArray(v) && v.length > 0);
|
|
1452
|
+
const hasV3 = Array.isArray(assignmentsArr) && assignmentsArr.length > 0;
|
|
1453
|
+
if (!hasV1 && !hasV3) {
|
|
1454
|
+
this.warn(
|
|
1455
|
+
issues,
|
|
1456
|
+
32,
|
|
1457
|
+
`Node "${node.name}" set node has no fields defined \u2014 it will pass data through unchanged`,
|
|
1458
|
+
node.id
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
// Rule 33 (WARN): scheduleTrigger with no schedule rules
|
|
1464
|
+
checkRule33(w, issues) {
|
|
1465
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1466
|
+
for (const node of w.nodes) {
|
|
1467
|
+
if (node.type !== "n8n-nodes-base.scheduleTrigger") continue;
|
|
1468
|
+
const params = node.parameters;
|
|
1469
|
+
const rule = params?.["rule"];
|
|
1470
|
+
const intervals = rule?.["interval"];
|
|
1471
|
+
if (!Array.isArray(intervals) || intervals.length === 0) {
|
|
1472
|
+
this.warn(issues, 33, `Node "${node.name}" scheduleTrigger has no schedule rules defined`, node.id);
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
// Rule 34 (WARN): webhook path contains spaces, starts with slash, or looks like a full URL
|
|
1477
|
+
checkRule34(w, issues) {
|
|
1478
|
+
if (!Array.isArray(w.nodes)) return;
|
|
1479
|
+
for (const node of w.nodes) {
|
|
1480
|
+
if (node.type !== "n8n-nodes-base.webhook") continue;
|
|
1481
|
+
const params = node.parameters;
|
|
1482
|
+
const path = params?.["path"];
|
|
1483
|
+
if (typeof path !== "string") continue;
|
|
1484
|
+
if (/\s/.test(path)) {
|
|
1485
|
+
this.warn(
|
|
1486
|
+
issues,
|
|
1487
|
+
34,
|
|
1488
|
+
`Node "${node.name}" webhook path contains spaces: "${path}" \u2014 use hyphens or underscores instead`,
|
|
1489
|
+
node.id
|
|
1490
|
+
);
|
|
1491
|
+
} else if (/^https?:\/\//i.test(path)) {
|
|
1492
|
+
this.warn(
|
|
1493
|
+
issues,
|
|
1494
|
+
34,
|
|
1495
|
+
`Node "${node.name}" webhook path looks like a full URL \u2014 it should be a relative path (e.g. "my-hook")`,
|
|
1496
|
+
node.id
|
|
1497
|
+
);
|
|
1498
|
+
} else if (path.startsWith("/")) {
|
|
1499
|
+
this.warn(
|
|
1500
|
+
issues,
|
|
1501
|
+
34,
|
|
1502
|
+
`Node "${node.name}" webhook path starts with "/" \u2014 n8n adds the leading slash automatically`,
|
|
1503
|
+
node.id
|
|
1161
1504
|
);
|
|
1162
1505
|
}
|
|
1163
1506
|
}
|
|
@@ -1212,7 +1555,26 @@ var ProviderError = class extends KairosError {
|
|
|
1212
1555
|
}
|
|
1213
1556
|
};
|
|
1214
1557
|
|
|
1558
|
+
// src/errors/guard-error.ts
|
|
1559
|
+
var GuardError = class extends KairosError {
|
|
1560
|
+
constructor(message) {
|
|
1561
|
+
super(message);
|
|
1562
|
+
this.name = "GuardError";
|
|
1563
|
+
}
|
|
1564
|
+
};
|
|
1565
|
+
|
|
1215
1566
|
// src/utils/retry.ts
|
|
1567
|
+
function isTransientNetworkError(err) {
|
|
1568
|
+
const TRANSIENT_CODES = /* @__PURE__ */ new Set(["ECONNRESET", "ETIMEDOUT", "ECONNREFUSED", "ENOTFOUND", "ECONNABORTED"]);
|
|
1569
|
+
let current = err;
|
|
1570
|
+
for (let i = 0; i < 4; i++) {
|
|
1571
|
+
if (current === null || typeof current !== "object") break;
|
|
1572
|
+
const code = current.code;
|
|
1573
|
+
if (typeof code === "string" && TRANSIENT_CODES.has(code)) return true;
|
|
1574
|
+
current = current.cause;
|
|
1575
|
+
}
|
|
1576
|
+
return false;
|
|
1577
|
+
}
|
|
1216
1578
|
async function withRetry(fn, maxAttempts, delayMs, shouldRetry) {
|
|
1217
1579
|
let lastError;
|
|
1218
1580
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
@@ -1237,6 +1599,7 @@ function fetchWithTimeout(url, init, timeoutMs) {
|
|
|
1237
1599
|
|
|
1238
1600
|
// src/providers/n8n/api-client.ts
|
|
1239
1601
|
var EXECUTION_LIMIT_CAP = 100;
|
|
1602
|
+
var N8N_API_PAGE_SIZE = 250;
|
|
1240
1603
|
var REQUEST_TIMEOUT_MS = 3e4;
|
|
1241
1604
|
var RETRY_ATTEMPTS = 3;
|
|
1242
1605
|
var RETRY_DELAY_MS = 1e3;
|
|
@@ -1245,6 +1608,17 @@ var N8nApiClient = class {
|
|
|
1245
1608
|
this.baseUrl = baseUrl;
|
|
1246
1609
|
this.apiKey = apiKey;
|
|
1247
1610
|
this.logger = logger;
|
|
1611
|
+
if (!baseUrl || typeof baseUrl !== "string") {
|
|
1612
|
+
throw new GuardError("N8nApiClient: baseUrl must be a non-empty string");
|
|
1613
|
+
}
|
|
1614
|
+
try {
|
|
1615
|
+
new URL(baseUrl);
|
|
1616
|
+
} catch {
|
|
1617
|
+
throw new GuardError(`N8nApiClient: baseUrl is not a valid URL: "${baseUrl}"`);
|
|
1618
|
+
}
|
|
1619
|
+
if (!apiKey || typeof apiKey !== "string") {
|
|
1620
|
+
throw new GuardError("N8nApiClient: apiKey must be a non-empty string");
|
|
1621
|
+
}
|
|
1248
1622
|
}
|
|
1249
1623
|
baseUrl;
|
|
1250
1624
|
apiKey;
|
|
@@ -1254,7 +1628,12 @@ var N8nApiClient = class {
|
|
|
1254
1628
|
this.logger.debug(`n8n ${method} ${path}`);
|
|
1255
1629
|
const isSafe = method === "GET";
|
|
1256
1630
|
if (!isSafe) {
|
|
1257
|
-
return
|
|
1631
|
+
return withRetry(
|
|
1632
|
+
() => this.singleRequest(url, method, path, body),
|
|
1633
|
+
2,
|
|
1634
|
+
RETRY_DELAY_MS,
|
|
1635
|
+
isTransientNetworkError
|
|
1636
|
+
);
|
|
1258
1637
|
}
|
|
1259
1638
|
return withRetry(
|
|
1260
1639
|
() => this.singleRequest(url, method, path, body),
|
|
@@ -1309,7 +1688,7 @@ var N8nApiClient = class {
|
|
|
1309
1688
|
}
|
|
1310
1689
|
async listWorkflows() {
|
|
1311
1690
|
const all = [];
|
|
1312
|
-
let path =
|
|
1691
|
+
let path = `/workflows?limit=${N8N_API_PAGE_SIZE}`;
|
|
1313
1692
|
for (; ; ) {
|
|
1314
1693
|
const response = await this.request("GET", path);
|
|
1315
1694
|
for (const w of response.data) {
|
|
@@ -1323,7 +1702,7 @@ var N8nApiClient = class {
|
|
|
1323
1702
|
});
|
|
1324
1703
|
}
|
|
1325
1704
|
if (!response.nextCursor) break;
|
|
1326
|
-
path = `/workflows?limit
|
|
1705
|
+
path = `/workflows?limit=${N8N_API_PAGE_SIZE}&cursor=${response.nextCursor}`;
|
|
1327
1706
|
}
|
|
1328
1707
|
return all;
|
|
1329
1708
|
}
|
|
@@ -1353,14 +1732,14 @@ var N8nApiClient = class {
|
|
|
1353
1732
|
}
|
|
1354
1733
|
async listTags() {
|
|
1355
1734
|
const all = [];
|
|
1356
|
-
let path =
|
|
1735
|
+
let path = `/tags?limit=${N8N_API_PAGE_SIZE}`;
|
|
1357
1736
|
for (; ; ) {
|
|
1358
1737
|
const response = await this.request("GET", path);
|
|
1359
1738
|
for (const t of response.data) {
|
|
1360
1739
|
all.push({ id: t.id, name: t.name });
|
|
1361
1740
|
}
|
|
1362
1741
|
if (!response.nextCursor) break;
|
|
1363
|
-
path = `/tags?limit
|
|
1742
|
+
path = `/tags?limit=${N8N_API_PAGE_SIZE}&cursor=${response.nextCursor}`;
|
|
1364
1743
|
}
|
|
1365
1744
|
return all;
|
|
1366
1745
|
}
|
|
@@ -1384,6 +1763,32 @@ var N8nApiClient = class {
|
|
|
1384
1763
|
return [];
|
|
1385
1764
|
}
|
|
1386
1765
|
}
|
|
1766
|
+
async triggerManual(workflowId) {
|
|
1767
|
+
const raw = await this.request("POST", `/workflows/${workflowId}/run`);
|
|
1768
|
+
const inner = raw["data"];
|
|
1769
|
+
const execId = inner?.["executionId"] ?? raw["executionId"];
|
|
1770
|
+
if (execId === void 0 || execId === null) {
|
|
1771
|
+
throw new ProviderError(
|
|
1772
|
+
`n8n trigger response missing executionId \u2014 got: ${JSON.stringify(raw)}`
|
|
1773
|
+
);
|
|
1774
|
+
}
|
|
1775
|
+
return String(execId);
|
|
1776
|
+
}
|
|
1777
|
+
async triggerWebhookTest(path) {
|
|
1778
|
+
const cleanPath = path.startsWith("/") ? path : `/${path}`;
|
|
1779
|
+
const url = `${this.baseUrl.replace(/\/$/, "")}/webhook-test${cleanPath}`;
|
|
1780
|
+
this.logger.debug(`n8n POST webhook-test ${cleanPath}`);
|
|
1781
|
+
try {
|
|
1782
|
+
const response = await fetchWithTimeout(
|
|
1783
|
+
url,
|
|
1784
|
+
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}) },
|
|
1785
|
+
REQUEST_TIMEOUT_MS
|
|
1786
|
+
);
|
|
1787
|
+
return response.status;
|
|
1788
|
+
} catch (err) {
|
|
1789
|
+
throw new ProviderError(`Webhook test request failed for path "${path}"`, err);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1387
1792
|
mapExecution(e) {
|
|
1388
1793
|
return {
|
|
1389
1794
|
id: e.id,
|
|
@@ -1600,6 +2005,14 @@ Cron: { "rule": { "interval": [{ "field": "cronExpression", "expression": "0 9 *
|
|
|
1600
2005
|
8. No deprecated $node["NodeName"].json \u2014 use $('NodeName').item.json.field
|
|
1601
2006
|
9. No $json.items[0] array indexing \u2014 access fields directly as $json.field
|
|
1602
2007
|
10. No bare $('NodeName').json \u2014 always use .first().json.field or .all()
|
|
2008
|
+
11. httpRequest URL is a real endpoint (not "example.com" or "YOUR_URL")
|
|
2009
|
+
12. code nodes contain actual logic \u2014 not empty or comment-only
|
|
2010
|
+
13. Slack message nodes have a channel specified (channelId or channel)
|
|
2011
|
+
14. Gmail send nodes have a recipient (to field non-empty)
|
|
2012
|
+
15. if nodes have at least one condition in conditions.conditions[]
|
|
2013
|
+
16. set nodes have at least one entry in assignments.assignments[]
|
|
2014
|
+
17. scheduleTrigger has at least one rule in rule.interval[]
|
|
2015
|
+
18. webhook path is relative (no spaces, no leading slash, no http://)
|
|
1603
2016
|
|
|
1604
2017
|
---
|
|
1605
2018
|
|
|
@@ -1607,7 +2020,7 @@ Respond ONLY with a generate_workflow tool call. No prose. No markdown outside t
|
|
|
1607
2020
|
If the request is impossible or unclear, set the error field instead of generating a workflow.`;
|
|
1608
2021
|
|
|
1609
2022
|
// src/validation/rule-metadata.ts
|
|
1610
|
-
var VALIDATOR_RULE_IDS = Array.from({ length:
|
|
2023
|
+
var VALIDATOR_RULE_IDS = Array.from({ length: 34 }, (_, i) => i + 1);
|
|
1611
2024
|
var RULE_PIPELINE_STAGES = {
|
|
1612
2025
|
1: "node_generation",
|
|
1613
2026
|
2: "node_generation",
|
|
@@ -1634,7 +2047,15 @@ var RULE_PIPELINE_STAGES = {
|
|
|
1634
2047
|
23: "node_generation",
|
|
1635
2048
|
24: "expression_syntax",
|
|
1636
2049
|
25: "expression_syntax",
|
|
1637
|
-
26: "expression_syntax"
|
|
2050
|
+
26: "expression_syntax",
|
|
2051
|
+
27: "node_generation",
|
|
2052
|
+
28: "node_generation",
|
|
2053
|
+
29: "node_generation",
|
|
2054
|
+
30: "node_generation",
|
|
2055
|
+
31: "node_generation",
|
|
2056
|
+
32: "node_generation",
|
|
2057
|
+
33: "node_generation",
|
|
2058
|
+
34: "node_generation"
|
|
1638
2059
|
};
|
|
1639
2060
|
var RULE_EXAMPLES = {
|
|
1640
2061
|
17: {
|
|
@@ -1652,6 +2073,38 @@ var RULE_EXAMPLES = {
|
|
|
1652
2073
|
26: {
|
|
1653
2074
|
bad: "$('Fetch Data').json.email",
|
|
1654
2075
|
good: "$('Fetch Data').first().json.email"
|
|
2076
|
+
},
|
|
2077
|
+
27: {
|
|
2078
|
+
bad: '"url": "https://example.com/api/data"',
|
|
2079
|
+
good: '"url": "https://api.yourservice.com/v1/endpoint"'
|
|
2080
|
+
},
|
|
2081
|
+
28: {
|
|
2082
|
+
bad: '"jsCode": "// TODO: implement this"',
|
|
2083
|
+
good: '"jsCode": "return items.map(item => ({ json: { result: item.json.value * 2 } }))"'
|
|
2084
|
+
},
|
|
2085
|
+
29: {
|
|
2086
|
+
bad: '"channelId": ""',
|
|
2087
|
+
good: '"channelId": { "__rl": true, "value": "C0123456789", "mode": "id" }'
|
|
2088
|
+
},
|
|
2089
|
+
30: {
|
|
2090
|
+
bad: '"operation": "send", "to": ""',
|
|
2091
|
+
good: '"operation": "send", "to": "recipient@example.com"'
|
|
2092
|
+
},
|
|
2093
|
+
31: {
|
|
2094
|
+
bad: '"conditions": { "combinator": "and", "conditions": [] }',
|
|
2095
|
+
good: '"conditions": { "combinator": "and", "conditions": [{ "leftValue": "={{ $json.status }}", "rightValue": "active", "operator": { "type": "string", "operation": "equals" } }] }'
|
|
2096
|
+
},
|
|
2097
|
+
32: {
|
|
2098
|
+
bad: '"assignments": { "assignments": [] }',
|
|
2099
|
+
good: '"assignments": { "assignments": [{ "id": "f1", "name": "status", "value": "processed", "type": "string" }] }'
|
|
2100
|
+
},
|
|
2101
|
+
33: {
|
|
2102
|
+
bad: '"rule": { "interval": [] }',
|
|
2103
|
+
good: '"rule": { "interval": [{ "field": "cronExpression", "expression": "0 9 * * 1-5" }] }'
|
|
2104
|
+
},
|
|
2105
|
+
34: {
|
|
2106
|
+
bad: '"path": "/my webhook"',
|
|
2107
|
+
good: '"path": "my-webhook"'
|
|
1655
2108
|
}
|
|
1656
2109
|
};
|
|
1657
2110
|
var RULE_MITIGATIONS = {
|
|
@@ -1680,7 +2133,15 @@ var RULE_MITIGATIONS = {
|
|
|
1680
2133
|
23: "Use node types that exist in the n8n registry \u2014 check with kairos_sync",
|
|
1681
2134
|
24: 'Use modern accessor syntax: $("NodeName").item.json.field instead of deprecated $node["NodeName"].json.field',
|
|
1682
2135
|
25: "Access item fields directly with $json.field \u2014 n8n flattens items automatically, do not use $json.items[0]",
|
|
1683
|
-
26: 'Use $("NodeName").first().json.field or $("NodeName").all() \u2014 bare $("NodeName").json without .first() or .all() throws at runtime'
|
|
2136
|
+
26: 'Use $("NodeName").first().json.field or $("NodeName").all() \u2014 bare $("NodeName").json without .first() or .all() throws at runtime',
|
|
2137
|
+
27: 'Replace placeholder URLs with your actual API endpoint \u2014 do not use "example.com" or "YOUR_URL" patterns',
|
|
2138
|
+
28: "Add executable code to the code node \u2014 empty or comment-only code nodes do nothing at runtime",
|
|
2139
|
+
29: "Set the channel parameter for Slack message operations (channelId with __rl object, or channel as string)",
|
|
2140
|
+
30: "Set the to parameter for Gmail send operations with at least one recipient email address",
|
|
2141
|
+
31: "Add at least one condition to the if node \u2014 conditions.conditions array must be non-empty",
|
|
2142
|
+
32: "Add field assignments to the set node \u2014 assignments.assignments array must be non-empty for typeVersion 3.x",
|
|
2143
|
+
33: "Add at least one schedule rule to scheduleTrigger \u2014 rule.interval array must have at least one entry",
|
|
2144
|
+
34: 'Webhook path must be a relative path without spaces, leading slashes, or protocol prefixes (e.g. "my-hook")'
|
|
1684
2145
|
};
|
|
1685
2146
|
|
|
1686
2147
|
// src/generation/prompt-builder.ts
|
|
@@ -1712,18 +2173,37 @@ var PromptBuilder = class {
|
|
|
1712
2173
|
}
|
|
1713
2174
|
build(request, matches, globalFailureRates = [], dynamicCatalog) {
|
|
1714
2175
|
const mode = this.resolveMode(matches);
|
|
1715
|
-
const system = this.buildSystem(matches, mode, globalFailureRates, dynamicCatalog);
|
|
2176
|
+
const system = this.buildSystem(matches, mode, globalFailureRates, dynamicCatalog, request.description);
|
|
1716
2177
|
const userMessage = this.buildUserMessage(request, matches, mode);
|
|
1717
2178
|
return { system, userMessage, mode, matches };
|
|
1718
2179
|
}
|
|
1719
|
-
buildCorrectionMessage(request, matches, allIssues, attempt) {
|
|
2180
|
+
buildCorrectionMessage(request, matches, allIssues, attempt, failingRuleIds) {
|
|
1720
2181
|
const base = this.buildUserMessage(request, matches, this.resolveMode(matches));
|
|
2182
|
+
let examplesSection = "";
|
|
2183
|
+
if (failingRuleIds && failingRuleIds.length > 0) {
|
|
2184
|
+
const uniqueRules = [...new Set(failingRuleIds)];
|
|
2185
|
+
const exampleLines = [];
|
|
2186
|
+
for (const rule of uniqueRules) {
|
|
2187
|
+
const ex = RULE_EXAMPLES[rule];
|
|
2188
|
+
if (ex) {
|
|
2189
|
+
exampleLines.push(`Rule ${rule}:
|
|
2190
|
+
Bad: ${ex.bad}
|
|
2191
|
+
Good: ${ex.good}`);
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
if (exampleLines.length > 0) {
|
|
2195
|
+
examplesSection = `
|
|
2196
|
+
|
|
2197
|
+
## Concrete Fix Examples
|
|
2198
|
+
${exampleLines.join("\n\n")}`;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
1721
2201
|
return `${base}
|
|
1722
2202
|
|
|
1723
2203
|
IMPORTANT: A previous generation attempt (attempt ${attempt}) failed validation with these issues:
|
|
1724
2204
|
${allIssues.join("\n")}
|
|
1725
2205
|
|
|
1726
|
-
Fix ALL of the above issues in your new response. Do not repeat any of these mistakes
|
|
2206
|
+
Fix ALL of the above issues in your new response. Do not repeat any of these mistakes.${examplesSection}`;
|
|
1727
2207
|
}
|
|
1728
2208
|
resolveMode(matches) {
|
|
1729
2209
|
if (matches.length === 0) return "scratch";
|
|
@@ -1731,7 +2211,7 @@ Fix ALL of the above issues in your new response. Do not repeat any of these mis
|
|
|
1731
2211
|
if (!top) return "scratch";
|
|
1732
2212
|
return scoreToMode(top.score);
|
|
1733
2213
|
}
|
|
1734
|
-
buildSystem(matches, mode, globalFailureRates = [], dynamicCatalog) {
|
|
2214
|
+
buildSystem(matches, mode, globalFailureRates = [], dynamicCatalog, description) {
|
|
1735
2215
|
let basePrompt = SYSTEM_PROMPT_V1;
|
|
1736
2216
|
if (dynamicCatalog) {
|
|
1737
2217
|
basePrompt = basePrompt.replace(
|
|
@@ -1791,7 +2271,7 @@ A loosely similar workflow (score: ${hint.score.toFixed(2)}) used these node typ
|
|
|
1791
2271
|
});
|
|
1792
2272
|
}
|
|
1793
2273
|
}
|
|
1794
|
-
const warnings = this.buildFailureWarnings(matches, globalFailureRates);
|
|
2274
|
+
const warnings = this.buildFailureWarnings(matches, globalFailureRates, description);
|
|
1795
2275
|
if (warnings) {
|
|
1796
2276
|
blocks.push({ type: "text", text: warnings });
|
|
1797
2277
|
}
|
|
@@ -1818,15 +2298,34 @@ A loosely similar workflow (score: ${hint.score.toFixed(2)}) used these node typ
|
|
|
1818
2298
|
const patterns = this._lastActivePatterns ?? this.getActivePatterns(this.resolveMaxPatterns());
|
|
1819
2299
|
return patterns.map((p) => p.rule);
|
|
1820
2300
|
}
|
|
1821
|
-
getActivePatterns(maxCount = 10) {
|
|
2301
|
+
getActivePatterns(maxCount = 10, description) {
|
|
1822
2302
|
const all = this.loadPatterns().filter((p) => p.state !== "resolved" && p.confidence > 0);
|
|
1823
2303
|
const regressed = all.filter((p) => p.regressed).sort((a, b) => b.compositeScore - a.compositeScore);
|
|
1824
2304
|
const confirmed = all.filter((p) => !p.regressed && p.state === "confirmed").sort((a, b) => b.compositeScore - a.compositeScore);
|
|
1825
2305
|
const drafts = all.filter((p) => !p.regressed && p.state !== "confirmed").sort((a, b) => b.compositeScore - a.compositeScore);
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
2306
|
+
const ordered = [...regressed, ...confirmed, ...drafts];
|
|
2307
|
+
if (this.profile === "minimal" && description) {
|
|
2308
|
+
return this.rankByRelevance(ordered, description).slice(0, maxCount);
|
|
2309
|
+
}
|
|
2310
|
+
return ordered.slice(0, maxCount);
|
|
2311
|
+
}
|
|
2312
|
+
rankByRelevance(patterns, description) {
|
|
2313
|
+
const lower = description.toLowerCase();
|
|
2314
|
+
const STAGE_KEYWORDS = {
|
|
2315
|
+
credential_injection: ["credential", "auth", "api key", "token", "oauth", "smtp", "imap", "password", "secret"],
|
|
2316
|
+
connection_wiring: ["connect", "link", "wire", "chain", "merge", "branch", "join"],
|
|
2317
|
+
expression_syntax: ["expression", "variable", "json", "field", "data", "$json", "item"],
|
|
2318
|
+
workflow_structure: ["trigger", "webhook", "schedule", "structure", "workflow"],
|
|
2319
|
+
node_generation: ["node", "generate", "create", "build", "send", "fetch", "email", "slack", "http"]
|
|
2320
|
+
};
|
|
2321
|
+
return patterns.map((p) => {
|
|
2322
|
+
const keywords = STAGE_KEYWORDS[p.pipelineStage] ?? [];
|
|
2323
|
+
const relevanceBoost = keywords.some((kw) => lower.includes(kw)) ? 1 : 0;
|
|
2324
|
+
return { pattern: p, sort: relevanceBoost * 10 + p.compositeScore };
|
|
2325
|
+
}).sort((a, b) => b.sort - a.sort).map((x) => x.pattern);
|
|
2326
|
+
}
|
|
2327
|
+
buildFailureWarnings(matches, globalFailureRates, description) {
|
|
2328
|
+
const richPatterns = this.getActivePatterns(this.resolveMaxPatterns(), description);
|
|
1830
2329
|
this._lastActivePatterns = richPatterns;
|
|
1831
2330
|
if (richPatterns.length > 0) {
|
|
1832
2331
|
return this.buildStageGroupedWarnings(richPatterns, matches);
|
|
@@ -1993,19 +2492,20 @@ var TelemetryReader = class {
|
|
|
1993
2492
|
}
|
|
1994
2493
|
const events = await this.readRecentEvents(days);
|
|
1995
2494
|
const buildSessions = new Set(
|
|
1996
|
-
events.filter((e) => e.eventType === "build_complete").map((e) => e.sessionId)
|
|
2495
|
+
events.filter((e) => e.eventType === "build_complete").map((e) => e.runId ?? e.sessionId)
|
|
1997
2496
|
);
|
|
1998
2497
|
const MIN_BUILDS_FOR_RATES = 3;
|
|
1999
2498
|
if (buildSessions.size < MIN_BUILDS_FOR_RATES) return [];
|
|
2000
2499
|
const ruleSessions = /* @__PURE__ */ new Map();
|
|
2001
2500
|
for (const event of events) {
|
|
2002
2501
|
if (event.eventType !== "generation_attempt") continue;
|
|
2003
|
-
|
|
2502
|
+
const eventKey = event.runId ?? event.sessionId;
|
|
2503
|
+
if (!buildSessions.has(eventKey)) continue;
|
|
2004
2504
|
const data = event.data;
|
|
2005
2505
|
if (data.validationPassed || !data.issues) continue;
|
|
2006
2506
|
for (const issue of data.issues) {
|
|
2007
2507
|
const entry = ruleSessions.get(issue.rule) ?? { sessions: /* @__PURE__ */ new Set(), messages: /* @__PURE__ */ new Map() };
|
|
2008
|
-
entry.sessions.add(
|
|
2508
|
+
entry.sessions.add(eventKey);
|
|
2009
2509
|
entry.messages.set(issue.message, (entry.messages.get(issue.message) ?? 0) + 1);
|
|
2010
2510
|
ruleSessions.set(issue.rule, entry);
|
|
2011
2511
|
}
|
|
@@ -2047,22 +2547,24 @@ var PatternAnalyzer = class _PatternAnalyzer {
|
|
|
2047
2547
|
telemetryDir;
|
|
2048
2548
|
outputDir;
|
|
2049
2549
|
_cachedEvents = null;
|
|
2550
|
+
_cachedPreviousPatterns = null;
|
|
2050
2551
|
constructor(telemetryDir) {
|
|
2051
2552
|
const defaultDir = (0, import_node_path5.join)((0, import_node_os4.homedir)(), ".kairos", "telemetry");
|
|
2052
2553
|
this.telemetryDir = telemetryDir ?? defaultDir;
|
|
2053
2554
|
this.outputDir = telemetryDir ? (0, import_node_path5.join)(telemetryDir, "..") : (0, import_node_path5.join)((0, import_node_os4.homedir)(), ".kairos");
|
|
2054
2555
|
}
|
|
2055
2556
|
async loadPreviousPatterns() {
|
|
2557
|
+
if (this._cachedPreviousPatterns !== null) return this._cachedPreviousPatterns;
|
|
2056
2558
|
try {
|
|
2057
2559
|
const raw = await (0, import_promises3.readFile)((0, import_node_path5.join)(this.outputDir, "patterns.json"), "utf-8");
|
|
2058
2560
|
const prev = JSON.parse(raw);
|
|
2059
2561
|
const version = prev.schemaVersion ?? 0;
|
|
2060
2562
|
const patterns = prev.topFailureRules ?? [];
|
|
2061
|
-
|
|
2062
|
-
return this.migratePatterns(patterns, version);
|
|
2563
|
+
this._cachedPreviousPatterns = version === PATTERN_SCHEMA_VERSION ? patterns : this.migratePatterns(patterns, version);
|
|
2063
2564
|
} catch {
|
|
2064
|
-
|
|
2565
|
+
this._cachedPreviousPatterns = [];
|
|
2065
2566
|
}
|
|
2567
|
+
return this._cachedPreviousPatterns;
|
|
2066
2568
|
}
|
|
2067
2569
|
migratePatterns(patterns, fromVersion) {
|
|
2068
2570
|
let migrated = patterns;
|
|
@@ -2094,7 +2596,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
|
|
|
2094
2596
|
this._cachedEvents = events;
|
|
2095
2597
|
const starts = events.filter((e) => e.eventType === "build_start");
|
|
2096
2598
|
const attempts = events.filter((e) => e.eventType === "generation_attempt");
|
|
2097
|
-
const
|
|
2599
|
+
const _passed = attempts.filter(
|
|
2098
2600
|
(a) => a.data.validationPassed === true
|
|
2099
2601
|
);
|
|
2100
2602
|
const failed = attempts.filter(
|
|
@@ -2362,6 +2864,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
|
|
|
2362
2864
|
const tmpPath = `${outputPath}.tmp`;
|
|
2363
2865
|
await (0, import_promises3.writeFile)(tmpPath, JSON.stringify(analysis, null, 2), "utf-8");
|
|
2364
2866
|
await (0, import_promises3.rename)(tmpPath, outputPath);
|
|
2867
|
+
this._cachedPreviousPatterns = null;
|
|
2365
2868
|
const historySummary = {
|
|
2366
2869
|
timestamp: analysis.generatedAt,
|
|
2367
2870
|
totalBuilds: analysis.summary.totalBuilds,
|
|
@@ -2410,7 +2913,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
|
|
|
2410
2913
|
})
|
|
2411
2914
|
));
|
|
2412
2915
|
return {
|
|
2413
|
-
sessionId: bc.sessionId,
|
|
2916
|
+
sessionId: bc.runId ?? bc.sessionId,
|
|
2414
2917
|
date: bc.fileDate,
|
|
2415
2918
|
description: data.description ?? "",
|
|
2416
2919
|
workflowType: data.workflowType ?? null,
|
|
@@ -2443,7 +2946,7 @@ var PatternAnalyzer = class _PatternAnalyzer {
|
|
|
2443
2946
|
alerts.push({
|
|
2444
2947
|
type: "stale_pattern",
|
|
2445
2948
|
rule: p.rule,
|
|
2446
|
-
message: `Pattern references Rule ${p.rule} which does not exist in the current validator (rules 1-
|
|
2949
|
+
message: `Pattern references Rule ${p.rule} which does not exist in the current validator (rules 1-34)`
|
|
2447
2950
|
});
|
|
2448
2951
|
}
|
|
2449
2952
|
}
|
|
@@ -2676,16 +3179,45 @@ function inferWorkflowType(description) {
|
|
|
2676
3179
|
|
|
2677
3180
|
// src/mcp-server.ts
|
|
2678
3181
|
var import_node_fs3 = require("fs");
|
|
2679
|
-
var
|
|
3182
|
+
var import_node_path8 = require("path");
|
|
2680
3183
|
var import_node_os6 = require("os");
|
|
2681
3184
|
var import_node_url = require("url");
|
|
3185
|
+
|
|
3186
|
+
// src/utils/node-catalog-cache.ts
|
|
3187
|
+
var import_promises5 = require("fs/promises");
|
|
3188
|
+
var import_node_path7 = require("path");
|
|
3189
|
+
var CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
3190
|
+
async function readCatalogCache(cachePath) {
|
|
3191
|
+
try {
|
|
3192
|
+
const raw = await (0, import_promises5.readFile)(cachePath, "utf-8");
|
|
3193
|
+
const cached = JSON.parse(raw);
|
|
3194
|
+
if (Date.now() - cached.cachedAt > CACHE_TTL_MS) return null;
|
|
3195
|
+
return cached.syncResult;
|
|
3196
|
+
} catch {
|
|
3197
|
+
return null;
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
async function writeCatalogCache(cachePath, syncResult) {
|
|
3201
|
+
try {
|
|
3202
|
+
await (0, import_promises5.mkdir)((0, import_node_path7.dirname)(cachePath), { recursive: true });
|
|
3203
|
+
const payload = { cachedAt: Date.now(), syncResult };
|
|
3204
|
+
await (0, import_promises5.writeFile)(cachePath, JSON.stringify(payload), "utf-8");
|
|
3205
|
+
} catch {
|
|
3206
|
+
}
|
|
3207
|
+
}
|
|
3208
|
+
|
|
3209
|
+
// src/mcp-server.ts
|
|
2682
3210
|
var import_meta = {};
|
|
2683
|
-
var __dirname = (0,
|
|
2684
|
-
var pkg = JSON.parse((0, import_node_fs3.readFileSync)((0,
|
|
3211
|
+
var __dirname = (0, import_node_path8.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
|
|
3212
|
+
var pkg = JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path8.join)(__dirname, "..", "package.json"), "utf-8"));
|
|
2685
3213
|
var library = new FileLibrary();
|
|
2686
|
-
var
|
|
3214
|
+
var _validator = new N8nValidator();
|
|
3215
|
+
function getValidator() {
|
|
3216
|
+
return _validator;
|
|
3217
|
+
}
|
|
2687
3218
|
var nodeSyncer = new NodeSyncer();
|
|
2688
3219
|
var lastSync = null;
|
|
3220
|
+
var AUTO_SYNC_TIMEOUT_MS = 5e3;
|
|
2689
3221
|
var stripper = new N8nFieldStripper();
|
|
2690
3222
|
var promptBuilder = new PromptBuilder(getMcpPatternsPath());
|
|
2691
3223
|
function getMcpTelemetry() {
|
|
@@ -2696,9 +3228,9 @@ function getMcpTelemetry() {
|
|
|
2696
3228
|
function getMcpPatternsPath() {
|
|
2697
3229
|
const val = process.env["KAIROS_TELEMETRY"];
|
|
2698
3230
|
if (val && val !== "false" && val !== "true") {
|
|
2699
|
-
return (0,
|
|
3231
|
+
return (0, import_node_path8.join)(val, "..", "patterns.json");
|
|
2700
3232
|
}
|
|
2701
|
-
return (0,
|
|
3233
|
+
return (0, import_node_path8.join)((0, import_node_os6.homedir)(), ".kairos", "patterns.json");
|
|
2702
3234
|
}
|
|
2703
3235
|
var mcpTelemetry = getMcpTelemetry();
|
|
2704
3236
|
var mcpSessions = /* @__PURE__ */ new Map();
|
|
@@ -2716,20 +3248,51 @@ function getTelemetryReader() {
|
|
|
2716
3248
|
return null;
|
|
2717
3249
|
}
|
|
2718
3250
|
}
|
|
3251
|
+
function getMcpMode() {
|
|
3252
|
+
const mode = process.env["KAIROS_MCP_MODE"]?.toLowerCase();
|
|
3253
|
+
if (mode === "readonly" || mode === "validate") return mode;
|
|
3254
|
+
return "deploy";
|
|
3255
|
+
}
|
|
2719
3256
|
function isAllowed(action) {
|
|
3257
|
+
const mode = getMcpMode();
|
|
3258
|
+
if (mode === "readonly" || mode === "validate") return false;
|
|
2720
3259
|
const key = `KAIROS_MCP_ALLOW_${action.toUpperCase()}`;
|
|
2721
3260
|
return process.env[key] === "true";
|
|
2722
3261
|
}
|
|
3262
|
+
function mcpText(text) {
|
|
3263
|
+
return { content: [{ type: "text", text }] };
|
|
3264
|
+
}
|
|
3265
|
+
function mcpError(text) {
|
|
3266
|
+
return { content: [{ type: "text", text }], isError: true };
|
|
3267
|
+
}
|
|
3268
|
+
function checkMcpAuth(provided) {
|
|
3269
|
+
const expected = process.env["KAIROS_MCP_SECRET"];
|
|
3270
|
+
if (!expected) return null;
|
|
3271
|
+
if (provided === expected) return null;
|
|
3272
|
+
return mcpError(JSON.stringify({ error: "Unauthorized: missing or incorrect kairos_secret" }));
|
|
3273
|
+
}
|
|
2723
3274
|
function getApiClient() {
|
|
2724
3275
|
const baseUrl = process.env["N8N_BASE_URL"];
|
|
2725
3276
|
const apiKey = process.env["N8N_API_KEY"];
|
|
2726
3277
|
if (!baseUrl || !apiKey) {
|
|
2727
|
-
throw new
|
|
3278
|
+
throw new GuardError("N8N_BASE_URL and N8N_API_KEY environment variables are required for n8n operations");
|
|
2728
3279
|
}
|
|
2729
3280
|
return new N8nApiClient(baseUrl, apiKey, nullLogger);
|
|
2730
3281
|
}
|
|
3282
|
+
function getCatalogCachePath() {
|
|
3283
|
+
const telemetry = process.env["KAIROS_TELEMETRY"];
|
|
3284
|
+
const base = telemetry ? (0, import_node_path8.join)(telemetry, "..") : (0, import_node_path8.join)((0, import_node_os6.homedir)(), ".kairos");
|
|
3285
|
+
return (0, import_node_path8.join)(base, "node-catalog-cache.json");
|
|
3286
|
+
}
|
|
2731
3287
|
async function autoSync() {
|
|
2732
3288
|
if (lastSync) return lastSync;
|
|
3289
|
+
const cachePath = getCatalogCachePath();
|
|
3290
|
+
const cached = await readCatalogCache(cachePath);
|
|
3291
|
+
if (cached) {
|
|
3292
|
+
lastSync = cached;
|
|
3293
|
+
_validator = new N8nValidator(lastSync.registry);
|
|
3294
|
+
return lastSync;
|
|
3295
|
+
}
|
|
2733
3296
|
const baseUrl = process.env["N8N_BASE_URL"];
|
|
2734
3297
|
const apiKey = process.env["N8N_API_KEY"];
|
|
2735
3298
|
if (!baseUrl || !apiKey) return null;
|
|
@@ -2738,7 +3301,9 @@ async function autoSync() {
|
|
|
2738
3301
|
const nodeTypes = await client.getNodeTypes();
|
|
2739
3302
|
if (nodeTypes.length === 0) return null;
|
|
2740
3303
|
lastSync = nodeSyncer.sync(nodeTypes);
|
|
2741
|
-
|
|
3304
|
+
_validator = new N8nValidator(lastSync.registry);
|
|
3305
|
+
writeCatalogCache(cachePath, lastSync).catch(() => {
|
|
3306
|
+
});
|
|
2742
3307
|
return lastSync;
|
|
2743
3308
|
} catch {
|
|
2744
3309
|
return null;
|
|
@@ -2757,24 +3322,20 @@ server.tool(
|
|
|
2757
3322
|
},
|
|
2758
3323
|
async ({ description, name }) => {
|
|
2759
3324
|
evictStaleSessions();
|
|
2760
|
-
const baseUrl = process.env["N8N_BASE_URL"];
|
|
2761
|
-
const apiKey = process.env["N8N_API_KEY"];
|
|
2762
|
-
if (!baseUrl || !apiKey) {
|
|
2763
|
-
return {
|
|
2764
|
-
content: [{
|
|
2765
|
-
type: "text",
|
|
2766
|
-
text: JSON.stringify({ error: "N8N_BASE_URL and N8N_API_KEY are required. Kairos needs to sync your n8n instance's node types to generate accurate workflows." })
|
|
2767
|
-
}],
|
|
2768
|
-
isError: true
|
|
2769
|
-
};
|
|
2770
|
-
}
|
|
2771
3325
|
const runId = generateUUID();
|
|
2772
3326
|
const workflowType = inferWorkflowType(description);
|
|
3327
|
+
const hasN8nCreds = !!(process.env["N8N_BASE_URL"] && process.env["N8N_API_KEY"]);
|
|
3328
|
+
const syncPromise = autoSync();
|
|
3329
|
+
const syncTimeout = new Promise((resolve) => setTimeout(() => resolve(null), AUTO_SYNC_TIMEOUT_MS));
|
|
2773
3330
|
await library.initialize();
|
|
2774
|
-
const syncResult = await
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
3331
|
+
const [syncResult, matches, failureRates] = await Promise.all([
|
|
3332
|
+
Promise.race([syncPromise, syncTimeout]),
|
|
3333
|
+
library.search(description),
|
|
3334
|
+
(async () => {
|
|
3335
|
+
const reader = getTelemetryReader();
|
|
3336
|
+
return reader ? reader.getFailureRates() : [];
|
|
3337
|
+
})()
|
|
3338
|
+
]);
|
|
2778
3339
|
const request = { description, ...name ? { name } : {} };
|
|
2779
3340
|
const built = promptBuilder.build(request, matches, failureRates, syncResult?.catalogText);
|
|
2780
3341
|
if (mcpTelemetry) {
|
|
@@ -2783,48 +3344,46 @@ server.tool(
|
|
|
2783
3344
|
startTime: Date.now(),
|
|
2784
3345
|
validateAttempts: 0,
|
|
2785
3346
|
warnedRules: promptBuilder.getWarnedRules(),
|
|
2786
|
-
workflowType
|
|
3347
|
+
workflowType,
|
|
3348
|
+
matchCount: matches.length
|
|
2787
3349
|
});
|
|
2788
3350
|
await mcpTelemetry.emit("build_start", { description, model: "mcp-decomposed", dryRun: false }, runId);
|
|
2789
3351
|
}
|
|
2790
3352
|
const systemText = built.system.map((block) => block.text).join("\n\n---\n\n");
|
|
2791
|
-
return {
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
}, null, 2)
|
|
2821
|
-
}]
|
|
2822
|
-
};
|
|
3353
|
+
return mcpText(JSON.stringify({
|
|
3354
|
+
kairos_run_id: runId,
|
|
3355
|
+
mode: built.mode,
|
|
3356
|
+
matchCount: matches.length,
|
|
3357
|
+
topMatchScore: matches[0]?.score ?? null,
|
|
3358
|
+
nodeCatalog: syncResult ? "synced" : "static",
|
|
3359
|
+
nodeCount: syncResult?.nodeCount ?? null,
|
|
3360
|
+
...syncResult ? {} : {
|
|
3361
|
+
syncWarning: hasN8nCreds ? "Could not sync node types from your n8n instance. Using static fallback catalog \u2014 generated workflows may not match your exact n8n setup." : "N8N_BASE_URL and N8N_API_KEY are not set. Using static fallback catalog \u2014 node types may not match your n8n instance. Set these env vars to enable accurate generation and deployment."
|
|
3362
|
+
},
|
|
3363
|
+
systemPrompt: systemText,
|
|
3364
|
+
userMessage: built.userMessage,
|
|
3365
|
+
outputFormat: {
|
|
3366
|
+
description: "Generate a JSON object with this exact structure. The workflow field contains the n8n workflow. credentialsNeeded lists services requiring credentials.",
|
|
3367
|
+
schema: {
|
|
3368
|
+
workflow: {
|
|
3369
|
+
name: "string \u2014 descriptive workflow name",
|
|
3370
|
+
nodes: "array \u2014 n8n node objects with id (UUID v4), type, typeVersion, name, position, parameters",
|
|
3371
|
+
connections: "object \u2014 keyed by source node NAME, maps to target nodes",
|
|
3372
|
+
settings: 'object \u2014 include executionOrder: "v1"'
|
|
3373
|
+
},
|
|
3374
|
+
credentialsNeeded: [{
|
|
3375
|
+
service: 'string \u2014 e.g. "Slack"',
|
|
3376
|
+
credentialType: 'string \u2014 e.g. "slackOAuth2Api"',
|
|
3377
|
+
description: "string \u2014 what the user needs to set up"
|
|
3378
|
+
}]
|
|
3379
|
+
}
|
|
3380
|
+
}
|
|
3381
|
+
}, null, 2));
|
|
2823
3382
|
}
|
|
2824
3383
|
);
|
|
2825
3384
|
server.tool(
|
|
2826
3385
|
"kairos_validate",
|
|
2827
|
-
"Validate n8n workflow JSON against
|
|
3386
|
+
"Validate n8n workflow JSON against 34 structural rules. Returns pass/fail with specific issues. If validation fails, fix the issues and call this again. Errors block deployment; warnings are advisory.",
|
|
2828
3387
|
{
|
|
2829
3388
|
workflow: import_zod.z.string().describe("The workflow JSON string to validate"),
|
|
2830
3389
|
kairos_run_id: import_zod.z.string().optional().describe("Run ID from kairos_prompt \u2014 enables telemetry correlation")
|
|
@@ -2834,17 +3393,9 @@ server.tool(
|
|
|
2834
3393
|
try {
|
|
2835
3394
|
parsed = JSON.parse(workflowStr);
|
|
2836
3395
|
} catch (e) {
|
|
2837
|
-
return {
|
|
2838
|
-
content: [{
|
|
2839
|
-
type: "text",
|
|
2840
|
-
text: JSON.stringify({
|
|
2841
|
-
valid: false,
|
|
2842
|
-
error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}`
|
|
2843
|
-
}, null, 2)
|
|
2844
|
-
}]
|
|
2845
|
-
};
|
|
3396
|
+
return mcpText(JSON.stringify({ valid: false, error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` }, null, 2));
|
|
2846
3397
|
}
|
|
2847
|
-
const result =
|
|
3398
|
+
const result = getValidator().validate(parsed);
|
|
2848
3399
|
const errors = result.issues.filter((i) => i.severity === "error");
|
|
2849
3400
|
const warnings = result.issues.filter((i) => i.severity === "warn");
|
|
2850
3401
|
if (mcpTelemetry && kairos_run_id) {
|
|
@@ -2865,27 +3416,14 @@ server.tool(
|
|
|
2865
3416
|
}, kairos_run_id);
|
|
2866
3417
|
}
|
|
2867
3418
|
}
|
|
2868
|
-
return {
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
rule: i.rule,
|
|
2877
|
-
message: i.message,
|
|
2878
|
-
nodeId: i.nodeId ?? null
|
|
2879
|
-
})),
|
|
2880
|
-
warnings: warnings.map((i) => ({
|
|
2881
|
-
rule: i.rule,
|
|
2882
|
-
message: i.message,
|
|
2883
|
-
nodeId: i.nodeId ?? null
|
|
2884
|
-
})),
|
|
2885
|
-
deployable: errors.length === 0
|
|
2886
|
-
}, null, 2)
|
|
2887
|
-
}]
|
|
2888
|
-
};
|
|
3419
|
+
return mcpText(JSON.stringify({
|
|
3420
|
+
valid: result.valid,
|
|
3421
|
+
errorCount: errors.length,
|
|
3422
|
+
warningCount: warnings.length,
|
|
3423
|
+
errors: errors.map((i) => ({ rule: i.rule, message: i.message, nodeId: i.nodeId ?? null })),
|
|
3424
|
+
warnings: warnings.map((i) => ({ rule: i.rule, message: i.message, nodeId: i.nodeId ?? null })),
|
|
3425
|
+
deployable: errors.length === 0
|
|
3426
|
+
}, null, 2));
|
|
2889
3427
|
}
|
|
2890
3428
|
);
|
|
2891
3429
|
server.tool(
|
|
@@ -2894,101 +3432,151 @@ server.tool(
|
|
|
2894
3432
|
{
|
|
2895
3433
|
workflow: import_zod.z.string().describe("The validated workflow JSON string to deploy"),
|
|
2896
3434
|
activate: import_zod.z.boolean().default(false).describe("Activate the workflow immediately after deployment"),
|
|
2897
|
-
|
|
3435
|
+
description: import_zod.z.string().optional().describe("The original user intent / description for this workflow \u2014 used to improve library search quality over time"),
|
|
3436
|
+
kairos_run_id: import_zod.z.string().optional().describe("Run ID from kairos_prompt \u2014 enables telemetry correlation"),
|
|
3437
|
+
kairos_secret: import_zod.z.string().optional().describe("Required when KAIROS_MCP_SECRET env var is set")
|
|
2898
3438
|
},
|
|
2899
|
-
async ({ workflow: workflowStr, activate, kairos_run_id }) => {
|
|
3439
|
+
async ({ workflow: workflowStr, activate, description: userDescription, kairos_run_id, kairos_secret }) => {
|
|
3440
|
+
const authError = checkMcpAuth(kairos_secret);
|
|
3441
|
+
if (authError) return authError;
|
|
2900
3442
|
if (!isAllowed("deploy")) {
|
|
2901
|
-
return {
|
|
2902
|
-
content: [{
|
|
2903
|
-
type: "text",
|
|
2904
|
-
text: JSON.stringify({ error: "Deploy is disabled. Set KAIROS_MCP_ALLOW_DEPLOY=true to enable." })
|
|
2905
|
-
}],
|
|
2906
|
-
isError: true
|
|
2907
|
-
};
|
|
3443
|
+
return mcpError(JSON.stringify({ error: "Deploy is disabled. Set KAIROS_MCP_ALLOW_DEPLOY=true to enable." }));
|
|
2908
3444
|
}
|
|
2909
3445
|
let parsed;
|
|
2910
3446
|
try {
|
|
2911
3447
|
parsed = JSON.parse(workflowStr);
|
|
2912
3448
|
} catch (e) {
|
|
2913
|
-
return {
|
|
2914
|
-
content: [{
|
|
2915
|
-
type: "text",
|
|
2916
|
-
text: JSON.stringify({ error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` })
|
|
2917
|
-
}]
|
|
2918
|
-
};
|
|
3449
|
+
return mcpError(JSON.stringify({ error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` }));
|
|
2919
3450
|
}
|
|
2920
|
-
const validation =
|
|
3451
|
+
const validation = getValidator().validate(parsed);
|
|
2921
3452
|
const errors = validation.issues.filter((i) => i.severity === "error");
|
|
2922
3453
|
if (errors.length > 0) {
|
|
2923
|
-
return {
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
error: "Workflow has validation errors \u2014 fix them before deploying",
|
|
2928
|
-
errors: errors.map((i) => ({ rule: i.rule, message: i.message }))
|
|
2929
|
-
}, null, 2)
|
|
2930
|
-
}]
|
|
2931
|
-
};
|
|
3454
|
+
return mcpError(JSON.stringify({
|
|
3455
|
+
error: "Workflow has validation errors \u2014 fix them before deploying",
|
|
3456
|
+
errors: errors.map((i) => ({ rule: i.rule, message: i.message }))
|
|
3457
|
+
}, null, 2));
|
|
2932
3458
|
}
|
|
2933
3459
|
const client = getApiClient();
|
|
2934
3460
|
const stripped = stripper.stripForCreate(parsed);
|
|
2935
3461
|
const response = await client.createWorkflow(stripped);
|
|
2936
3462
|
if (activate) {
|
|
2937
3463
|
if (!isAllowed("activate")) {
|
|
2938
|
-
return {
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
warning: "Workflow deployed but activation is disabled. Set KAIROS_MCP_ALLOW_ACTIVATE=true to enable.",
|
|
2946
|
-
url: `${process.env["N8N_BASE_URL"]}/workflow/${response.id}`
|
|
2947
|
-
}, null, 2)
|
|
2948
|
-
}]
|
|
2949
|
-
};
|
|
3464
|
+
return mcpText(JSON.stringify({
|
|
3465
|
+
workflowId: response.id,
|
|
3466
|
+
name: response.name,
|
|
3467
|
+
activated: false,
|
|
3468
|
+
warning: "Workflow deployed but activation is disabled. Set KAIROS_MCP_ALLOW_ACTIVATE=true to enable.",
|
|
3469
|
+
url: `${process.env["N8N_BASE_URL"]}/workflow/${response.id}`
|
|
3470
|
+
}, null, 2));
|
|
2950
3471
|
}
|
|
2951
3472
|
await client.activateWorkflow(response.id);
|
|
2952
3473
|
}
|
|
3474
|
+
const session = kairos_run_id ? mcpSessions.get(kairos_run_id) : void 0;
|
|
3475
|
+
const missingSessionWarning = kairos_run_id && !session ? `
|
|
3476
|
+
|
|
3477
|
+
Note: kairos_run_id "${kairos_run_id}" was provided but no active session was found. This usually means kairos_deploy was called without a prior kairos_prompt call, or the session expired. Telemetry and pattern learning for this build were skipped.` : "";
|
|
2953
3478
|
await library.initialize();
|
|
2954
3479
|
await library.save(parsed, {
|
|
2955
|
-
description: parsed.name,
|
|
2956
|
-
generationMode: "scratch",
|
|
2957
|
-
generationAttempts: 1
|
|
3480
|
+
description: session?.description ?? userDescription ?? parsed.name,
|
|
3481
|
+
generationMode: session && session.matchCount > 0 ? "reference" : "scratch",
|
|
3482
|
+
generationAttempts: session?.validateAttempts ?? 1,
|
|
3483
|
+
n8nWorkflowId: response.id
|
|
2958
3484
|
});
|
|
2959
|
-
if (mcpTelemetry && kairos_run_id) {
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
PatternAnalyzer.fromEnv().analyzeAndSave().catch(() => {
|
|
2978
|
-
});
|
|
2979
|
-
}
|
|
3485
|
+
if (mcpTelemetry && kairos_run_id && session) {
|
|
3486
|
+
await mcpTelemetry.emit("build_complete", {
|
|
3487
|
+
description: session.description,
|
|
3488
|
+
success: true,
|
|
3489
|
+
totalAttempts: session.validateAttempts,
|
|
3490
|
+
totalDurationMs: Date.now() - session.startTime,
|
|
3491
|
+
totalTokensInput: 0,
|
|
3492
|
+
totalTokensOutput: 0,
|
|
3493
|
+
workflowName: response.name,
|
|
3494
|
+
workflowId: response.id,
|
|
3495
|
+
dryRun: false,
|
|
3496
|
+
credentialsNeeded: 0,
|
|
3497
|
+
warnedRules: session.warnedRules,
|
|
3498
|
+
workflowType: session.workflowType
|
|
3499
|
+
}, kairos_run_id);
|
|
3500
|
+
mcpSessions.delete(kairos_run_id);
|
|
3501
|
+
PatternAnalyzer.fromEnv().analyzeAndSave().catch(() => {
|
|
3502
|
+
});
|
|
2980
3503
|
}
|
|
2981
|
-
return {
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
3504
|
+
return mcpText(JSON.stringify({
|
|
3505
|
+
workflowId: response.id,
|
|
3506
|
+
name: response.name,
|
|
3507
|
+
activated: activate,
|
|
3508
|
+
url: `${process.env["N8N_BASE_URL"]}/workflow/${response.id}`
|
|
3509
|
+
}, null, 2) + missingSessionWarning);
|
|
3510
|
+
}
|
|
3511
|
+
);
|
|
3512
|
+
server.tool(
|
|
3513
|
+
"kairos_replace",
|
|
3514
|
+
"Replace an existing n8n workflow with a new version. Validates before updating. Use kairos_prompt \u2192 kairos_validate \u2192 kairos_replace for iteration on existing workflows.",
|
|
3515
|
+
{
|
|
3516
|
+
workflow_id: import_zod.z.string().describe("The n8n workflow ID to replace"),
|
|
3517
|
+
workflow: import_zod.z.string().describe("The validated workflow JSON string"),
|
|
3518
|
+
description: import_zod.z.string().optional().describe("The original user intent / description for this workflow \u2014 used to improve library search quality over time"),
|
|
3519
|
+
kairos_run_id: import_zod.z.string().optional().describe("Run ID from kairos_prompt \u2014 enables telemetry correlation"),
|
|
3520
|
+
kairos_secret: import_zod.z.string().optional().describe("Required when KAIROS_MCP_SECRET env var is set")
|
|
3521
|
+
},
|
|
3522
|
+
async ({ workflow_id, workflow: workflowStr, description: userDescription, kairos_run_id, kairos_secret }) => {
|
|
3523
|
+
const authError = checkMcpAuth(kairos_secret);
|
|
3524
|
+
if (authError) return authError;
|
|
3525
|
+
if (!isAllowed("deploy")) {
|
|
3526
|
+
return mcpError(JSON.stringify({ error: "Replace is disabled. Set KAIROS_MCP_ALLOW_DEPLOY=true or KAIROS_MCP_MODE=deploy to enable." }));
|
|
3527
|
+
}
|
|
3528
|
+
let parsed;
|
|
3529
|
+
try {
|
|
3530
|
+
parsed = JSON.parse(workflowStr);
|
|
3531
|
+
} catch (e) {
|
|
3532
|
+
return mcpError(JSON.stringify({ error: `Invalid JSON: ${e instanceof Error ? e.message : String(e)}` }));
|
|
3533
|
+
}
|
|
3534
|
+
const validation = getValidator().validate(parsed);
|
|
3535
|
+
const errors = validation.issues.filter((i) => i.severity === "error");
|
|
3536
|
+
if (errors.length > 0) {
|
|
3537
|
+
return mcpError(JSON.stringify({
|
|
3538
|
+
error: "Workflow has validation errors \u2014 fix them before replacing",
|
|
3539
|
+
errors: errors.map((i) => ({ rule: i.rule, message: i.message }))
|
|
3540
|
+
}, null, 2));
|
|
3541
|
+
}
|
|
3542
|
+
const client = getApiClient();
|
|
3543
|
+
const stripped = stripper.stripForUpdate(parsed);
|
|
3544
|
+
const response = await client.updateWorkflow(workflow_id, stripped);
|
|
3545
|
+
const session = kairos_run_id ? mcpSessions.get(kairos_run_id) : void 0;
|
|
3546
|
+
const missingSessionWarning = kairos_run_id && !session ? `
|
|
3547
|
+
|
|
3548
|
+
Note: kairos_run_id "${kairos_run_id}" was provided but no active session was found.` : "";
|
|
3549
|
+
await library.initialize();
|
|
3550
|
+
await library.save(parsed, {
|
|
3551
|
+
description: session?.description ?? userDescription ?? parsed.name,
|
|
3552
|
+
generationMode: session && session.matchCount > 0 ? "reference" : "scratch",
|
|
3553
|
+
generationAttempts: session?.validateAttempts ?? 1,
|
|
3554
|
+
n8nWorkflowId: workflow_id
|
|
3555
|
+
});
|
|
3556
|
+
if (mcpTelemetry && kairos_run_id && session) {
|
|
3557
|
+
await mcpTelemetry.emit("build_complete", {
|
|
3558
|
+
description: session.description,
|
|
3559
|
+
success: true,
|
|
3560
|
+
totalAttempts: session.validateAttempts,
|
|
3561
|
+
totalDurationMs: Date.now() - session.startTime,
|
|
3562
|
+
totalTokensInput: 0,
|
|
3563
|
+
totalTokensOutput: 0,
|
|
3564
|
+
workflowName: response.name,
|
|
3565
|
+
workflowId: response.id,
|
|
3566
|
+
dryRun: false,
|
|
3567
|
+
credentialsNeeded: 0,
|
|
3568
|
+
warnedRules: session.warnedRules,
|
|
3569
|
+
workflowType: session.workflowType
|
|
3570
|
+
}, kairos_run_id);
|
|
3571
|
+
mcpSessions.delete(kairos_run_id);
|
|
3572
|
+
PatternAnalyzer.fromEnv().analyzeAndSave().catch(() => {
|
|
3573
|
+
});
|
|
3574
|
+
}
|
|
3575
|
+
return mcpText(JSON.stringify({
|
|
3576
|
+
workflowId: response.id,
|
|
3577
|
+
name: response.name,
|
|
3578
|
+
url: `${process.env["N8N_BASE_URL"]}/workflow/${response.id}`
|
|
3579
|
+
}, null, 2) + missingSessionWarning);
|
|
2992
3580
|
}
|
|
2993
3581
|
);
|
|
2994
3582
|
server.tool(
|
|
@@ -3001,23 +3589,20 @@ server.tool(
|
|
|
3001
3589
|
async ({ query, limit }) => {
|
|
3002
3590
|
await library.initialize();
|
|
3003
3591
|
const matches = await library.search(query);
|
|
3004
|
-
return
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
3008
|
-
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
)
|
|
3019
|
-
}]
|
|
3020
|
-
};
|
|
3592
|
+
return mcpText(JSON.stringify(
|
|
3593
|
+
matches.slice(0, limit).map((m) => ({
|
|
3594
|
+
id: m.workflow.id,
|
|
3595
|
+
score: Number(m.score.toFixed(3)),
|
|
3596
|
+
mode: m.mode,
|
|
3597
|
+
description: m.workflow.description,
|
|
3598
|
+
nodeCount: m.workflow.workflow.nodes.length,
|
|
3599
|
+
nodes: m.workflow.workflow.nodes.map((n) => n.name),
|
|
3600
|
+
n8nWorkflowId: m.workflow.n8nWorkflowId ?? null,
|
|
3601
|
+
failurePatterns: m.workflow.failurePatterns ?? []
|
|
3602
|
+
})),
|
|
3603
|
+
null,
|
|
3604
|
+
2
|
|
3605
|
+
));
|
|
3021
3606
|
}
|
|
3022
3607
|
);
|
|
3023
3608
|
server.tool(
|
|
@@ -3028,36 +3613,19 @@ server.tool(
|
|
|
3028
3613
|
const baseUrl = process.env["N8N_BASE_URL"];
|
|
3029
3614
|
const apiKey = process.env["N8N_API_KEY"];
|
|
3030
3615
|
if (!baseUrl || !apiKey) {
|
|
3031
|
-
return {
|
|
3032
|
-
content: [{
|
|
3033
|
-
type: "text",
|
|
3034
|
-
text: JSON.stringify({ error: "N8N_BASE_URL and N8N_API_KEY are required for sync." })
|
|
3035
|
-
}],
|
|
3036
|
-
isError: true
|
|
3037
|
-
};
|
|
3616
|
+
return mcpError(JSON.stringify({ error: "N8N_BASE_URL and N8N_API_KEY are required for sync." }));
|
|
3038
3617
|
}
|
|
3039
3618
|
lastSync = null;
|
|
3040
3619
|
const result = await autoSync();
|
|
3041
3620
|
if (!result) {
|
|
3042
|
-
return {
|
|
3043
|
-
content: [{
|
|
3044
|
-
type: "text",
|
|
3045
|
-
text: JSON.stringify({ error: "Failed to fetch node types from n8n. Check your credentials and that your instance is running." })
|
|
3046
|
-
}],
|
|
3047
|
-
isError: true
|
|
3048
|
-
};
|
|
3621
|
+
return mcpError(JSON.stringify({ error: "Failed to fetch node types from n8n. Check your credentials and that your instance is running." }));
|
|
3049
3622
|
}
|
|
3050
|
-
return {
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
newNodes: result.newNodes,
|
|
3057
|
-
message: `Synced ${result.nodeCount} node types from your n8n instance (${result.newNodes} not in default catalog).`
|
|
3058
|
-
}, null, 2)
|
|
3059
|
-
}]
|
|
3060
|
-
};
|
|
3623
|
+
return mcpText(JSON.stringify({
|
|
3624
|
+
synced: true,
|
|
3625
|
+
nodeCount: result.nodeCount,
|
|
3626
|
+
newNodes: result.newNodes,
|
|
3627
|
+
message: `Synced ${result.nodeCount} node types from your n8n instance (${result.newNodes} not in default catalog).`
|
|
3628
|
+
}, null, 2));
|
|
3061
3629
|
}
|
|
3062
3630
|
);
|
|
3063
3631
|
server.tool(
|
|
@@ -3073,12 +3641,72 @@ server.tool(
|
|
|
3073
3641
|
if (limit !== void 0 && limit > 0) {
|
|
3074
3642
|
analysis.topFailureRules = analysis.topFailureRules.slice(0, limit);
|
|
3075
3643
|
}
|
|
3076
|
-
return
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3644
|
+
return mcpText(JSON.stringify(analysis, null, 2));
|
|
3645
|
+
}
|
|
3646
|
+
);
|
|
3647
|
+
server.tool(
|
|
3648
|
+
"kairos_library",
|
|
3649
|
+
"Browse the local Kairos workflow library. Returns saved workflow metadata. Use the optional query to search, or omit it to list all entries.",
|
|
3650
|
+
{
|
|
3651
|
+
query: import_zod.z.string().optional().describe("Optional search query \u2014 omit to list all entries"),
|
|
3652
|
+
limit: import_zod.z.number().default(20).describe("Maximum entries to return")
|
|
3653
|
+
},
|
|
3654
|
+
async ({ query, limit }) => {
|
|
3655
|
+
await library.initialize();
|
|
3656
|
+
if (query) {
|
|
3657
|
+
const matches = await library.search(query);
|
|
3658
|
+
return mcpText(JSON.stringify(
|
|
3659
|
+
matches.slice(0, limit).map((m) => ({
|
|
3660
|
+
id: m.workflow.id,
|
|
3661
|
+
description: m.workflow.description,
|
|
3662
|
+
score: Number(m.score.toFixed(3)),
|
|
3663
|
+
mode: m.mode,
|
|
3664
|
+
nodeCount: m.workflow.workflow.nodes.length,
|
|
3665
|
+
nodes: m.workflow.workflow.nodes.map((n) => n.name),
|
|
3666
|
+
deployCount: m.workflow.deployCount,
|
|
3667
|
+
n8nWorkflowId: m.workflow.n8nWorkflowId ?? null,
|
|
3668
|
+
createdAt: m.workflow.createdAt
|
|
3669
|
+
})),
|
|
3670
|
+
null,
|
|
3671
|
+
2
|
|
3672
|
+
));
|
|
3673
|
+
}
|
|
3674
|
+
const all = await library.list();
|
|
3675
|
+
return mcpText(JSON.stringify(
|
|
3676
|
+
all.slice(0, limit).map((w) => ({
|
|
3677
|
+
id: w.id,
|
|
3678
|
+
description: w.description,
|
|
3679
|
+
nodeCount: w.workflow.nodes.length,
|
|
3680
|
+
nodes: w.workflow.nodes.map((n) => n.name),
|
|
3681
|
+
deployCount: w.deployCount,
|
|
3682
|
+
n8nWorkflowId: w.n8nWorkflowId ?? null,
|
|
3683
|
+
timesRetrieved: w.timesRetrieved ?? 0,
|
|
3684
|
+
createdAt: w.createdAt
|
|
3685
|
+
})),
|
|
3686
|
+
null,
|
|
3687
|
+
2
|
|
3688
|
+
));
|
|
3689
|
+
}
|
|
3690
|
+
);
|
|
3691
|
+
server.tool(
|
|
3692
|
+
"kairos_outcome",
|
|
3693
|
+
"Record the outcome of a workflow build against a library entry. Trains the pattern learning system to know what works and what fails over time.",
|
|
3694
|
+
{
|
|
3695
|
+
library_id: import_zod.z.string().describe("The Kairos library entry ID (returned by kairos_deploy, kairos_replace, or kairos_library)"),
|
|
3696
|
+
attempts: import_zod.z.number().describe("Number of generation+validation attempts before success"),
|
|
3697
|
+
first_try_pass: import_zod.z.boolean().describe("Whether the first attempt passed validation"),
|
|
3698
|
+
failed_rules: import_zod.z.array(import_zod.z.number()).describe("Validation rule IDs that failed during generation"),
|
|
3699
|
+
mode: import_zod.z.enum(["direct", "reference"]).describe("How the library entry was used during generation")
|
|
3700
|
+
},
|
|
3701
|
+
async ({ library_id, attempts, first_try_pass, failed_rules, mode }) => {
|
|
3702
|
+
await library.initialize();
|
|
3703
|
+
await library.recordOutcome(library_id, {
|
|
3704
|
+
attempts,
|
|
3705
|
+
firstTryPass: first_try_pass,
|
|
3706
|
+
failedRules: failed_rules,
|
|
3707
|
+
mode
|
|
3708
|
+
});
|
|
3709
|
+
return mcpText(JSON.stringify({ recorded: true, libraryId: library_id }));
|
|
3082
3710
|
}
|
|
3083
3711
|
);
|
|
3084
3712
|
server.tool(
|
|
@@ -3088,12 +3716,7 @@ server.tool(
|
|
|
3088
3716
|
async () => {
|
|
3089
3717
|
const client = getApiClient();
|
|
3090
3718
|
const workflows = await client.listWorkflows();
|
|
3091
|
-
return
|
|
3092
|
-
content: [{
|
|
3093
|
-
type: "text",
|
|
3094
|
-
text: JSON.stringify(workflows, null, 2)
|
|
3095
|
-
}]
|
|
3096
|
-
};
|
|
3719
|
+
return mcpText(JSON.stringify(workflows, null, 2));
|
|
3097
3720
|
}
|
|
3098
3721
|
);
|
|
3099
3722
|
server.tool(
|
|
@@ -3105,12 +3728,7 @@ server.tool(
|
|
|
3105
3728
|
async ({ workflow_id }) => {
|
|
3106
3729
|
const client = getApiClient();
|
|
3107
3730
|
const workflow = await client.getWorkflow(workflow_id);
|
|
3108
|
-
return
|
|
3109
|
-
content: [{
|
|
3110
|
-
type: "text",
|
|
3111
|
-
text: JSON.stringify(workflow, null, 2)
|
|
3112
|
-
}]
|
|
3113
|
-
};
|
|
3731
|
+
return mcpText(JSON.stringify(workflow, null, 2));
|
|
3114
3732
|
}
|
|
3115
3733
|
);
|
|
3116
3734
|
server.tool(
|
|
@@ -3121,22 +3739,11 @@ server.tool(
|
|
|
3121
3739
|
},
|
|
3122
3740
|
async ({ workflow_id }) => {
|
|
3123
3741
|
if (!isAllowed("activate")) {
|
|
3124
|
-
return {
|
|
3125
|
-
content: [{
|
|
3126
|
-
type: "text",
|
|
3127
|
-
text: JSON.stringify({ error: "Activate is disabled. Set KAIROS_MCP_ALLOW_ACTIVATE=true to enable." })
|
|
3128
|
-
}],
|
|
3129
|
-
isError: true
|
|
3130
|
-
};
|
|
3742
|
+
return mcpError(JSON.stringify({ error: "Activate is disabled. Set KAIROS_MCP_ALLOW_ACTIVATE=true to enable." }));
|
|
3131
3743
|
}
|
|
3132
3744
|
const client = getApiClient();
|
|
3133
3745
|
await client.activateWorkflow(workflow_id);
|
|
3134
|
-
return {
|
|
3135
|
-
content: [{
|
|
3136
|
-
type: "text",
|
|
3137
|
-
text: `Activated workflow ${workflow_id}`
|
|
3138
|
-
}]
|
|
3139
|
-
};
|
|
3746
|
+
return mcpText(`Activated workflow ${workflow_id}`);
|
|
3140
3747
|
}
|
|
3141
3748
|
);
|
|
3142
3749
|
server.tool(
|
|
@@ -3148,38 +3755,25 @@ server.tool(
|
|
|
3148
3755
|
async ({ workflow_id }) => {
|
|
3149
3756
|
const client = getApiClient();
|
|
3150
3757
|
await client.deactivateWorkflow(workflow_id);
|
|
3151
|
-
return {
|
|
3152
|
-
content: [{
|
|
3153
|
-
type: "text",
|
|
3154
|
-
text: `Deactivated workflow ${workflow_id}`
|
|
3155
|
-
}]
|
|
3156
|
-
};
|
|
3758
|
+
return mcpText(`Deactivated workflow ${workflow_id}`);
|
|
3157
3759
|
}
|
|
3158
3760
|
);
|
|
3159
3761
|
server.tool(
|
|
3160
3762
|
"kairos_delete",
|
|
3161
3763
|
"Delete a workflow from n8n. This is irreversible.",
|
|
3162
3764
|
{
|
|
3163
|
-
workflow_id: import_zod.z.string().describe("The n8n workflow ID to delete")
|
|
3765
|
+
workflow_id: import_zod.z.string().describe("The n8n workflow ID to delete"),
|
|
3766
|
+
kairos_secret: import_zod.z.string().optional().describe("Required when KAIROS_MCP_SECRET env var is set")
|
|
3164
3767
|
},
|
|
3165
|
-
async ({ workflow_id }) => {
|
|
3768
|
+
async ({ workflow_id, kairos_secret }) => {
|
|
3769
|
+
const authError = checkMcpAuth(kairos_secret);
|
|
3770
|
+
if (authError) return authError;
|
|
3166
3771
|
if (!isAllowed("delete")) {
|
|
3167
|
-
return {
|
|
3168
|
-
content: [{
|
|
3169
|
-
type: "text",
|
|
3170
|
-
text: JSON.stringify({ error: "Delete is disabled. Set KAIROS_MCP_ALLOW_DELETE=true to enable." })
|
|
3171
|
-
}],
|
|
3172
|
-
isError: true
|
|
3173
|
-
};
|
|
3772
|
+
return mcpError(JSON.stringify({ error: "Delete is disabled. Set KAIROS_MCP_ALLOW_DELETE=true to enable." }));
|
|
3174
3773
|
}
|
|
3175
3774
|
const client = getApiClient();
|
|
3176
3775
|
await client.deleteWorkflow(workflow_id);
|
|
3177
|
-
return {
|
|
3178
|
-
content: [{
|
|
3179
|
-
type: "text",
|
|
3180
|
-
text: `Deleted workflow ${workflow_id}`
|
|
3181
|
-
}]
|
|
3182
|
-
};
|
|
3776
|
+
return mcpText(`Deleted workflow ${workflow_id}`);
|
|
3183
3777
|
}
|
|
3184
3778
|
);
|
|
3185
3779
|
server.tool(
|
|
@@ -3192,17 +3786,36 @@ server.tool(
|
|
|
3192
3786
|
async ({ workflow_id, limit }) => {
|
|
3193
3787
|
const client = getApiClient();
|
|
3194
3788
|
const executions = await client.getExecutions(workflow_id, { limit });
|
|
3195
|
-
return
|
|
3196
|
-
content: [{
|
|
3197
|
-
type: "text",
|
|
3198
|
-
text: JSON.stringify(executions, null, 2)
|
|
3199
|
-
}]
|
|
3200
|
-
};
|
|
3789
|
+
return mcpText(JSON.stringify(executions, null, 2));
|
|
3201
3790
|
}
|
|
3202
3791
|
);
|
|
3203
3792
|
async function main() {
|
|
3204
|
-
|
|
3205
|
-
|
|
3793
|
+
if (!process.env["ANTHROPIC_API_KEY"]) {
|
|
3794
|
+
process.stderr.write(
|
|
3795
|
+
"[kairos-mcp] WARNING: ANTHROPIC_API_KEY is not set \u2014 kairos_prompt will fail. Set it before using workflow generation tools.\n"
|
|
3796
|
+
);
|
|
3797
|
+
}
|
|
3798
|
+
const useHttp = process.argv.includes("--http");
|
|
3799
|
+
if (useHttp) {
|
|
3800
|
+
const port = parseInt(process.env["KAIROS_MCP_PORT"] ?? "3000", 10);
|
|
3801
|
+
const transport = new import_streamableHttp.StreamableHTTPServerTransport();
|
|
3802
|
+
await server.connect(transport);
|
|
3803
|
+
const httpServer = (0, import_node_http.createServer)(async (req, res) => {
|
|
3804
|
+
if (req.method === "GET" || req.method === "POST" || req.method === "DELETE") {
|
|
3805
|
+
await transport.handleRequest(req, res);
|
|
3806
|
+
} else {
|
|
3807
|
+
res.writeHead(405, { "Content-Type": "application/json" });
|
|
3808
|
+
res.end(JSON.stringify({ error: "Method not allowed" }));
|
|
3809
|
+
}
|
|
3810
|
+
});
|
|
3811
|
+
httpServer.listen(port, () => {
|
|
3812
|
+
process.stderr.write(`[kairos-mcp] HTTP transport listening on port ${port}
|
|
3813
|
+
`);
|
|
3814
|
+
});
|
|
3815
|
+
} else {
|
|
3816
|
+
const transport = new import_stdio.StdioServerTransport();
|
|
3817
|
+
await server.connect(transport);
|
|
3818
|
+
}
|
|
3206
3819
|
}
|
|
3207
3820
|
main().catch((err) => {
|
|
3208
3821
|
console.error("Kairos MCP server failed to start:", err);
|