mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-23 10:53:07 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f0738e637 | ||
|
|
93816fce30 | ||
|
|
ec19c9dade |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2.40.5] - 2026-03-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Webhook workflows created via MCP get 404 errors** (Issue #643): Auto-inject `webhookId` (UUID) on webhook-type nodes (`webhook`, `webhookTrigger`, `formTrigger`, `chatTrigger`) during `cleanWorkflowForCreate()` and `cleanWorkflowForUpdate()`. n8n 2.10+ requires this field for proper webhook URL registration; without it, webhooks silently fail with 404. Existing `webhookId` values are preserved.
|
||||||
|
|
||||||
|
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||||
|
|
||||||
|
## [2.40.4] - 2026-03-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Incorrect data tables availability info**: Removed "enterprise/cloud only" restriction from tool description and documentation — data tables are available on all n8n plans including self-hosted
|
||||||
|
- **Redundant pitfalls removed**: Removed "Requires N8N_API_URL and N8N_API_KEY" and "enterprise or cloud plans" pitfalls — the first is implicit for all n8n management tools, the second was incorrect
|
||||||
|
|
||||||
|
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||||
|
|
||||||
|
## [2.40.3] - 2026-03-22
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Notification 400 disconnect storms (#654)**: `handleRequest()` now returns 202 Accepted for JSON-RPC notifications with stale/expired session IDs instead of 400. Per JSON-RPC 2.0 spec, notifications don't expect responses — returning 400 caused Claude's proxy to trigger reconnection storms (930 errors/day, 216 users affected)
|
||||||
|
- **TOCTOU race in session lookup**: Added null guard after transport assignment to handle sessions removed between the existence check and use
|
||||||
|
- **`updateTable` silently ignoring `columns` parameter**: Now returns a warning message when `columns` is passed to `updateTable`, clarifying that table schema is immutable after creation via the public API
|
||||||
|
- **Tool schema descriptions clarified**: `name` and `columns` parameter descriptions now explicitly document that `updateTable` is rename-only and columns are for `createTable` only
|
||||||
|
|
||||||
|
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
|
||||||
|
|
||||||
## [2.40.2] - 2026-03-22
|
## [2.40.2] - 2026-03-22
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
2
dist/services/n8n-validation.d.ts.map
vendored
2
dist/services/n8n-validation.d.ts.map
vendored
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"n8n-validation.d.ts","sourceRoot":"","sources":["../../src/services/n8n-validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAM9E,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiB7B,CAAC;AAkBH,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gCAUpC,CAAC;AAEF,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAWjC,CAAC;AAGH,eAAO,MAAM,uBAAuB;;;;;;CAMnC,CAAC;AAGF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,OAAO,GAAG,YAAY,CAEhE;AAED,wBAAgB,2BAA2B,CAAC,WAAW,EAAE,OAAO,GAAG,kBAAkB,CAEpF;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAElG;AAGD,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,QAAQ,CAAC,CAsBrF;AAiBD,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAoE5E;AAGD,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,GAAG,MAAM,EAAE,CAkQ/E;AAGD,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAK7D;AAMD,wBAAgB,+BAA+B,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,EAAE,CA+F5E;AAMD,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CA0D/E;AAGD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAmB/D;AAGD,wBAAgB,2BAA2B,IAAI,MAAM,CA6CpD;AAGD,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAmBpE"}
|
{"version":3,"file":"n8n-validation.d.ts","sourceRoot":"","sources":["../../src/services/n8n-validation.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAM9E,eAAO,MAAM,kBAAkB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiB7B,CAAC;AAkBH,eAAO,MAAM,wBAAwB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;gCAUpC,CAAC;AAEF,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAWjC,CAAC;AAGH,eAAO,MAAM,uBAAuB;;;;;;CAMnC,CAAC;AAGF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,OAAO,GAAG,YAAY,CAEhE;AAED,wBAAgB,2BAA2B,CAAC,WAAW,EAAE,OAAO,GAAG,kBAAkB,CAEpF;AAED,wBAAgB,wBAAwB,CAAC,QAAQ,EAAE,OAAO,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAElG;AAmBD,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,GAAG,OAAO,CAAC,QAAQ,CAAC,CAwBrF;AAiBD,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAsE5E;AAGD,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,OAAO,CAAC,QAAQ,CAAC,GAAG,MAAM,EAAE,CAkQ/E;AAGD,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,QAAQ,GAAG,OAAO,CAK7D;AAMD,wBAAgB,+BAA+B,CAAC,IAAI,EAAE,YAAY,GAAG,MAAM,EAAE,CA+F5E;AAMD,wBAAgB,yBAAyB,CAAC,QAAQ,EAAE,GAAG,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CA0D/E;AAGD,wBAAgB,aAAa,CAAC,QAAQ,EAAE,QAAQ,GAAG,MAAM,GAAG,IAAI,CAmB/D;AAGD,wBAAgB,2BAA2B,IAAI,MAAM,CA6CpD;AAGD,wBAAgB,yBAAyB,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAmBpE"}
|
||||||
21
dist/services/n8n-validation.js
vendored
21
dist/services/n8n-validation.js
vendored
@@ -1,4 +1,7 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
Object.defineProperty(exports, "__esModule", { value: true });
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
exports.defaultWorkflowSettings = exports.workflowSettingsSchema = exports.workflowConnectionSchema = exports.workflowNodeSchema = void 0;
|
exports.defaultWorkflowSettings = exports.workflowSettingsSchema = exports.workflowConnectionSchema = exports.workflowNodeSchema = void 0;
|
||||||
exports.validateWorkflowNode = validateWorkflowNode;
|
exports.validateWorkflowNode = validateWorkflowNode;
|
||||||
@@ -13,6 +16,7 @@ exports.validateOperatorStructure = validateOperatorStructure;
|
|||||||
exports.getWebhookUrl = getWebhookUrl;
|
exports.getWebhookUrl = getWebhookUrl;
|
||||||
exports.getWorkflowStructureExample = getWorkflowStructureExample;
|
exports.getWorkflowStructureExample = getWorkflowStructureExample;
|
||||||
exports.getWorkflowFixSuggestions = getWorkflowFixSuggestions;
|
exports.getWorkflowFixSuggestions = getWorkflowFixSuggestions;
|
||||||
|
const crypto_1 = __importDefault(require("crypto"));
|
||||||
const zod_1 = require("zod");
|
const zod_1 = require("zod");
|
||||||
const node_type_utils_1 = require("../utils/node-type-utils");
|
const node_type_utils_1 = require("../utils/node-type-utils");
|
||||||
const node_classification_1 = require("../utils/node-classification");
|
const node_classification_1 = require("../utils/node-classification");
|
||||||
@@ -76,11 +80,27 @@ function validateWorkflowConnections(connections) {
|
|||||||
function validateWorkflowSettings(settings) {
|
function validateWorkflowSettings(settings) {
|
||||||
return exports.workflowSettingsSchema.parse(settings);
|
return exports.workflowSettingsSchema.parse(settings);
|
||||||
}
|
}
|
||||||
|
const WEBHOOK_NODE_TYPES = new Set([
|
||||||
|
'n8n-nodes-base.webhook',
|
||||||
|
'n8n-nodes-base.webhookTrigger',
|
||||||
|
'n8n-nodes-base.formTrigger',
|
||||||
|
'@n8n/n8n-nodes-langchain.chatTrigger',
|
||||||
|
]);
|
||||||
|
function ensureWebhookIds(nodes) {
|
||||||
|
if (!nodes)
|
||||||
|
return;
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (WEBHOOK_NODE_TYPES.has(node.type) && !node.webhookId) {
|
||||||
|
node.webhookId = crypto_1.default.randomUUID();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
function cleanWorkflowForCreate(workflow) {
|
function cleanWorkflowForCreate(workflow) {
|
||||||
const { id, createdAt, updatedAt, versionId, meta, active, tags, ...cleanedWorkflow } = workflow;
|
const { id, createdAt, updatedAt, versionId, meta, active, tags, ...cleanedWorkflow } = workflow;
|
||||||
if (!cleanedWorkflow.settings || Object.keys(cleanedWorkflow.settings).length === 0) {
|
if (!cleanedWorkflow.settings || Object.keys(cleanedWorkflow.settings).length === 0) {
|
||||||
cleanedWorkflow.settings = exports.defaultWorkflowSettings;
|
cleanedWorkflow.settings = exports.defaultWorkflowSettings;
|
||||||
}
|
}
|
||||||
|
ensureWebhookIds(cleanedWorkflow.nodes);
|
||||||
return cleanedWorkflow;
|
return cleanedWorkflow;
|
||||||
}
|
}
|
||||||
function cleanWorkflowForUpdate(workflow) {
|
function cleanWorkflowForUpdate(workflow) {
|
||||||
@@ -116,6 +136,7 @@ function cleanWorkflowForUpdate(workflow) {
|
|||||||
else {
|
else {
|
||||||
cleanedWorkflow.settings = { executionOrder: 'v1' };
|
cleanedWorkflow.settings = { executionOrder: 'v1' };
|
||||||
}
|
}
|
||||||
|
ensureWebhookIds(cleanedWorkflow.nodes);
|
||||||
return cleanedWorkflow;
|
return cleanedWorkflow;
|
||||||
}
|
}
|
||||||
function validateWorkflowStructure(workflow) {
|
function validateWorkflowStructure(workflow) {
|
||||||
|
|||||||
2
dist/services/n8n-validation.js.map
vendored
2
dist/services/n8n-validation.js.map
vendored
File diff suppressed because one or more lines are too long
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.37.3",
|
"version": "2.40.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.37.3",
|
"version": "2.40.5",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@modelcontextprotocol/sdk": "^1.27.1",
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "n8n-mcp",
|
"name": "n8n-mcp",
|
||||||
"version": "2.40.2",
|
"version": "2.40.5",
|
||||||
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@@ -254,6 +254,22 @@ export class SingleSessionHTTPServer {
|
|||||||
return Boolean(sessionId && sessionId.length > 0);
|
return Boolean(sessionId && sessionId.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a request body is a JSON-RPC notification (or batch of only notifications).
|
||||||
|
* Per JSON-RPC 2.0 §4.1, a notification is a request without an "id" member.
|
||||||
|
* Note: `!('id' in msg)` is strict — messages with `id: null` are treated as
|
||||||
|
* requests, not notifications. This is spec-compliant.
|
||||||
|
*/
|
||||||
|
private isJsonRpcNotification(body: unknown): boolean {
|
||||||
|
if (!body || typeof body !== 'object') return false;
|
||||||
|
const isSingleNotification = (msg: any): boolean =>
|
||||||
|
msg && typeof msg.method === 'string' && !('id' in msg);
|
||||||
|
if (Array.isArray(body)) {
|
||||||
|
return body.length > 0 && body.every(isSingleNotification);
|
||||||
|
}
|
||||||
|
return isSingleNotification(body);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitize error information for client responses
|
* Sanitize error information for client responses
|
||||||
*/
|
*/
|
||||||
@@ -614,6 +630,22 @@ export class SingleSessionHTTPServer {
|
|||||||
logger.info('handleRequest: Reusing existing transport for session', { sessionId });
|
logger.info('handleRequest: Reusing existing transport for session', { sessionId });
|
||||||
transport = this.transports[sessionId];
|
transport = this.transports[sessionId];
|
||||||
|
|
||||||
|
// TOCTOU guard: session may have been removed between the check above and here
|
||||||
|
if (!transport) {
|
||||||
|
if (this.isJsonRpcNotification(req.body)) {
|
||||||
|
logger.info('handleRequest: Session removed during lookup, accepting notification', { sessionId });
|
||||||
|
res.status(202).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
logger.warn('handleRequest: Session removed between check and use (TOCTOU)', { sessionId });
|
||||||
|
res.status(400).json({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
error: { code: -32000, message: 'Bad Request: Session not found or expired' },
|
||||||
|
id: req.body?.id || null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// In multi-tenant shared mode, update instance context if provided
|
// In multi-tenant shared mode, update instance context if provided
|
||||||
const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true';
|
const isMultiTenantEnabled = process.env.ENABLE_MULTI_TENANT === 'true';
|
||||||
const sessionStrategy = process.env.MULTI_TENANT_SESSION_STRATEGY || 'instance';
|
const sessionStrategy = process.env.MULTI_TENANT_SESSION_STRATEGY || 'instance';
|
||||||
@@ -627,7 +659,17 @@ export class SingleSessionHTTPServer {
|
|||||||
this.updateSessionAccess(sessionId);
|
this.updateSessionAccess(sessionId);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Invalid request - no session ID and not an initialize request
|
// Notifications are fire-and-forget; returning 400 triggers reconnection storms (#654)
|
||||||
|
if (this.isJsonRpcNotification(req.body)) {
|
||||||
|
logger.info('handleRequest: Accepting notification for stale/missing session', {
|
||||||
|
method: req.body?.method,
|
||||||
|
sessionId: sessionId || 'none',
|
||||||
|
});
|
||||||
|
res.status(202).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only return 400 for actual requests that need a valid session
|
||||||
const errorDetails = {
|
const errorDetails = {
|
||||||
hasSessionId: !!sessionId,
|
hasSessionId: !!sessionId,
|
||||||
isInitialize: isInitialize,
|
isInitialize: isInitialize,
|
||||||
|
|||||||
@@ -2834,10 +2834,13 @@ export async function handleUpdateTable(args: unknown, context?: InstanceContext
|
|||||||
const client = ensureApiConfigured(context);
|
const client = ensureApiConfigured(context);
|
||||||
const { tableId, name } = updateTableSchema.parse(args);
|
const { tableId, name } = updateTableSchema.parse(args);
|
||||||
const dataTable = await client.updateDataTable(tableId, { name });
|
const dataTable = await client.updateDataTable(tableId, { name });
|
||||||
|
const rawArgs = args as Record<string, unknown>;
|
||||||
|
const hasColumns = rawArgs && typeof rawArgs === 'object' && 'columns' in rawArgs;
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: dataTable,
|
data: dataTable,
|
||||||
message: `Data table renamed to "${dataTable.name}"`,
|
message: `Data table renamed to "${dataTable.name}"` +
|
||||||
|
(hasColumns ? '. Note: columns parameter was ignored — table schema is immutable after creation via the public API' : ''),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return handleDataTableError(error);
|
return handleDataTableError(error);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const n8nManageDatatableDoc: ToolDocumentation = {
|
|||||||
'Use dryRun: true to preview update/upsert/delete before applying',
|
'Use dryRun: true to preview update/upsert/delete before applying',
|
||||||
'Filter supports: eq, neq, like, ilike, gt, gte, lt, lte conditions',
|
'Filter supports: eq, neq, like, ilike, gt, gte, lt, lte conditions',
|
||||||
'Use returnData: true to get affected rows back from update/upsert/delete',
|
'Use returnData: true to get affected rows back from update/upsert/delete',
|
||||||
'Requires n8n enterprise or cloud with data tables feature'
|
'Requires N8N_API_URL and N8N_API_KEY configured'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
full: {
|
full: {
|
||||||
@@ -96,8 +96,6 @@ export const n8nManageDatatableDoc: ToolDocumentation = {
|
|||||||
'Use sortBy for deterministic row ordering',
|
'Use sortBy for deterministic row ordering',
|
||||||
],
|
],
|
||||||
pitfalls: [
|
pitfalls: [
|
||||||
'Requires N8N_API_URL and N8N_API_KEY configured',
|
|
||||||
'Feature only available on n8n enterprise or cloud plans',
|
|
||||||
'deleteTable permanently deletes all rows — cannot be undone',
|
'deleteTable permanently deletes all rows — cannot be undone',
|
||||||
'deleteRows requires a filter — cannot delete all rows without one',
|
'deleteRows requires a filter — cannot delete all rows without one',
|
||||||
'Column types cannot be changed after table creation via API',
|
'Column types cannot be changed after table creation via API',
|
||||||
|
|||||||
@@ -609,7 +609,7 @@ export const n8nManagementTools: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'n8n_manage_datatable',
|
name: 'n8n_manage_datatable',
|
||||||
description: `Manage n8n data tables and rows. Actions: createTable, listTables, getTable, updateTable, deleteTable, getRows, insertRows, updateRows, upsertRows, deleteRows. Requires n8n enterprise/cloud with data tables feature.`,
|
description: `Manage n8n data tables and rows. Actions: createTable, listTables, getTable, updateTable, deleteTable, getRows, insertRows, updateRows, upsertRows, deleteRows.`,
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
@@ -619,10 +619,10 @@ export const n8nManagementTools: ToolDefinition[] = [
|
|||||||
description: 'Operation to perform',
|
description: 'Operation to perform',
|
||||||
},
|
},
|
||||||
tableId: { type: 'string', description: 'Data table ID (required for all actions except createTable and listTables)' },
|
tableId: { type: 'string', description: 'Data table ID (required for all actions except createTable and listTables)' },
|
||||||
name: { type: 'string', description: 'For createTable/updateTable: table name' },
|
name: { type: 'string', description: 'For createTable: table name. For updateTable: new name (rename only — schema is immutable after creation)' },
|
||||||
columns: {
|
columns: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'For createTable: column definitions',
|
description: 'For createTable only: column definitions (schema is immutable after creation via public API)',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
properties: {
|
properties: {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { WorkflowNode, WorkflowConnection, Workflow } from '../types/n8n-api';
|
import { WorkflowNode, WorkflowConnection, Workflow } from '../types/n8n-api';
|
||||||
import { isTriggerNode, isActivatableTrigger } from '../utils/node-type-utils';
|
import { isTriggerNode, isActivatableTrigger } from '../utils/node-type-utils';
|
||||||
@@ -87,6 +88,22 @@ export function validateWorkflowSettings(settings: unknown): z.infer<typeof work
|
|||||||
return workflowSettingsSchema.parse(settings);
|
return workflowSettingsSchema.parse(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WEBHOOK_NODE_TYPES = new Set([
|
||||||
|
'n8n-nodes-base.webhook',
|
||||||
|
'n8n-nodes-base.webhookTrigger',
|
||||||
|
'n8n-nodes-base.formTrigger',
|
||||||
|
'@n8n/n8n-nodes-langchain.chatTrigger',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function ensureWebhookIds(nodes?: WorkflowNode[]): void {
|
||||||
|
if (!nodes) return;
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (WEBHOOK_NODE_TYPES.has(node.type) && !node.webhookId) {
|
||||||
|
node.webhookId = crypto.randomUUID();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clean workflow data for API operations
|
// Clean workflow data for API operations
|
||||||
export function cleanWorkflowForCreate(workflow: Partial<Workflow>): Partial<Workflow> {
|
export function cleanWorkflowForCreate(workflow: Partial<Workflow>): Partial<Workflow> {
|
||||||
const {
|
const {
|
||||||
@@ -109,6 +126,8 @@ export function cleanWorkflowForCreate(workflow: Partial<Workflow>): Partial<Wor
|
|||||||
cleanedWorkflow.settings = defaultWorkflowSettings;
|
cleanedWorkflow.settings = defaultWorkflowSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureWebhookIds(cleanedWorkflow.nodes);
|
||||||
|
|
||||||
return cleanedWorkflow;
|
return cleanedWorkflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +213,8 @@ export function cleanWorkflowForUpdate(workflow: Workflow): Partial<Workflow> {
|
|||||||
cleanedWorkflow.settings = { executionOrder: 'v1' as const };
|
cleanedWorkflow.settings = { executionOrder: 'v1' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureWebhookIds(cleanedWorkflow.nodes);
|
||||||
|
|
||||||
return cleanedWorkflow;
|
return cleanedWorkflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ describe('HTTP Server Session Management', () => {
|
|||||||
status: vi.fn().mockReturnThis(),
|
status: vi.fn().mockReturnThis(),
|
||||||
json: vi.fn().mockReturnThis(),
|
json: vi.fn().mockReturnThis(),
|
||||||
send: vi.fn().mockReturnThis(),
|
send: vi.fn().mockReturnThis(),
|
||||||
|
end: vi.fn().mockReturnThis(),
|
||||||
setHeader: vi.fn((key: string, value: string) => {
|
setHeader: vi.fn((key: string, value: string) => {
|
||||||
headers[key.toLowerCase()] = value;
|
headers[key.toLowerCase()] = value;
|
||||||
}),
|
}),
|
||||||
@@ -1186,4 +1187,121 @@ describe('HTTP Server Session Management', () => {
|
|||||||
expect(sessionInfo.age).toBeGreaterThanOrEqual(0);
|
expect(sessionInfo.age).toBeGreaterThanOrEqual(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Notification handling for stale sessions (#654)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Re-apply mockImplementation after vi.clearAllMocks() resets it
|
||||||
|
mockConsoleManager.wrapOperation.mockImplementation(async (fn: () => Promise<any>) => {
|
||||||
|
return await fn();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 202 for notification with stale session ID', async () => {
|
||||||
|
server = new SingleSessionHTTPServer();
|
||||||
|
|
||||||
|
const { req, res } = createMockReqRes();
|
||||||
|
|
||||||
|
req.headers = { 'mcp-session-id': 'stale-session-that-does-not-exist' };
|
||||||
|
req.method = 'POST';
|
||||||
|
req.body = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'notifications/initialized',
|
||||||
|
};
|
||||||
|
|
||||||
|
await server.handleRequest(req as any, res as any);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(202);
|
||||||
|
expect(res.end).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 202 for notification batch with stale session ID', async () => {
|
||||||
|
server = new SingleSessionHTTPServer();
|
||||||
|
|
||||||
|
const { req, res } = createMockReqRes();
|
||||||
|
|
||||||
|
req.headers = { 'mcp-session-id': 'stale-session-that-does-not-exist' };
|
||||||
|
req.method = 'POST';
|
||||||
|
req.body = [
|
||||||
|
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
||||||
|
{ jsonrpc: '2.0', method: 'notifications/cancelled' },
|
||||||
|
];
|
||||||
|
|
||||||
|
await server.handleRequest(req as any, res as any);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(202);
|
||||||
|
expect(res.end).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for request (with id) with stale session ID', async () => {
|
||||||
|
server = new SingleSessionHTTPServer();
|
||||||
|
|
||||||
|
const { req, res } = createMockReqRes();
|
||||||
|
req.headers = { 'mcp-session-id': 'stale-session-that-does-not-exist' };
|
||||||
|
req.method = 'POST';
|
||||||
|
req.body = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'search_nodes', arguments: { query: 'http' } },
|
||||||
|
id: 42,
|
||||||
|
};
|
||||||
|
|
||||||
|
await server.handleRequest(req as any, res as any);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: 'Bad Request: Session not found or expired',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 202 for notification with no session ID', async () => {
|
||||||
|
server = new SingleSessionHTTPServer();
|
||||||
|
|
||||||
|
const { req, res } = createMockReqRes();
|
||||||
|
|
||||||
|
req.method = 'POST';
|
||||||
|
req.body = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'notifications/cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
await server.handleRequest(req as any, res as any);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(202);
|
||||||
|
expect(res.end).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for request with no session ID and not initialize', async () => {
|
||||||
|
server = new SingleSessionHTTPServer();
|
||||||
|
|
||||||
|
const { req, res } = createMockReqRes();
|
||||||
|
req.method = 'POST';
|
||||||
|
req.body = {
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method: 'tools/list',
|
||||||
|
id: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
await server.handleRequest(req as any, res as any);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return 400 for mixed batch (notification + request) with stale session', async () => {
|
||||||
|
server = new SingleSessionHTTPServer();
|
||||||
|
|
||||||
|
const { req, res } = createMockReqRes();
|
||||||
|
req.headers = { 'mcp-session-id': 'stale-session-that-does-not-exist' };
|
||||||
|
req.method = 'POST';
|
||||||
|
req.body = [
|
||||||
|
{ jsonrpc: '2.0', method: 'notifications/initialized' },
|
||||||
|
{ jsonrpc: '2.0', method: 'tools/list', id: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
await server.handleRequest(req as any, res as any);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -363,6 +363,22 @@ describe('Data Table Handlers (n8n_manage_datatable)', () => {
|
|||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toBe('Update failed');
|
expect(result.error).toBe('Update failed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should warn when columns parameter is passed', async () => {
|
||||||
|
const updatedTable = { id: 'dt-1', name: 'Renamed' };
|
||||||
|
mockApiClient.updateDataTable.mockResolvedValue(updatedTable);
|
||||||
|
|
||||||
|
const result = await handlers.handleUpdateTable({
|
||||||
|
tableId: 'dt-1',
|
||||||
|
name: 'Renamed',
|
||||||
|
columns: [{ name: 'phone', type: 'string' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.message).toContain('columns parameter was ignored');
|
||||||
|
expect(result.message).toContain('immutable after creation');
|
||||||
|
expect(mockApiClient.updateDataTable).toHaveBeenCalledWith('dt-1', { name: 'Renamed' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ import { WorkflowBuilder } from '../../utils/builders/workflow.builder';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { WorkflowNode, WorkflowConnection, Workflow } from '../../../src/types/n8n-api';
|
import { WorkflowNode, WorkflowConnection, Workflow } from '../../../src/types/n8n-api';
|
||||||
|
|
||||||
|
function webhookNode(id: string, name: string, type: string, typeVersion = 2): WorkflowNode {
|
||||||
|
return { id, name, type, typeVersion, position: [250, 300] as [number, number], parameters: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowWithNodes(nodes: WorkflowNode[]): Partial<Workflow> {
|
||||||
|
return { name: 'Test', nodes, connections: {} };
|
||||||
|
}
|
||||||
|
|
||||||
describe('n8n-validation', () => {
|
describe('n8n-validation', () => {
|
||||||
describe('Zod Schemas', () => {
|
describe('Zod Schemas', () => {
|
||||||
describe('workflowNodeSchema', () => {
|
describe('workflowNodeSchema', () => {
|
||||||
@@ -301,6 +309,44 @@ describe('n8n-validation', () => {
|
|||||||
const cleaned = cleanWorkflowForCreate(workflow as Workflow);
|
const cleaned = cleanWorkflowForCreate(workflow as Workflow);
|
||||||
expect(cleaned.settings).toEqual(customSettings);
|
expect(cleaned.settings).toEqual(customSettings);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should inject webhookId on webhook nodes missing it', () => {
|
||||||
|
const workflow = workflowWithNodes([
|
||||||
|
webhookNode('1', 'Webhook', 'n8n-nodes-base.webhook'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cleaned = cleanWorkflowForCreate(workflow as Workflow);
|
||||||
|
expect(cleaned.nodes![0].webhookId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve existing webhookId on webhook nodes', () => {
|
||||||
|
const workflow = workflowWithNodes([
|
||||||
|
{ ...webhookNode('1', 'Webhook', 'n8n-nodes-base.webhook'), webhookId: 'existing-id' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cleaned = cleanWorkflowForCreate(workflow as Workflow);
|
||||||
|
expect(cleaned.nodes![0].webhookId).toBe('existing-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inject webhookId on formTrigger and chatTrigger nodes', () => {
|
||||||
|
const workflow = workflowWithNodes([
|
||||||
|
webhookNode('1', 'Form', 'n8n-nodes-base.formTrigger'),
|
||||||
|
webhookNode('2', 'Chat', '@n8n/n8n-nodes-langchain.chatTrigger'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cleaned = cleanWorkflowForCreate(workflow as Workflow);
|
||||||
|
expect(cleaned.nodes![0].webhookId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/);
|
||||||
|
expect(cleaned.nodes![1].webhookId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not inject webhookId on non-webhook nodes', () => {
|
||||||
|
const workflow = workflowWithNodes([
|
||||||
|
webhookNode('1', 'Set', 'n8n-nodes-base.set', 3.4),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const cleaned = cleanWorkflowForCreate(workflow as Workflow);
|
||||||
|
expect(cleaned.nodes![0].webhookId).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('cleanWorkflowForUpdate', () => {
|
describe('cleanWorkflowForUpdate', () => {
|
||||||
@@ -533,6 +579,44 @@ describe('n8n-validation', () => {
|
|||||||
});
|
});
|
||||||
expect(cleaned.settings).not.toHaveProperty('someOtherProperty');
|
expect(cleaned.settings).not.toHaveProperty('someOtherProperty');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should inject webhookId on webhook nodes missing it', () => {
|
||||||
|
const workflow = workflowWithNodes([
|
||||||
|
webhookNode('1', 'Webhook', 'n8n-nodes-base.webhook'),
|
||||||
|
]) as any;
|
||||||
|
|
||||||
|
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||||
|
expect(cleaned.nodes![0].webhookId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve existing webhookId on webhook nodes', () => {
|
||||||
|
const workflow = workflowWithNodes([
|
||||||
|
{ ...webhookNode('1', 'Webhook', 'n8n-nodes-base.webhook'), webhookId: 'existing-id' },
|
||||||
|
]) as any;
|
||||||
|
|
||||||
|
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||||
|
expect(cleaned.nodes![0].webhookId).toBe('existing-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should inject webhookId on formTrigger and chatTrigger nodes', () => {
|
||||||
|
const workflow = workflowWithNodes([
|
||||||
|
webhookNode('1', 'Form', 'n8n-nodes-base.formTrigger'),
|
||||||
|
webhookNode('2', 'Chat', '@n8n/n8n-nodes-langchain.chatTrigger'),
|
||||||
|
]) as any;
|
||||||
|
|
||||||
|
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||||
|
expect(cleaned.nodes![0].webhookId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/);
|
||||||
|
expect(cleaned.nodes![1].webhookId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not inject webhookId on non-webhook nodes', () => {
|
||||||
|
const workflow = workflowWithNodes([
|
||||||
|
webhookNode('1', 'Set', 'n8n-nodes-base.set', 3.4),
|
||||||
|
]) as any;
|
||||||
|
|
||||||
|
const cleaned = cleanWorkflowForUpdate(workflow);
|
||||||
|
expect(cleaned.nodes![0].webhookId).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user