n8n-decanter 0.11.1 → 0.11.2

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/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.2] - 2026-10-21
11
+
12
+ ### Fixed
13
+
14
+ - **`test --scenario` and `test --execution` work again on n8n 2.34 and
15
+ newer** ([#327](https://github.com/buttjer/n8n-decanter/issues/327)). The run
16
+ succeeded on the instance, but decanter then stopped with `Tool get_execution
17
+ not found` and compared nothing. n8n 2.34 renamed that MCP tool to
18
+ `get_workflow_execution`, and renamed two more that decanter uses:
19
+ `search_executions` (behind `preflight`'s `history` check, which quietly
20
+ fell back to the REST API or skipped) and `prepare_test_pin_data` (behind
21
+ `scenario create --scaffold`, which quietly produced fills without schemas).
22
+ decanter now asks for the new name and falls back to the old one, so older n8n
23
+ keeps working too.
24
+
10
25
  ## [0.11.1] - 2026-09-10
11
26
 
12
27
  ### Added
package/dist/lib/mcp.mjs CHANGED
@@ -33,6 +33,63 @@ export class McpToolError extends Error {
33
33
  export function isUnavailableInMcp(err) {
34
34
  return err instanceof Error && /not available in MCP/i.test(err.message);
35
35
  }
36
+ /**
37
+ * The tools decanter calls that n8n RENAMED, current name first. n8n 2.34.0
38
+ * renamed them (n8n-io/n8n#34347) and kept no alias, so each name works on
39
+ * one side of that release only: the old one fails "Tool get_execution not
40
+ * found" on 2.34+ (#327), the new one fails the same way on everything older.
41
+ * Source-verified 2026-10-21: old names on n8n@2.33.7, new names from
42
+ * n8n@2.34.0 through n8n@2.44.5. Every other tool decanter calls kept its name.
43
+ */
44
+ export const MCP_TOOL = {
45
+ getExecution: ["get_workflow_execution", "get_execution"],
46
+ searchExecutions: ["search_workflow_executions", "search_executions"],
47
+ preparePinData: ["prepare_workflow_pin_data", "prepare_test_pin_data"],
48
+ };
49
+ /**
50
+ * The SDK's unknown-tool answer names the tool, so the match is per name: a
51
+ * real tool's own "… not found" (an execution, a node) never reads as this.
52
+ * Covers both carriers — a JSON-RPC error and an `isError` result.
53
+ */
54
+ function isToolNotFound(err, name) {
55
+ return err instanceof Error && new RegExp(`\\bTool ${name} not found\\b`).test(err.message);
56
+ }
57
+ /** Per client, the name each renamed tool answered to — one probe per process, not per call. */
58
+ const resolvedToolName = new WeakMap();
59
+ /**
60
+ * Call a renamed tool by whichever of its names this n8n knows. Probing beats
61
+ * `tools/list` here: a server only ever lacks one of the names, and a probe
62
+ * that misses costs exactly the one round-trip a listing would. A name is
63
+ * remembered once the tool itself answered — a result, or its own `isError`
64
+ * (`McpToolError`). A transport failure, a 429 that outlasted the backoff or
65
+ * any other JSON-RPC error proves nothing about the name, so it is not
66
+ * remembered: caching it would send every later call in a multi-ref run to a
67
+ * name an older n8n does not have.
68
+ */
69
+ export async function callToolByAnyName(mcp, names, args) {
70
+ const cache = resolvedToolName.get(mcp) ?? new Map();
71
+ resolvedToolName.set(mcp, cache);
72
+ const known = cache.get(names[0]);
73
+ if (known !== undefined)
74
+ return mcp.callTool(known, args);
75
+ let lastErr;
76
+ for (const name of names) {
77
+ try {
78
+ const out = await mcp.callTool(name, args);
79
+ cache.set(names[0], name);
80
+ return out;
81
+ }
82
+ catch (err) {
83
+ if (!isToolNotFound(err, name)) {
84
+ if (err instanceof McpToolError)
85
+ cache.set(names[0], name);
86
+ throw err;
87
+ }
88
+ lastErr = err;
89
+ }
90
+ }
91
+ throw lastErr;
92
+ }
36
93
  /**
37
94
  * Strictly local — the path `init` mints into and every rotation rewrites.
38
95
  * `readAuthFilePath` is the read-side counterpart, which may resolve elsewhere.
@@ -739,25 +796,26 @@ export async function updateWorkflow(mcp, id, operations) {
739
796
  }
740
797
  /** Ask n8n for the per-node output schemas of the nodes a test run must pin (read-only). */
741
798
  export async function prepareTestPinData(mcp, id) {
742
- return mcp.callTool("prepare_test_pin_data", { workflowId: id });
799
+ return callToolByAnyName(mcp, MCP_TOOL.preparePinData, { workflowId: id });
743
800
  }
744
801
  /**
745
802
  * Recent executions of a workflow — **metadata only** (status/timing, no run
746
- * data; `readOnlyHint`/`idempotentHint`), over MCP `search_executions` (n8n
747
- * ≥ ~2.30, shape source-verified 2026-07-22). The health signal behind
803
+ * data; `readOnlyHint`/`idempotentHint`), over MCP `search_workflow_executions`
804
+ * (`search_executions` on n8n ~2.30–2.33; shape source-verified 2026-07-22,
805
+ * unchanged through 2.44.5 bar an added `nextCursor`). The health signal behind
748
806
  * `preflight`'s `history` check. The tool doesn't guarantee an order — the
749
807
  * caller sorts/interprets. Throws (in-band `error` or an MCP `isError`) when the
750
808
  * server lacks the tool or refuses access, so the caller can fall back to the
751
809
  * REST executions API.
752
810
  */
753
811
  export async function searchExecutions(mcp, { workflowId, status, limit }) {
754
- const res = await mcp.callTool("search_executions", {
812
+ const res = await callToolByAnyName(mcp, MCP_TOOL.searchExecutions, {
755
813
  ...(workflowId !== undefined && { workflowId }),
756
814
  ...(status !== undefined && { status }),
757
815
  ...(limit !== undefined && { limit }),
758
816
  });
759
817
  if (res.error !== undefined)
760
- throw new McpToolError("search_executions", res.error);
818
+ throw new McpToolError(MCP_TOOL.searchExecutions[0], res.error);
761
819
  return res.data ?? [];
762
820
  }
763
821
  /**
@@ -311,9 +311,9 @@ export async function runPreflight(ctx) {
311
311
  skip(id, "sync", "workflow not available in MCP", ENABLE_MCP_HINT);
312
312
  }
313
313
  }
314
- // history: production-run health. MCP search_executions preferred; REST
315
- // listExecutions (no run data) fallback; neither → skip. Never fails —
316
- // the draft isn't guilty of the live workflow's past.
314
+ // history: production-run health. MCP search_workflow_executions
315
+ // preferred; REST listExecutions (no run data) fallback; neither → skip.
316
+ // Never fails — the draft isn't guilty of the live workflow's past.
317
317
  await run("history", "sync", async () => historyCheck(ctx));
318
318
  }
319
319
  }
@@ -485,8 +485,8 @@ async function historyCheck(ctx) {
485
485
  }
486
486
  }
487
487
  if (rows === undefined) {
488
- const reason = ctx.restAvailable ? "search_executions unavailable and the REST executions probe failed" : "no MCP search_executions and no N8N_API_KEY for the REST fallback";
489
- return { status: "skip", message: reason, reason, unlock: "upgrade n8n for search_executions, or set N8N_API_KEY" };
488
+ const reason = ctx.restAvailable ? "MCP execution search unavailable and the REST executions probe failed" : "no MCP execution search and no N8N_API_KEY for the REST fallback";
489
+ return { status: "skip", message: reason, reason, unlock: "upgrade n8n for MCP execution search, or set N8N_API_KEY" };
490
490
  }
491
491
  if (rows.length === 0)
492
492
  return { status: "info", message: "no recent production runs" };
@@ -9,7 +9,7 @@
9
9
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { EXECUTIONS_DIR } from "./executions.mjs";
12
- import { getWorkflowDetails, updateWorkflow } from "./mcp.mjs";
12
+ import { callToolByAnyName, getWorkflowDetails, MCP_TOOL, updateWorkflow } from "./mcp.mjs";
13
13
  import { createPrompt } from "./prompt.mjs";
14
14
  import { buildNodeCode, pushWorkflow } from "./push.mjs";
15
15
  import { readState, writeState } from "./state.mjs";
@@ -284,7 +284,7 @@ export async function runTest(mcp, config, dir, id, { ref, source, trigger }, lo
284
284
  const diffs = [];
285
285
  let coverage;
286
286
  if (result.executionId !== null && result.status === "success") {
287
- const execution = await mcp.callTool("get_execution", {
287
+ const execution = await callToolByAnyName(mcp, MCP_TOOL.getExecution, {
288
288
  workflowId: id,
289
289
  executionId: result.executionId,
290
290
  includeData: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "n8n-decanter",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
4
4
  "description": "The toolkit for building code-heavy n8n workflows — agent-first, MCP-native",
5
5
  "keywords": [
6
6
  "n8n",
@@ -59,7 +59,7 @@
59
59
  "luxon": "^3.7.2"
60
60
  },
61
61
  "devDependencies": {
62
- "@biomejs/biome": "2.5.11",
62
+ "@biomejs/biome": "2.5.15",
63
63
  "@types/jmespath": "^0.15.2",
64
64
  "@types/luxon": "^3.7.2",
65
65
  "@types/node": "^26.1.1",
@@ -632,7 +632,7 @@ n8n-decanter preflight "<workflow>" --simulate --scenario <slug> # replay the
632
632
  ```
633
633
 
634
634
  Add `--scaffold` to `scenario create` to annotate each gap with its **output
635
- schema** (from n8n's read-only `prepare_test_pin_data`) — a `--scaffold` with no
635
+ schema** (from n8n's read-only `prepare_workflow_pin_data`) — a `--scaffold` with no
636
636
  `--execution` builds a from-scratch set where *every* pinnable node is a fill
637
637
  entry. Provenance is tracked per node: `capture` (real, diff-asserted),
638
638
  `authored` (hand-filled), `scaffolded` (schema-guided). A run on a scenario with