n8n-mcp-community 0.1.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 +116 -0
- package/CODE_OF_CONDUCT.md +51 -0
- package/CONTRIBUTING.md +69 -0
- package/LICENSE +21 -0
- package/README.md +271 -0
- package/ROADMAP.md +41 -0
- package/SECURITY.md +53 -0
- package/SUPPORT.md +19 -0
- package/THIRD_PARTY_NOTICES.md +48 -0
- package/dist/config.js +76 -0
- package/dist/content/node-docs.js +47 -0
- package/dist/content/official-urls.js +11 -0
- package/dist/doctor.js +88 -0
- package/dist/index.js +119 -0
- package/dist/introspect/collector.js +642 -0
- package/dist/introspect/contracts.js +309 -0
- package/dist/introspect/engine.js +184 -0
- package/dist/introspect/http-client.js +142 -0
- package/dist/introspect/index.js +5 -0
- package/dist/introspect/order.js +3 -0
- package/dist/introspect/render.js +25 -0
- package/dist/introspect/rule-ids.js +25 -0
- package/dist/introspect/rules.js +905 -0
- package/dist/introspect/sanitize.js +66 -0
- package/dist/n8n/client.js +170 -0
- package/dist/prompts.js +69 -0
- package/dist/resources.js +42 -0
- package/dist/security/operation-policy.js +20 -0
- package/dist/security/redaction.js +253 -0
- package/dist/server.js +16 -0
- package/dist/tools/common.js +54 -0
- package/dist/tools/credentials.js +259 -0
- package/dist/tools/definition.js +169 -0
- package/dist/tools/endpoint-contracts.js +53 -0
- package/dist/tools/executions.js +180 -0
- package/dist/tools/registry.js +24 -0
- package/dist/tools/schemas.js +203 -0
- package/dist/tools/tags.js +73 -0
- package/dist/tools/users.js +130 -0
- package/dist/tools/utilities.js +347 -0
- package/dist/tools/workflows.js +771 -0
- package/dist/version.js +1 -0
- package/docs/README.md +21 -0
- package/docs/architecture.md +122 -0
- package/docs/assets/demo.gif +0 -0
- package/docs/clients.md +95 -0
- package/docs/compatibility.md +112 -0
- package/docs/configuration.md +87 -0
- package/docs/demo-transcript.md +31 -0
- package/docs/examples.md +86 -0
- package/docs/faq.md +90 -0
- package/docs/getting-started.md +93 -0
- package/docs/installation.md +120 -0
- package/docs/provenance.md +152 -0
- package/docs/security-model.md +170 -0
- package/docs/tools.md +937 -0
- package/docs/troubleshooting.md +185 -0
- package/package.json +126 -0
- package/server.json +51 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { cursor } from "./schemas.js";
|
|
3
|
+
export const apiIdentifier = z.union([z.string(), z.number()]).transform(String);
|
|
4
|
+
export const entitySchema = z.object({ id: apiIdentifier }).passthrough();
|
|
5
|
+
export const listEnvelopeSchema = z
|
|
6
|
+
.object({
|
|
7
|
+
data: z.array(z.record(z.unknown())).max(100),
|
|
8
|
+
nextCursor: cursor.nullable().optional(),
|
|
9
|
+
})
|
|
10
|
+
.passthrough();
|
|
11
|
+
export function booleanQuery(value) {
|
|
12
|
+
return value === undefined ? undefined : String(value);
|
|
13
|
+
}
|
|
14
|
+
export function numberQuery(value) {
|
|
15
|
+
return value === undefined ? undefined : String(value);
|
|
16
|
+
}
|
|
17
|
+
export function requireSafeAscii(value, label, maximumLength = 256) {
|
|
18
|
+
if (value.length < 1 ||
|
|
19
|
+
value.length > maximumLength ||
|
|
20
|
+
!/^[\x20-\x7e]+$/.test(value) ||
|
|
21
|
+
value !== value.trim()) {
|
|
22
|
+
throw new Error(`${label} must contain 1-${maximumLength} trimmed printable ASCII characters.`);
|
|
23
|
+
}
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
export function projectMetadata(value) {
|
|
27
|
+
const source = z.record(z.unknown()).parse(value);
|
|
28
|
+
const allowed = [
|
|
29
|
+
"id",
|
|
30
|
+
"name",
|
|
31
|
+
"type",
|
|
32
|
+
"active",
|
|
33
|
+
"isArchived",
|
|
34
|
+
"createdAt",
|
|
35
|
+
"updatedAt",
|
|
36
|
+
"versionId",
|
|
37
|
+
"workflowId",
|
|
38
|
+
"status",
|
|
39
|
+
"startedAt",
|
|
40
|
+
"stoppedAt",
|
|
41
|
+
"mode",
|
|
42
|
+
"finished",
|
|
43
|
+
"retryOf",
|
|
44
|
+
"retrySuccessId",
|
|
45
|
+
"role",
|
|
46
|
+
"disabled",
|
|
47
|
+
];
|
|
48
|
+
const output = {};
|
|
49
|
+
for (const key of allowed) {
|
|
50
|
+
if (Object.hasOwn(source, key))
|
|
51
|
+
output[key] = source[key];
|
|
52
|
+
}
|
|
53
|
+
return output;
|
|
54
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { defineTool } from "./definition.js";
|
|
3
|
+
import { booleanQuery, numberQuery } from "./common.js";
|
|
4
|
+
import { assertSafeJson, confirmation, cursor, identifier, pageLimit, pathSegment, safeJsonValue, } from "./schemas.js";
|
|
5
|
+
const credentialMetadataSchema = z.object({
|
|
6
|
+
id: identifier(),
|
|
7
|
+
name: z.string().min(1).max(256),
|
|
8
|
+
type: z.string().min(1).max(256),
|
|
9
|
+
createdAt: z.string().optional(),
|
|
10
|
+
updatedAt: z.string().optional(),
|
|
11
|
+
isManaged: z.boolean().optional(),
|
|
12
|
+
isGlobal: z.boolean().optional(),
|
|
13
|
+
isResolvable: z.boolean().optional(),
|
|
14
|
+
});
|
|
15
|
+
const credentialListSchema = z.object({
|
|
16
|
+
data: z.array(credentialMetadataSchema).max(100),
|
|
17
|
+
nextCursor: cursor.nullable().optional(),
|
|
18
|
+
});
|
|
19
|
+
// n8n's canonical node credential reference is { id: string | null; name: string }, and legacy
|
|
20
|
+
// imports can carry name-only strings or unresolved null ids. Coerce any shape to a bounded
|
|
21
|
+
// { id } so a single unresolved reference can never abort the whole page scan; unresolved
|
|
22
|
+
// references (null/absent/non-scalar id, or a legacy string form) are counted but never matched.
|
|
23
|
+
const workflowCredentialReferenceSchema = z
|
|
24
|
+
.unknown()
|
|
25
|
+
.transform((reference) => {
|
|
26
|
+
if (reference !== null && typeof reference === "object" && !Array.isArray(reference)) {
|
|
27
|
+
const { id } = reference;
|
|
28
|
+
if (typeof id === "string" || typeof id === "number")
|
|
29
|
+
return { id: String(id) };
|
|
30
|
+
}
|
|
31
|
+
return { id: null };
|
|
32
|
+
});
|
|
33
|
+
const usageWorkflowSchema = z.object({
|
|
34
|
+
id: identifier(),
|
|
35
|
+
name: z.string().min(1).max(256),
|
|
36
|
+
active: z.boolean(),
|
|
37
|
+
nodes: z
|
|
38
|
+
.array(z.object({
|
|
39
|
+
id: identifier().optional(),
|
|
40
|
+
name: z.string().min(1).max(256),
|
|
41
|
+
type: z.string().min(1).max(256),
|
|
42
|
+
credentials: z.record(workflowCredentialReferenceSchema).optional(),
|
|
43
|
+
}))
|
|
44
|
+
.max(1_000),
|
|
45
|
+
});
|
|
46
|
+
const usageListSchema = z.object({
|
|
47
|
+
data: z.array(usageWorkflowSchema).max(100),
|
|
48
|
+
nextCursor: cursor.nullable().optional(),
|
|
49
|
+
});
|
|
50
|
+
export const credentialTools = Object.freeze([
|
|
51
|
+
defineTool({
|
|
52
|
+
name: "n8n_credentials_create",
|
|
53
|
+
title: "Create credential",
|
|
54
|
+
description: "Create a credential while keeping credential values out of output and logs.",
|
|
55
|
+
operation: "write",
|
|
56
|
+
input: {
|
|
57
|
+
name: z.string().min(1).max(128),
|
|
58
|
+
type: z.string().regex(/^[A-Za-z0-9_.-]{1,128}$/),
|
|
59
|
+
data: z.record(safeJsonValue),
|
|
60
|
+
isResolvable: z.boolean().optional(),
|
|
61
|
+
},
|
|
62
|
+
handler: async (input, context) => {
|
|
63
|
+
assertSafeJson(input.data);
|
|
64
|
+
return credentialMetadataSchema.parse(await context.client().request({ method: "POST", path: "/credentials", body: input }));
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
defineTool({
|
|
68
|
+
name: "n8n_credentials_delete",
|
|
69
|
+
title: "Delete credential",
|
|
70
|
+
description: "Permanently delete one stored credential after exact confirmation.",
|
|
71
|
+
operation: "unsafe",
|
|
72
|
+
input: { credentialId: identifier(), confirmation },
|
|
73
|
+
confirmation: (input) => ({
|
|
74
|
+
supplied: input.confirmation,
|
|
75
|
+
expected: `DELETE ${input.credentialId}`,
|
|
76
|
+
}),
|
|
77
|
+
handler: async (input, context) => {
|
|
78
|
+
await context
|
|
79
|
+
.client()
|
|
80
|
+
.request({ method: "DELETE", path: `/credentials/${pathSegment(input.credentialId)}` });
|
|
81
|
+
return { credentialId: input.credentialId, deleted: true };
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
defineTool({
|
|
85
|
+
name: "n8n_credentials_schema",
|
|
86
|
+
title: "Get credential schema",
|
|
87
|
+
description: "Get the supported public schema for one credential type.",
|
|
88
|
+
operation: "read-only",
|
|
89
|
+
input: { credentialType: identifier() },
|
|
90
|
+
handler: async (input, context) => z.record(z.unknown()).parse(await context.client().request({
|
|
91
|
+
path: `/credentials/schema/${pathSegment(input.credentialType)}`,
|
|
92
|
+
})),
|
|
93
|
+
}),
|
|
94
|
+
defineTool({
|
|
95
|
+
name: "n8n_credentials_list",
|
|
96
|
+
title: "List credentials",
|
|
97
|
+
description: "List credential metadata through the public endpoint verified in n8n Community 2.30.5 and 2.30.7. Credential values are never returned.",
|
|
98
|
+
operation: "read-only",
|
|
99
|
+
input: { limit: pageLimit(), cursor: cursor.optional() },
|
|
100
|
+
handler: async (input, context) => credentialListSchema.parse(await context.client().request({
|
|
101
|
+
path: "/credentials",
|
|
102
|
+
query: { limit: numberQuery(input.limit), cursor: input.cursor },
|
|
103
|
+
})),
|
|
104
|
+
}),
|
|
105
|
+
defineTool({
|
|
106
|
+
name: "n8n_credentials_get",
|
|
107
|
+
title: "Get credential",
|
|
108
|
+
description: "Get public metadata for one credential without retrieving secret values.",
|
|
109
|
+
operation: "read-only",
|
|
110
|
+
input: { credentialId: identifier() },
|
|
111
|
+
handler: async (input, context) => credentialMetadataSchema.parse(await context.client().request({ path: `/credentials/${pathSegment(input.credentialId)}` })),
|
|
112
|
+
}),
|
|
113
|
+
defineTool({
|
|
114
|
+
name: "n8n_credentials_update",
|
|
115
|
+
title: "Update credential",
|
|
116
|
+
description: "Update credential metadata or values while keeping secret material out of output and logs.",
|
|
117
|
+
operation: "write",
|
|
118
|
+
destructive: true,
|
|
119
|
+
input: {
|
|
120
|
+
credentialId: identifier(),
|
|
121
|
+
name: z.string().min(1).max(128).optional(),
|
|
122
|
+
type: z
|
|
123
|
+
.string()
|
|
124
|
+
.regex(/^[A-Za-z0-9_.-]{1,128}$/)
|
|
125
|
+
.optional(),
|
|
126
|
+
data: z.record(safeJsonValue).optional(),
|
|
127
|
+
isGlobal: z.boolean().optional(),
|
|
128
|
+
isResolvable: z.boolean().optional(),
|
|
129
|
+
isPartialData: z.boolean().default(false),
|
|
130
|
+
},
|
|
131
|
+
handler: async (input, context) => {
|
|
132
|
+
if (input.name === undefined &&
|
|
133
|
+
input.type === undefined &&
|
|
134
|
+
input.data === undefined &&
|
|
135
|
+
input.isGlobal === undefined &&
|
|
136
|
+
input.isResolvable === undefined) {
|
|
137
|
+
throw new Error("Provide at least one credential field to update.");
|
|
138
|
+
}
|
|
139
|
+
if (input.type !== undefined && input.data === undefined) {
|
|
140
|
+
throw new Error("Changing a credential type also requires replacement credential data.");
|
|
141
|
+
}
|
|
142
|
+
if (input.data !== undefined)
|
|
143
|
+
assertSafeJson(input.data);
|
|
144
|
+
return credentialMetadataSchema.parse(await context.client().request({
|
|
145
|
+
method: "PATCH",
|
|
146
|
+
path: `/credentials/${pathSegment(input.credentialId)}`,
|
|
147
|
+
body: {
|
|
148
|
+
...(input.name === undefined ? {} : { name: input.name }),
|
|
149
|
+
...(input.type === undefined ? {} : { type: input.type }),
|
|
150
|
+
...(input.data === undefined ? {} : { data: input.data }),
|
|
151
|
+
...(input.isGlobal === undefined ? {} : { isGlobal: input.isGlobal }),
|
|
152
|
+
...(input.isResolvable === undefined ? {} : { isResolvable: input.isResolvable }),
|
|
153
|
+
isPartialData: input.isPartialData,
|
|
154
|
+
},
|
|
155
|
+
}));
|
|
156
|
+
},
|
|
157
|
+
}),
|
|
158
|
+
defineTool({
|
|
159
|
+
name: "n8n_credentials_test",
|
|
160
|
+
title: "Test credential",
|
|
161
|
+
description: "Test one stored credential. This may contact the credential's external service.",
|
|
162
|
+
operation: "unsafe",
|
|
163
|
+
openWorld: true,
|
|
164
|
+
input: { credentialId: identifier(), confirmation },
|
|
165
|
+
confirmation: (input) => ({
|
|
166
|
+
supplied: input.confirmation,
|
|
167
|
+
expected: `TEST ${input.credentialId}`,
|
|
168
|
+
}),
|
|
169
|
+
handler: async (input, context) => {
|
|
170
|
+
// The external test already ran by the time this response arrives, so an over-long
|
|
171
|
+
// status/message must be truncated rather than rejected: discarding a completed outcome
|
|
172
|
+
// would report a generic error for a side effect that genuinely happened.
|
|
173
|
+
const raw = z.object({ status: z.string(), message: z.string().optional() }).parse(await context.client().request({
|
|
174
|
+
method: "POST",
|
|
175
|
+
path: `/credentials/${pathSegment(input.credentialId)}/test`,
|
|
176
|
+
}));
|
|
177
|
+
const status = raw.status.slice(0, 64);
|
|
178
|
+
const message = raw.message === undefined ? undefined : raw.message.slice(0, 512);
|
|
179
|
+
const truncated = status.length < raw.status.length ||
|
|
180
|
+
(raw.message !== undefined && message !== undefined && message.length < raw.message.length);
|
|
181
|
+
return {
|
|
182
|
+
credentialId: input.credentialId,
|
|
183
|
+
status,
|
|
184
|
+
...(message === undefined ? {} : { message }),
|
|
185
|
+
...(truncated ? { truncated: true } : {}),
|
|
186
|
+
};
|
|
187
|
+
},
|
|
188
|
+
}),
|
|
189
|
+
defineTool({
|
|
190
|
+
name: "n8n_credentials_usage",
|
|
191
|
+
title: "Find credential usage",
|
|
192
|
+
description: "Scan one bounded workflow page for exact references to one credential ID.",
|
|
193
|
+
operation: "read-only",
|
|
194
|
+
input: {
|
|
195
|
+
credentialId: identifier(),
|
|
196
|
+
cursor: cursor.optional(),
|
|
197
|
+
limit: pageLimit(100, 50),
|
|
198
|
+
active: z.boolean().optional(),
|
|
199
|
+
},
|
|
200
|
+
handler: async (input, context) => {
|
|
201
|
+
const page = usageListSchema.parse(await context.client().request({
|
|
202
|
+
path: "/workflows",
|
|
203
|
+
query: {
|
|
204
|
+
cursor: input.cursor,
|
|
205
|
+
limit: numberQuery(input.limit),
|
|
206
|
+
active: booleanQuery(input.active),
|
|
207
|
+
excludePinnedData: "true",
|
|
208
|
+
},
|
|
209
|
+
}));
|
|
210
|
+
const matches = [];
|
|
211
|
+
let matchingWorkflowCount = 0;
|
|
212
|
+
let omittedNodeDetails = 0;
|
|
213
|
+
let retainedNodeDetails = 0;
|
|
214
|
+
let referencesScanned = 0;
|
|
215
|
+
let referencesUnresolved = 0;
|
|
216
|
+
for (const workflow of page.data) {
|
|
217
|
+
for (const node of workflow.nodes) {
|
|
218
|
+
for (const reference of Object.values(node.credentials ?? {})) {
|
|
219
|
+
referencesScanned += 1;
|
|
220
|
+
if (reference.id === null)
|
|
221
|
+
referencesUnresolved += 1;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
const nodes = workflow.nodes.filter((node) => Object.values(node.credentials ?? {}).some((reference) => reference.id === input.credentialId));
|
|
225
|
+
if (nodes.length === 0)
|
|
226
|
+
continue;
|
|
227
|
+
matchingWorkflowCount += 1;
|
|
228
|
+
const available = Math.max(0, 200 - retainedNodeDetails);
|
|
229
|
+
const details = nodes.slice(0, Math.min(20, available));
|
|
230
|
+
retainedNodeDetails += details.length;
|
|
231
|
+
omittedNodeDetails += nodes.length - details.length;
|
|
232
|
+
matches.push({
|
|
233
|
+
workflowId: workflow.id,
|
|
234
|
+
workflowName: workflow.name,
|
|
235
|
+
active: workflow.active,
|
|
236
|
+
matchingNodeCount: nodes.length,
|
|
237
|
+
nodes: details.map((node) => ({
|
|
238
|
+
nodeId: node.id ?? null,
|
|
239
|
+
nodeName: node.name,
|
|
240
|
+
nodeType: node.type,
|
|
241
|
+
})),
|
|
242
|
+
detailsTruncated: details.length < nodes.length,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
credentialId: input.credentialId,
|
|
247
|
+
workflowsExamined: page.data.length,
|
|
248
|
+
matchingWorkflowCount,
|
|
249
|
+
workflows: matches,
|
|
250
|
+
nextCursor: page.nextCursor ?? null,
|
|
251
|
+
scanComplete: page.nextCursor === undefined || page.nextCursor === null,
|
|
252
|
+
truncated: omittedNodeDetails > 0,
|
|
253
|
+
omittedNodeDetails,
|
|
254
|
+
referencesScanned,
|
|
255
|
+
referencesUnresolved,
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
}),
|
|
259
|
+
]);
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { readN8nConnection } from "../config.js";
|
|
4
|
+
import { N8nClient } from "../n8n/client.js";
|
|
5
|
+
import { authorizeOperation } from "../security/operation-policy.js";
|
|
6
|
+
import { boundedJson, sanitizeForOutput } from "../security/redaction.js";
|
|
7
|
+
import { TOOL_ENDPOINT_CONTRACTS } from "./endpoint-contracts.js";
|
|
8
|
+
import { confirmation as confirmationField } from "./schemas.js";
|
|
9
|
+
function annotationsFor(spec) {
|
|
10
|
+
return {
|
|
11
|
+
title: spec.title,
|
|
12
|
+
readOnlyHint: spec.operation === "read-only",
|
|
13
|
+
destructiveHint: spec.destructive ?? spec.operation !== "read-only",
|
|
14
|
+
idempotentHint: spec.idempotent ?? spec.operation === "read-only",
|
|
15
|
+
openWorldHint: spec.openWorld ?? true,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function errorResult(error, correlationId, toolName) {
|
|
19
|
+
// A Zod failure reaching here is a RESPONSE-schema failure (older/newer n8n shape drift): the
|
|
20
|
+
// per-tool inputs are validated by the SDK before the handler runs, and local invariants —
|
|
21
|
+
// including the introspect input and output contracts — are thrown as plain coded Errors, not
|
|
22
|
+
// raw ZodErrors. Map it to a stable, distinct code with a bounded message that names only the
|
|
23
|
+
// tool, so the raw ZodError issue dump (paths/expectations) can never surface.
|
|
24
|
+
if (error instanceof z.ZodError) {
|
|
25
|
+
const safe = sanitizeForOutput({
|
|
26
|
+
error: {
|
|
27
|
+
code: "upstream_shape_mismatch",
|
|
28
|
+
message: `The ${toolName} response did not match the supported n8n API schema.`,
|
|
29
|
+
correlationId,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return { isError: true, content: [{ type: "text", text: boundedJson(safe) }] };
|
|
33
|
+
}
|
|
34
|
+
const candidate = error && typeof error === "object" && "code" in error && typeof error.code === "string"
|
|
35
|
+
? error.code
|
|
36
|
+
: "tool_error";
|
|
37
|
+
const message = error instanceof Error ? error.message : "The tool could not complete safely.";
|
|
38
|
+
const safe = sanitizeForOutput({ error: { code: candidate, message, correlationId } });
|
|
39
|
+
return { isError: true, content: [{ type: "text", text: boundedJson(safe) }] };
|
|
40
|
+
}
|
|
41
|
+
function successResult(value) {
|
|
42
|
+
const safe = sanitizeForOutput(value);
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: boundedJson(safe) }],
|
|
45
|
+
structuredContent: { ...safe },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
// When a mutation's full result exceeds the output cap, the write already landed upstream, so
|
|
49
|
+
// summarize it to a bounded, stable shape (truncated + the scalar identity fields) instead of
|
|
50
|
+
// reporting an error that would induce a retry of an applied mutation.
|
|
51
|
+
function truncatedMutationResult(value) {
|
|
52
|
+
const identity = {};
|
|
53
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
|
|
54
|
+
let retained = 0;
|
|
55
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
56
|
+
if (retained >= 32)
|
|
57
|
+
break;
|
|
58
|
+
if (entry === null ||
|
|
59
|
+
typeof entry === "boolean" ||
|
|
60
|
+
typeof entry === "number" ||
|
|
61
|
+
(typeof entry === "string" && entry.length <= 512)) {
|
|
62
|
+
identity[key] = entry;
|
|
63
|
+
retained += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
truncated: true,
|
|
69
|
+
outcome: "success",
|
|
70
|
+
detail: "The mutation completed upstream, but its full result exceeded the output size limit and was summarized to the identity fields below.",
|
|
71
|
+
identity,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function buildSuccessResult(value, operation) {
|
|
75
|
+
try {
|
|
76
|
+
return successResult(value);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
// boundedJson's output-size cap is the only throw here. Read-only tools keep the existing
|
|
80
|
+
// cap error; a mutating tool has already applied its write, so an over-cap result is a
|
|
81
|
+
// truthful, bounded success summary rather than a misleading failure.
|
|
82
|
+
if (operation === "read-only")
|
|
83
|
+
throw error;
|
|
84
|
+
return successResult(truncatedMutationResult(value));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function createToolContext(startup) {
|
|
88
|
+
const connection = () => readN8nConnection(startup);
|
|
89
|
+
return {
|
|
90
|
+
startup,
|
|
91
|
+
connection,
|
|
92
|
+
client: () => new N8nClient(connection()),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
export function defineTool(spec) {
|
|
96
|
+
const annotations = annotationsFor(spec);
|
|
97
|
+
const endpointContract = TOOL_ENDPOINT_CONTRACTS[spec.name];
|
|
98
|
+
if (endpointContract === undefined) {
|
|
99
|
+
throw new Error(`Tool ${spec.name} is missing its endpoint documentation contract.`);
|
|
100
|
+
}
|
|
101
|
+
// Make the required confirmation phrase discoverable in the tool schema by deriving it from the
|
|
102
|
+
// tool's own confirmation function (single source of truth, so the documented phrase can never
|
|
103
|
+
// drift from the enforced one). A placeholder proxy turns `DELETE ${input.workflowId}` into the
|
|
104
|
+
// template `DELETE <workflowId>`. The phrase is documented on the field but is deliberately never
|
|
105
|
+
// echoed back on a mismatch, so the guard still demands a deliberate, constructed confirmation.
|
|
106
|
+
const inputShape = { ...spec.input };
|
|
107
|
+
let confirmationPhrase;
|
|
108
|
+
if (spec.confirmation && "confirmation" in inputShape) {
|
|
109
|
+
const placeholder = new Proxy({}, { get: (_target, property) => (typeof property === "string" ? `<${property}>` : undefined) });
|
|
110
|
+
confirmationPhrase = spec.confirmation(placeholder).expected;
|
|
111
|
+
const substitution = confirmationPhrase.includes("<") ? " — substitute the real value(s)" : "";
|
|
112
|
+
inputShape.confirmation = confirmationField.describe(`Deliberate-action guard. Must equal exactly: ${confirmationPhrase}${substitution}. A mismatch is rejected without echoing the expected phrase.`);
|
|
113
|
+
}
|
|
114
|
+
const inputSchema = z.object(inputShape).strict();
|
|
115
|
+
return Object.freeze({
|
|
116
|
+
name: spec.name,
|
|
117
|
+
title: spec.title,
|
|
118
|
+
description: spec.description,
|
|
119
|
+
operation: spec.operation,
|
|
120
|
+
annotations,
|
|
121
|
+
endpointContract: Object.freeze([...endpointContract]),
|
|
122
|
+
...(confirmationPhrase === undefined ? {} : { confirmationPhrase }),
|
|
123
|
+
validateInput: (input) => inputSchema.parse(input),
|
|
124
|
+
register(server, context) {
|
|
125
|
+
server.registerTool(spec.name, {
|
|
126
|
+
title: spec.title,
|
|
127
|
+
description: spec.description,
|
|
128
|
+
inputSchema,
|
|
129
|
+
outputSchema: spec.outputSchema ??
|
|
130
|
+
z.object({
|
|
131
|
+
data: z.unknown(),
|
|
132
|
+
redacted: z.boolean(),
|
|
133
|
+
untrusted: z.literal(true),
|
|
134
|
+
}),
|
|
135
|
+
annotations,
|
|
136
|
+
}, async (input) => {
|
|
137
|
+
const correlationId = randomUUID();
|
|
138
|
+
try {
|
|
139
|
+
authorizeOperation(context.startup.mode, spec.operation, spec.confirmation?.(input));
|
|
140
|
+
const value = await spec.handler(input, context);
|
|
141
|
+
if (spec.operation !== "read-only") {
|
|
142
|
+
console.error(JSON.stringify({
|
|
143
|
+
timestamp: new Date().toISOString(),
|
|
144
|
+
event: "security_operation",
|
|
145
|
+
tool: spec.name,
|
|
146
|
+
outcome: "success",
|
|
147
|
+
correlationId,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
if (spec.formatResult)
|
|
151
|
+
return spec.formatResult(value);
|
|
152
|
+
return buildSuccessResult(value, spec.operation);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (spec.operation !== "read-only") {
|
|
156
|
+
console.error(JSON.stringify({
|
|
157
|
+
timestamp: new Date().toISOString(),
|
|
158
|
+
event: "security_operation",
|
|
159
|
+
tool: spec.name,
|
|
160
|
+
outcome: "failure",
|
|
161
|
+
correlationId,
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
return errorResult(error, correlationId, spec.name);
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const TOOL_ENDPOINT_CONTRACTS = Object.freeze({
|
|
2
|
+
n8n_workflows_list: ["GET /workflows"],
|
|
3
|
+
n8n_workflows_get: ["GET /workflows/{workflowId}"],
|
|
4
|
+
n8n_workflows_create: ["POST /workflows"],
|
|
5
|
+
n8n_workflows_update: ["GET /workflows/{workflowId}", "PUT /workflows/{workflowId}"],
|
|
6
|
+
n8n_update_node: ["GET /workflows/{workflowId}", "PUT /workflows/{workflowId}"],
|
|
7
|
+
n8n_workflows_delete: ["DELETE /workflows/{workflowId}"],
|
|
8
|
+
n8n_workflows_activate: ["POST /workflows/{workflowId}/activate"],
|
|
9
|
+
n8n_workflows_deactivate: ["POST /workflows/{workflowId}/deactivate"],
|
|
10
|
+
n8n_workflows_get_version: ["GET /workflows/{workflowId}/{versionId}"],
|
|
11
|
+
n8n_workflows_get_tags: ["GET /workflows/{workflowId}/tags"],
|
|
12
|
+
n8n_workflows_update_tags: ["PUT /workflows/{workflowId}/tags"],
|
|
13
|
+
n8n_workflows_archive: ["POST /workflows/{workflowId}/archive"],
|
|
14
|
+
n8n_workflows_unarchive: ["POST /workflows/{workflowId}/unarchive"],
|
|
15
|
+
n8n_workflows_diff: [
|
|
16
|
+
"GET /workflows/{workflowId}/{versionId}",
|
|
17
|
+
"GET /workflows/{workflowId}?excludePinnedData=true",
|
|
18
|
+
],
|
|
19
|
+
n8n_executions_list: ["GET /executions"],
|
|
20
|
+
n8n_executions_get: ["GET /executions/{executionId}"],
|
|
21
|
+
n8n_executions_delete: ["DELETE /executions/{executionId}"],
|
|
22
|
+
n8n_executions_retry: ["POST /executions/{executionId}/retry"],
|
|
23
|
+
n8n_executions_stop: ["POST /executions/{executionId}/stop"],
|
|
24
|
+
n8n_credentials_create: ["POST /credentials"],
|
|
25
|
+
n8n_credentials_delete: ["DELETE /credentials/{credentialId}"],
|
|
26
|
+
n8n_credentials_schema: ["GET /credentials/schema/{credentialType}"],
|
|
27
|
+
n8n_credentials_list: ["GET /credentials"],
|
|
28
|
+
n8n_credentials_get: ["GET /credentials/{credentialId}"],
|
|
29
|
+
n8n_credentials_update: ["PATCH /credentials/{credentialId}"],
|
|
30
|
+
n8n_credentials_test: ["POST /credentials/{credentialId}/test"],
|
|
31
|
+
n8n_credentials_usage: ["GET /workflows?excludePinnedData=true"],
|
|
32
|
+
n8n_tags_list: ["GET /tags"],
|
|
33
|
+
n8n_tags_get: ["GET /tags/{tagId}"],
|
|
34
|
+
n8n_tags_create: ["POST /tags"],
|
|
35
|
+
n8n_tags_update: ["PUT /tags/{tagId}"],
|
|
36
|
+
n8n_tags_delete: ["DELETE /tags/{tagId}"],
|
|
37
|
+
n8n_users_list: ["GET /users"],
|
|
38
|
+
n8n_users_get: ["GET /users/{id-or-email}"],
|
|
39
|
+
n8n_users_create: ["POST /users"],
|
|
40
|
+
n8n_users_delete: ["DELETE /users/{userId}"],
|
|
41
|
+
n8n_health: ["GET /healthz"],
|
|
42
|
+
n8n_insights_summary: ["GET /insights/summary"],
|
|
43
|
+
n8n_audit_generate: ["POST /audit"],
|
|
44
|
+
n8n_search_workflows: ["GET /workflows?excludePinnedData=true"],
|
|
45
|
+
n8n_get_node_docs: [],
|
|
46
|
+
n8n_list_node_types: ["GET /workflows?excludePinnedData=true"],
|
|
47
|
+
n8n_introspect: [
|
|
48
|
+
"GET /workflows/{workflowId}?excludePinnedData=true",
|
|
49
|
+
"GET /executions",
|
|
50
|
+
"GET /executions/{executionId}?redactExecutionData=true",
|
|
51
|
+
],
|
|
52
|
+
n8n_community_packages_list: ["GET /community-packages"],
|
|
53
|
+
});
|