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 +210 -0
- package/README.md +4 -3
- package/dist/lib/backup.mjs +1 -1
- package/dist/lib/compile.mjs +56 -17
- package/dist/lib/init.mjs +11 -0
- package/dist/lib/mcp.mjs +1 -1
- package/dist/lib/mcpconnect.mjs +78 -14
- package/dist/lib/mcpserve.mjs +40 -2
- package/dist/lib/mirror.mjs +14 -1
- package/dist/lib/preflight.mjs +24 -2
- package/dist/lib/pull.mjs +12 -2
- package/dist/lib/push.mjs +29 -6
- package/dist/lib/run.mjs +56 -36
- package/dist/lib/simulate.mjs +176 -17
- package/dist/lib/status.mjs +15 -2
- package/dist/lib/testrun.mjs +68 -5
- package/dist/lib/util.mjs +28 -0
- package/dist/lib/validate.mjs +36 -10
- package/dist/scripts/typecheck.mjs +38 -4
- package/package.json +2 -2
- package/template/.claude/hooks/mcp-route-check.mjs.example +62 -11
- package/template/AGENTS.md.example +58 -13
- package/template/tsconfig.json.example +10 -8
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
|
|
38
|
-
|
|
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 | ✅
|
|
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 |
|
package/dist/lib/backup.mjs
CHANGED
|
@@ -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 {
|
package/dist/lib/compile.mjs
CHANGED
|
@@ -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)
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
|
147
|
+
const blocking = [];
|
|
148
|
+
const advisory = [];
|
|
134
149
|
for (const spec of specifiers) {
|
|
135
150
|
if (spec.startsWith("node:") || BUILTINS.has(packageName(spec))) {
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
176
|
-
if (
|
|
177
|
-
throw new Error(`${file}:\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
|
-
|
|
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
|
|
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.
|
package/dist/lib/mcpconnect.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
49
|
-
|
|
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
|
-
|
|
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 (
|
|
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)?`;
|
package/dist/lib/mcpserve.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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:
|
|
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
|