@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.
@@ -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
- return crypto.randomUUID();
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
- var WEIGHTS = {
30
- tfidf: 0.35,
31
- nodeFingerprint: 0.3,
32
- outcome: 0.2,
33
- deploy: 0.15
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
- return candidates.map((c) => {
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
- ...cluster ? { clusterPattern: cluster.pattern } : {}
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 MAX_LIBRARY_SIZE = 500;
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
- idf.set(token, Math.log((docCount + 1) / (docsWithToken + 1)) + 1);
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.deployCount ?? 0) - (a.deployCount ?? 0);
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 indexPath = (0, import_node_path.join)(this.dir, "index.json");
523
- const tmpPath = `${indexPath}.tmp`;
524
- await (0, import_promises.writeFile)(tmpPath, JSON.stringify(this.meta, null, 2), "utf-8");
525
- await (0, import_promises.rename)(tmpPath, indexPath);
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 indexPath = (0, import_node_path.join)(this.dir, "index.json");
530
- let onDisk = [];
688
+ const releaseLock = await this.acquireLock();
531
689
  try {
532
- const raw = await (0, import_promises.readFile)(indexPath, "utf-8");
533
- const parsed = JSON.parse(raw);
534
- if (Array.isArray(parsed)) {
535
- onDisk = parsed.filter(isValidMeta);
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
- } catch {
538
- }
539
- const ourIds = new Set(this.meta.map((m) => m.id));
540
- const external = onDisk.filter((m) => !ourIds.has(m.id));
541
- let merged = [...this.meta, ...external];
542
- if (merged.length > MAX_LIBRARY_SIZE) {
543
- merged.sort((a, b) => (b.deployCount ?? 0) - (a.deployCount ?? 0));
544
- merged = merged.slice(0, MAX_LIBRARY_SIZE);
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 (!this.registry.isVersionSafe(node.type, node.typeVersion)) {
981
- this.warn(
982
- issues,
983
- 19,
984
- `Node "${node.name}" uses typeVersion ${node.typeVersion} for type "${node.type}" which is not in the known safe list`,
985
- node.id
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 21 (WARN): webhook with responseMode="responseNode" must have respondToWebhook node
1145
- checkRule21(w, issues) {
1348
+ // Rule 27 (WARN): httpRequest URL is a placeholder
1349
+ checkRule27(w, issues) {
1146
1350
  if (!Array.isArray(w.nodes)) return;
1147
- const webhooksNeedingResponse = w.nodes.filter((n) => {
1148
- if (!n.type.includes("webhook")) return false;
1149
- const params = n.parameters;
1150
- return params?.responseMode === "responseNode";
1151
- });
1152
- if (webhooksNeedingResponse.length === 0) return;
1153
- const hasRespondNode = w.nodes.some((n) => n.type.includes("respondToWebhook"));
1154
- if (!hasRespondNode) {
1155
- for (const wh of webhooksNeedingResponse) {
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
- 21,
1159
- `Webhook "${wh.name}" uses responseMode "responseNode" but no respondToWebhook node exists in the workflow`,
1160
- wh.id
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 this.singleRequest(url, method, path, body);
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 = "/workflows?limit=250";
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=250&cursor=${response.nextCursor}`;
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 = "/tags?limit=250";
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=250&cursor=${response.nextCursor}`;
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: 26 }, (_, i) => i + 1);
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
- return [...regressed, ...confirmed, ...drafts].slice(0, maxCount);
1827
- }
1828
- buildFailureWarnings(matches, globalFailureRates) {
1829
- const richPatterns = this.getActivePatterns(this.resolveMaxPatterns());
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
- if (!buildSessions.has(event.sessionId)) continue;
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(event.sessionId);
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
- if (version === PATTERN_SCHEMA_VERSION) return patterns;
2062
- return this.migratePatterns(patterns, version);
2563
+ this._cachedPreviousPatterns = version === PATTERN_SCHEMA_VERSION ? patterns : this.migratePatterns(patterns, version);
2063
2564
  } catch {
2064
- return [];
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 passed = attempts.filter(
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-26)`
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 import_node_path7 = require("path");
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, import_node_path7.dirname)((0, import_node_url.fileURLToPath)(import_meta.url));
2684
- var pkg = JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path7.join)(__dirname, "..", "package.json"), "utf-8"));
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 validator = new N8nValidator();
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, import_node_path7.join)(val, "..", "patterns.json");
3231
+ return (0, import_node_path8.join)(val, "..", "patterns.json");
2700
3232
  }
2701
- return (0, import_node_path7.join)((0, import_node_os6.homedir)(), ".kairos", "patterns.json");
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 Error("N8N_BASE_URL and N8N_API_KEY environment variables are required for n8n operations");
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
- validator = new N8nValidator(lastSync.registry);
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 autoSync();
2775
- const matches = await library.search(description);
2776
- const telemetryReader = getTelemetryReader();
2777
- const failureRates = await telemetryReader?.getFailureRates() ?? [];
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
- content: [{
2793
- type: "text",
2794
- text: JSON.stringify({
2795
- kairos_run_id: runId,
2796
- mode: built.mode,
2797
- matchCount: matches.length,
2798
- topMatchScore: matches[0]?.score ?? null,
2799
- nodeCatalog: syncResult ? "synced" : "static",
2800
- nodeCount: syncResult?.nodeCount ?? null,
2801
- ...syncResult ? {} : { syncWarning: "Could not sync node types from your n8n instance. Using static fallback catalog \u2014 generated workflows may not match your exact n8n setup." },
2802
- systemPrompt: systemText,
2803
- userMessage: built.userMessage,
2804
- outputFormat: {
2805
- description: "Generate a JSON object with this exact structure. The workflow field contains the n8n workflow. credentialsNeeded lists services requiring credentials.",
2806
- schema: {
2807
- workflow: {
2808
- name: "string \u2014 descriptive workflow name",
2809
- nodes: "array \u2014 n8n node objects with id (UUID v4), type, typeVersion, name, position, parameters",
2810
- connections: "object \u2014 keyed by source node NAME, maps to target nodes",
2811
- settings: 'object \u2014 include executionOrder: "v1"'
2812
- },
2813
- credentialsNeeded: [{
2814
- service: 'string \u2014 e.g. "Slack"',
2815
- credentialType: 'string \u2014 e.g. "slackOAuth2Api"',
2816
- description: "string \u2014 what the user needs to set up"
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 26 structural rules. Returns pass/fail with specific issues. If validation fails, fix the issues and call this again. Errors block deployment; warnings are advisory.",
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 = validator.validate(parsed);
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
- content: [{
2870
- type: "text",
2871
- text: JSON.stringify({
2872
- valid: result.valid,
2873
- errorCount: errors.length,
2874
- warningCount: warnings.length,
2875
- errors: errors.map((i) => ({
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
- kairos_run_id: import_zod.z.string().optional().describe("Run ID from kairos_prompt \u2014 enables telemetry correlation")
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 = validator.validate(parsed);
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
- content: [{
2925
- type: "text",
2926
- text: JSON.stringify({
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
- content: [{
2940
- type: "text",
2941
- text: JSON.stringify({
2942
- workflowId: response.id,
2943
- name: response.name,
2944
- activated: false,
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
- const session = mcpSessions.get(kairos_run_id);
2961
- if (session) {
2962
- await mcpTelemetry.emit("build_complete", {
2963
- description: session.description,
2964
- success: true,
2965
- totalAttempts: session.validateAttempts,
2966
- totalDurationMs: Date.now() - session.startTime,
2967
- totalTokensInput: 0,
2968
- totalTokensOutput: 0,
2969
- workflowName: response.name,
2970
- workflowId: response.id,
2971
- dryRun: false,
2972
- credentialsNeeded: 0,
2973
- warnedRules: session.warnedRules,
2974
- workflowType: session.workflowType
2975
- }, kairos_run_id);
2976
- mcpSessions.delete(kairos_run_id);
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
- content: [{
2983
- type: "text",
2984
- text: JSON.stringify({
2985
- workflowId: response.id,
2986
- name: response.name,
2987
- activated: activate,
2988
- url: `${process.env["N8N_BASE_URL"]}/workflow/${response.id}`
2989
- }, null, 2)
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
- content: [{
3006
- type: "text",
3007
- text: JSON.stringify(
3008
- matches.slice(0, limit).map((m) => ({
3009
- score: Number(m.score.toFixed(3)),
3010
- mode: m.mode,
3011
- description: m.workflow.description,
3012
- nodeCount: m.workflow.workflow.nodes.length,
3013
- nodes: m.workflow.workflow.nodes.map((n) => n.name),
3014
- failurePatterns: m.workflow.failurePatterns ?? []
3015
- })),
3016
- null,
3017
- 2
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
- content: [{
3052
- type: "text",
3053
- text: JSON.stringify({
3054
- synced: true,
3055
- nodeCount: result.nodeCount,
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
- content: [{
3078
- type: "text",
3079
- text: JSON.stringify(analysis, null, 2)
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
- const transport = new import_stdio.StdioServerTransport();
3205
- await server.connect(transport);
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);