n8n-mcp-community 0.2.1 → 0.3.0
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 +40 -0
- package/README.md +2 -2
- package/ROADMAP.md +1 -1
- package/SECURITY.md +1 -1
- package/dist/introspect/contracts.js +8 -7
- package/dist/security/redaction.js +59 -21
- package/dist/tools/common.js +2 -2
- package/dist/tools/credentials.js +4 -7
- package/dist/tools/definition.js +38 -14
- package/dist/tools/output-contracts.js +4 -3
- package/dist/tools/response-contracts.js +11 -7
- package/dist/tools/schemas.js +40 -0
- package/dist/tools/utilities.js +35 -30
- package/dist/tools/workflows.js +16 -19
- package/dist/version.js +1 -1
- package/docs/compatibility.md +30 -7
- package/docs/installation.md +5 -4
- package/docs/security-model.md +4 -1
- package/docs/tools.md +26 -16
- package/package.json +2 -2
- package/server.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,45 @@ All notable changes to this project will be documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.3.0] - Frozen release candidate
|
|
10
|
+
|
|
11
|
+
Version 0.3.0 is a frozen release candidate. Availability is established only
|
|
12
|
+
by matching npm provenance, GitHub Release assets and signatures, and MCP
|
|
13
|
+
Registry readbacks; this immutable entry is not rewritten after publication.
|
|
14
|
+
|
|
15
|
+
### Removed
|
|
16
|
+
|
|
17
|
+
- **Breaking input-schema change:** `n8n_audit_generate` no longer publishes or
|
|
18
|
+
accepts `daysAbandonedWorkflow`. n8n Community 2.30.x stores that request
|
|
19
|
+
override in a legacy setting while the reporter reads typed instance
|
|
20
|
+
configuration, so the input could not reliably change the audit. Normal MCP
|
|
21
|
+
clients refresh the smaller schema after reconnecting; raw callers must stop
|
|
22
|
+
sending the removed field, omit empty category arrays, and deduplicate category
|
|
23
|
+
names. This incompatible `0.x` change is released as a new minor version.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- `n8n_audit_generate` now publishes truthful read-only and idempotent safety
|
|
28
|
+
hints while retaining the conservative unsafe authorization gate for its
|
|
29
|
+
sensitive instance-wide report. It makes one request, accepts 1–5 unique
|
|
30
|
+
categories, treats n8n's empty-array clean result as an empty report, and
|
|
31
|
+
accepts nullable version metadata from n8n's version feed. It fails
|
|
32
|
+
explicitly instead of returning a partial audit when sanitizer or final
|
|
33
|
+
256 KiB result limits are exceeded; multi-category callers can retry with
|
|
34
|
+
fewer categories, while single-category callers can review the full report in
|
|
35
|
+
n8n.
|
|
36
|
+
- Sanitized read results now distinguish content redaction from size reduction.
|
|
37
|
+
Audit reports use validated higher collection bounds within the same final
|
|
38
|
+
256 KiB envelope, while ordinary read tools keep safe bounded projections.
|
|
39
|
+
- The generic 256 KiB ceiling now covers the complete serialized MCP result,
|
|
40
|
+
including text and structured content. Large reads that previously fit only
|
|
41
|
+
the text-side check now fail safely; already-applied mutations return a
|
|
42
|
+
bounded success summary with `truncated=true` and `redacted=true`.
|
|
43
|
+
- Record parsing now preserves own `__proto__` keys through validation and
|
|
44
|
+
rewrites prototype-like output keys safely. Workflow updates retain those
|
|
45
|
+
graph entries instead of silently dropping them.
|
|
46
|
+
- Development dependency `globals` was updated from 17.8.0 to 17.9.0.
|
|
47
|
+
|
|
9
48
|
## [0.2.1] - Frozen release candidate
|
|
10
49
|
|
|
11
50
|
Version 0.2.1 is a frozen release candidate. Availability is established only
|
|
@@ -304,6 +343,7 @@ Registry readbacks; this immutable entry is not rewritten after publication.
|
|
|
304
343
|
publishable history; authenticated public release receipts remain supported.
|
|
305
344
|
|
|
306
345
|
[Unreleased]: #unreleased
|
|
346
|
+
[0.3.0]: #030---frozen-release-candidate
|
|
307
347
|
[0.2.1]: #021---frozen-release-candidate
|
|
308
348
|
[0.2.0]: #020---frozen-release-candidate
|
|
309
349
|
[0.1.4]: #014---frozen-release-candidate
|
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ how to check the download.
|
|
|
104
104
|
The current source candidate has been verified with:
|
|
105
105
|
|
|
106
106
|
- exactly **44 tools**, **5 resources**, and **4 prompts** over real stdio;
|
|
107
|
-
- **
|
|
107
|
+
- **313 passing tests** locally on Node.js 24.19.0; CI and release gates require
|
|
108
108
|
the pinned Node.js 22.23.1 and 24.18.0 matrix;
|
|
109
109
|
- zero findings from Gitleaks, Semgrep, Trivy, and both project-root
|
|
110
110
|
production/full `npm audit` runs; all three source scanners are reproduced in
|
|
@@ -291,7 +291,7 @@ API. The project does not redistribute the `n8n-nodes-base` catalog and does not
|
|
|
291
291
|
use browser cookies, interactive session routes, or runtime package downloads to
|
|
292
292
|
construct one.
|
|
293
293
|
|
|
294
|
-
The v0.
|
|
294
|
+
The v0.3.0 candidate surface intentionally excludes arbitrary workflow execution,
|
|
295
295
|
credential/workflow transfer, folders, data tables, beta evaluation endpoints,
|
|
296
296
|
and execution annotations. See the [roadmap](ROADMAP.md) for the annotation
|
|
297
297
|
proposal retained outside the release target.
|
package/ROADMAP.md
CHANGED
|
@@ -18,7 +18,7 @@ maintainer approval.
|
|
|
18
18
|
|
|
19
19
|
## Accepted backlog proposal
|
|
20
20
|
|
|
21
|
-
Execution annotations remain a coherent two-tool proposal outside v0.
|
|
21
|
+
Execution annotations remain a coherent two-tool proposal outside v0.3.0:
|
|
22
22
|
|
|
23
23
|
- `n8n_executions_get_tags`
|
|
24
24
|
- `n8n_executions_update_tags`
|
package/SECURITY.md
CHANGED
|
@@ -6,7 +6,7 @@ before public disclosure.
|
|
|
6
6
|
|
|
7
7
|
## Supported versions
|
|
8
8
|
|
|
9
|
-
The latest maintained minor line — currently 0.
|
|
9
|
+
The latest maintained minor line — currently 0.3.x — receives security fixes.
|
|
10
10
|
Older lines may be asked to upgrade when a safe backport is impractical.
|
|
11
11
|
|
|
12
12
|
## Report a vulnerability
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { preservingRecord } from "../tools/schemas.js";
|
|
2
3
|
import { INTROSPECT_RULE_IDS } from "./rule-ids.js";
|
|
3
4
|
export const INTROSPECT_SCHEMA_VERSION = "1.0.0";
|
|
4
5
|
export const INTROSPECT_ENGINE_VERSION = "2.0.0";
|
|
@@ -98,8 +99,8 @@ export const WorkflowNodeSchema = z
|
|
|
98
99
|
onError: z.string().max(128).optional(),
|
|
99
100
|
executeOnce: z.boolean().optional(),
|
|
100
101
|
alwaysOutputData: z.boolean().optional(),
|
|
101
|
-
parameters:
|
|
102
|
-
credentials:
|
|
102
|
+
parameters: preservingRecord(z.unknown()).optional(),
|
|
103
|
+
credentials: preservingRecord(z.unknown()).optional(),
|
|
103
104
|
})
|
|
104
105
|
.passthrough();
|
|
105
106
|
export const WorkflowResponseSchema = z
|
|
@@ -109,9 +110,9 @@ export const WorkflowResponseSchema = z
|
|
|
109
110
|
active: z.boolean(),
|
|
110
111
|
triggerCount: z.number().int().nonnegative().optional(),
|
|
111
112
|
nodes: z.array(WorkflowNodeSchema).max(1_000).default([]),
|
|
112
|
-
connections:
|
|
113
|
-
settings:
|
|
114
|
-
pinData:
|
|
113
|
+
connections: preservingRecord(z.unknown()).default({}),
|
|
114
|
+
settings: preservingRecord(z.unknown()).default({}),
|
|
115
|
+
pinData: preservingRecord(z.unknown()).nullable().optional(),
|
|
115
116
|
})
|
|
116
117
|
.passthrough();
|
|
117
118
|
const ApiIdentifierSchema = z.union([z.string(), z.number()]).transform(String);
|
|
@@ -138,7 +139,7 @@ export const ExecutionListResponseSchema = z
|
|
|
138
139
|
})
|
|
139
140
|
.passthrough();
|
|
140
141
|
export const ExecutionDetailResponseSchema = ExecutionMetadataSchema.extend({
|
|
141
|
-
data:
|
|
142
|
+
data: preservingRecord(z.unknown()).optional(),
|
|
142
143
|
}).passthrough();
|
|
143
144
|
export const SeveritySchema = z.enum(["critical", "high", "medium", "low", "info"]);
|
|
144
145
|
export const ConfidenceSchema = z.enum(["high", "medium", "low"]);
|
|
@@ -171,7 +172,7 @@ export const FindingSchema = z.object({
|
|
|
171
172
|
}),
|
|
172
173
|
evidence: z.object({
|
|
173
174
|
summary: z.string(),
|
|
174
|
-
facts:
|
|
175
|
+
facts: preservingRecord(FactValueSchema).optional(),
|
|
175
176
|
}),
|
|
176
177
|
remediation: z.string(),
|
|
177
178
|
documentationUrl: z
|
|
@@ -54,6 +54,32 @@ export class OutputLimitError extends Error {
|
|
|
54
54
|
this.name = "OutputLimitError";
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
const DEFAULT_SANITIZATION_LIMITS = Object.freeze({
|
|
58
|
+
arrayLength: 1_000,
|
|
59
|
+
depth: 20,
|
|
60
|
+
nodes: 20_000,
|
|
61
|
+
objectEntries: 1_000,
|
|
62
|
+
stringInputLength: 131_072,
|
|
63
|
+
stringOutputLength: 32_768,
|
|
64
|
+
});
|
|
65
|
+
function resolvedLimit(value, fallback, name) {
|
|
66
|
+
if (value === undefined)
|
|
67
|
+
return fallback;
|
|
68
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
69
|
+
throw new RangeError(`${name} must be a positive safe integer.`);
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function sanitizationLimits(options) {
|
|
74
|
+
return {
|
|
75
|
+
arrayLength: resolvedLimit(options.maxArrayLength, DEFAULT_SANITIZATION_LIMITS.arrayLength, "maxArrayLength"),
|
|
76
|
+
depth: resolvedLimit(options.maxDepth, DEFAULT_SANITIZATION_LIMITS.depth, "maxDepth"),
|
|
77
|
+
nodes: resolvedLimit(options.maxNodes, DEFAULT_SANITIZATION_LIMITS.nodes, "maxNodes"),
|
|
78
|
+
objectEntries: resolvedLimit(options.maxObjectEntries, DEFAULT_SANITIZATION_LIMITS.objectEntries, "maxObjectEntries"),
|
|
79
|
+
stringInputLength: resolvedLimit(options.maxStringInputLength, DEFAULT_SANITIZATION_LIMITS.stringInputLength, "maxStringInputLength"),
|
|
80
|
+
stringOutputLength: resolvedLimit(options.maxStringOutputLength, DEFAULT_SANITIZATION_LIMITS.stringOutputLength, "maxStringOutputLength"),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
57
83
|
function compareStrings(left, right) {
|
|
58
84
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
59
85
|
}
|
|
@@ -103,13 +129,16 @@ function looksLikeOpaquePathSegment(value) {
|
|
|
103
129
|
}
|
|
104
130
|
return entropy >= (value.length >= 32 ? 3.5 : 4);
|
|
105
131
|
}
|
|
106
|
-
function sanitizeString(value, structuralKey) {
|
|
107
|
-
const boundedInput = value.length >
|
|
132
|
+
function sanitizeString(value, structuralKey, limits = DEFAULT_SANITIZATION_LIMITS) {
|
|
133
|
+
const boundedInput = value.length > limits.stringInputLength ? value.slice(0, limits.stringInputLength) : value;
|
|
108
134
|
const normalizedInput = boundedInput.normalize("NFKC");
|
|
109
135
|
let output = normalizedInput
|
|
110
|
-
.slice(0,
|
|
136
|
+
.slice(0, limits.stringInputLength)
|
|
111
137
|
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "");
|
|
112
|
-
let redacted = boundedInput !== value ||
|
|
138
|
+
let redacted = boundedInput !== value ||
|
|
139
|
+
normalizedInput.length > limits.stringInputLength ||
|
|
140
|
+
output !== boundedInput;
|
|
141
|
+
let sizeReduced = boundedInput !== value || normalizedInput.length > limits.stringInputLength;
|
|
113
142
|
for (const [pattern, replacement] of HIGH_CONFIDENCE_CREDENTIAL_REDACTIONS) {
|
|
114
143
|
pattern.lastIndex = 0;
|
|
115
144
|
const filtered = output.replace(pattern, replacement);
|
|
@@ -132,7 +161,7 @@ function sanitizeString(value, structuralKey) {
|
|
|
132
161
|
((structuralKey === "documentationUrl" || structuralKey === "officialUrl") &&
|
|
133
162
|
isOfficialN8nDocumentationUrl(output));
|
|
134
163
|
if (validatedInternalValueIsSafe)
|
|
135
|
-
return { value: output, redacted };
|
|
164
|
+
return { value: output, redacted, sizeReduced };
|
|
136
165
|
for (const [pattern, replacement] of MANDATORY_VALUE_REDACTIONS) {
|
|
137
166
|
pattern.lastIndex = 0;
|
|
138
167
|
const filtered = output.replace(pattern, replacement);
|
|
@@ -151,7 +180,7 @@ function sanitizeString(value, structuralKey) {
|
|
|
151
180
|
STRUCTURAL_IDENTIFIER_KEY.test(structuralKey) &&
|
|
152
181
|
SAFE_IDENTIFIER_VALUE.test(output);
|
|
153
182
|
if (structuralValueIsSafe)
|
|
154
|
-
return { value: output, redacted };
|
|
183
|
+
return { value: output, redacted, sizeReduced };
|
|
155
184
|
for (const [pattern, replacement] of TOKEN_REDACTIONS) {
|
|
156
185
|
pattern.lastIndex = 0;
|
|
157
186
|
const filtered = output.replace(pattern, replacement);
|
|
@@ -170,31 +199,35 @@ function sanitizeString(value, structuralKey) {
|
|
|
170
199
|
const withPhone = redactPhone(output);
|
|
171
200
|
redacted ||= withPhone !== output;
|
|
172
201
|
output = withPhone;
|
|
173
|
-
if (output.length >
|
|
174
|
-
output = `${output.slice(0,
|
|
202
|
+
if (output.length > limits.stringOutputLength) {
|
|
203
|
+
output = `${output.slice(0, Math.max(0, limits.stringOutputLength - 1))}\u2026`;
|
|
175
204
|
redacted = true;
|
|
205
|
+
sizeReduced = true;
|
|
176
206
|
}
|
|
177
|
-
return { value: output, redacted };
|
|
207
|
+
return { value: output, redacted, sizeReduced };
|
|
178
208
|
}
|
|
179
209
|
function sanitizeValue(value, state, depth, structuralKey, preserveChildKeys = false, sensitiveDescriptor = false, treatSensitiveKeyAsStructural = false) {
|
|
180
210
|
state.nodes += 1;
|
|
181
|
-
if (state.nodes >
|
|
211
|
+
if (state.nodes > state.limits.nodes || depth > state.limits.depth) {
|
|
182
212
|
state.redacted = true;
|
|
183
213
|
state.structurallyReduced = true;
|
|
214
|
+
state.sizeReduced = true;
|
|
184
215
|
return "[TRUNCATED]";
|
|
185
216
|
}
|
|
186
217
|
if (typeof value === "string") {
|
|
187
|
-
const result = sanitizeString(value, structuralKey);
|
|
218
|
+
const result = sanitizeString(value, structuralKey, state.limits);
|
|
188
219
|
state.redacted ||= result.redacted;
|
|
220
|
+
state.sizeReduced ||= result.sizeReduced;
|
|
189
221
|
return result.value;
|
|
190
222
|
}
|
|
191
223
|
if (value === null || typeof value === "boolean" || typeof value === "number")
|
|
192
224
|
return value;
|
|
193
225
|
if (Array.isArray(value)) {
|
|
194
|
-
const limit = Math.min(value.length,
|
|
226
|
+
const limit = Math.min(value.length, state.limits.arrayLength);
|
|
195
227
|
if (limit < value.length) {
|
|
196
228
|
state.redacted = true;
|
|
197
229
|
state.structurallyReduced = true;
|
|
230
|
+
state.sizeReduced = true;
|
|
198
231
|
}
|
|
199
232
|
return value
|
|
200
233
|
.slice(0, limit)
|
|
@@ -225,14 +258,11 @@ function sanitizeValue(value, state, depth, structuralKey, preserveChildKeys = f
|
|
|
225
258
|
typeof candidate === "string" &&
|
|
226
259
|
SENSITIVE_KEY.test(candidate));
|
|
227
260
|
const originalKeys = new Set(entries.map(([key]) => key));
|
|
228
|
-
for (const [entryIndex, [key, child]] of entries.slice(0,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
const sanitizedKey = sanitizeString(key);
|
|
261
|
+
for (const [entryIndex, [key, child]] of entries.slice(0, state.limits.objectEntries).entries()) {
|
|
262
|
+
const sanitizedKey = sanitizeString(key, undefined, state.limits);
|
|
263
|
+
state.sizeReduced ||= sanitizedKey.sizeReduced;
|
|
234
264
|
let outputKey = key;
|
|
235
|
-
if (sanitizedKey.redacted) {
|
|
265
|
+
if (PROTOTYPE_KEY.test(key) || sanitizedKey.redacted) {
|
|
236
266
|
let placeholderIndex = entryIndex + 1;
|
|
237
267
|
do {
|
|
238
268
|
outputKey = `[REDACTED_KEY_${placeholderIndex}]`;
|
|
@@ -277,18 +307,26 @@ function sanitizeValue(value, state, depth, structuralKey, preserveChildKeys = f
|
|
|
277
307
|
}
|
|
278
308
|
output[outputKey] = sanitizeValue(child, state, depth + 1, key, STRUCTURAL_MAP_KEY.test(key), false);
|
|
279
309
|
}
|
|
280
|
-
if (entries.length >
|
|
310
|
+
if (entries.length > state.limits.objectEntries) {
|
|
281
311
|
state.redacted = true;
|
|
282
312
|
state.structurallyReduced = true;
|
|
313
|
+
state.sizeReduced = true;
|
|
283
314
|
}
|
|
284
315
|
return output;
|
|
285
316
|
}
|
|
286
317
|
export function sanitizeForOutputDetailed(value, options = {}) {
|
|
287
|
-
const state = {
|
|
318
|
+
const state = {
|
|
319
|
+
nodes: 0,
|
|
320
|
+
redacted: false,
|
|
321
|
+
structurallyReduced: false,
|
|
322
|
+
sizeReduced: false,
|
|
323
|
+
limits: sanitizationLimits(options),
|
|
324
|
+
};
|
|
288
325
|
const data = sanitizeValue(value, state, 0, undefined, false, false, options.preserveValidatedRootRecordValues ?? false);
|
|
289
326
|
return {
|
|
290
327
|
output: { data, redacted: state.redacted, untrusted: true },
|
|
291
328
|
structurallyReduced: state.structurallyReduced,
|
|
329
|
+
sizeReduced: state.sizeReduced,
|
|
292
330
|
};
|
|
293
331
|
}
|
|
294
332
|
export function sanitizeForOutput(value, options = {}) {
|
package/dist/tools/common.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { cursor } from "./schemas.js";
|
|
2
|
+
import { cursor, preservingRecord } from "./schemas.js";
|
|
3
3
|
export const apiIdentifier = z.union([z.string(), z.number()]).transform(String);
|
|
4
4
|
export const entitySchema = z.object({ id: apiIdentifier }).passthrough();
|
|
5
5
|
export const listEnvelopeSchema = z
|
|
6
6
|
.object({
|
|
7
|
-
data: z.array(
|
|
7
|
+
data: z.array(preservingRecord(z.unknown())).max(100),
|
|
8
8
|
nextCursor: cursor.nullable().optional(),
|
|
9
9
|
})
|
|
10
10
|
.passthrough();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { defineTool } from "./definition.js";
|
|
3
3
|
import { booleanQuery, numberQuery } from "./common.js";
|
|
4
|
-
import { assertSafeJson, cursor, identifier, pageLimit, pathSegment, safeJsonValue, } from "./schemas.js";
|
|
4
|
+
import { assertSafeJson, cursor, identifier, pageLimit, pathSegment, preservingRecord, safeJsonValue, } from "./schemas.js";
|
|
5
5
|
import { credentialTypeSchema } from "./response-contracts.js";
|
|
6
6
|
const credentialMetadataSchema = z.object({
|
|
7
7
|
id: identifier(),
|
|
@@ -47,7 +47,7 @@ const usageWorkflowSchema = z.object({
|
|
|
47
47
|
id: identifier().optional(),
|
|
48
48
|
name: z.string().min(1).max(256),
|
|
49
49
|
type: z.string().min(1).max(256),
|
|
50
|
-
credentials:
|
|
50
|
+
credentials: preservingRecord(workflowCredentialReferenceSchema).optional(),
|
|
51
51
|
}))
|
|
52
52
|
.max(1_000),
|
|
53
53
|
});
|
|
@@ -73,9 +73,7 @@ export const credentialTools = Object.freeze([
|
|
|
73
73
|
.string()
|
|
74
74
|
.regex(/^[A-Za-z0-9_.-]{1,128}$/)
|
|
75
75
|
.describe("Public n8n credential type returned by n8n_credentials_schema."),
|
|
76
|
-
data:
|
|
77
|
-
.record(safeJsonValue)
|
|
78
|
-
.describe("Credential field values matching the selected public schema; never returned."),
|
|
76
|
+
data: preservingRecord(safeJsonValue).describe("Credential field values matching the selected public schema; never returned."),
|
|
79
77
|
isResolvable: z
|
|
80
78
|
.boolean()
|
|
81
79
|
.optional()
|
|
@@ -157,8 +155,7 @@ export const credentialTools = Object.freeze([
|
|
|
157
155
|
.regex(/^[A-Za-z0-9_.-]{1,128}$/)
|
|
158
156
|
.optional()
|
|
159
157
|
.describe("Replacement public credential type; requires replacement data when supplied."),
|
|
160
|
-
data:
|
|
161
|
-
.record(safeJsonValue)
|
|
158
|
+
data: preservingRecord(safeJsonValue)
|
|
162
159
|
.optional()
|
|
163
160
|
.describe("Replacement or partial credential field values; never returned."),
|
|
164
161
|
isGlobal: z.boolean().optional().describe("Optional replacement for the n8n global flag."),
|
package/dist/tools/definition.js
CHANGED
|
@@ -6,12 +6,15 @@ import { authorizeOperation } from "../security/operation-policy.js";
|
|
|
6
6
|
import { boundedJson, OutputLimitError, sanitizeForOutput, sanitizeForOutputDetailed, } from "../security/redaction.js";
|
|
7
7
|
import { TOOL_ENDPOINT_CONTRACTS } from "./endpoint-contracts.js";
|
|
8
8
|
import { genericToolOutputContract, MUTATION_IDENTITY_KEYS } from "./output-contracts.js";
|
|
9
|
+
const MAX_SERIALIZED_TOOL_RESULT_BYTES = 256 * 1024;
|
|
10
|
+
const DEFAULT_READ_ONLY_OUTPUT_LIMIT_MESSAGE = "The sanitized result exceeded safe output limits. Use narrower inputs when available; otherwise inspect the full object in n8n.";
|
|
9
11
|
function annotationsFor(spec) {
|
|
12
|
+
const readOnly = spec.readOnly ?? spec.operation === "read-only";
|
|
10
13
|
return {
|
|
11
14
|
title: spec.title,
|
|
12
|
-
readOnlyHint:
|
|
15
|
+
readOnlyHint: readOnly,
|
|
13
16
|
destructiveHint: spec.destructive ?? spec.operation !== "read-only",
|
|
14
|
-
idempotentHint: spec.idempotent ??
|
|
17
|
+
idempotentHint: spec.idempotent ?? readOnly,
|
|
15
18
|
openWorldHint: spec.openWorld ?? true,
|
|
16
19
|
};
|
|
17
20
|
}
|
|
@@ -40,8 +43,8 @@ function errorResult(error, correlationId, toolName) {
|
|
|
40
43
|
}
|
|
41
44
|
class ToolOutputError extends Error {
|
|
42
45
|
code = "invalid_output";
|
|
43
|
-
constructor() {
|
|
44
|
-
super(
|
|
46
|
+
constructor(message = "The tool produced a result that did not match its published output contract.") {
|
|
47
|
+
super(message);
|
|
45
48
|
this.name = "ToolOutputError";
|
|
46
49
|
}
|
|
47
50
|
}
|
|
@@ -52,13 +55,21 @@ function validatedOutput(value, outputSchema) {
|
|
|
52
55
|
return parsed.data;
|
|
53
56
|
}
|
|
54
57
|
function resultFromValidatedOutput(safe) {
|
|
55
|
-
|
|
58
|
+
const result = {
|
|
56
59
|
content: [{ type: "text", text: boundedJson(safe) }],
|
|
57
60
|
structuredContent: { ...safe },
|
|
58
61
|
};
|
|
62
|
+
// The MCP result carries both the compatibility text and structured content. Bound their
|
|
63
|
+
// combined serialized form so consumers never receive a nominally successful result above
|
|
64
|
+
// the documented 256 KiB envelope.
|
|
65
|
+
if (Buffer.byteLength(JSON.stringify(result)) > MAX_SERIALIZED_TOOL_RESULT_BYTES) {
|
|
66
|
+
throw new OutputLimitError();
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
59
69
|
}
|
|
60
70
|
function fallbackResult(value, outputSchema) {
|
|
61
|
-
|
|
71
|
+
const sanitized = sanitizeForOutput(value);
|
|
72
|
+
return resultFromValidatedOutput(validatedOutput({ ...sanitized, redacted: true }, outputSchema));
|
|
62
73
|
}
|
|
63
74
|
// When a mutation's full result exceeds the output cap, the write already landed upstream, so
|
|
64
75
|
// summarize it to a bounded, stable shape (truncated + the scalar identity fields) instead of
|
|
@@ -84,11 +95,17 @@ function truncatedMutationResult(value) {
|
|
|
84
95
|
identity,
|
|
85
96
|
};
|
|
86
97
|
}
|
|
87
|
-
function buildSuccessResult(value,
|
|
98
|
+
function buildSuccessResult(value, readOnly, primaryDataSchema, outputSchema, preserveValidatedRootRecordValues = false, sanitizationOptions = {}, failOnSanitizerLimit = false, readOnlyOutputLimitMessage) {
|
|
88
99
|
// A handler may return only its official primary shape. The public mutation fallback is a
|
|
89
100
|
// server-generated recovery form and can never be supplied directly by an upstream handler.
|
|
90
101
|
validatedOutput(value, primaryDataSchema);
|
|
91
|
-
const sanitized = sanitizeForOutputDetailed(value, {
|
|
102
|
+
const sanitized = sanitizeForOutputDetailed(value, {
|
|
103
|
+
...sanitizationOptions,
|
|
104
|
+
preserveValidatedRootRecordValues,
|
|
105
|
+
});
|
|
106
|
+
if (failOnSanitizerLimit && sanitized.sizeReduced) {
|
|
107
|
+
throw new ToolOutputError(readOnlyOutputLimitMessage ?? DEFAULT_READ_ONLY_OUTPUT_LIMIT_MESSAGE);
|
|
108
|
+
}
|
|
92
109
|
let safe;
|
|
93
110
|
try {
|
|
94
111
|
safe = validatedOutput(sanitized.output, outputSchema);
|
|
@@ -96,9 +113,7 @@ function buildSuccessResult(value, operation, primaryDataSchema, outputSchema, p
|
|
|
96
113
|
catch (error) {
|
|
97
114
|
// A completed mutation falls back only when the sanitizer explicitly reduced structure
|
|
98
115
|
// because of its depth/node/breadth bounds. Ordinary post-sanitization schema drift must fail.
|
|
99
|
-
if (
|
|
100
|
-
!(error instanceof ToolOutputError) ||
|
|
101
|
-
!sanitized.structurallyReduced) {
|
|
116
|
+
if (readOnly || !(error instanceof ToolOutputError) || !sanitized.structurallyReduced) {
|
|
102
117
|
throw error;
|
|
103
118
|
}
|
|
104
119
|
return fallbackResult(truncatedMutationResult(value), outputSchema);
|
|
@@ -107,7 +122,10 @@ function buildSuccessResult(value, operation, primaryDataSchema, outputSchema, p
|
|
|
107
122
|
return resultFromValidatedOutput(safe);
|
|
108
123
|
}
|
|
109
124
|
catch (error) {
|
|
110
|
-
if (
|
|
125
|
+
if (readOnly && error instanceof OutputLimitError) {
|
|
126
|
+
throw new ToolOutputError(readOnlyOutputLimitMessage ?? DEFAULT_READ_ONLY_OUTPUT_LIMIT_MESSAGE);
|
|
127
|
+
}
|
|
128
|
+
if (readOnly || !(error instanceof OutputLimitError))
|
|
111
129
|
throw error;
|
|
112
130
|
// A mutating tool has already applied its write, so an over-cap result is a
|
|
113
131
|
// truthful, bounded success summary rather than a misleading failure.
|
|
@@ -124,6 +142,12 @@ export function createToolContext(startup) {
|
|
|
124
142
|
}
|
|
125
143
|
export function defineTool(spec) {
|
|
126
144
|
const annotations = annotationsFor(spec);
|
|
145
|
+
if (annotations.readOnlyHint === true && annotations.destructiveHint === true) {
|
|
146
|
+
throw new Error(`Tool ${spec.name} cannot be both read-only and destructive.`);
|
|
147
|
+
}
|
|
148
|
+
if (spec.failOnSanitizerLimit === true && annotations.readOnlyHint !== true) {
|
|
149
|
+
throw new Error(`Tool ${spec.name} can fail on sanitizer limits only when it is read-only.`);
|
|
150
|
+
}
|
|
127
151
|
const endpointContract = TOOL_ENDPOINT_CONTRACTS[spec.name];
|
|
128
152
|
if (endpointContract === undefined) {
|
|
129
153
|
throw new Error(`Tool ${spec.name} is missing its endpoint documentation contract.`);
|
|
@@ -131,7 +155,7 @@ export function defineTool(spec) {
|
|
|
131
155
|
const inputShape = { ...spec.input };
|
|
132
156
|
const inputSchema = z.object(inputShape).strict();
|
|
133
157
|
const genericOutput = spec.outputSchema === undefined
|
|
134
|
-
? genericToolOutputContract(spec.name,
|
|
158
|
+
? genericToolOutputContract(spec.name, annotations.readOnlyHint === true, spec.outputDataDescription)
|
|
135
159
|
: undefined;
|
|
136
160
|
const outputSchema = spec.outputSchema ?? genericOutput?.outputSchema;
|
|
137
161
|
if (outputSchema === undefined) {
|
|
@@ -166,7 +190,7 @@ export function defineTool(spec) {
|
|
|
166
190
|
if (genericOutput === undefined) {
|
|
167
191
|
throw new Error(`Tool ${spec.name} is missing its primary output contract.`);
|
|
168
192
|
}
|
|
169
|
-
result = buildSuccessResult(value,
|
|
193
|
+
result = buildSuccessResult(value, annotations.readOnlyHint === true, genericOutput.primaryDataSchema, outputSchema, spec.preserveValidatedRootRecordValues, spec.sanitizationOptions, spec.failOnSanitizerLimit, spec.readOnlyOutputLimitMessage);
|
|
170
194
|
}
|
|
171
195
|
if (spec.operation !== "read-only") {
|
|
172
196
|
console.error(JSON.stringify({
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { credentialTypeSchema, securityAuditSchema, workflowLifecycleMetadataSchema, } from "./response-contracts.js";
|
|
3
|
+
import { preservingRecord } from "./schemas.js";
|
|
3
4
|
const text = () => z.string();
|
|
4
5
|
const nullableText = () => z.string().nullable();
|
|
5
6
|
const count = () => z.number().int().nonnegative();
|
|
6
7
|
const cursor = () => z.string().nullable();
|
|
7
|
-
const unknownObject = () =>
|
|
8
|
+
const unknownObject = () => preservingRecord(z.unknown());
|
|
8
9
|
function workflowNode() {
|
|
9
10
|
return z
|
|
10
11
|
.object({
|
|
@@ -466,12 +467,12 @@ const outputDataSchemas = Object.freeze({
|
|
|
466
467
|
.strict()),
|
|
467
468
|
});
|
|
468
469
|
export const TOOL_OUTPUT_CONTRACT_NAMES = Object.freeze(Object.keys(outputDataSchemas).sort());
|
|
469
|
-
export function genericToolOutputContract(toolName,
|
|
470
|
+
export function genericToolOutputContract(toolName, readOnly, dataDescription) {
|
|
470
471
|
const expectedData = outputDataSchemas[toolName];
|
|
471
472
|
if (expectedData === undefined) {
|
|
472
473
|
throw new Error(`Tool ${toolName} is missing its output data contract.`);
|
|
473
474
|
}
|
|
474
|
-
const dataSchema =
|
|
475
|
+
const dataSchema = readOnly ? expectedData : z.union([expectedData, truncatedMutation()]);
|
|
475
476
|
return {
|
|
476
477
|
primaryDataSchema: expectedData,
|
|
477
478
|
outputSchema: z
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { preservingRecord } from "./schemas.js";
|
|
2
3
|
function boundedRecord(valueSchema, maximumKeys, maximumKeyLength) {
|
|
3
|
-
return
|
|
4
|
+
return preservingRecord(valueSchema).superRefine((value, context) => {
|
|
4
5
|
const keys = Object.keys(value);
|
|
5
6
|
if (keys.length > maximumKeys) {
|
|
6
7
|
context.addIssue({
|
|
@@ -42,7 +43,7 @@ function credentialDependency() {
|
|
|
42
43
|
.object({
|
|
43
44
|
if: z
|
|
44
45
|
.object({
|
|
45
|
-
properties: boundedRecord(
|
|
46
|
+
properties: boundedRecord(preservingRecord(z.unknown()), 1_000, 256),
|
|
46
47
|
required: textArray().optional(),
|
|
47
48
|
})
|
|
48
49
|
.strict()
|
|
@@ -136,7 +137,7 @@ function auditedWorkflowNode() {
|
|
|
136
137
|
type: z.string(),
|
|
137
138
|
typeVersion: z.number().finite(),
|
|
138
139
|
position: z.array(z.number().finite()).length(2),
|
|
139
|
-
parameters:
|
|
140
|
+
parameters: preservingRecord(z.unknown()),
|
|
140
141
|
iconData: z
|
|
141
142
|
.object({
|
|
142
143
|
type: z.string(),
|
|
@@ -155,10 +156,10 @@ function nextVersion() {
|
|
|
155
156
|
createdAt: z.string(),
|
|
156
157
|
description: z.string(),
|
|
157
158
|
documentationUrl: z.string(),
|
|
158
|
-
hasBreakingChange: z.boolean(),
|
|
159
|
-
hasSecurityFix: z.boolean(),
|
|
160
|
-
hasSecurityIssue: z.boolean(),
|
|
161
|
-
securityIssueFixVersion: z.string(),
|
|
159
|
+
hasBreakingChange: z.boolean().nullable().optional(),
|
|
160
|
+
hasSecurityFix: z.boolean().nullable().optional(),
|
|
161
|
+
hasSecurityIssue: z.boolean().nullable().optional(),
|
|
162
|
+
securityIssueFixVersion: z.string().nullable().optional(),
|
|
162
163
|
})
|
|
163
164
|
.strict();
|
|
164
165
|
}
|
|
@@ -193,6 +194,9 @@ function instanceAuditReport() {
|
|
|
193
194
|
/**
|
|
194
195
|
* n8n Community 2.30.7 Public API security-audit response.
|
|
195
196
|
*
|
|
197
|
+
* The endpoint's official empty-array state is normalized to an empty map by the tool handler
|
|
198
|
+
* before this report-map schema runs.
|
|
199
|
+
*
|
|
196
200
|
* Report titles are upstream-defined map keys, while every report, section, and location is
|
|
197
201
|
* discriminated by the official n8n audit types. Breadth limits keep validation deterministic.
|
|
198
202
|
*/
|
package/dist/tools/schemas.js
CHANGED
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
|
3
3
|
const IDENTIFIER = /^[A-Za-z0-9_-]{1,128}$/;
|
|
4
4
|
const PROTOTYPE_SEGMENT = /^(?:__proto__|prototype|constructor)$/;
|
|
5
|
+
const OWN_RECORD_ESCAPE_PREFIX = "\u0000n8n-mcp-own-key:";
|
|
5
6
|
const MAX_SAFE_JSON_NODES = 20_000;
|
|
6
7
|
export const MUTABLE_NODE_ROOTS = [
|
|
7
8
|
"parameters",
|
|
@@ -48,6 +49,45 @@ export const tagName = z
|
|
|
48
49
|
export function isRecord(value) {
|
|
49
50
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
50
51
|
}
|
|
52
|
+
function escapedOwnRecordKey(key) {
|
|
53
|
+
if (key === "__proto__")
|
|
54
|
+
return `${OWN_RECORD_ESCAPE_PREFIX}p`;
|
|
55
|
+
return key.startsWith(OWN_RECORD_ESCAPE_PREFIX) ? `${OWN_RECORD_ESCAPE_PREFIX}k${key}` : key;
|
|
56
|
+
}
|
|
57
|
+
function restoredOwnRecordKey(key) {
|
|
58
|
+
if (key === `${OWN_RECORD_ESCAPE_PREFIX}p`)
|
|
59
|
+
return "__proto__";
|
|
60
|
+
const escapedPrefix = `${OWN_RECORD_ESCAPE_PREFIX}k`;
|
|
61
|
+
return key.startsWith(escapedPrefix) ? key.slice(escapedPrefix.length) : key;
|
|
62
|
+
}
|
|
63
|
+
// Zod v3 intentionally skips an own `__proto__` key while parsing records. Reversibly escape
|
|
64
|
+
// every colliding key before its value validation. Safe-key records keep ordinary object
|
|
65
|
+
// semantics; records with prototype-related keys use a null prototype so trusted upstream JSON
|
|
66
|
+
// can never disappear or mutate Object.prototype before sanitization.
|
|
67
|
+
export function preservingRecord(valueSchema) {
|
|
68
|
+
const parsed = z.preprocess((value) => {
|
|
69
|
+
if (!isRecord(value))
|
|
70
|
+
return value;
|
|
71
|
+
const escaped = Object.create(null);
|
|
72
|
+
for (const [key, child] of Object.entries(value)) {
|
|
73
|
+
escaped[escapedOwnRecordKey(key)] = child;
|
|
74
|
+
}
|
|
75
|
+
return escaped;
|
|
76
|
+
}, z.record(valueSchema));
|
|
77
|
+
return parsed.transform((value) => {
|
|
78
|
+
const restoredEntries = Object.entries(value).map(([key, child]) => [restoredOwnRecordKey(key), child]);
|
|
79
|
+
const restored = (restoredEntries.some(([key]) => PROTOTYPE_SEGMENT.test(key)) ? Object.create(null) : {});
|
|
80
|
+
for (const [key, child] of restoredEntries) {
|
|
81
|
+
Object.defineProperty(restored, key, {
|
|
82
|
+
configurable: true,
|
|
83
|
+
enumerable: true,
|
|
84
|
+
value: child,
|
|
85
|
+
writable: true,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return restored;
|
|
89
|
+
});
|
|
90
|
+
}
|
|
51
91
|
export function assertSafeJson(value) {
|
|
52
92
|
const stack = [{ value, depth: 0 }];
|
|
53
93
|
let visited = 0;
|
package/dist/tools/utilities.js
CHANGED
|
@@ -4,7 +4,7 @@ import { sanitizeIntrospectResultForOutput } from "../introspect/sanitize.js";
|
|
|
4
4
|
import { NODE_DOCUMENTATION } from "../content/node-docs.js";
|
|
5
5
|
import { defineTool } from "./definition.js";
|
|
6
6
|
import { booleanQuery, numberQuery, requireSafeAscii } from "./common.js";
|
|
7
|
-
import { cursor, identifier, pageLimit } from "./schemas.js";
|
|
7
|
+
import { cursor, identifier, pageLimit, preservingRecord } from "./schemas.js";
|
|
8
8
|
import { compareCodeUnits } from "../introspect/order.js";
|
|
9
9
|
import { securityAuditSchema } from "./response-contracts.js";
|
|
10
10
|
const workflowSearchSchema = z.object({
|
|
@@ -51,11 +51,11 @@ function boundedCommunityPackageCollection(value) {
|
|
|
51
51
|
}
|
|
52
52
|
const insightsSchema = z
|
|
53
53
|
.object({
|
|
54
|
-
total:
|
|
55
|
-
failed:
|
|
56
|
-
failureRate:
|
|
57
|
-
timeSaved:
|
|
58
|
-
averageRunTime:
|
|
54
|
+
total: preservingRecord(z.unknown()),
|
|
55
|
+
failed: preservingRecord(z.unknown()),
|
|
56
|
+
failureRate: preservingRecord(z.unknown()),
|
|
57
|
+
timeSaved: preservingRecord(z.unknown()),
|
|
58
|
+
averageRunTime: preservingRecord(z.unknown()),
|
|
59
59
|
})
|
|
60
60
|
.passthrough();
|
|
61
61
|
function includesQuery(value, query) {
|
|
@@ -112,39 +112,44 @@ export const utilityTools = Object.freeze([
|
|
|
112
112
|
defineTool({
|
|
113
113
|
name: "n8n_audit_generate",
|
|
114
114
|
title: "Generate security audit",
|
|
115
|
-
description: "Run
|
|
115
|
+
description: "Run one read-only n8n security scan. Omit categories to use n8n's complete default, or provide 1-5 unique risk areas; an empty array is rejected. It makes exactly one POST /audit request and never changes resources or configuration. It is safe to retry but remains unsafe-mode and owner-authorized because the report can expose sensitive security posture. Use n8n_introspect for one workflow. Returns a sanitized, untrusted report; a clean scan returns an empty map. If it cannot fit, retry with fewer categories or read the full report in n8n.",
|
|
116
116
|
operation: "unsafe",
|
|
117
117
|
preserveValidatedRootRecordValues: true,
|
|
118
|
-
|
|
118
|
+
sanitizationOptions: {
|
|
119
|
+
maxArrayLength: 10_000,
|
|
120
|
+
maxDepth: 100,
|
|
121
|
+
maxNodes: 200_000,
|
|
122
|
+
maxObjectEntries: 10_000,
|
|
123
|
+
maxStringInputLength: 262_144,
|
|
124
|
+
maxStringOutputLength: 262_144,
|
|
125
|
+
},
|
|
126
|
+
failOnSanitizerLimit: true,
|
|
127
|
+
outputDataDescription: "Possibly empty map of upstream report titles to validated reports. Each report has one official risk category and bounded sections with recommendations plus typed locations or instance details; all content is sanitized and untrusted.",
|
|
128
|
+
readOnly: true,
|
|
129
|
+
readOnlyOutputLimitMessage: "The audit report exceeded safe output limits. If you selected multiple categories, retry with fewer; otherwise review the full report in n8n.",
|
|
119
130
|
destructive: false,
|
|
131
|
+
idempotent: true,
|
|
120
132
|
input: {
|
|
121
133
|
categories: z
|
|
122
134
|
.array(z.enum(["credentials", "database", "nodes", "filesystem", "instance"]))
|
|
123
|
-
.max(5)
|
|
124
|
-
.optional()
|
|
125
|
-
.describe("Optional audit categories; omit to let n8n use its default complete selection."),
|
|
126
|
-
daysAbandonedWorkflow: z
|
|
127
|
-
.number()
|
|
128
|
-
.int()
|
|
129
135
|
.min(1)
|
|
130
|
-
.max(
|
|
136
|
+
.max(5)
|
|
137
|
+
.refine((values) => new Set(values).size === values.length, {
|
|
138
|
+
message: "categories must not contain duplicates.",
|
|
139
|
+
})
|
|
131
140
|
.optional()
|
|
132
|
-
.describe("
|
|
141
|
+
.describe("Optional 1-5 unique audit categories; omit to let n8n use its complete default selection."),
|
|
142
|
+
},
|
|
143
|
+
handler: async (input, context) => {
|
|
144
|
+
const result = await context.client().request({
|
|
145
|
+
method: "POST",
|
|
146
|
+
path: "/audit",
|
|
147
|
+
body: input.categories === undefined
|
|
148
|
+
? {}
|
|
149
|
+
: { additionalOptions: { categories: input.categories } },
|
|
150
|
+
});
|
|
151
|
+
return Array.isArray(result) && result.length === 0 ? {} : securityAuditSchema.parse(result);
|
|
133
152
|
},
|
|
134
|
-
handler: async (input, context) => securityAuditSchema.parse(await context.client().request({
|
|
135
|
-
method: "POST",
|
|
136
|
-
path: "/audit",
|
|
137
|
-
body: input.categories === undefined && input.daysAbandonedWorkflow === undefined
|
|
138
|
-
? {}
|
|
139
|
-
: {
|
|
140
|
-
additionalOptions: {
|
|
141
|
-
...(input.categories === undefined ? {} : { categories: input.categories }),
|
|
142
|
-
...(input.daysAbandonedWorkflow === undefined
|
|
143
|
-
? {}
|
|
144
|
-
: { daysAbandonedWorkflow: input.daysAbandonedWorkflow }),
|
|
145
|
-
},
|
|
146
|
-
},
|
|
147
|
-
})),
|
|
148
153
|
}),
|
|
149
154
|
defineTool({
|
|
150
155
|
name: "n8n_search_workflows",
|
package/dist/tools/workflows.js
CHANGED
|
@@ -2,7 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { N8nApiError } from "../n8n/client.js";
|
|
3
3
|
import { boundedJson, sanitizeForOutput, sanitizePathSegmentForOutput, } from "../security/redaction.js";
|
|
4
4
|
import { defineTool } from "./definition.js";
|
|
5
|
-
import { assertBoundedDepth, assertSafeJson, cursor, identifier, isRecord, MUTABLE_NODE_ROOTS, pageLimit, pathSegment, requiredSafeJsonValue, safeJsonValue, setUnknownPath, validateDotPath, } from "./schemas.js";
|
|
5
|
+
import { assertBoundedDepth, assertSafeJson, cursor, identifier, isRecord, MUTABLE_NODE_ROOTS, pageLimit, pathSegment, preservingRecord, requiredSafeJsonValue, safeJsonValue, setUnknownPath, validateDotPath, } from "./schemas.js";
|
|
6
6
|
import { booleanQuery, numberQuery } from "./common.js";
|
|
7
7
|
import { parseWorkflowLifecycleMetadata } from "./response-contracts.js";
|
|
8
8
|
// Trusted server-returned workflow data is carried through unchanged (large or prototype-named
|
|
@@ -26,8 +26,8 @@ const nodeSchema = z
|
|
|
26
26
|
.array(z.number().finite())
|
|
27
27
|
.length(2)
|
|
28
28
|
.describe("Node canvas position as exactly two finite numbers."),
|
|
29
|
-
parameters:
|
|
30
|
-
credentials:
|
|
29
|
+
parameters: preservingRecord(z.unknown()).default({}),
|
|
30
|
+
credentials: preservingRecord(z.unknown()).optional(),
|
|
31
31
|
disabled: z.boolean().optional(),
|
|
32
32
|
webhookId: identifier("Stable dynamic-webhook routing identifier when present.").optional(),
|
|
33
33
|
})
|
|
@@ -39,8 +39,10 @@ const connectionTargetSchema = z
|
|
|
39
39
|
index: z.number().int().nonnegative().max(1_000),
|
|
40
40
|
})
|
|
41
41
|
.passthrough();
|
|
42
|
-
const connectionsSchema =
|
|
43
|
-
const staticDataSchema = z
|
|
42
|
+
const connectionsSchema = preservingRecord(preservingRecord(z.array(z.array(connectionTargetSchema).max(1_000)).max(1_000)));
|
|
43
|
+
const staticDataSchema = z
|
|
44
|
+
.union([preservingRecord(z.unknown()), z.string().max(1024 * 1024)])
|
|
45
|
+
.nullable();
|
|
44
46
|
const workflowSchema = z.object({
|
|
45
47
|
id: identifier(),
|
|
46
48
|
versionId: identifier().optional(),
|
|
@@ -50,8 +52,8 @@ const workflowSchema = z.object({
|
|
|
50
52
|
isArchived: z.boolean().optional(),
|
|
51
53
|
nodes: z.array(nodeSchema).max(1_000),
|
|
52
54
|
connections: connectionsSchema,
|
|
53
|
-
settings:
|
|
54
|
-
pinData:
|
|
55
|
+
settings: preservingRecord(z.unknown()).default({}),
|
|
56
|
+
pinData: preservingRecord(z.unknown()).nullable().optional(),
|
|
55
57
|
staticData: staticDataSchema.optional(),
|
|
56
58
|
nodeGroups: z.array(z.unknown()).max(1_000).optional(),
|
|
57
59
|
});
|
|
@@ -106,12 +108,10 @@ const workflowWriteFields = {
|
|
|
106
108
|
connections: connectionsSchema
|
|
107
109
|
.optional()
|
|
108
110
|
.describe("Complete replacement connection graph keyed by node name."),
|
|
109
|
-
settings: z
|
|
110
|
-
.record(z.unknown())
|
|
111
|
+
settings: preservingRecord(z.unknown())
|
|
111
112
|
.optional()
|
|
112
113
|
.describe("Complete replacement workflow settings object."),
|
|
113
|
-
pinData: z
|
|
114
|
-
.record(z.unknown())
|
|
114
|
+
pinData: preservingRecord(z.unknown())
|
|
115
115
|
.nullable()
|
|
116
116
|
.optional()
|
|
117
117
|
.describe("Complete replacement pinned-data map, or null to clear it; values are never returned."),
|
|
@@ -136,7 +136,7 @@ class WorkflowContractError extends Error {
|
|
|
136
136
|
// MUTABLE_NODE_ROOTS exactly; every allowed root is bounded so a corrupt value (a string
|
|
137
137
|
// for a boolean, a non-integer retry count, an unknown onError enum) is rejected before PUT.
|
|
138
138
|
const NODE_ROOT_VALUE_CONTRACTS = {
|
|
139
|
-
parameters:
|
|
139
|
+
parameters: preservingRecord(z.unknown()),
|
|
140
140
|
position: z.tuple([z.number().finite(), z.number().finite()]),
|
|
141
141
|
disabled: z.boolean(),
|
|
142
142
|
retryOnFail: z.boolean(),
|
|
@@ -339,9 +339,8 @@ function diffSnapshot(value) {
|
|
|
339
339
|
}
|
|
340
340
|
const MAX_PARAMETER_PATH_SEGMENTS = 16;
|
|
341
341
|
const MAX_PARAMETER_CHANGE_DETAILS = 200;
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
// summaries, coverage, omission metadata, and pretty-print indentation.
|
|
342
|
+
// Keep the compact workflow-diff data below 128 KiB so its duplicated structured and pretty-text
|
|
343
|
+
// forms retain bounded headroom inside the shared 256 KiB serialized-result ceiling.
|
|
345
344
|
const MAX_WORKFLOW_DIFF_OUTPUT_BYTES = 128 * 1024;
|
|
346
345
|
function diffValueType(value) {
|
|
347
346
|
if (value === null)
|
|
@@ -733,8 +732,7 @@ export const workflowTools = Object.freeze([
|
|
|
733
732
|
.max(1_000)
|
|
734
733
|
.describe("Complete non-empty node array with unique names; supplied IDs, when present, must also be unique."),
|
|
735
734
|
connections: connectionsSchema.describe("Complete connection graph keyed by existing node names."),
|
|
736
|
-
settings: z
|
|
737
|
-
.record(z.unknown())
|
|
735
|
+
settings: preservingRecord(z.unknown())
|
|
738
736
|
.default({})
|
|
739
737
|
.describe("Workflow settings object (default empty object)."),
|
|
740
738
|
nodeGroups: z
|
|
@@ -745,8 +743,7 @@ export const workflowTools = Object.freeze([
|
|
|
745
743
|
staticData: staticDataSchema
|
|
746
744
|
.optional()
|
|
747
745
|
.describe("Optional static workflow data; accepted for creation but never returned."),
|
|
748
|
-
pinData: z
|
|
749
|
-
.record(z.unknown())
|
|
746
|
+
pinData: preservingRecord(z.unknown())
|
|
750
747
|
.nullable()
|
|
751
748
|
.optional()
|
|
752
749
|
.describe("Optional pinned-data map or null; accepted for creation but never returned."),
|
package/dist/version.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const PACKAGE_VERSION = "0.
|
|
1
|
+
export const PACKAGE_VERSION = "0.3.0";
|
package/docs/compatibility.md
CHANGED
|
@@ -9,7 +9,7 @@ release evidence from the client-matrix gates that remain open.
|
|
|
9
9
|
| ---------------------- | ------------------------------------------------------------- |
|
|
10
10
|
| Node.js 22.23.1 | Pinned CI/release target; exact candidate proof comes from CI |
|
|
11
11
|
| Node.js 24.18.0 | Pinned CI/release target; exact candidate proof comes from CI |
|
|
12
|
-
| Node.js 24.
|
|
12
|
+
| Node.js 24.19.0 | Current complete local candidate gate |
|
|
13
13
|
| MCP transport | stdio inventory verified as 44 tools, 5 resources, 4 prompts |
|
|
14
14
|
| macOS on Apple silicon | Development and local verification environment |
|
|
15
15
|
| Linux and Windows | No public release claim yet |
|
|
@@ -18,17 +18,40 @@ release evidence from the client-matrix gates that remain open.
|
|
|
18
18
|
|
|
19
19
|
The package declares Node.js `>=22.0.0`. That engine range is an acceptance
|
|
20
20
|
floor, not evidence that every later major has completed the release matrix.
|
|
21
|
-
The supported release lines for v0.
|
|
21
|
+
The supported release lines for v0.3.0 are Node.js 22 and 24.
|
|
22
|
+
|
|
23
|
+
## Upgrading to 0.3.0
|
|
24
|
+
|
|
25
|
+
Install `n8n-mcp-community@latest` again, then restart the MCP client. The
|
|
26
|
+
connection values, command, modes, inventory, and non-audit inputs are unchanged.
|
|
27
|
+
|
|
28
|
+
The 256 KiB generic output limit now covers the complete serialized MCP result,
|
|
29
|
+
including both text and structured content. Large single-object reads that fit
|
|
30
|
+
in 0.2.x can therefore fail safely in 0.3.0. Use narrower inputs when the tool
|
|
31
|
+
offers them; otherwise inspect the full object in n8n. Mutations that already
|
|
32
|
+
landed return a bounded success summary with `truncated=true` and `redacted=true`
|
|
33
|
+
instead of inviting a retry.
|
|
34
|
+
|
|
35
|
+
`n8n_audit_generate` no longer accepts `daysAbandonedWorkflow`. n8n Community
|
|
36
|
+
2.30.x writes that request value to a legacy setting, while the audit reporter
|
|
37
|
+
reads typed instance configuration, so callers could not rely on the override.
|
|
38
|
+
Normal MCP clients refresh the smaller schema when they reconnect. Raw scripted
|
|
39
|
+
callers must stop sending that field. Use the n8n instance setting when the
|
|
40
|
+
upstream product exposes a working configuration path.
|
|
41
|
+
|
|
42
|
+
Raw audit callers must also omit `categories` when they want n8n's complete
|
|
43
|
+
default, instead of sending an empty array, and must remove duplicate category
|
|
44
|
+
names. MCP clients receive these constraints when they reconnect.
|
|
22
45
|
|
|
23
46
|
## Upgrading from 0.1.x
|
|
24
47
|
|
|
25
48
|
The connection values are unchanged. Global npm users keep the same client
|
|
26
49
|
entry. Users who followed the 0.1.x npx guide must replace the pinned
|
|
27
50
|
`n8n-mcp-community@0.1.4` argument with `n8n-mcp-community@latest` or
|
|
28
|
-
`n8n-mcp-community@0.
|
|
51
|
+
`n8n-mcp-community@0.3.0`. After updating, restart the MCP client and confirm the
|
|
29
52
|
version and 44/5/4 inventory.
|
|
30
53
|
|
|
31
|
-
|
|
54
|
+
Since 0.2.0, the 14 unsafe tools no longer publish or require a `confirmation` field;
|
|
32
55
|
`N8N_MCP_MODE=unsafe` is the only server-side mode gate. Raw scripted callers
|
|
33
56
|
must stop sending that removed field. Privately distributed MCPB updates are
|
|
34
57
|
manual; the [install guide](installation.md#update-or-roll-back-the-mcpb) gives
|
|
@@ -36,7 +59,7 @@ update and rollback steps.
|
|
|
36
59
|
|
|
37
60
|
## n8n Community Edition
|
|
38
61
|
|
|
39
|
-
The v0.
|
|
62
|
+
The v0.3.0 floor candidate is n8n Community Edition 2.30.5, with 2.30.7 as the
|
|
40
63
|
current comparison target. The compiled public 44-tool candidate passed fresh,
|
|
41
64
|
disposable lifecycles on both versions with synthetic fixtures, API-key
|
|
42
65
|
revocation, resource cleanup, enforced candidate-process egress isolation, and
|
|
@@ -48,7 +71,7 @@ browser cookies, interactive `/rest` routes, paid project transfers, arbitrary
|
|
|
48
71
|
workflow execution, or runtime node-catalog downloads.
|
|
49
72
|
|
|
50
73
|
`n8n_credentials_list` is supported only from n8n 2.30.5 onward. Project
|
|
51
|
-
selectors are intentionally absent because v0.
|
|
74
|
+
selectors are intentionally absent because v0.3.0 does not present paid project
|
|
52
75
|
capabilities as Community features. Other tools may also depend on endpoint
|
|
53
76
|
availability and the permissions assigned to the API key; an HTTP 403 does not
|
|
54
77
|
by itself mean the endpoint is absent.
|
|
@@ -87,7 +110,7 @@ surface.
|
|
|
87
110
|
|
|
88
111
|
## Deliberate exclusions
|
|
89
112
|
|
|
90
|
-
v0.
|
|
113
|
+
v0.3.0 does not include:
|
|
91
114
|
|
|
92
115
|
- folders or data tables;
|
|
93
116
|
- beta evaluation endpoints;
|
package/docs/installation.md
CHANGED
|
@@ -83,15 +83,16 @@ Node versions.
|
|
|
83
83
|
Run the install command again to update. To roll back, replace `latest` with a
|
|
84
84
|
version, such as `0.1.4`.
|
|
85
85
|
|
|
86
|
-
### Upgrade from 0.1.x
|
|
86
|
+
### Upgrade from 0.1.x
|
|
87
87
|
|
|
88
88
|
Your n8n URL, API key, and `N8N_MCP_MODE` values stay the same. Update the
|
|
89
|
-
package, then restart the MCP client so it reloads the tool schemas.
|
|
90
|
-
|
|
89
|
+
package, then restart the MCP client so it reloads the tool schemas. Version
|
|
90
|
+
0.2.0 removed the second confirmation input; unsafe mode alone now unlocks
|
|
91
|
+
unsafe tools.
|
|
91
92
|
|
|
92
93
|
Global npm users do not edit the client entry. If you followed the 0.1.x npx
|
|
93
94
|
guide, your client is pinned to `n8n-mcp-community@0.1.4`: replace that argument
|
|
94
|
-
with `n8n-mcp-community@latest` or `n8n-mcp-community@0.
|
|
95
|
+
with `n8n-mcp-community@latest` or `n8n-mcp-community@0.3.0`. Restart the client,
|
|
95
96
|
run the npx version check below, and confirm the 44/5/4 inventory.
|
|
96
97
|
|
|
97
98
|
If custom code sends raw tool-call JSON, remove the former `confirmation` field
|
package/docs/security-model.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Security model
|
|
2
2
|
|
|
3
|
-
This document describes the controls and residual risks of the v0.
|
|
3
|
+
This document describes the controls and residual risks of the v0.3.0 source
|
|
4
4
|
candidate. It is a threat model, not a claim that the software or the connected
|
|
5
5
|
n8n instance is invulnerable.
|
|
6
6
|
|
|
@@ -94,6 +94,9 @@ The shared sanitizer:
|
|
|
94
94
|
- bounds strings, arrays, object entries, depth, and total traversed nodes.
|
|
95
95
|
|
|
96
96
|
Generic serialized results are limited to 256 KiB and marked `untrusted: true`.
|
|
97
|
+
That limit covers the complete MCP result, including its text and structured
|
|
98
|
+
forms. A mutation summary sets `redacted: true` whenever the original result was
|
|
99
|
+
truncated, even when the retained identity fields need no additional redaction.
|
|
97
100
|
`redacted: false` means no configured sanitizer matched; it does not certify
|
|
98
101
|
that content is harmless, anonymous, or free of unknown secret formats.
|
|
99
102
|
Introspect emits its allowlisted reduced schema directly after applying the
|
package/docs/tools.md
CHANGED
|
@@ -21,14 +21,19 @@ result envelope in abbreviated form.
|
|
|
21
21
|
tool-specific `data` contract in `tools/list`; the sections below are the
|
|
22
22
|
normative human-readable detail. The sanitized envelope is validated against
|
|
23
23
|
that same contract before a success is returned. The final serialized result is limited to
|
|
24
|
-
256 KiB.
|
|
24
|
+
256 KiB. `redacted=true` means untrusted content was removed, replaced, or
|
|
25
|
+
shortened, so generic reads are safe projections rather than byte-exact
|
|
26
|
+
exports. When a successful operation with `RO=false` produces a
|
|
25
27
|
result above that cap, the tool reports a truncated success summary
|
|
26
28
|
(`truncated: true`, `outcome: "success"`, and bounded identity fields)
|
|
27
|
-
instead of an error, because
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
instead of an error, because a mutation may already have been applied;
|
|
30
|
+
tools with `RO=true` keep the over-cap error even when their authorization
|
|
31
|
+
class is unsafe. When a generic read has no narrowing input, inspect the full
|
|
32
|
+
object in n8n. `n8n_introspect` applies the shared sanitizer and returns its
|
|
33
|
+
declared diagnostic schema directly, with one concise summary block and one
|
|
34
|
+
exact JSON fallback. Its dedicated limits are 128 KiB for structured output
|
|
35
|
+
and 320 KiB for the combined summary/JSON rendering; they are separate from
|
|
36
|
+
the generic envelope cap.
|
|
32
37
|
- **HTTP:** Public API paths are relative to `/api/v1`; `/healthz` is the only
|
|
33
38
|
root path. Requests are same-origin, redirect-free, time-bounded, and limited
|
|
34
39
|
to 2 MiB in each direction.
|
|
@@ -813,25 +818,30 @@ Reads n8n's official insights summary, optionally constrained by date.
|
|
|
813
818
|
## n8n_audit_generate
|
|
814
819
|
|
|
815
820
|
Requests n8n's instance security audit. The endpoint performs broad instance
|
|
816
|
-
inspection and uses POST
|
|
817
|
-
|
|
821
|
+
inspection and uses POST. The scan is read-only and safe to retry, but this
|
|
822
|
+
server keeps it behind the conservative unsafe policy because the report can
|
|
823
|
+
expose sensitive security posture.
|
|
818
824
|
|
|
819
825
|
- **Policy and endpoint:** unsafe; `POST /audit`;
|
|
820
|
-
`RO=
|
|
826
|
+
`RO=true, D=false, I=true, OW=true`. Requires mode `unsafe`; the policy class
|
|
827
|
+
controls authorization, while the annotations describe external effects.
|
|
821
828
|
- **Requirements:** Requires mode `unsafe` and API-key permission to generate the instance audit.
|
|
822
|
-
- **Community Edition:** Verified on Community 2.30.5 and 2.30.7.
|
|
823
|
-
- **Inputs:** optional `categories`, an array of
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
sent under the official `additionalOptions` request property.
|
|
829
|
+
- **Community Edition:** Verified on Community 2.30.5 and 2.30.7. Each call makes exactly one request and reads current state without changing instance resources or configuration. The ineffective upstream `daysAbandonedWorkflow` override is intentionally not exposed because n8n 2.30.x writes a legacy setting while its reporter reads typed configuration ([official 2.30.7 source](https://github.com/n8n-io/n8n/blob/n8n%402.30.7/packages/cli/src/security-audit/security-audit.service.ts)); category support still depends on n8n.
|
|
830
|
+
- **Inputs:** optional `categories`, an array of 1–5 unique selections from
|
|
831
|
+
`credentials`, `database`, `nodes`, `filesystem`, and `instance`. Omit it for
|
|
832
|
+
n8n's complete default; an explicit empty or duplicate selection is rejected.
|
|
833
|
+
Selected categories are sent under the official `additionalOptions` request property.
|
|
827
834
|
- **Returns:** a bounded map of n8n-defined report titles. Every report has an
|
|
828
835
|
official `risk` category and typed `sections`; sections contain a title,
|
|
829
836
|
description, recommendation, and either typed credential/node/package/file
|
|
830
837
|
locations or bounded instance settings and version details.
|
|
838
|
+
A valid audit with no risks returns an empty map.
|
|
831
839
|
- **Failures and privacy:** instance-wide audit output can reveal sensitive
|
|
832
840
|
security posture and remains untrusted. Unsupported categories, permissions,
|
|
833
|
-
versions, or malformed reports return sanitized errors.
|
|
834
|
-
|
|
841
|
+
versions, or malformed reports return sanitized errors. If the report exceeds
|
|
842
|
+
safe output limits, retry with fewer categories when more than one was selected;
|
|
843
|
+
otherwise review the full report in n8n.
|
|
844
|
+
- **Example:** `{ "categories": ["credentials"] }` →
|
|
835
845
|
`{ "data": { "Credentials Risk Report": { "risk": "credentials", "sections": [{ "title": "Unused credentials", "description": "Credentials not used by any workflow.", "recommendation": "Delete credentials that are no longer required.", "location": [{ "kind": "credential", "id": "cred_1", "name": "n8n API" }] }] } }, "redacted": false, "untrusted": true }`.
|
|
836
846
|
|
|
837
847
|
## n8n_search_workflows
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "n8n-mcp-community",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "A security-focused MCP server for self-hosted n8n Community Edition.",
|
|
5
5
|
"private": false,
|
|
6
6
|
"releaseState": "candidate",
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
"ajv-formats": "3.0.1",
|
|
126
126
|
"eslint": "10.8.0",
|
|
127
127
|
"fflate": "0.8.3",
|
|
128
|
-
"globals": "17.
|
|
128
|
+
"globals": "17.9.0",
|
|
129
129
|
"prettier": "3.9.6",
|
|
130
130
|
"typescript": "5.9.3",
|
|
131
131
|
"typescript-eslint": "8.65.0",
|
package/server.json
CHANGED
|
@@ -8,13 +8,13 @@
|
|
|
8
8
|
"url": "https://github.com/drzamarian/n8n-mcp-community",
|
|
9
9
|
"source": "github"
|
|
10
10
|
},
|
|
11
|
-
"version": "0.
|
|
11
|
+
"version": "0.3.0",
|
|
12
12
|
"packages": [
|
|
13
13
|
{
|
|
14
14
|
"registryType": "npm",
|
|
15
15
|
"registryBaseUrl": "https://registry.npmjs.org",
|
|
16
16
|
"identifier": "n8n-mcp-community",
|
|
17
|
-
"version": "0.
|
|
17
|
+
"version": "0.3.0",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
20
20
|
},
|