n8n-mcp-community 0.2.0 → 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,64 @@ 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
+
48
+ ## [0.2.1] - Frozen release candidate
49
+
50
+ Version 0.2.1 is a frozen release candidate. Availability is established only
51
+ by matching npm provenance, GitHub Release assets and signatures, and MCP
52
+ Registry readbacks; this immutable entry is not rewritten after publication.
53
+
54
+ ### Changed
55
+
56
+ - Forty tool definitions now state their exact selector semantics, pagination
57
+ behavior, authorization or configuration boundary, side effects, and return
58
+ contract in shorter agent-facing language. The four definitions already
59
+ rated 5.0 by Glama remain unchanged.
60
+ - The durable 44-tool definition matrix now checks parameter, authorization,
61
+ configuration, return, annotation, and task-support invariants independently.
62
+ Controlled mutations prove that every invariant rejects semantic drift.
63
+ - Execution-stop documentation now names every stoppable state and the
64
+ terminal-race mapping. Health documentation now states the explicit opt-in
65
+ required for remote plaintext HTTP.
66
+
9
67
  ## [0.2.0] - Frozen release candidate
10
68
 
11
69
  Version 0.2.0 is a frozen release candidate. Availability is established only
@@ -285,6 +343,8 @@ Registry readbacks; this immutable entry is not rewritten after publication.
285
343
  publishable history; authenticated public release receipts remain supported.
286
344
 
287
345
  [Unreleased]: #unreleased
346
+ [0.3.0]: #030---frozen-release-candidate
347
+ [0.2.1]: #021---frozen-release-candidate
288
348
  [0.2.0]: #020---frozen-release-candidate
289
349
  [0.1.4]: #014---frozen-release-candidate
290
350
  [0.1.3]: #013---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
- - **296 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
@@ -170,13 +170,13 @@ failure, and security reference.
170
170
 
171
171
  ### Executions
172
172
 
173
- | Tool | Mode | Purpose |
174
- | -------------------------------------------------------------- | --------- | ------------------------------------------------------------------ |
175
- | [`n8n_executions_list`](docs/tools.md#n8n_executions_list) | Read-only | List execution metadata with value-free data-presence summaries. |
176
- | [`n8n_executions_get`](docs/tools.md#n8n_executions_get) | Read-only | Read one execution's metadata without returning workflow payloads. |
177
- | [`n8n_executions_delete`](docs/tools.md#n8n_executions_delete) | Unsafe | Permanently delete a saved execution. |
178
- | [`n8n_executions_retry`](docs/tools.md#n8n_executions_retry) | Unsafe | Retry an eligible saved execution. |
179
- | [`n8n_executions_stop`](docs/tools.md#n8n_executions_stop) | Unsafe | Stop one running execution and bind identity from validated input. |
173
+ | Tool | Mode | Purpose |
174
+ | -------------------------------------------------------------- | --------- | -------------------------------------------------------------------- |
175
+ | [`n8n_executions_list`](docs/tools.md#n8n_executions_list) | Read-only | List execution metadata with value-free data-presence summaries. |
176
+ | [`n8n_executions_get`](docs/tools.md#n8n_executions_get) | Read-only | Read one execution's metadata without returning workflow payloads. |
177
+ | [`n8n_executions_delete`](docs/tools.md#n8n_executions_delete) | Unsafe | Permanently delete a saved execution. |
178
+ | [`n8n_executions_retry`](docs/tools.md#n8n_executions_retry) | Unsafe | Retry an eligible saved execution. |
179
+ | [`n8n_executions_stop`](docs/tools.md#n8n_executions_stop) | Unsafe | Stop one stoppable execution and bind identity from validated input. |
180
180
 
181
181
  ### Credentials
182
182
 
@@ -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.0 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.0:
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
  });
@@ -59,7 +59,7 @@ export const credentialTools = Object.freeze([
59
59
  defineTool({
60
60
  name: "n8n_credentials_create",
61
61
  title: "Create credential",
62
- description: "Create and persist one credential as an additive write. Use n8n_credentials_schema first; use n8n_credentials_update when the credential exists. type selects the schema the caller should use to construct data, and isResolvable is sent only when supplied. Requires write/unsafe mode plus credential-create permission. Secret values belong only in input and are excluded from logs and output; returns metadata only.",
62
+ description: "Create and persist one credential as an additive write. First call n8n_credentials_schema with the planned type, then build data from its field contract; name is only the stored label. isResolvable is forwarded only when supplied. Use n8n_credentials_update for an existing credential. Requires write/unsafe mode plus create permission. Secret values belong only in data input and are excluded from logs and output; returns metadata only.",
63
63
  operation: "write",
64
64
  outputDataDescription: "Validated credential metadata with id, name, type, optional timestamps, managed/global flags, and resolvability. Stored credential values are never returned.",
65
65
  destructive: false,
@@ -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()
@@ -89,7 +87,7 @@ export const credentialTools = Object.freeze([
89
87
  defineTool({
90
88
  name: "n8n_credentials_delete",
91
89
  title: "Delete credential",
92
- description: "Permanently delete one stored credential, which can break every referencing workflow. Use n8n_credentials_usage across all pages first; use n8n_credentials_update when replacement is sufficient. No secret or rollback is returned. Requires unsafe mode plus credential-delete permission; returns the request-bound ID with deleted=true.",
90
+ description: "Permanently delete one stored credential, which can break every referencing workflow. credentialId must be the stable ID from n8n_credentials_list, get, or create; a credential name or type is not accepted. Scan all n8n_credentials_usage pages first, or update when replacement is enough. No secret or rollback is returned. Requires unsafe mode plus credential-delete permission; returns the request-bound credentialId with deleted=true.",
93
91
  operation: "unsafe",
94
92
  outputDataDescription: "Object with the validated input credentialId and deleted=true. Identity is bound to the request because n8n does not consistently return the deleted credential ID.",
95
93
  input: { credentialId: identifier("Stable ID of the credential to delete.") },
@@ -103,7 +101,7 @@ export const credentialTools = Object.freeze([
103
101
  defineTool({
104
102
  name: "n8n_credentials_schema",
105
103
  title: "Get credential schema",
106
- description: "Get n8n's Public API field schema for one credential type. Use it before n8n_credentials_create or replacement-data updates; use n8n_credentials_get for stored metadata. credentialType is a route selector, not a stored credential ID, so this call never reads credential values. Requires schema-read permission; returns the validated upstream field contract without testing credentials.",
104
+ description: "Get n8n's Public API field contract for one credential type. credentialType is the public type name used by create/update, not a stored credential ID, name, or secret. Its result tells the caller which keys belong in data; it does not read or test a stored credential. Use n8n_credentials_get for stored metadata. Requires schema-read permission; returns the validated upstream field contract.",
107
105
  operation: "read-only",
108
106
  outputDataDescription: "Validated Public API schema object for the requested credential type. It describes accepted fields and constraints but never contains stored credential values.",
109
107
  input: {
@@ -116,7 +114,7 @@ export const credentialTools = Object.freeze([
116
114
  defineTool({
117
115
  name: "n8n_credentials_list",
118
116
  title: "List credentials",
119
- description: "List one Public API page of credential metadata using the endpoint supported from n8n Community 2.30.5. Use it for discovery; use n8n_credentials_get when the ID is known. cursor resumes a prior page and limit bounds that single request; this call never auto-paginates. Requires credential-list permission; returns metadata and nextCursor, never stored values.",
117
+ description: "List one Public API page of credential metadata; the endpoint is supported from n8n Community 2.30.5. Omit cursor for page one. For later pages, pass nextCursor back unchanged; limit sizes only that request, and the tool never auto-paginates. Use n8n_credentials_get when an ID is known. Requires credential-list permission; returns metadata and nextCursor, never stored values.",
120
118
  operation: "read-only",
121
119
  outputDataDescription: "Object with data (up to 100 credential metadata records) and nextCursor (string or null). Records contain allowlisted metadata only; credential values are rejected.",
122
120
  input: { limit: pageLimit(), cursor: cursor.optional() },
@@ -131,7 +129,7 @@ export const credentialTools = Object.freeze([
131
129
  defineTool({
132
130
  name: "n8n_credentials_get",
133
131
  title: "Get credential",
134
- description: "Get public metadata for one stored credential by stable ID. Use it for a known target; use n8n_credentials_list for discovery and n8n_credentials_schema for type fields. credentialId identifies stored metadata only: the endpoint and output contract do not retrieve credential values or test the external service. Requires credential-read permission; returns validated identity, type, flags, and timestamps.",
132
+ description: "Get public metadata for one stored credential. credentialId is the stable ID from n8n_credentials_list or create, not its name, type, or secret data. Use n8n_credentials_schema for type fields. The endpoint does not retrieve credential values or test the external service. Requires credential-read permission; returns validated identity, type, flags, and timestamps.",
135
133
  operation: "read-only",
136
134
  outputDataDescription: "One validated credential metadata record with id, name, type, optional timestamps, managed/global flags, and resolvability; no stored credential values.",
137
135
  input: { credentialId: identifier("Stable ID of the credential metadata to retrieve.") },
@@ -140,7 +138,7 @@ export const credentialTools = Object.freeze([
140
138
  defineTool({
141
139
  name: "n8n_credentials_update",
142
140
  title: "Update credential",
143
- description: "Update selected metadata or values on one stored credential, potentially breaking dependent workflows. Use n8n_credentials_schema for data fields and n8n_credentials_create for a new credential. Supply at least one field; changing type also requires data. isPartialData=false treats data as replacement, while true requests a partial merge. Requires write/unsafe mode plus update permission; returns metadata without secret values.",
141
+ description: "Update one stored credential, potentially breaking dependent workflows. credentialId selects the record; omit fields to keep them and supply at least one change. If type changes, data is also required and must match n8n_credentials_schema. With data, isPartialData=false replaces the saved field set, while true requests a partial merge. Use create for a new credential. Requires write/unsafe mode plus update permission; returns metadata without secret values.",
144
142
  operation: "write",
145
143
  outputDataDescription: "Updated credential metadata with id, name, type, optional timestamps, managed/global flags, and resolvability. Supplied credential values are never echoed.",
146
144
  destructive: true,
@@ -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."),
@@ -201,7 +198,7 @@ export const credentialTools = Object.freeze([
201
198
  defineTool({
202
199
  name: "n8n_credentials_test",
203
200
  title: "Test credential",
204
- description: "Test one stored credential by allowing n8n to contact its external service, which receives and may log the attempt. Use n8n_credentials_get for metadata-only inspection and do not call this when network contact is unwanted. Requires unsafe mode plus test permission; returns only the target-bound OK/Error outcome and withholds the upstream diagnostic message because it may contain secrets.",
201
+ description: "Test one already stored credential by allowing n8n to contact its external service, which receives and may log the attempt. credentialId is the stable ID from n8n_credentials_list or get; inline credential data, names, and types are not accepted. Use get for metadata-only inspection and skip this tool when network contact is unwanted. Requires unsafe mode plus test permission; returns only the target-bound OK/Error outcome and withholds the upstream diagnostic message.",
205
202
  operation: "unsafe",
206
203
  outputDataDescription: "Object with credentialId, status OK or Error, and a server-authored message that discloses only whether the test succeeded. The untrusted upstream diagnostic message is never returned.",
207
204
  openWorld: true,
@@ -221,7 +218,7 @@ export const credentialTools = Object.freeze([
221
218
  defineTool({
222
219
  name: "n8n_credentials_usage",
223
220
  title: "Find credential usage",
224
- description: "Scan one bounded workflow page for exact references to a credential ID. Use it before n8n_credentials_update or deletion; use n8n_credentials_get for metadata only. active filters upstream, cursor resumes the scan, and unresolved legacy references are counted but never matched; coverage is complete only when nextCursor is null. Requires workflow-list permission; returns bounded workflow/node matches and omission counts.",
221
+ description: "Scan one workflow page for exact references to a credential. credentialId is the stable ID from n8n_credentials_list or get. Omit cursor for page one; keep credentialId, active, and limit unchanged when passing nextCursor. Unresolved legacy references are counted but never matched, and coverage is complete only when nextCursor is null. Use this before update/delete. Requires workflow-list permission; returns bounded workflow/node matches and omission counts.",
225
222
  operation: "read-only",
226
223
  outputDataDescription: "Object with credentialId, workflowsExamined, matchingWorkflowCount, workflows with at most 200 total node details and 20 per workflow, nextCursor, scanComplete, truncation counts, referencesScanned, and referencesUnresolved.",
227
224
  input: {
@@ -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({