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 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
- - **297 passing tests** locally on Node.js 24.18.1; CI and release gates require
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.2.1 candidate surface intentionally excludes arbitrary workflow execution,
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.2.1:
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.2.x — receives security fixes.
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: z.record(z.unknown()).optional(),
102
- credentials: z.record(z.unknown()).optional(),
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: z.record(z.unknown()).default({}),
113
- settings: z.record(z.unknown()).default({}),
114
- pinData: z.record(z.unknown()).nullable().optional(),
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: z.record(z.unknown()).optional(),
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: z.record(FactValueSchema).optional(),
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 > 131_072 ? value.slice(0, 131_072) : value;
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, 131_072)
136
+ .slice(0, limits.stringInputLength)
111
137
  .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "");
112
- let redacted = boundedInput !== value || normalizedInput.length > 131_072 || output !== boundedInput;
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 > 32_768) {
174
- output = `${output.slice(0, 32_767)}\u2026`;
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 > 20_000 || depth > 20) {
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, 1_000);
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, 1_000).entries()) {
229
- if (PROTOTYPE_KEY.test(key)) {
230
- state.redacted = true;
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 > 1_000) {
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 = { nodes: 0, redacted: false, structurallyReduced: false };
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 = {}) {
@@ -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(z.record(z.unknown())).max(100),
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: z.record(workflowCredentialReferenceSchema).optional(),
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: z
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: z
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."),
@@ -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: spec.operation === "read-only",
15
+ readOnlyHint: readOnly,
13
16
  destructiveHint: spec.destructive ?? spec.operation !== "read-only",
14
- idempotentHint: spec.idempotent ?? spec.operation === "read-only",
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("The tool produced a result that did not match its published output contract.");
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
- return {
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
- return resultFromValidatedOutput(validatedOutput(sanitizeForOutput(value), outputSchema));
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, operation, primaryDataSchema, outputSchema, preserveValidatedRootRecordValues = false) {
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, { preserveValidatedRootRecordValues });
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 (operation === "read-only" ||
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 (operation === "read-only" || !(error instanceof OutputLimitError))
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, spec.operation, spec.outputDataDescription)
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, spec.operation, genericOutput.primaryDataSchema, outputSchema, spec.preserveValidatedRootRecordValues);
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 = () => z.record(z.unknown());
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, operation, dataDescription) {
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 = operation === "read-only" ? expectedData : z.union([expectedData, truncatedMutation()]);
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 z.record(valueSchema).superRefine((value, context) => {
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(z.record(z.unknown()), 1_000, 256),
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: z.record(z.unknown()),
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
  */
@@ -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;
@@ -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: z.record(z.unknown()),
55
- failed: z.record(z.unknown()),
56
- failureRate: z.record(z.unknown()),
57
- timeSaved: z.record(z.unknown()),
58
- averageRunTime: z.record(z.unknown()),
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 n8n's broad, non-destructive instance security scan. Omit categories to use n8n's complete default, or supply the exact risk areas to include. daysAbandonedWorkflow is independent: it changes only the inactive-workflow age threshold and does not select categories. Use n8n_introspect for one workflow. Requires unsafe mode and owner-authorized API access; returns a sanitized but untrusted report without changing configuration.",
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
- outputDataDescription: "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.",
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(3_650)
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("Age threshold in days for classifying an inactive workflow as abandoned."),
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",
@@ -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: z.record(z.unknown()).default({}),
30
- credentials: z.record(z.unknown()).optional(),
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 = z.record(z.record(z.array(z.array(connectionTargetSchema).max(1_000)).max(1_000)));
43
- const staticDataSchema = z.union([z.record(z.unknown()), z.string().max(1024 * 1024)]).nullable();
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: z.record(z.unknown()).default({}),
54
- pinData: z.record(z.unknown()).nullable().optional(),
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: z.record(z.unknown()),
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
- // Fit the exact sanitized MCP success envelope with headroom below the shared 256 KiB
343
- // transport ceiling. This accounts for NFKC expansion, redaction markers, selectors,
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.2.1";
1
+ export const PACKAGE_VERSION = "0.3.0";
@@ -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.18.1 | Current complete local candidate gate |
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.2.1 are Node.js 22 and 24.
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.2.0`. After updating, restart the MCP client and confirm the
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
- The 14 unsafe tools no longer publish or require a `confirmation` field;
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.2.1 floor candidate is n8n Community Edition 2.30.5, with 2.30.7 as the
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.2.1 does not present paid project
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.2.1 does not include:
113
+ v0.3.0 does not include:
91
114
 
92
115
  - folders or data tables;
93
116
  - beta evaluation endpoints;
@@ -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 to 0.2.0
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. Unsafe
90
- mode alone now unlocks unsafe tools; there is no second confirmation input.
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.2.0`. Restart the client,
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
@@ -1,6 +1,6 @@
1
1
  # Security model
2
2
 
3
- This document describes the controls and residual risks of the v0.2.1 source
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. When a successful write or unsafe operation produces a
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 the mutation has already been applied;
28
- read-only tools keep the over-cap error. `n8n_introspect` applies the shared sanitizer and returns
29
- its declared diagnostic schema directly, with one concise summary block and
30
- one exact JSON fallback; its dedicated reducer and combined-output budget are
31
- documented below.
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, so this server applies the conservative unsafe
817
- operation policy even though the scan is non-destructive.
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=false, D=false, I=false, OW=true`. Requires mode `unsafe`.
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. The POST report endpoint remains unsafe-gated but is annotated non-destructive because it does not change instance resources or configuration; category support still depends on n8n.
823
- - **Inputs:** optional `categories`, an array of at most five selections
824
- from `credentials`, `database`, `nodes`, `filesystem`, and `instance`;
825
- optional `daysAbandonedWorkflow` integer from 1 through 3,650. Options are
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
- - **Example:** `{ "categories": ["credentials"], "daysAbandonedWorkflow": 30 }` →
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.2.1",
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.8.0",
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.2.1",
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.2.1",
17
+ "version": "0.3.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },