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 +60 -0
- package/README.md +9 -9
- package/ROADMAP.md +1 -1
- package/SECURITY.md +1 -1
- package/dist/introspect/contracts.js +8 -7
- package/dist/security/redaction.js +59 -21
- package/dist/tools/common.js +2 -2
- package/dist/tools/credentials.js +12 -15
- package/dist/tools/definition.js +38 -14
- package/dist/tools/executions.js +8 -8
- package/dist/tools/output-contracts.js +4 -3
- package/dist/tools/response-contracts.js +11 -7
- package/dist/tools/schemas.js +40 -0
- package/dist/tools/tags.js +5 -5
- package/dist/tools/users.js +3 -3
- package/dist/tools/utilities.js +40 -35
- package/dist/tools/workflows.js +30 -33
- package/dist/version.js +1 -1
- package/docs/compatibility.md +30 -7
- package/docs/installation.md +5 -4
- package/docs/security-model.md +4 -1
- package/docs/tools.md +43 -25
- package/package.json +2 -2
- package/server.json +2 -2
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
|
-
- **
|
|
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
|
|
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.
|
|
294
|
+
The v0.3.0 candidate surface intentionally excludes arbitrary workflow execution,
|
|
295
295
|
credential/workflow transfer, folders, data tables, beta evaluation endpoints,
|
|
296
296
|
and execution annotations. See the [roadmap](ROADMAP.md) for the annotation
|
|
297
297
|
proposal retained outside the release target.
|
package/ROADMAP.md
CHANGED
|
@@ -18,7 +18,7 @@ maintainer approval.
|
|
|
18
18
|
|
|
19
19
|
## Accepted backlog proposal
|
|
20
20
|
|
|
21
|
-
Execution annotations remain a coherent two-tool proposal outside v0.
|
|
21
|
+
Execution annotations remain a coherent two-tool proposal outside v0.3.0:
|
|
22
22
|
|
|
23
23
|
- `n8n_executions_get_tags`
|
|
24
24
|
- `n8n_executions_update_tags`
|
package/SECURITY.md
CHANGED
|
@@ -6,7 +6,7 @@ before public disclosure.
|
|
|
6
6
|
|
|
7
7
|
## Supported versions
|
|
8
8
|
|
|
9
|
-
The latest maintained minor line — currently 0.
|
|
9
|
+
The latest maintained minor line — currently 0.3.x — receives security fixes.
|
|
10
10
|
Older lines may be asked to upgrade when a safe backport is impractical.
|
|
11
11
|
|
|
12
12
|
## Report a vulnerability
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { preservingRecord } from "../tools/schemas.js";
|
|
2
3
|
import { INTROSPECT_RULE_IDS } from "./rule-ids.js";
|
|
3
4
|
export const INTROSPECT_SCHEMA_VERSION = "1.0.0";
|
|
4
5
|
export const INTROSPECT_ENGINE_VERSION = "2.0.0";
|
|
@@ -98,8 +99,8 @@ export const WorkflowNodeSchema = z
|
|
|
98
99
|
onError: z.string().max(128).optional(),
|
|
99
100
|
executeOnce: z.boolean().optional(),
|
|
100
101
|
alwaysOutputData: z.boolean().optional(),
|
|
101
|
-
parameters:
|
|
102
|
-
credentials:
|
|
102
|
+
parameters: preservingRecord(z.unknown()).optional(),
|
|
103
|
+
credentials: preservingRecord(z.unknown()).optional(),
|
|
103
104
|
})
|
|
104
105
|
.passthrough();
|
|
105
106
|
export const WorkflowResponseSchema = z
|
|
@@ -109,9 +110,9 @@ export const WorkflowResponseSchema = z
|
|
|
109
110
|
active: z.boolean(),
|
|
110
111
|
triggerCount: z.number().int().nonnegative().optional(),
|
|
111
112
|
nodes: z.array(WorkflowNodeSchema).max(1_000).default([]),
|
|
112
|
-
connections:
|
|
113
|
-
settings:
|
|
114
|
-
pinData:
|
|
113
|
+
connections: preservingRecord(z.unknown()).default({}),
|
|
114
|
+
settings: preservingRecord(z.unknown()).default({}),
|
|
115
|
+
pinData: preservingRecord(z.unknown()).nullable().optional(),
|
|
115
116
|
})
|
|
116
117
|
.passthrough();
|
|
117
118
|
const ApiIdentifierSchema = z.union([z.string(), z.number()]).transform(String);
|
|
@@ -138,7 +139,7 @@ export const ExecutionListResponseSchema = z
|
|
|
138
139
|
})
|
|
139
140
|
.passthrough();
|
|
140
141
|
export const ExecutionDetailResponseSchema = ExecutionMetadataSchema.extend({
|
|
141
|
-
data:
|
|
142
|
+
data: preservingRecord(z.unknown()).optional(),
|
|
142
143
|
}).passthrough();
|
|
143
144
|
export const SeveritySchema = z.enum(["critical", "high", "medium", "low", "info"]);
|
|
144
145
|
export const ConfidenceSchema = z.enum(["high", "medium", "low"]);
|
|
@@ -171,7 +172,7 @@ export const FindingSchema = z.object({
|
|
|
171
172
|
}),
|
|
172
173
|
evidence: z.object({
|
|
173
174
|
summary: z.string(),
|
|
174
|
-
facts:
|
|
175
|
+
facts: preservingRecord(FactValueSchema).optional(),
|
|
175
176
|
}),
|
|
176
177
|
remediation: z.string(),
|
|
177
178
|
documentationUrl: z
|
|
@@ -54,6 +54,32 @@ export class OutputLimitError extends Error {
|
|
|
54
54
|
this.name = "OutputLimitError";
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
const DEFAULT_SANITIZATION_LIMITS = Object.freeze({
|
|
58
|
+
arrayLength: 1_000,
|
|
59
|
+
depth: 20,
|
|
60
|
+
nodes: 20_000,
|
|
61
|
+
objectEntries: 1_000,
|
|
62
|
+
stringInputLength: 131_072,
|
|
63
|
+
stringOutputLength: 32_768,
|
|
64
|
+
});
|
|
65
|
+
function resolvedLimit(value, fallback, name) {
|
|
66
|
+
if (value === undefined)
|
|
67
|
+
return fallback;
|
|
68
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
69
|
+
throw new RangeError(`${name} must be a positive safe integer.`);
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function sanitizationLimits(options) {
|
|
74
|
+
return {
|
|
75
|
+
arrayLength: resolvedLimit(options.maxArrayLength, DEFAULT_SANITIZATION_LIMITS.arrayLength, "maxArrayLength"),
|
|
76
|
+
depth: resolvedLimit(options.maxDepth, DEFAULT_SANITIZATION_LIMITS.depth, "maxDepth"),
|
|
77
|
+
nodes: resolvedLimit(options.maxNodes, DEFAULT_SANITIZATION_LIMITS.nodes, "maxNodes"),
|
|
78
|
+
objectEntries: resolvedLimit(options.maxObjectEntries, DEFAULT_SANITIZATION_LIMITS.objectEntries, "maxObjectEntries"),
|
|
79
|
+
stringInputLength: resolvedLimit(options.maxStringInputLength, DEFAULT_SANITIZATION_LIMITS.stringInputLength, "maxStringInputLength"),
|
|
80
|
+
stringOutputLength: resolvedLimit(options.maxStringOutputLength, DEFAULT_SANITIZATION_LIMITS.stringOutputLength, "maxStringOutputLength"),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
57
83
|
function compareStrings(left, right) {
|
|
58
84
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
59
85
|
}
|
|
@@ -103,13 +129,16 @@ function looksLikeOpaquePathSegment(value) {
|
|
|
103
129
|
}
|
|
104
130
|
return entropy >= (value.length >= 32 ? 3.5 : 4);
|
|
105
131
|
}
|
|
106
|
-
function sanitizeString(value, structuralKey) {
|
|
107
|
-
const boundedInput = value.length >
|
|
132
|
+
function sanitizeString(value, structuralKey, limits = DEFAULT_SANITIZATION_LIMITS) {
|
|
133
|
+
const boundedInput = value.length > limits.stringInputLength ? value.slice(0, limits.stringInputLength) : value;
|
|
108
134
|
const normalizedInput = boundedInput.normalize("NFKC");
|
|
109
135
|
let output = normalizedInput
|
|
110
|
-
.slice(0,
|
|
136
|
+
.slice(0, limits.stringInputLength)
|
|
111
137
|
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "");
|
|
112
|
-
let redacted = boundedInput !== value ||
|
|
138
|
+
let redacted = boundedInput !== value ||
|
|
139
|
+
normalizedInput.length > limits.stringInputLength ||
|
|
140
|
+
output !== boundedInput;
|
|
141
|
+
let sizeReduced = boundedInput !== value || normalizedInput.length > limits.stringInputLength;
|
|
113
142
|
for (const [pattern, replacement] of HIGH_CONFIDENCE_CREDENTIAL_REDACTIONS) {
|
|
114
143
|
pattern.lastIndex = 0;
|
|
115
144
|
const filtered = output.replace(pattern, replacement);
|
|
@@ -132,7 +161,7 @@ function sanitizeString(value, structuralKey) {
|
|
|
132
161
|
((structuralKey === "documentationUrl" || structuralKey === "officialUrl") &&
|
|
133
162
|
isOfficialN8nDocumentationUrl(output));
|
|
134
163
|
if (validatedInternalValueIsSafe)
|
|
135
|
-
return { value: output, redacted };
|
|
164
|
+
return { value: output, redacted, sizeReduced };
|
|
136
165
|
for (const [pattern, replacement] of MANDATORY_VALUE_REDACTIONS) {
|
|
137
166
|
pattern.lastIndex = 0;
|
|
138
167
|
const filtered = output.replace(pattern, replacement);
|
|
@@ -151,7 +180,7 @@ function sanitizeString(value, structuralKey) {
|
|
|
151
180
|
STRUCTURAL_IDENTIFIER_KEY.test(structuralKey) &&
|
|
152
181
|
SAFE_IDENTIFIER_VALUE.test(output);
|
|
153
182
|
if (structuralValueIsSafe)
|
|
154
|
-
return { value: output, redacted };
|
|
183
|
+
return { value: output, redacted, sizeReduced };
|
|
155
184
|
for (const [pattern, replacement] of TOKEN_REDACTIONS) {
|
|
156
185
|
pattern.lastIndex = 0;
|
|
157
186
|
const filtered = output.replace(pattern, replacement);
|
|
@@ -170,31 +199,35 @@ function sanitizeString(value, structuralKey) {
|
|
|
170
199
|
const withPhone = redactPhone(output);
|
|
171
200
|
redacted ||= withPhone !== output;
|
|
172
201
|
output = withPhone;
|
|
173
|
-
if (output.length >
|
|
174
|
-
output = `${output.slice(0,
|
|
202
|
+
if (output.length > limits.stringOutputLength) {
|
|
203
|
+
output = `${output.slice(0, Math.max(0, limits.stringOutputLength - 1))}\u2026`;
|
|
175
204
|
redacted = true;
|
|
205
|
+
sizeReduced = true;
|
|
176
206
|
}
|
|
177
|
-
return { value: output, redacted };
|
|
207
|
+
return { value: output, redacted, sizeReduced };
|
|
178
208
|
}
|
|
179
209
|
function sanitizeValue(value, state, depth, structuralKey, preserveChildKeys = false, sensitiveDescriptor = false, treatSensitiveKeyAsStructural = false) {
|
|
180
210
|
state.nodes += 1;
|
|
181
|
-
if (state.nodes >
|
|
211
|
+
if (state.nodes > state.limits.nodes || depth > state.limits.depth) {
|
|
182
212
|
state.redacted = true;
|
|
183
213
|
state.structurallyReduced = true;
|
|
214
|
+
state.sizeReduced = true;
|
|
184
215
|
return "[TRUNCATED]";
|
|
185
216
|
}
|
|
186
217
|
if (typeof value === "string") {
|
|
187
|
-
const result = sanitizeString(value, structuralKey);
|
|
218
|
+
const result = sanitizeString(value, structuralKey, state.limits);
|
|
188
219
|
state.redacted ||= result.redacted;
|
|
220
|
+
state.sizeReduced ||= result.sizeReduced;
|
|
189
221
|
return result.value;
|
|
190
222
|
}
|
|
191
223
|
if (value === null || typeof value === "boolean" || typeof value === "number")
|
|
192
224
|
return value;
|
|
193
225
|
if (Array.isArray(value)) {
|
|
194
|
-
const limit = Math.min(value.length,
|
|
226
|
+
const limit = Math.min(value.length, state.limits.arrayLength);
|
|
195
227
|
if (limit < value.length) {
|
|
196
228
|
state.redacted = true;
|
|
197
229
|
state.structurallyReduced = true;
|
|
230
|
+
state.sizeReduced = true;
|
|
198
231
|
}
|
|
199
232
|
return value
|
|
200
233
|
.slice(0, limit)
|
|
@@ -225,14 +258,11 @@ function sanitizeValue(value, state, depth, structuralKey, preserveChildKeys = f
|
|
|
225
258
|
typeof candidate === "string" &&
|
|
226
259
|
SENSITIVE_KEY.test(candidate));
|
|
227
260
|
const originalKeys = new Set(entries.map(([key]) => key));
|
|
228
|
-
for (const [entryIndex, [key, child]] of entries.slice(0,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
const sanitizedKey = sanitizeString(key);
|
|
261
|
+
for (const [entryIndex, [key, child]] of entries.slice(0, state.limits.objectEntries).entries()) {
|
|
262
|
+
const sanitizedKey = sanitizeString(key, undefined, state.limits);
|
|
263
|
+
state.sizeReduced ||= sanitizedKey.sizeReduced;
|
|
234
264
|
let outputKey = key;
|
|
235
|
-
if (sanitizedKey.redacted) {
|
|
265
|
+
if (PROTOTYPE_KEY.test(key) || sanitizedKey.redacted) {
|
|
236
266
|
let placeholderIndex = entryIndex + 1;
|
|
237
267
|
do {
|
|
238
268
|
outputKey = `[REDACTED_KEY_${placeholderIndex}]`;
|
|
@@ -277,18 +307,26 @@ function sanitizeValue(value, state, depth, structuralKey, preserveChildKeys = f
|
|
|
277
307
|
}
|
|
278
308
|
output[outputKey] = sanitizeValue(child, state, depth + 1, key, STRUCTURAL_MAP_KEY.test(key), false);
|
|
279
309
|
}
|
|
280
|
-
if (entries.length >
|
|
310
|
+
if (entries.length > state.limits.objectEntries) {
|
|
281
311
|
state.redacted = true;
|
|
282
312
|
state.structurallyReduced = true;
|
|
313
|
+
state.sizeReduced = true;
|
|
283
314
|
}
|
|
284
315
|
return output;
|
|
285
316
|
}
|
|
286
317
|
export function sanitizeForOutputDetailed(value, options = {}) {
|
|
287
|
-
const state = {
|
|
318
|
+
const state = {
|
|
319
|
+
nodes: 0,
|
|
320
|
+
redacted: false,
|
|
321
|
+
structurallyReduced: false,
|
|
322
|
+
sizeReduced: false,
|
|
323
|
+
limits: sanitizationLimits(options),
|
|
324
|
+
};
|
|
288
325
|
const data = sanitizeValue(value, state, 0, undefined, false, false, options.preserveValidatedRootRecordValues ?? false);
|
|
289
326
|
return {
|
|
290
327
|
output: { data, redacted: state.redacted, untrusted: true },
|
|
291
328
|
structurallyReduced: state.structurallyReduced,
|
|
329
|
+
sizeReduced: state.sizeReduced,
|
|
292
330
|
};
|
|
293
331
|
}
|
|
294
332
|
export function sanitizeForOutput(value, options = {}) {
|
package/dist/tools/common.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { cursor } from "./schemas.js";
|
|
2
|
+
import { cursor, preservingRecord } from "./schemas.js";
|
|
3
3
|
export const apiIdentifier = z.union([z.string(), z.number()]).transform(String);
|
|
4
4
|
export const entitySchema = z.object({ id: apiIdentifier }).passthrough();
|
|
5
5
|
export const listEnvelopeSchema = z
|
|
6
6
|
.object({
|
|
7
|
-
data: z.array(
|
|
7
|
+
data: z.array(preservingRecord(z.unknown())).max(100),
|
|
8
8
|
nextCursor: cursor.nullable().optional(),
|
|
9
9
|
})
|
|
10
10
|
.passthrough();
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { defineTool } from "./definition.js";
|
|
3
3
|
import { booleanQuery, numberQuery } from "./common.js";
|
|
4
|
-
import { assertSafeJson, cursor, identifier, pageLimit, pathSegment, safeJsonValue, } from "./schemas.js";
|
|
4
|
+
import { assertSafeJson, cursor, identifier, pageLimit, pathSegment, preservingRecord, safeJsonValue, } from "./schemas.js";
|
|
5
5
|
import { credentialTypeSchema } from "./response-contracts.js";
|
|
6
6
|
const credentialMetadataSchema = z.object({
|
|
7
7
|
id: identifier(),
|
|
@@ -47,7 +47,7 @@ const usageWorkflowSchema = z.object({
|
|
|
47
47
|
id: identifier().optional(),
|
|
48
48
|
name: z.string().min(1).max(256),
|
|
49
49
|
type: z.string().min(1).max(256),
|
|
50
|
-
credentials:
|
|
50
|
+
credentials: preservingRecord(workflowCredentialReferenceSchema).optional(),
|
|
51
51
|
}))
|
|
52
52
|
.max(1_000),
|
|
53
53
|
});
|
|
@@ -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.
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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: {
|
package/dist/tools/definition.js
CHANGED
|
@@ -6,12 +6,15 @@ import { authorizeOperation } from "../security/operation-policy.js";
|
|
|
6
6
|
import { boundedJson, OutputLimitError, sanitizeForOutput, sanitizeForOutputDetailed, } from "../security/redaction.js";
|
|
7
7
|
import { TOOL_ENDPOINT_CONTRACTS } from "./endpoint-contracts.js";
|
|
8
8
|
import { genericToolOutputContract, MUTATION_IDENTITY_KEYS } from "./output-contracts.js";
|
|
9
|
+
const MAX_SERIALIZED_TOOL_RESULT_BYTES = 256 * 1024;
|
|
10
|
+
const DEFAULT_READ_ONLY_OUTPUT_LIMIT_MESSAGE = "The sanitized result exceeded safe output limits. Use narrower inputs when available; otherwise inspect the full object in n8n.";
|
|
9
11
|
function annotationsFor(spec) {
|
|
12
|
+
const readOnly = spec.readOnly ?? spec.operation === "read-only";
|
|
10
13
|
return {
|
|
11
14
|
title: spec.title,
|
|
12
|
-
readOnlyHint:
|
|
15
|
+
readOnlyHint: readOnly,
|
|
13
16
|
destructiveHint: spec.destructive ?? spec.operation !== "read-only",
|
|
14
|
-
idempotentHint: spec.idempotent ??
|
|
17
|
+
idempotentHint: spec.idempotent ?? readOnly,
|
|
15
18
|
openWorldHint: spec.openWorld ?? true,
|
|
16
19
|
};
|
|
17
20
|
}
|
|
@@ -40,8 +43,8 @@ function errorResult(error, correlationId, toolName) {
|
|
|
40
43
|
}
|
|
41
44
|
class ToolOutputError extends Error {
|
|
42
45
|
code = "invalid_output";
|
|
43
|
-
constructor() {
|
|
44
|
-
super(
|
|
46
|
+
constructor(message = "The tool produced a result that did not match its published output contract.") {
|
|
47
|
+
super(message);
|
|
45
48
|
this.name = "ToolOutputError";
|
|
46
49
|
}
|
|
47
50
|
}
|
|
@@ -52,13 +55,21 @@ function validatedOutput(value, outputSchema) {
|
|
|
52
55
|
return parsed.data;
|
|
53
56
|
}
|
|
54
57
|
function resultFromValidatedOutput(safe) {
|
|
55
|
-
|
|
58
|
+
const result = {
|
|
56
59
|
content: [{ type: "text", text: boundedJson(safe) }],
|
|
57
60
|
structuredContent: { ...safe },
|
|
58
61
|
};
|
|
62
|
+
// The MCP result carries both the compatibility text and structured content. Bound their
|
|
63
|
+
// combined serialized form so consumers never receive a nominally successful result above
|
|
64
|
+
// the documented 256 KiB envelope.
|
|
65
|
+
if (Buffer.byteLength(JSON.stringify(result)) > MAX_SERIALIZED_TOOL_RESULT_BYTES) {
|
|
66
|
+
throw new OutputLimitError();
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
59
69
|
}
|
|
60
70
|
function fallbackResult(value, outputSchema) {
|
|
61
|
-
|
|
71
|
+
const sanitized = sanitizeForOutput(value);
|
|
72
|
+
return resultFromValidatedOutput(validatedOutput({ ...sanitized, redacted: true }, outputSchema));
|
|
62
73
|
}
|
|
63
74
|
// When a mutation's full result exceeds the output cap, the write already landed upstream, so
|
|
64
75
|
// summarize it to a bounded, stable shape (truncated + the scalar identity fields) instead of
|
|
@@ -84,11 +95,17 @@ function truncatedMutationResult(value) {
|
|
|
84
95
|
identity,
|
|
85
96
|
};
|
|
86
97
|
}
|
|
87
|
-
function buildSuccessResult(value,
|
|
98
|
+
function buildSuccessResult(value, readOnly, primaryDataSchema, outputSchema, preserveValidatedRootRecordValues = false, sanitizationOptions = {}, failOnSanitizerLimit = false, readOnlyOutputLimitMessage) {
|
|
88
99
|
// A handler may return only its official primary shape. The public mutation fallback is a
|
|
89
100
|
// server-generated recovery form and can never be supplied directly by an upstream handler.
|
|
90
101
|
validatedOutput(value, primaryDataSchema);
|
|
91
|
-
const sanitized = sanitizeForOutputDetailed(value, {
|
|
102
|
+
const sanitized = sanitizeForOutputDetailed(value, {
|
|
103
|
+
...sanitizationOptions,
|
|
104
|
+
preserveValidatedRootRecordValues,
|
|
105
|
+
});
|
|
106
|
+
if (failOnSanitizerLimit && sanitized.sizeReduced) {
|
|
107
|
+
throw new ToolOutputError(readOnlyOutputLimitMessage ?? DEFAULT_READ_ONLY_OUTPUT_LIMIT_MESSAGE);
|
|
108
|
+
}
|
|
92
109
|
let safe;
|
|
93
110
|
try {
|
|
94
111
|
safe = validatedOutput(sanitized.output, outputSchema);
|
|
@@ -96,9 +113,7 @@ function buildSuccessResult(value, operation, primaryDataSchema, outputSchema, p
|
|
|
96
113
|
catch (error) {
|
|
97
114
|
// A completed mutation falls back only when the sanitizer explicitly reduced structure
|
|
98
115
|
// because of its depth/node/breadth bounds. Ordinary post-sanitization schema drift must fail.
|
|
99
|
-
if (
|
|
100
|
-
!(error instanceof ToolOutputError) ||
|
|
101
|
-
!sanitized.structurallyReduced) {
|
|
116
|
+
if (readOnly || !(error instanceof ToolOutputError) || !sanitized.structurallyReduced) {
|
|
102
117
|
throw error;
|
|
103
118
|
}
|
|
104
119
|
return fallbackResult(truncatedMutationResult(value), outputSchema);
|
|
@@ -107,7 +122,10 @@ function buildSuccessResult(value, operation, primaryDataSchema, outputSchema, p
|
|
|
107
122
|
return resultFromValidatedOutput(safe);
|
|
108
123
|
}
|
|
109
124
|
catch (error) {
|
|
110
|
-
if (
|
|
125
|
+
if (readOnly && error instanceof OutputLimitError) {
|
|
126
|
+
throw new ToolOutputError(readOnlyOutputLimitMessage ?? DEFAULT_READ_ONLY_OUTPUT_LIMIT_MESSAGE);
|
|
127
|
+
}
|
|
128
|
+
if (readOnly || !(error instanceof OutputLimitError))
|
|
111
129
|
throw error;
|
|
112
130
|
// A mutating tool has already applied its write, so an over-cap result is a
|
|
113
131
|
// truthful, bounded success summary rather than a misleading failure.
|
|
@@ -124,6 +142,12 @@ export function createToolContext(startup) {
|
|
|
124
142
|
}
|
|
125
143
|
export function defineTool(spec) {
|
|
126
144
|
const annotations = annotationsFor(spec);
|
|
145
|
+
if (annotations.readOnlyHint === true && annotations.destructiveHint === true) {
|
|
146
|
+
throw new Error(`Tool ${spec.name} cannot be both read-only and destructive.`);
|
|
147
|
+
}
|
|
148
|
+
if (spec.failOnSanitizerLimit === true && annotations.readOnlyHint !== true) {
|
|
149
|
+
throw new Error(`Tool ${spec.name} can fail on sanitizer limits only when it is read-only.`);
|
|
150
|
+
}
|
|
127
151
|
const endpointContract = TOOL_ENDPOINT_CONTRACTS[spec.name];
|
|
128
152
|
if (endpointContract === undefined) {
|
|
129
153
|
throw new Error(`Tool ${spec.name} is missing its endpoint documentation contract.`);
|
|
@@ -131,7 +155,7 @@ export function defineTool(spec) {
|
|
|
131
155
|
const inputShape = { ...spec.input };
|
|
132
156
|
const inputSchema = z.object(inputShape).strict();
|
|
133
157
|
const genericOutput = spec.outputSchema === undefined
|
|
134
|
-
? genericToolOutputContract(spec.name,
|
|
158
|
+
? genericToolOutputContract(spec.name, annotations.readOnlyHint === true, spec.outputDataDescription)
|
|
135
159
|
: undefined;
|
|
136
160
|
const outputSchema = spec.outputSchema ?? genericOutput?.outputSchema;
|
|
137
161
|
if (outputSchema === undefined) {
|
|
@@ -166,7 +190,7 @@ export function defineTool(spec) {
|
|
|
166
190
|
if (genericOutput === undefined) {
|
|
167
191
|
throw new Error(`Tool ${spec.name} is missing its primary output contract.`);
|
|
168
192
|
}
|
|
169
|
-
result = buildSuccessResult(value,
|
|
193
|
+
result = buildSuccessResult(value, annotations.readOnlyHint === true, genericOutput.primaryDataSchema, outputSchema, spec.preserveValidatedRootRecordValues, spec.sanitizationOptions, spec.failOnSanitizerLimit, spec.readOnlyOutputLimitMessage);
|
|
170
194
|
}
|
|
171
195
|
if (spec.operation !== "read-only") {
|
|
172
196
|
console.error(JSON.stringify({
|