feat: add n8n_manage_datatable MCP tool with full CRUD (#640) (#650)

* feat: add n8n_create_data_table MCP tool and projectId for create workflow (#640)

Add new MCP tool to create n8n data tables via the REST API:
- n8n_create_data_table tool definition with name + columns schema
- handleCreateDataTable handler with Zod validation and N8nApiError handling
- N8nApiClient.createDataTable() calling POST /data-tables
- DataTable, DataTableColumn, DataTableColumnResponse types per OpenAPI spec
- Column types: string | number | boolean | date | json
- Input validation: .min(1) on table name and column names
- Tool documentation with examples, use cases, and pitfalls

Also adds projectId parameter to n8n_create_workflow for enterprise
project support, and fixes stale management tool count in health check.

Based on work by @djakielski in PR #646.
Co-Authored-By: Dominik Jakielski <dominik.jakielski@urlaubsguru.de>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: replace n8n_create_data_table with n8n_manage_datatable (10 actions)

Replaces the single-purpose n8n_create_data_table tool with a comprehensive
n8n_manage_datatable tool covering all 10 n8n data table API endpoints:

Table operations: createTable, listTables, getTable, updateTable, deleteTable
Row operations: getRows, insertRows, updateRows, upsertRows, deleteRows

- Filter system with and/or logic and 8 condition operators
- Dry-run support for updateRows, upsertRows, deleteRows
- Pagination, sorting, and full-text search for row listing
- 9 new N8nApiClient methods for all data table endpoints
- Shared error handler and consolidated Zod schemas
- Comprehensive tool documentation with examples per action
- 36 handler tests + 18 API client tests

BREAKING: n8n_create_data_table removed. Use n8n_manage_datatable with
action="createTable" instead.

Based on work by @djakielski in PR #646.
Co-Authored-By: Dominik Jakielski <dominik.jakielski@urlaubsguru.de>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
This commit is contained in:
Romuald Członkowski
2026-03-21 19:06:22 +01:00
committed by GitHub
parent 47a1cb135d
commit be3d07dbdc
14 changed files with 1741 additions and 8 deletions

View File

@@ -8,7 +8,7 @@ import {
WebhookRequest,
McpToolResponse,
ExecutionFilterOptions,
ExecutionMode
ExecutionMode,
} from '../types/n8n-api';
import type { TriggerType, TestWorkflowInput } from '../triggers/types';
import {
@@ -383,6 +383,7 @@ const createWorkflowSchema = z.object({
executionTimeout: z.number().optional(),
errorWorkflow: z.string().optional(),
}).optional(),
projectId: z.string().optional(),
});
const updateWorkflowSchema = z.object({
@@ -1974,7 +1975,7 @@ export async function handleDiagnostic(request: any, context?: InstanceContext):
// Check which tools are available
const documentationTools = 7; // Base documentation tools (after v2.26.0 consolidation)
const managementTools = apiConfigured ? 13 : 0; // Management tools requiring API (includes n8n_deploy_template)
const managementTools = apiConfigured ? 14 : 0; // Management tools requiring API (includes n8n_manage_datatable)
const totalTools = documentationTools + managementTools;
// Check npm version
@@ -2688,3 +2689,243 @@ export async function handleTriggerWebhookWorkflow(args: unknown, context?: Inst
};
}
}
// ========================================================================
// Data Table Handlers
// ========================================================================
// Shared Zod schemas for data table operations
const dataTableFilterConditionSchema = z.object({
columnName: z.string().min(1),
condition: z.enum(['eq', 'neq', 'like', 'ilike', 'gt', 'gte', 'lt', 'lte']),
value: z.any(),
});
const dataTableFilterSchema = z.object({
type: z.enum(['and', 'or']).optional().default('and'),
filters: z.array(dataTableFilterConditionSchema).min(1, 'At least one filter condition is required'),
});
// Shared base schema for actions requiring a tableId
const tableIdSchema = z.object({
tableId: z.string().min(1, 'tableId is required'),
});
// Per-action Zod schemas
const createTableSchema = z.object({
name: z.string().min(1, 'Table name cannot be empty'),
columns: z.array(z.object({
name: z.string().min(1, 'Column name cannot be empty'),
type: z.enum(['string', 'number', 'boolean', 'date', 'json']).optional(),
})).optional(),
});
const listTablesSchema = z.object({
limit: z.number().min(1).max(100).optional(),
cursor: z.string().optional(),
});
const updateTableSchema = tableIdSchema.extend({
name: z.string().min(1, 'New table name cannot be empty'),
});
const getRowsSchema = tableIdSchema.extend({
limit: z.number().min(1).max(100).optional(),
cursor: z.string().optional(),
filter: z.union([dataTableFilterSchema, z.string()]).optional(),
sortBy: z.string().optional(),
search: z.string().optional(),
});
const insertRowsSchema = tableIdSchema.extend({
data: z.array(z.record(z.unknown())).min(1, 'At least one row is required'),
returnType: z.enum(['count', 'id', 'all']).optional(),
});
// Shared schema for update/upsert (identical structure)
const mutateRowsSchema = tableIdSchema.extend({
filter: dataTableFilterSchema,
data: z.record(z.unknown()),
returnData: z.boolean().optional(),
dryRun: z.boolean().optional(),
});
const deleteRowsSchema = tableIdSchema.extend({
filter: dataTableFilterSchema,
returnData: z.boolean().optional(),
dryRun: z.boolean().optional(),
});
function handleDataTableError(error: unknown): McpToolResponse {
if (error instanceof z.ZodError) {
return { success: false, error: 'Invalid input', details: { errors: error.errors } };
}
if (error instanceof N8nApiError) {
return {
success: false,
error: getUserFriendlyErrorMessage(error),
code: error.code,
details: error.details as Record<string, unknown> | undefined,
};
}
return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' };
}
export async function handleCreateTable(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const input = createTableSchema.parse(args);
const dataTable = await client.createDataTable(input);
if (!dataTable || !dataTable.id) {
return { success: false, error: 'Data table creation failed: n8n API returned an empty or invalid response' };
}
return {
success: true,
data: { id: dataTable.id, name: dataTable.name },
message: `Data table "${dataTable.name}" created with ID: ${dataTable.id}`,
};
} catch (error) {
return handleDataTableError(error);
}
}
export async function handleListTables(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const input = listTablesSchema.parse(args || {});
const result = await client.listDataTables(input);
return {
success: true,
data: {
tables: result.data,
count: result.data.length,
nextCursor: result.nextCursor || undefined,
},
};
} catch (error) {
return handleDataTableError(error);
}
}
export async function handleGetTable(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const { tableId } = tableIdSchema.parse(args);
const dataTable = await client.getDataTable(tableId);
return { success: true, data: dataTable };
} catch (error) {
return handleDataTableError(error);
}
}
export async function handleUpdateTable(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const { tableId, name } = updateTableSchema.parse(args);
const dataTable = await client.updateDataTable(tableId, { name });
return {
success: true,
data: dataTable,
message: `Data table renamed to "${dataTable.name}"`,
};
} catch (error) {
return handleDataTableError(error);
}
}
export async function handleDeleteTable(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const { tableId } = tableIdSchema.parse(args);
await client.deleteDataTable(tableId);
return { success: true, message: `Data table ${tableId} deleted successfully` };
} catch (error) {
return handleDataTableError(error);
}
}
export async function handleGetRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const { tableId, filter, ...params } = getRowsSchema.parse(args);
const queryParams: Record<string, unknown> = { ...params };
if (filter) {
queryParams.filter = typeof filter === 'string' ? filter : JSON.stringify(filter);
}
const result = await client.getDataTableRows(tableId, queryParams as any);
return {
success: true,
data: {
rows: result.data,
count: result.data.length,
nextCursor: result.nextCursor || undefined,
},
};
} catch (error) {
return handleDataTableError(error);
}
}
export async function handleInsertRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const { tableId, ...params } = insertRowsSchema.parse(args);
const result = await client.insertDataTableRows(tableId, params);
return {
success: true,
data: result,
message: `Rows inserted into data table ${tableId}`,
};
} catch (error) {
return handleDataTableError(error);
}
}
export async function handleUpdateRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const { tableId, ...params } = mutateRowsSchema.parse(args);
const result = await client.updateDataTableRows(tableId, params);
return {
success: true,
data: result,
message: params.dryRun ? 'Dry run: rows matched (no changes applied)' : 'Rows updated successfully',
};
} catch (error) {
return handleDataTableError(error);
}
}
export async function handleUpsertRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const { tableId, ...params } = mutateRowsSchema.parse(args);
const result = await client.upsertDataTableRow(tableId, params);
return {
success: true,
data: result,
message: params.dryRun ? 'Dry run: upsert previewed (no changes applied)' : 'Row upserted successfully',
};
} catch (error) {
return handleDataTableError(error);
}
}
export async function handleDeleteRows(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const { tableId, filter, ...params } = deleteRowsSchema.parse(args);
const queryParams = {
filter: JSON.stringify(filter),
...params,
};
const result = await client.deleteDataTableRows(tableId, queryParams as any);
return {
success: true,
data: result,
message: params.dryRun ? 'Dry run: rows matched for deletion (no changes applied)' : 'Rows deleted successfully',
};
} catch (error) {
return handleDataTableError(error);
}
}

View File

@@ -1029,6 +1029,11 @@ export class N8NDocumentationMCPServer {
? { valid: true, errors: [] }
: { valid: false, errors: [{ field: 'action', message: 'action is required' }] };
break;
case 'n8n_manage_datatable':
validationResult = args.action
? { valid: true, errors: [] }
: { valid: false, errors: [{ field: 'action', message: 'action is required' }] };
break;
case 'n8n_deploy_template':
// Requires templateId parameter
validationResult = args.templateId !== undefined
@@ -1496,6 +1501,26 @@ export class N8NDocumentationMCPServer {
if (!this.repository) throw new Error('Repository not initialized');
return n8nHandlers.handleDeployTemplate(args, this.templateService, this.repository, this.instanceContext);
case 'n8n_manage_datatable': {
this.validateToolParams(name, args, ['action']);
const dtAction = args.action;
// Each handler validates its own inputs via Zod schemas
switch (dtAction) {
case 'createTable': return n8nHandlers.handleCreateTable(args, this.instanceContext);
case 'listTables': return n8nHandlers.handleListTables(args, this.instanceContext);
case 'getTable': return n8nHandlers.handleGetTable(args, this.instanceContext);
case 'updateTable': return n8nHandlers.handleUpdateTable(args, this.instanceContext);
case 'deleteTable': return n8nHandlers.handleDeleteTable(args, this.instanceContext);
case 'getRows': return n8nHandlers.handleGetRows(args, this.instanceContext);
case 'insertRows': return n8nHandlers.handleInsertRows(args, this.instanceContext);
case 'updateRows': return n8nHandlers.handleUpdateRows(args, this.instanceContext);
case 'upsertRows': return n8nHandlers.handleUpsertRows(args, this.instanceContext);
case 'deleteRows': return n8nHandlers.handleDeleteRows(args, this.instanceContext);
default:
throw new Error(`Unknown action: ${dtAction}. Valid actions: createTable, listTables, getTable, updateTable, deleteTable, getRows, insertRows, updateRows, upsertRows, deleteRows`);
}
}
default:
throw new Error(`Unknown tool: ${name}`);
}

View File

@@ -22,7 +22,8 @@ import {
n8nTestWorkflowDoc,
n8nExecutionsDoc,
n8nWorkflowVersionsDoc,
n8nDeployTemplateDoc
n8nDeployTemplateDoc,
n8nManageDatatableDoc
} from './workflow_management';
// Combine all tool documentations into a single object
@@ -60,7 +61,8 @@ export const toolsDocumentation: Record<string, ToolDocumentation> = {
n8n_test_workflow: n8nTestWorkflowDoc,
n8n_executions: n8nExecutionsDoc,
n8n_workflow_versions: n8nWorkflowVersionsDoc,
n8n_deploy_template: n8nDeployTemplateDoc
n8n_deploy_template: n8nDeployTemplateDoc,
n8n_manage_datatable: n8nManageDatatableDoc
};
// Re-export types

View File

@@ -10,3 +10,4 @@ export { n8nTestWorkflowDoc } from './n8n-test-workflow';
export { n8nExecutionsDoc } from './n8n-executions';
export { n8nWorkflowVersionsDoc } from './n8n-workflow-versions';
export { n8nDeployTemplateDoc } from './n8n-deploy-template';
export { n8nManageDatatableDoc } from './n8n-manage-datatable';

View File

@@ -0,0 +1,109 @@
import { ToolDocumentation } from '../types';
export const n8nManageDatatableDoc: ToolDocumentation = {
name: 'n8n_manage_datatable',
category: 'workflow_management',
essentials: {
description: 'Manage n8n data tables and rows. Unified tool for table CRUD and row operations with filtering, pagination, and dry-run support.',
keyParameters: ['action', 'tableId', 'name', 'data', 'filter'],
example: 'n8n_manage_datatable({action: "createTable", name: "Contacts", columns: [{name: "email", type: "string"}]})',
performance: 'Fast (100-500ms)',
tips: [
'Table actions: createTable, listTables, getTable, updateTable, deleteTable',
'Row actions: getRows, insertRows, updateRows, upsertRows, deleteRows',
'Use dryRun: true to preview update/upsert/delete before applying',
'Filter supports: eq, neq, like, ilike, gt, gte, lt, lte conditions',
'Use returnData: true to get affected rows back from update/upsert/delete',
'Requires n8n enterprise or cloud with data tables feature'
]
},
full: {
description: `**Table Actions:**
- **createTable**: Create a new data table with optional typed columns
- **listTables**: List all data tables (paginated)
- **getTable**: Get table details and column definitions by ID
- **updateTable**: Rename an existing table
- **deleteTable**: Permanently delete a table and all its rows
**Row Actions:**
- **getRows**: List rows with filtering, sorting, search, and pagination
- **insertRows**: Insert one or more rows (bulk)
- **updateRows**: Update rows matching a filter condition
- **upsertRows**: Update matching row or insert if none match
- **deleteRows**: Delete rows matching a filter condition (filter required)
**Filter System:** Used in getRows, updateRows, upsertRows, deleteRows
- Combine conditions with "and" (default) or "or"
- Conditions: eq, neq, like, ilike, gt, gte, lt, lte
- Example: {type: "and", filters: [{columnName: "status", condition: "eq", value: "active"}]}
**Dry Run:** updateRows, upsertRows, and deleteRows support dryRun: true to preview changes without applying them.`,
parameters: {
action: { type: 'string', required: true, description: 'Operation to perform' },
tableId: { type: 'string', required: false, description: 'Data table ID (required for all except createTable and listTables)' },
name: { type: 'string', required: false, description: 'For createTable/updateTable: table name' },
columns: { type: 'array', required: false, description: 'For createTable: column definitions [{name, type?}]. Types: string, number, boolean, date, json' },
data: { type: 'array|object', required: false, description: 'For insertRows: array of row objects. For updateRows/upsertRows: object with column values' },
filter: { type: 'object', required: false, description: 'Filter: {type?: "and"|"or", filters: [{columnName, condition, value}]}' },
limit: { type: 'number', required: false, description: 'For listTables/getRows: max results (1-100)' },
cursor: { type: 'string', required: false, description: 'For listTables/getRows: pagination cursor' },
sortBy: { type: 'string', required: false, description: 'For getRows: "columnName:asc" or "columnName:desc"' },
search: { type: 'string', required: false, description: 'For getRows: full-text search across string columns' },
returnType: { type: 'string', required: false, description: 'For insertRows: "count" (default), "id", or "all"' },
returnData: { type: 'boolean', required: false, description: 'For updateRows/upsertRows/deleteRows: return affected rows (default: false)' },
dryRun: { type: 'boolean', required: false, description: 'For updateRows/upsertRows/deleteRows: preview without applying (default: false)' },
},
returns: `Depends on action:
- createTable: {id, name}
- listTables: {tables, count, nextCursor?}
- getTable: Full table object with columns
- updateTable: Updated table object
- deleteTable: Success message
- getRows: {rows, count, nextCursor?}
- insertRows: Depends on returnType (count/ids/rows)
- updateRows: Update result with optional rows
- upsertRows: Upsert result with action type
- deleteRows: Delete result with optional rows`,
examples: [
'// Create a table\nn8n_manage_datatable({action: "createTable", name: "Contacts", columns: [{name: "email", type: "string"}, {name: "score", type: "number"}]})',
'// List all tables\nn8n_manage_datatable({action: "listTables"})',
'// Get table details\nn8n_manage_datatable({action: "getTable", tableId: "dt-123"})',
'// Rename a table\nn8n_manage_datatable({action: "updateTable", tableId: "dt-123", name: "New Name"})',
'// Delete a table\nn8n_manage_datatable({action: "deleteTable", tableId: "dt-123"})',
'// Get rows with filter\nn8n_manage_datatable({action: "getRows", tableId: "dt-123", filter: {filters: [{columnName: "status", condition: "eq", value: "active"}]}, limit: 50})',
'// Search rows\nn8n_manage_datatable({action: "getRows", tableId: "dt-123", search: "john", sortBy: "name:asc"})',
'// Insert rows\nn8n_manage_datatable({action: "insertRows", tableId: "dt-123", data: [{email: "a@b.com", score: 10}], returnType: "all"})',
'// Update rows (dry run)\nn8n_manage_datatable({action: "updateRows", tableId: "dt-123", filter: {filters: [{columnName: "score", condition: "lt", value: 5}]}, data: {status: "inactive"}, dryRun: true})',
'// Upsert a row\nn8n_manage_datatable({action: "upsertRows", tableId: "dt-123", filter: {filters: [{columnName: "email", condition: "eq", value: "a@b.com"}]}, data: {score: 15}, returnData: true})',
'// Delete rows\nn8n_manage_datatable({action: "deleteRows", tableId: "dt-123", filter: {filters: [{columnName: "status", condition: "eq", value: "deleted"}]}})',
],
useCases: [
'Persist structured workflow data across executions',
'Store and query lookup tables for workflow logic',
'Bulk insert records from external data sources',
'Conditionally update records matching criteria',
'Upsert to maintain unique records by key column',
'Clean up old or invalid rows with filtered delete',
'Preview changes with dryRun before modifying data',
],
performance: 'Table operations: 50-300ms. Row operations: 100-500ms depending on data size and filters.',
bestPractices: [
'Define column types upfront for schema consistency',
'Use dryRun: true before bulk updates/deletes to verify filter correctness',
'Use returnType: "count" (default) for insertRows to minimize response size',
'Use filter with specific conditions to avoid unintended bulk operations',
'Use cursor-based pagination for large result sets',
'Use sortBy for deterministic row ordering',
],
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',
'deleteRows requires a filter — cannot delete all rows without one',
'Column types cannot be changed after table creation via API',
'updateTable can only rename the table (no column modifications via public API)',
'projectId cannot be set via the public API — use the n8n UI',
],
relatedTools: ['n8n_create_workflow', 'n8n_list_workflows', 'n8n_health_check'],
},
};

View File

@@ -63,6 +63,10 @@ export const n8nManagementTools: ToolDefinition[] = [
executionTimeout: { type: 'number' },
errorWorkflow: { type: 'string' }
}
},
projectId: {
type: 'string',
description: 'Optional project ID to create the workflow in (enterprise feature)'
}
},
required: ['name', 'nodes', 'connections']
@@ -602,5 +606,52 @@ export const n8nManagementTools: ToolDefinition[] = [
destructiveHint: false,
openWorldHint: true,
},
}
},
{
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.`,
inputSchema: {
type: 'object',
properties: {
action: {
type: 'string',
enum: ['createTable', 'listTables', 'getTable', 'updateTable', 'deleteTable', 'getRows', 'insertRows', 'updateRows', 'upsertRows', 'deleteRows'],
description: 'Operation to perform',
},
tableId: { type: 'string', description: 'Data table ID (required for all actions except createTable and listTables)' },
name: { type: 'string', description: 'For createTable/updateTable: table name' },
columns: {
type: 'array',
description: 'For createTable: column definitions',
items: {
type: 'object',
properties: {
name: { type: 'string' },
type: { type: 'string', enum: ['string', 'number', 'boolean', 'date', 'json'] },
},
required: ['name'],
},
},
data: { description: 'For insertRows: array of row objects. For updateRows/upsertRows: object with column values.' },
filter: {
type: 'object',
description: 'For getRows/updateRows/upsertRows/deleteRows: {type?: "and"|"or", filters: [{columnName, condition, value}]}',
},
limit: { type: 'number', description: 'For listTables/getRows: max results (1-100)' },
cursor: { type: 'string', description: 'For listTables/getRows: pagination cursor' },
sortBy: { type: 'string', description: 'For getRows: "columnName:asc" or "columnName:desc"' },
search: { type: 'string', description: 'For getRows: text search across string columns' },
returnType: { type: 'string', enum: ['count', 'id', 'all'], description: 'For insertRows: what to return (default: count)' },
returnData: { type: 'boolean', description: 'For updateRows/upsertRows/deleteRows: return affected rows (default: false)' },
dryRun: { type: 'boolean', description: 'For updateRows/upsertRows/deleteRows: preview without applying (default: false)' },
},
required: ['action'],
},
annotations: {
title: 'Manage Data Tables',
readOnlyHint: false,
destructiveHint: true,
openWorldHint: true,
},
},
];