n8n-decanter 0.10.0 → 0.10.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/CHANGELOG.md CHANGED
@@ -7,6 +7,216 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.10.1] - 2026-08-16
11
+
12
+ ### Added
13
+
14
+ - **The live mirror now tells the agent when it overwrote your work.** It runs a
15
+ full `pull` after a structure edit, so it can replace an unpushed local code
16
+ edit with what is on the instance. It always warned about that — on stderr,
17
+ which is the one stream an MCP agent structurally cannot read, so the party
18
+ able to react never heard it. The warning now rides the **result of the
19
+ agent's next tool call**, naming the files and how to recover them from the
20
+ safety commit. Delivered once, never repeated.
21
+
22
+ Only on `mcp connect` (the transport `init` scaffolds). `mcp serve` pipes
23
+ upstream responses through untouched — including SSE — and buffering them to
24
+ inject an advisory line would break streaming for every response to deliver it
25
+ on some. On that transport the stderr warning stays the only signal.
26
+
27
+ - **`init` now says that its permission rules only bind the next session.** It
28
+ writes `.claude/settings.json` with the deny rules that keep an agent off
29
+ `.decanter.json`, `.env` and `push --force` — but agents read permission config
30
+ at startup, not on change, and `init` is normally run *from inside* the session
31
+ those rules are meant to constrain. They were silently inert until a restart,
32
+ and the docs mentioned a restart only for the skills plugin — so the rules that
33
+ actually gate the agent went unmentioned. Printed once, when the file is first
34
+ written; a re-init in a set-up directory stays quiet.
35
+
36
+ - **`node run` fixtures can pin a node's other outputs, so `$('Node').all(1)`
37
+ finally answers.** Give a node one items array **per output** and the branch
38
+ is readable offline:
39
+
40
+ ```json
41
+ "nodes": { "Decide": [[{ "json": { "side": "true" } }], [{ "json": { "side": "false" } }]] }
42
+ ```
43
+
44
+ `all(1)` / `first(1)` / `last(1)` and `$items('Decide', 1)` read output 1, and
45
+ an **empty** array is a real answer — that branch took no items. `input` takes
46
+ the same shape, indexed by the node's *input* (a Merge node's second input).
47
+ A plain items array still means a single output, so existing fixtures are
48
+ unchanged; asking for an output the fixture doesn't supply still refuses,
49
+ now saying how many it has. Until now every such call was refused outright,
50
+ because a fixture could only express one array per node.
51
+
52
+ - **`preflight --simulate` now replays a pinned node's other outputs too.** The
53
+ stand-in decanter substitutes for a network node is a Code node, which has one
54
+ output — so an error output (or any second branch) captured in your execution
55
+ was replayed nowhere, and everything behind it sat with no input, emitted
56
+ nothing, and let the run pass. Each populated output now gets **its own
57
+ stand-in**, wired to the same input as the original and feeding exactly that
58
+ output's targets, so the branch really runs. Deliberately not wired to the
59
+ synthetic trigger: a stand-in fires only when the original would, so a replay
60
+ whose real nodes take a different path can't have the old branch's items
61
+ injected into it anyway. The `simulate` check names the splits in its details
62
+ (terminal and `--json`). **`test` still replays `main[0]` only** — n8n's
63
+ `pinData` is one flat items array per node, with no output dimension — and
64
+ `scenario check` now spells out which of the two you are looking at.
65
+
66
+ - **`test` now reports what the run actually moved, not just that it
67
+ finished** — a coverage line over the nodes that executed (enabled and
68
+ unpinned; a pinned node's items are the input you supplied):
69
+ `coverage: 7/9 unpinned node(s) emitted items — 2 emitted none: Group
70
+ products, Write rows`. A node counts as emitting if it put an item on **any**
71
+ output. Some empty nodes are normal — a filter that dropped everything — so
72
+ the line warns and nothing more.
73
+
74
+ **But a run in which not one unpinned node emitted an item now fails
75
+ (exit 1), even with synthetic pins.** n8n calls such a run `success` and
76
+ it is: nothing errored. No data moved either, so nothing was demonstrated,
77
+ and reporting it as a pass was the check lying. The message names the usual
78
+ cause — a pin replays a node's first output only.
79
+
80
+ - **`scenario check` warns about what the replay will throw away.** Both replay
81
+ paths (`test`'s `pinData`, `preflight --simulate`'s stand-in node) read
82
+ `main[0]` only, while the validator happily accepts — and `✓ valid`s — a
83
+ scenario carrying items on several outputs. The check now says so offline:
84
+ once for a node whose data populates more than one output, naming the indices
85
+ that get dropped, and once for a node source that reads a pinned node's
86
+ non-first output (`$('Enrich').all(1)`, `$items('Enrich', 1)`) — the call that
87
+ returns nothing and leaves the node emitting nothing. Warnings only; the
88
+ scenario stays valid for the outputs that do replay.
89
+
90
+ ### Changed
91
+
92
+ - **An explicit `.ts` extension in a node file's import no longer fails the
93
+ typecheck.** The scaffolded `tsconfig.json` now sets
94
+ `allowImportingTsExtensions`, so `import { total } from
95
+ "../../../shared/money.ts"` type-checks — until now it was rejected
96
+ (TS5097) even though `push` bundles it without complaint, which made the
97
+ gate and the bundler disagree over a pure spelling choice. Extensionless
98
+ stays the recommended form (it survives a helper later becoming `.js`);
99
+ both spellings resolve everywhere. Existing sync dirs are offered the
100
+ updated `tsconfig.json` on the next `init` — a `tsconfig.json` you edited
101
+ yourself is reported as drift and left alone, so add the option by hand
102
+ there.
103
+ - **Two of the four import rules for `.ts` nodes now warn instead of
104
+ blocking a push**: a relative import resolving outside the sync dir, and an
105
+ absolute-path import. Both only endanger the author's own portability — the
106
+ bundle still builds locally and fails loudly (`Could not resolve`) wherever
107
+ the target is genuinely absent — so blocking them was decanter making the
108
+ user's call. The advisory prints on every surface (`preflight`'s `layout`
109
+ details, `push`, `node run`) and exactly once per push;
110
+ `preflight --fail-on=warn` is the strict variant for CI. **Node builtins
111
+ and npm packages not opted into `bundleDependencies` still block** —
112
+ esbuild is silent about both, so without the block the failure would
113
+ surface at runtime on the n8n instance.
114
+ - **The scaffolded `mcp-route-check.mjs` session hook now also inspects
115
+ user-level agent config for direct n8n MCP routes** — Claude Code's
116
+ `~/.claude.json` (including its entry for the current project), Cursor's
117
+ `~/.cursor/mcp.json`, the VS Code user profile, and opencode's global
118
+ config. Previously it read only project files, so an `n8n` server added
119
+ with `claude mcp add -s user` (or any other user-scoped config) bypassed
120
+ the decanter guard without a word — exactly the "second door" the hook
121
+ exists to catch. Still a warning, never a gate. Re-run `init` in an
122
+ existing sync dir (or re-copy the hook from the template) to pick it up.
123
+ - **The scaffolded `tsconfig.json` now covers the whole sync dir**, not just
124
+ `shared/` and `workflows/` — helper code may live in any folder inside the
125
+ sync dir (`shared/` is only the scaffolded default), so the typecheck and
126
+ the editor's tsserver now own every root without a config edit. Existing
127
+ sync dirs keep their scaffolded file; to match, widen `include` to
128
+ `["n8n-globals.d.ts", "**/*.ts", "**/*.js"]` and add
129
+ `"**/backups/**", "**/executions/**", "decanter-ts-plugin", "dist"` to
130
+ `exclude`. Two consequences worth knowing: a loose node-shaped scratch file
131
+ (top-level `return` outside any workflow's `code/`) is now part of the
132
+ program and reports TS1108 — move it into a workflow or add its folder to
133
+ `exclude`; and when `init` scaffolds into an **existing project** that had
134
+ no `tsconfig.json`, the new config sweeps that project's own `.ts`/`.js`
135
+ into the node-file typecheck — add your app dirs to `exclude` if they
136
+ shouldn't gate pushes.
137
+
138
+ ### Fixed
139
+
140
+ - **`diff` and `preflight` no longer report a `CONFLICT` for a node with no
141
+ recorded sync hash.** "Changed both locally and remotely" is measured against
142
+ the last-sync baseline in `.decanter.json`; with no baseline nothing is
143
+ *known* to have moved on the instance, and `push` has always treated that as
144
+ pushable. The two disagreed, so the report described a dead end the CLI did
145
+ not have — worse, its documented exit (`push --force`) is denied to agents by
146
+ the permission rules `init` scaffolds. Such a node now reads as
147
+ `push pending`, which is what `push` does with it. `pull` no longer warns
148
+ `CONFLICT` for the same case on `.ts` nodes.
149
+ - **A `.js` → `.ts` conversion no longer reads as data loss.** Re-pointing a
150
+ node's `//@file:` placeholder is the sanctioned way to convert, and `push`
151
+ and `pull` both adopt it before doing anything — but `diff` and `preflight`
152
+ looked the file up in `.decanter.json` alone and announced `local file
153
+ code/<node>.js missing` for the file you had just replaced. They now read the
154
+ placeholder too, so a converted node reports `local changes in
155
+ code/<node>.ts — push pending`, identically for every converted node.
156
+ - **`preflight` no longer prints `✓ parity local code matches the draft`
157
+ directly above `✗ drift CONFLICT`.** Both checks read the same facts, so
158
+ `parity` may claim a match only when every node is in sync; divergence that
159
+ `drift` owns is reported as an `info` line pointing at it.
160
+ - **`mcp connect` survives an unreachable n8n instead of dying at the
161
+ handshake.** `initialize` was forwarded like any other message, so a
162
+ connection failure answered the *handshake* with an error: the agent's MCP
163
+ client got no `serverInfo` and tore the session down before a single tool
164
+ call could report what was wrong. The guard now completes the handshake
165
+ itself when n8n does not answer, and the failure surfaces on the tool call
166
+ that needed the instance. Once n8n is reachable, the handshake is replayed
167
+ upstream so the session it uses is a real one. The startup line now reads
168
+ `guard: ready — forwarding all n8n MCP tools to <host>` instead of
169
+ `connected to <host>`, which claimed a connection nothing had made yet.
170
+ - **The scaffolded `AGENTS.md` now names the n8n-side prerequisites.** MCP
171
+ access is a switch per *instance* **and** one per *workflow* ("Available in
172
+ MCP"), and until a workflow's is on, `pull`/`push`/`diff`/`preflight`/`watch`
173
+ all fail for it. The CLI's error says so; the file an agent reads *before*
174
+ running anything did not.
175
+ - **`preflight`'s `types` check now reports type errors in shared helper
176
+ files** instead of passing green while `push` fails on them. The scoped
177
+ typecheck dropped every diagnostic outside the graded workflow's own dir —
178
+ and helper code lives outside every workflow dir by definition — so a
179
+ workflow could grade `ready` on code `push` then rejected. Helper
180
+ diagnostics (under `shared/` or any other folder) now surface in every
181
+ workflow's `types` line, while another workflow's *node* errors stay out of
182
+ scope as before.
183
+ - **The documented shared-import path was one level short** for the `code/`
184
+ layout: `../../shared/money` in the TypeScript-nodes docs and the scaffolded
185
+ `AGENTS.md` resolved to `workflows/shared/money` and failed on copy-paste.
186
+ The correct depth from `workflows/<folder>/code/` is `../../../shared/money`.
187
+ - **Compiled module labels — and therefore sync hashes — are now stable when
188
+ the sync dir is reached through a symlinked path** (macOS `/tmp`, a
189
+ symlinked checkout). esbuild resolves bundled files to their realpaths, so
190
+ an un-realpathed label base produced machine-specific `../…`-climbing
191
+ labels inside the hashed bytes — nodes reading "push pending" across
192
+ machines with nobody touching code. The same spelling mismatch made a
193
+ *scoped* typecheck silently drop every node diagnostic under a symlinked
194
+ path; both now realpath. If a sync dir lives behind a symlink, the first
195
+ push after this release re-baselines the affected nodes' hashes once.
196
+ - **The route-check hook now reads opencode's real config shape**
197
+ (`mcp.<name>`). It previously looked only for `mcpServers` / `mcp.servers` /
198
+ `servers` bags, so an `opencode.json` routing straight at the instance was
199
+ listed as checked but never actually flagged.
200
+ - **A `watch` save now runs the same folder-wide compliance guard as `push`.** It
201
+ used to check only the saved file, which cannot see the workflow's node names —
202
+ so it could not catch a dangling `$('Renamed Node')` and pushed straight to the
203
+ draft what a manual `push` refuses outright. The break then surfaced at run time
204
+ in n8n instead of at save time. Four surfaces already claimed the guards were
205
+ the same; now they are.
206
+
207
+ **The abort is scoped, deliberately:** a save is refused when the violation is
208
+ in the file you just saved, and violations elsewhere in the folder are printed
209
+ on every save without blocking it. Repairing a rename means fixing several
210
+ files, and a folder-wide abort would stop every save until the last fix — which
211
+ would disable `watch` during exactly the job it is for.
212
+
213
+ - **`liveMirror` is documented as what it is: a full `pull`.** The shipped agent
214
+ contract and the config reference both described it as refreshing the
215
+ `workflow.json` snapshot. It also rewrites the `code/` files and
216
+ `.decanter.json` and moves files on a rename, so it can overwrite an unpushed
217
+ local edit — which is worth knowing before you restructure. No behaviour change;
218
+ the docs now say what the code has always done.
219
+
10
220
  ## [0.10.0] - 2026-08-07
11
221
 
12
222
  ### Added
package/README.md CHANGED
@@ -34,8 +34,9 @@ clean git diffs.
34
34
  every push and pull is auto-committed.
35
35
  - **TypeScript or typed JS** — write nodes in TS (compiled on push) or plain
36
36
  JS; n8n globals (`$input`, `$('…')`, …) are typed in both.
37
- - **Shared code and small libraries** — `.ts` nodes import from `shared/` and
38
- opted-in npm packages, bundled on push to run anywhere, n8n Cloud included.
37
+ - **Shared code and small libraries** — `.ts` nodes import helper files from
38
+ anywhere in the sync dir (`shared/` by default) and opted-in npm packages,
39
+ bundled on push to run anywhere, n8n Cloud included.
39
40
  - **Agent-native** — `init` scaffolds Claude Code / Cursor / Codex configs;
40
41
  `preflight --offline` and `node run` give agents a credential-free loop.
41
42
  - **Guarded agent access to n8n's MCP, wired by default** — the scaffolded
@@ -142,7 +143,7 @@ a broader whole-workflow authoring toolkit:
142
143
  | Capability | Native n8n (browser) | n8n-as-code | n8n-decanter |
143
144
  |---|---|---|---|
144
145
  | **TypeScript for Code nodes** | ❌ JavaScript or Python only | ❌ TS at workflow level, not node logic | ✅ `.ts` nodes, compiled on push, typed n8n globals |
145
- | **Shared types & helpers in Code nodes** | ❌ self-host `NODE_FUNCTION_ALLOW_*` only | ❌ not part of its model | ✅ `shared/*.ts` + npm bundled into self-contained nodes (Cloud-safe) |
146
+ | **Shared types & helpers in Code nodes** | ❌ self-host `NODE_FUNCTION_ALLOW_*` only | ❌ not part of its model | ✅ helper `.ts` anywhere in the sync dir + npm, bundled into self-contained nodes (Cloud-safe) |
146
147
  | **Code as individual files** | ❌ no source files (JSON blob) | 🟡 one `.workflow.ts` per workflow | ✅ folder per workflow; each Code node its own file |
147
148
  | **Code-level git versioning** | 🟡 in-app history; Git source control is Enterprise-only | ✅ GitOps sync of workflow source | ✅ real git — diffs, PRs, blame per Code node; auto-commit each sync |
148
149
  | **Preflights** (`preflight`, then `test`) | 🟡 re-run past executions, but online in-editor | 🟡 inspect executions against a live env | ✅ one read-only, CI-gateable verdict *before* the push; then `test` runs the pushed draft — each diffs every node vs a real capture |
@@ -135,7 +135,7 @@ async function assembleForRestore(dir, backup, log) {
135
135
  if (file === null)
136
136
  continue; // inline code (untracked node) — leave as-is
137
137
  try {
138
- const { jsCode } = await buildNodeCode(dir, file, log);
138
+ const { jsCode } = await buildNodeCode(dir, file, log, { quietImportWarnings: true });
139
139
  node.parameters.jsCode = jsCode;
140
140
  }
141
141
  catch {
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
2
2
  import { builtinModules } from "node:module";
3
3
  import path from "node:path";
4
4
  import { build, transform } from "esbuild";
@@ -119,38 +119,53 @@ export function findBundleContext(fromDir) {
119
119
  dir = parent;
120
120
  }
121
121
  }
122
+ /** realpath when resolvable, the input untouched otherwise (missing dirs). */
123
+ function realDir(dir) {
124
+ try {
125
+ return realpathSync(dir);
126
+ }
127
+ catch {
128
+ return dir;
129
+ }
130
+ }
122
131
  /** Package name of a bare specifier (`@scope/pkg/sub` → `@scope/pkg`). */
123
132
  function packageName(spec) {
124
133
  return spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
125
134
  }
126
135
  /**
127
- * Offline import rules for a node file (plans/14): relative imports stay
128
- * inside the sync dir, bare specifiers need a `bundleDependencies` opt-in,
129
- * builtins can never be bundled. Shared by the compliance guard and the
130
- * compiler, so preflight's `layout` check and `push` disagree on nothing.
136
+ * Offline import rules for a node file (plans/14), split by who the rule
137
+ * protects (Plan 79 task 7): a Node builtin or an un-opted-in npm package is
138
+ * **blocking** — esbuild is silent about both, so without the block the
139
+ * failure (a `__require` shim, a silently inlined package) surfaces at
140
+ * runtime on the instance. A relative import leaving the sync dir, or an
141
+ * absolute path, is **advisory** — it only endangers the author's own
142
+ * portability, and esbuild fails loudly wherever the target is genuinely
143
+ * absent. Shared by the compliance guard and the compiler, so preflight's
144
+ * `layout` check and `push` disagree on nothing.
131
145
  */
132
146
  export function checkNodeImports(file, specifiers, ctx) {
133
- const problems = [];
147
+ const blocking = [];
148
+ const advisory = [];
134
149
  for (const spec of specifiers) {
135
150
  if (spec.startsWith("node:") || BUILTINS.has(packageName(spec))) {
136
- problems.push(`imports the Node builtin "${spec}" — builtins cannot be bundled into a Code node (whether n8n allows them at runtime is the instance's NODE_FUNCTION_ALLOW_BUILTIN policy); inline the logic instead`);
151
+ blocking.push(`imports the Node builtin "${spec}" — builtins cannot be bundled into a Code node (whether n8n allows them at runtime is the instance's NODE_FUNCTION_ALLOW_BUILTIN policy); inline the logic instead`);
137
152
  }
138
153
  else if (spec.startsWith("./") || spec.startsWith("../")) {
139
154
  if (ctx.syncRoot !== null) {
140
155
  const resolved = path.resolve(path.dirname(file), spec);
141
156
  if (resolved !== ctx.syncRoot && !resolved.startsWith(ctx.syncRoot + path.sep)) {
142
- problems.push(`imports "${spec}", which resolves outside the sync dir (${ctx.syncRoot}) — shared code must live inside it`);
157
+ advisory.push(`imports "${spec}", which resolves outside the sync dir (${ctx.syncRoot}) — it bundles, but anyone whose checkout lacks the target can't build this node`);
143
158
  }
144
159
  }
145
160
  }
146
161
  else if (path.isAbsolute(spec)) {
147
- problems.push(`imports the absolute path "${spec}" — use a relative import inside the sync dir`);
162
+ advisory.push(`imports the absolute path "${spec}" — it bundles on this machine only; prefer a relative import inside the sync dir`);
148
163
  }
149
164
  else if (!ctx.bundleDependencies.includes(packageName(spec))) {
150
- problems.push(`imports the npm package "${packageName(spec)}" without opting it in — add it to "bundleDependencies" in decanter.config.json to bundle it into the pushed node`);
165
+ blocking.push(`imports the npm package "${packageName(spec)}" without opting it in — add it to "bundleDependencies" in decanter.config.json to bundle it into the pushed node`);
151
166
  }
152
167
  }
153
- return problems;
168
+ return { blocking, advisory };
154
169
  }
155
170
  /**
156
171
  * One-way compile of a .ts node file to the JS that runs inside n8n.
@@ -159,7 +174,7 @@ export function checkNodeImports(file, specifiers, ctx) {
159
174
  * imports, the file is bundled self-contained (see the header comment); the
160
175
  * output is still a function body ending in a top-level `return`.
161
176
  */
162
- export async function compileTs(file, log) {
177
+ export async function compileTs(file, log, opts) {
163
178
  const source = readFileSync(file, "utf8");
164
179
  const { importBlock, specifiers, body } = scanNodeImports(source);
165
180
  if (specifiers.length === 0) {
@@ -172,11 +187,35 @@ export async function compileTs(file, log) {
172
187
  return result.code.endsWith("\n") ? result.code : result.code + "\n";
173
188
  }
174
189
  const ctx = findBundleContext(path.dirname(file));
175
- const problems = checkNodeImports(file, specifiers, ctx);
176
- if (problems.length > 0) {
177
- throw new Error(`${file}:\n${problems.map((p) => ` ${p}`).join("\n")}`);
190
+ const { blocking, advisory } = checkNodeImports(file, specifiers, ctx);
191
+ if (blocking.length > 0) {
192
+ throw new Error(`${file}:\n${blocking.map((p) => ` ${p}`).join("\n")}`);
193
+ }
194
+ // Advisory findings warn and let the compile proceed (Plan 79 task 7). The
195
+ // push paths pass quietImportWarnings because their guard tier already
196
+ // printed the same findings — without it every violation would print twice
197
+ // per push, once per channel.
198
+ if (!opts?.quietImportWarnings) {
199
+ for (const p of advisory)
200
+ log?.warn(`${file}: ${p}`);
178
201
  }
179
- const workingDir = ctx.syncRoot ?? path.dirname(file);
202
+ // Realpath the label base: esbuild resolves every bundled module to its
203
+ // realpath, so a symlink anywhere in the sync dir's own path (macOS /tmp,
204
+ // a symlinked checkout) would otherwise make `path.relative` climb across
205
+ // the symlink — machine-specific `../…` module labels inside the hashed
206
+ // bytes, i.e. the exact cross-machine drift the sync-root-relative labels
207
+ // exist to prevent (Plan 79 task 4).
208
+ const workingDir = realDir(ctx.syncRoot ?? path.dirname(file));
209
+ // resolveDir follows, but ONLY when its realpath stays inside the real sync
210
+ // root (the whole-tree-behind-a-symlink shape, where realpathing keeps the
211
+ // entry label clean). When a symlink BETWEEN the root and the node file
212
+ // realpaths out of the tree, keep the caller's spelling: imports must
213
+ // resolve exactly where checkNodeImports approved them, or the guard and
214
+ // the bundler disagree — approving X/shared and then bundling (or failing
215
+ // on) something else entirely.
216
+ const spelledDir = path.dirname(file);
217
+ const realNodeDir = realDir(spelledDir);
218
+ const resolveDir = realNodeDir === workingDir || realNodeDir.startsWith(workingDir + path.sep) ? realNodeDir : spelledDir;
180
219
  // The entry must contain NO `export` syntax: n8n's task-runner sandbox
181
220
  // neuters getter property descriptors (Object.defineProperty with `get`
182
221
  // reads back undefined), and esbuild lowers module exports to exactly such
@@ -194,7 +233,7 @@ export async function compileTs(file, log) {
194
233
  stdin: {
195
234
  contents: entry,
196
235
  loader: "ts",
197
- resolveDir: path.dirname(file),
236
+ resolveDir,
198
237
  sourcefile: ENTRY_SOURCEFILE,
199
238
  },
200
239
  bundle: true,
package/dist/lib/init.mjs CHANGED
@@ -321,6 +321,17 @@ async function refreshTemplate(srcDir, destDir, { force, protect, version, extra
321
321
  log.info(`copied template -> ${destDir}`);
322
322
  else
323
323
  log.info(`template up to date (${uptodate} unchanged${added.length ? `, ${added.length} added` : ""})`);
324
+ // Permission rules do not hot-load, and `init` is normally run from INSIDE the
325
+ // session they are meant to constrain — so the deny rules it just wrote (no
326
+ // edits to `.decanter.json`, no reads of `.env`, no `push --force`) are inert
327
+ // until that session restarts. The docs said "restart" only for the skills
328
+ // plugin, so the rules that actually gate the agent went unmentioned: the tool
329
+ // knew something the user needed and never said it. Only when the file is new
330
+ // — re-running init in a set-up dir must not nag.
331
+ const settingsFile = path.join(".claude", "settings.json");
332
+ if (added.includes(settingsFile)) {
333
+ log.info(style.dim(" agent permission rules take effect on the NEXT session — restart your agent (or /reload) so they apply"));
334
+ }
324
335
  }
325
336
  /**
326
337
  * Bootstrap a sync dir (Plan 32: OAuth-first). Interactive by default: prompt
package/dist/lib/mcp.mjs CHANGED
@@ -10,7 +10,7 @@ import { HOST_UNSET } from "./config.mjs";
10
10
  import { CODE_NODE_TYPE } from "./util.mjs";
11
11
  export const MCP_PATH = "/mcp-server/http";
12
12
  export const AUTH_FILE = ".decanter-auth.json";
13
- const PROTOCOL_VERSION = "2025-03-26";
13
+ export const PROTOCOL_VERSION = "2025-03-26";
14
14
  /**
15
15
  * n8n's own guidance for a workflow that hasn't been opted into MCP —
16
16
  * mirrored (not invented) so CLI hints match what the server says.
@@ -17,8 +17,8 @@
17
17
  // SSE) are decoded back into per-line JSON-RPC messages. Parse failures fail
18
18
  // CLOSED, mirroring the HTTP guard.
19
19
  import { createInterface } from "node:readline";
20
- import { MCP_PATH } from "./mcp.mjs";
21
- import { guardMessage, guardPublish, logToolCall, mirrorTargetId } from "./mcpserve.mjs";
20
+ import { MCP_PATH, PROTOCOL_VERSION } from "./mcp.mjs";
21
+ import { attachMirrorNotices, guardMessage, guardPublish, logToolCall, mirrorTargetId } from "./mcpserve.mjs";
22
22
  /** JSON-RPC error codes used by the bridge (server-defined range). */
23
23
  const PARSE_ERROR = -32700;
24
24
  const UPSTREAM_ERROR = -32001;
@@ -26,6 +26,35 @@ const UPSTREAM_ERROR = -32001;
26
26
  function rpcError(id, code, text) {
27
27
  return { jsonrpc: "2.0", id: id ?? null, error: { code, message: text } };
28
28
  }
29
+ /** The single `initialize` request in a unit, or null (batches are not one). */
30
+ function initializeRequest(unit) {
31
+ if (unit === null || typeof unit !== "object" || Array.isArray(unit))
32
+ return null;
33
+ const msg = unit;
34
+ return msg.method === "initialize" && msg.id !== undefined ? msg : null;
35
+ }
36
+ /**
37
+ * The handshake the guard answers with when n8n cannot be reached.
38
+ *
39
+ * An error here is fatal to the whole session: an MCP client that gets no
40
+ * `serverInfo` tears the connection down and never makes the tool call whose
41
+ * failure it could have reported. So the guard — which is itself the server the
42
+ * agent spawned, and is running fine — completes the handshake with its own
43
+ * identity, and the unreachable instance surfaces on the first forwarded call.
44
+ * No capability beyond `tools` is claimed: everything else is n8n's to answer.
45
+ */
46
+ function localInitializeResult(request) {
47
+ const params = (request.params ?? {});
48
+ return {
49
+ jsonrpc: "2.0",
50
+ id: request.id ?? null,
51
+ result: {
52
+ protocolVersion: typeof params.protocolVersion === "string" ? params.protocolVersion : PROTOCOL_VERSION,
53
+ capabilities: { tools: {} },
54
+ serverInfo: { name: "n8n-decanter guard", version: "0" },
55
+ },
56
+ };
57
+ }
29
58
  /**
30
59
  * Run the stdio guard until stdin closes (the agent ending the session).
31
60
  * Messages are processed strictly in order — an MCP client awaits its
@@ -40,13 +69,23 @@ export async function runStdioGuard({ mcp, host, timeoutMs, mirror, log, input =
40
69
  // blocked nothing" and "never started" — and they are indistinguishable
41
70
  // exactly when it matters. (A Plan 35 harness bug left the guard dead for
42
71
  // three committed field-test rounds; the silence read as innocence.)
43
- log.info(`guard: connected to ${host} — forwarding all n8n MCP tools, blocking jsCode writes in update_workflow`);
72
+ // "ready", not "connected": nothing has been contacted yet, and the line
73
+ // claiming a live instance while the first handshake was still failing is
74
+ // exactly what made an unreachable host read as a decanter bug.
75
+ log.info(`guard: ready — forwarding all n8n MCP tools to ${host}, blocking jsCode writes in update_workflow`);
44
76
  /** One protocol message (or batch) out — a single output line. */
45
77
  const emit = (message) => {
46
- output.write(`${JSON.stringify(message)}\n`);
78
+ // Every outgoing message passes here, which makes this the one place a
79
+ // background live-mirror notice can reach the agent (Plan 68).
80
+ output.write(`${JSON.stringify(attachMirrorNotices(message, () => mirror?.takeNotices() ?? []))}\n`);
47
81
  };
48
- /** Forward one already-guarded JSON-RPC unit (message or batch) upstream. */
49
- const forward = async (unit, ids) => {
82
+ /**
83
+ * A handshake this guard answered itself because n8n was down. n8n never saw
84
+ * it, so the HTTP session is uninitialized — it has to be replayed before the
85
+ * next forward, or every later call rides a session n8n never opened.
86
+ */
87
+ let deferredHandshake = null;
88
+ const attempt = async (unit) => {
50
89
  let res;
51
90
  let refreshed = false;
52
91
  let rateRetries = 0;
@@ -88,9 +127,7 @@ export async function runStdioGuard({ mcp, host, timeoutMs, mirror, log, input =
88
127
  const name = err.name;
89
128
  const reason = name === "TimeoutError" || name === "AbortError" ? `no response within ${timeoutMs / 1000}s` : err.message;
90
129
  log.warn(`upstream request failed: ${reason}`);
91
- for (const id of ids)
92
- emit(rpcError(id, UPSTREAM_ERROR, `n8n unreachable through the decanter guard (${reason}) — is ${host} up?`));
93
- return;
130
+ return { ok: false, detail: `n8n unreachable through the decanter guard (${reason}) — is ${host} up?` };
94
131
  }
95
132
  const sid = res.headers.get("mcp-session-id");
96
133
  if (sid !== null)
@@ -109,16 +146,43 @@ export async function runStdioGuard({ mcp, host, timeoutMs, mirror, log, input =
109
146
  ? `n8n refused the request (403)${text !== "" ? `: ${text.slice(0, 200)}` : ""} — MCP access is switched off for this instance (n8n → Settings → MCP), or this token's user lacks access`
110
147
  : `n8n answered ${res.status} ${res.statusText}${text !== "" ? `: ${text.slice(0, 300)}` : ""}`;
111
148
  log.warn(detail);
149
+ return { ok: false, detail };
150
+ }
151
+ return { ok: true, text, sse: (res.headers.get("content-type") ?? "").includes("text/event-stream") };
152
+ };
153
+ /** Forward one already-guarded JSON-RPC unit (message or batch) upstream. */
154
+ const forward = async (unit, ids) => {
155
+ // Replay a handshake n8n never saw, so a recovered instance does not answer
156
+ // every later call with "session not initialized". Failure is not reported
157
+ // here — the real message follows and carries the error to the caller.
158
+ if (deferredHandshake !== null && sessionId === undefined) {
159
+ if ((await attempt(deferredHandshake)).ok) {
160
+ deferredHandshake = null;
161
+ await attempt({ jsonrpc: "2.0", method: "notifications/initialized" });
162
+ log.info(`guard: reached ${host} — the handshake answered locally is now live upstream`);
163
+ }
164
+ }
165
+ const result = await attempt(unit);
166
+ if (!result.ok) {
167
+ // A failed HANDSHAKE is answered by the guard itself: an error there ends
168
+ // the session before a single tool call is made, and the agent is left
169
+ // with a dead server instead of a working one pointed at a down instance.
170
+ const handshake = initializeRequest(unit);
171
+ if (handshake !== null) {
172
+ deferredHandshake = unit;
173
+ log.warn(`guard: n8n did not answer the handshake — completing it locally so the session survives; tool calls will report: ${result.detail}`);
174
+ return emit(localInitializeResult(handshake));
175
+ }
112
176
  for (const id of ids)
113
- emit(rpcError(id, UPSTREAM_ERROR, detail));
177
+ emit(rpcError(id, UPSTREAM_ERROR, result.detail));
114
178
  return;
115
179
  }
116
- if (text === "")
180
+ if (result.text === "")
117
181
  return; // 202 for notifications — nothing to relay
118
- if ((res.headers.get("content-type") ?? "").includes("text/event-stream")) {
182
+ if (result.sse) {
119
183
  // SSE: each data: line is one JSON-RPC message (response and/or
120
184
  // server notifications) — relay every one, a line each.
121
- for (const line of text.split("\n")) {
185
+ for (const line of result.text.split("\n")) {
122
186
  if (!line.startsWith("data:"))
123
187
  continue;
124
188
  try {
@@ -131,7 +195,7 @@ export async function runStdioGuard({ mcp, host, timeoutMs, mirror, log, input =
131
195
  return;
132
196
  }
133
197
  try {
134
- emit(JSON.parse(text)); // plain JSON body (message or batch) — one line
198
+ emit(JSON.parse(result.text)); // plain JSON body (message or batch) — one line
135
199
  }
136
200
  catch {
137
201
  const detail = `n8n answered 200 with non-JSON content — is ${host} really your n8n instance (captive portal, proxy)?`;
@@ -175,6 +175,37 @@ export function mirrorTargetId(msg) {
175
175
  const id = params.arguments?.workflowId;
176
176
  return typeof id === "string" && id !== "" ? id : null;
177
177
  }
178
+ /**
179
+ * Attach the live mirror's pending notices to an outgoing tool result (Plan 68).
180
+ *
181
+ * Shared by both transports for the same reason the two refusals are: a message
182
+ * the agent gets from `mcp connect` and not from `mcp serve` is a drift bug
183
+ * waiting to happen.
184
+ *
185
+ * Only a **successful tool result** carries them — an error result already has
186
+ * the agent's attention and its own instruction, and appending to it would
187
+ * compete with that. Anything else (handshakes, notifications, `tools/list`)
188
+ * passes through untouched, and if this message is not a shape we recognise the
189
+ * notices are handed back so the next one can take them: dropping the warning
190
+ * silently is the bug this whole plan is about.
191
+ */
192
+ export function attachMirrorNotices(msg, take) {
193
+ const record = msg;
194
+ const content = record?.result?.content;
195
+ if (!Array.isArray(content) || record.result?.isError === true)
196
+ return msg;
197
+ const first = content.find((c) => c?.type === "text");
198
+ if (first === undefined || typeof first.text !== "string")
199
+ return msg;
200
+ // Only NOW take them: `take` empties the queue, so asking before we know the
201
+ // message can carry them would drop the warning on every handshake that
202
+ // happens to precede a tool call.
203
+ const notices = take();
204
+ if (notices.length === 0)
205
+ return msg;
206
+ first.text = `${first.text}\n\n${notices.join("\n\n")}`;
207
+ return msg;
208
+ }
178
209
  /**
179
210
  * Start the guard proxy on 127.0.0.1. Auth is a per-session random secret
180
211
  * (the agent's MCP config carries it; the n8n credential never leaves this
@@ -302,7 +333,14 @@ export async function startGuardProxy({ mcp, host, configDir, port = DEFAULT_GUA
302
333
  mirror?.schedule(id);
303
334
  if (upstreamRes.body === null)
304
335
  return void res.end();
305
- // responses (incl. SSE) pipe through untouched
336
+ // Responses (incl. SSE) pipe through untouched — and that is why the live
337
+ // mirror's clobber notice (Plan 68) reaches agents on `mcp connect` but
338
+ // NOT here. `attachMirrorNotices` needs a parsed message; this path never
339
+ // parses one, and buffering to inject an advisory line would break SSE
340
+ // streaming for every response to deliver it on some. A deliberate,
341
+ // named asymmetry rather than a silent one: on the HTTP transport the
342
+ // stderr warning stays the only signal, so `liveMirror: false` (or
343
+ // pushing before restructuring) is the safer posture there.
306
344
  Readable.fromWeb(upstreamRes.body).pipe(res);
307
345
  })().catch((err) => {
308
346
  log.warn(`guard-proxy request failed: ${err.message}`);
@@ -320,7 +358,7 @@ export async function startGuardProxy({ mcp, host, configDir, port = DEFAULT_GUA
320
358
  const url = `http://127.0.0.1:${actualPort}${MCP_PATH}`;
321
359
  // Same "I am alive" line as the stdio guard — an empty log must never be
322
360
  // ambiguous between "ran, blocked nothing" and "never started".
323
- log.info(`guard: connected to ${host} — forwarding all n8n MCP tools, blocking jsCode writes in update_workflow`);
361
+ log.info(`guard: ready — forwarding all n8n MCP tools to ${host}, blocking jsCode writes in update_workflow`);
324
362
  const stateFile = path.join(configDir, PROXY_STATE_FILE);
325
363
  try {
326
364
  // discovery for tooling (the config-drift hook); gitignored, 0600