diff --git a/CHANGELOG.md b/CHANGELOG.md index f3b3655..2d6e4d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,23 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [2.39.0] - 2026-03-20 +## [2.40.0] - 2026-03-21 -### Added +### Changed -- **`n8n_create_data_table` MCP tool** (Issue #640): Create data tables in n8n via the REST API - - `N8nApiClient.createDataTable()` calling `POST /data-tables` - - Zod-validated handler with `N8nApiError` handling for structured error responses - - TypeScript interfaces matching the n8n OpenAPI spec (`DataTableColumn`, `DataTableColumnResponse`, `DataTable`) - - Column types per spec: `string | number | boolean | date | json` - - Input validation: `.min(1)` on table name and column names - - Tool documentation with examples, use cases, and pitfalls - - Requires n8n enterprise or cloud with data tables feature enabled +- **`n8n_manage_datatable` MCP tool** (replaces `n8n_create_data_table`): Full data table management 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 (eq, neq, like, ilike, gt, gte, lt, lte) + - Dry-run support for updateRows, upsertRows, deleteRows + - Pagination, sorting, and full-text search for row listing + - Shared error handler and consolidated Zod schemas for consistency + - 9 new `N8nApiClient` methods for all data table endpoints - **`projectId` parameter for `n8n_create_workflow`**: Create workflows directly in a specific team project (enterprise feature) -### Fixed +### Breaking -- **Health check management tool count**: Updated from 13 to 14 to include `n8n_create_data_table` +- `n8n_create_data_table` tool replaced by `n8n_manage_datatable` with `action: "createTable"` Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en diff --git a/package.json b/package.json index d19df52..f9271ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.39.0", + "version": "2.40.0", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/mcp/handlers-n8n-manager.ts b/src/mcp/handlers-n8n-manager.ts index 19d4c94..a2dc38a 100644 --- a/src/mcp/handlers-n8n-manager.ts +++ b/src/mcp/handlers-n8n-manager.ts @@ -1975,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 ? 14 : 0; // Management tools requiring API (includes n8n_create_data_table) + const managementTools = apiConfigured ? 14 : 0; // Management tools requiring API (includes n8n_manage_datatable) const totalTools = documentationTools + managementTools; // Check npm version @@ -2691,10 +2691,28 @@ export async function handleTriggerWebhookWorkflow(args: unknown, context?: Inst } // ======================================================================== -// Data Table Handler +// Data Table Handlers // ======================================================================== -const createDataTableSchema = z.object({ +// 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'), @@ -2702,45 +2720,212 @@ const createDataTableSchema = z.object({ })).optional(), }); -export async function handleCreateDataTable(args: unknown, context?: InstanceContext): Promise { +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 | undefined, + }; + } + return { success: false, error: error instanceof Error ? error.message : 'Unknown error occurred' }; +} + +export async function handleCreateTable(args: unknown, context?: InstanceContext): Promise { try { const client = ensureApiConfigured(context); - const input = createDataTableSchema.parse(args); + 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: 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}` + message: `Data table "${dataTable.name}" created with ID: ${dataTable.id}`, }; } catch (error) { - 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 | undefined - }; - } - - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred' - }; + return handleDataTableError(error); + } +} + +export async function handleListTables(args: unknown, context?: InstanceContext): Promise { + 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 { + 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 { + 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 { + 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 { + try { + const client = ensureApiConfigured(context); + const { tableId, filter, ...params } = getRowsSchema.parse(args); + const queryParams: Record = { ...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 { + 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 { + 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 { + 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 { + 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); } } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index d46326a..e3a5223 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -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,9 +1501,25 @@ export class N8NDocumentationMCPServer { if (!this.repository) throw new Error('Repository not initialized'); return n8nHandlers.handleDeployTemplate(args, this.templateService, this.repository, this.instanceContext); - case 'n8n_create_data_table': - this.validateToolParams(name, args, ['name']); - return n8nHandlers.handleCreateDataTable(args, 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}`); diff --git a/src/mcp/tool-docs/index.ts b/src/mcp/tool-docs/index.ts index 86993d3..85511d4 100644 --- a/src/mcp/tool-docs/index.ts +++ b/src/mcp/tool-docs/index.ts @@ -23,7 +23,7 @@ import { n8nExecutionsDoc, n8nWorkflowVersionsDoc, n8nDeployTemplateDoc, - n8nCreateDataTableDoc + n8nManageDatatableDoc } from './workflow_management'; // Combine all tool documentations into a single object @@ -62,7 +62,7 @@ export const toolsDocumentation: Record = { n8n_executions: n8nExecutionsDoc, n8n_workflow_versions: n8nWorkflowVersionsDoc, n8n_deploy_template: n8nDeployTemplateDoc, - n8n_create_data_table: n8nCreateDataTableDoc + n8n_manage_datatable: n8nManageDatatableDoc }; // Re-export types diff --git a/src/mcp/tool-docs/workflow_management/index.ts b/src/mcp/tool-docs/workflow_management/index.ts index 998d427..8eb2804 100644 --- a/src/mcp/tool-docs/workflow_management/index.ts +++ b/src/mcp/tool-docs/workflow_management/index.ts @@ -10,4 +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 { n8nCreateDataTableDoc } from './n8n-create-data-table'; +export { n8nManageDatatableDoc } from './n8n-manage-datatable'; diff --git a/src/mcp/tool-docs/workflow_management/n8n-create-data-table.ts b/src/mcp/tool-docs/workflow_management/n8n-create-data-table.ts deleted file mode 100644 index 5cd1b96..0000000 --- a/src/mcp/tool-docs/workflow_management/n8n-create-data-table.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { ToolDocumentation } from '../types'; - -export const n8nCreateDataTableDoc: ToolDocumentation = { - name: 'n8n_create_data_table', - category: 'workflow_management', - essentials: { - description: 'Create a new data table in n8n. Requires n8n enterprise or cloud with the data tables feature enabled.', - keyParameters: ['name', 'columns'], - example: 'n8n_create_data_table({name: "Contacts", columns: [{name: "email", type: "string"}]})', - performance: 'Fast (100-300ms)', - tips: [ - 'Available column types: string, number, boolean, date, json', - 'Columns are optional — a table can be created without columns', - 'Requires n8n enterprise or cloud plan with data tables feature', - 'projectId cannot be set via the public API — use the n8n UI for project assignment' - ] - }, - full: { - description: 'Creates a new data table in n8n. Data tables are structured, persistent storage for workflow data. Each table can have typed columns (string, number, boolean, date, json). Requires the data tables feature to be enabled on the n8n instance.', - parameters: { - name: { type: 'string', required: true, description: 'Name for the new data table' }, - columns: { - type: 'array', - required: false, - description: 'Column definitions. Each column has a name and optional type (string, number, boolean, date, json). Defaults to string if type is omitted.' - } - }, - returns: 'Object with id and name of the created data table on success.', - examples: [ - 'n8n_create_data_table({name: "Orders"}) - Create table without columns', - 'n8n_create_data_table({name: "Contacts", columns: [{name: "email", type: "string"}, {name: "score", type: "number"}]}) - Create table with typed columns', - 'n8n_create_data_table({name: "Events", columns: [{name: "payload", type: "json"}, {name: "occurred_at", type: "date"}]})' - ], - useCases: [ - 'Persist structured workflow data across executions', - 'Store lookup tables for workflow logic', - 'Accumulate records from multiple workflow runs', - 'Share data between different workflows' - ], - performance: 'Fast operation — typically 100-300ms.', - bestPractices: [ - 'Define columns upfront to enforce schema consistency', - 'Use typed columns for numeric or date data to enable proper filtering', - 'Use json type for nested or variable-structure data' - ], - pitfalls: [ - 'Requires N8N_API_URL and N8N_API_KEY configured', - 'Feature only available on n8n enterprise or cloud plans', - 'projectId cannot be set via the public API', - 'Column types cannot be changed after table creation via API' - ], - relatedTools: ['n8n_create_workflow', 'n8n_list_workflows', 'n8n_health_check'] - } -}; diff --git a/src/mcp/tool-docs/workflow_management/n8n-manage-datatable.ts b/src/mcp/tool-docs/workflow_management/n8n-manage-datatable.ts new file mode 100644 index 0000000..73214d9 --- /dev/null +++ b/src/mcp/tool-docs/workflow_management/n8n-manage-datatable.ts @@ -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'], + }, +}; diff --git a/src/mcp/tools-n8n-manager.ts b/src/mcp/tools-n8n-manager.ts index ace240a..cf82442 100644 --- a/src/mcp/tools-n8n-manager.ts +++ b/src/mcp/tools-n8n-manager.ts @@ -608,35 +608,49 @@ export const n8nManagementTools: ToolDefinition[] = [ }, }, { - name: 'n8n_create_data_table', - description: 'Create a new data table in n8n. Requires n8n enterprise or cloud with data tables feature.', + 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: { - name: { type: 'string', description: 'Name for the data table' }, + 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: 'Column definitions', + description: 'For createTable: column definitions', items: { type: 'object', properties: { - name: { type: 'string', description: 'Column name' }, - type: { - type: 'string', - enum: ['string', 'number', 'boolean', 'date', 'json'], - description: 'Column data type', - }, + 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: ['name'], + required: ['action'], }, annotations: { - title: 'Create Data Table', + title: 'Manage Data Tables', readOnlyHint: false, - destructiveHint: false, + destructiveHint: true, openWorldHint: true, }, }, diff --git a/src/services/n8n-api-client.ts b/src/services/n8n-api-client.ts index e198906..513011c 100644 --- a/src/services/n8n-api-client.ts +++ b/src/services/n8n-api-client.ts @@ -24,6 +24,13 @@ import { SourceControlPushResult, DataTable, DataTableColumn, + DataTableListParams, + DataTableRow, + DataTableRowListParams, + DataTableInsertRowsParams, + DataTableUpdateRowsParams, + DataTableUpsertRowParams, + DataTableDeleteRowsParams, } from '../types/n8n-api'; import { handleN8nApiError, logN8nError } from '../utils/n8n-errors'; import { cleanWorkflowForCreate, cleanWorkflowForUpdate } from './n8n-validation'; @@ -593,6 +600,86 @@ export class N8nApiClient { } } + async listDataTables(params: DataTableListParams = {}): Promise<{ data: DataTable[]; nextCursor?: string | null }> { + try { + const response = await this.client.get('/data-tables', { params }); + return this.validateListResponse(response.data, 'data-tables'); + } catch (error) { + throw handleN8nApiError(error); + } + } + + async getDataTable(id: string): Promise { + try { + const response = await this.client.get(`/data-tables/${id}`); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async updateDataTable(id: string, params: { name: string }): Promise { + try { + const response = await this.client.patch(`/data-tables/${id}`, params); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async deleteDataTable(id: string): Promise { + try { + await this.client.delete(`/data-tables/${id}`); + } catch (error) { + throw handleN8nApiError(error); + } + } + + async getDataTableRows(id: string, params: DataTableRowListParams = {}): Promise<{ data: DataTableRow[]; nextCursor?: string | null }> { + try { + const response = await this.client.get(`/data-tables/${id}/rows`, { params }); + return this.validateListResponse(response.data, 'data-table-rows'); + } catch (error) { + throw handleN8nApiError(error); + } + } + + async insertDataTableRows(id: string, params: DataTableInsertRowsParams): Promise { + try { + const response = await this.client.post(`/data-tables/${id}/rows`, params); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async updateDataTableRows(id: string, params: DataTableUpdateRowsParams): Promise { + try { + const response = await this.client.patch(`/data-tables/${id}/rows/update`, params); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async upsertDataTableRow(id: string, params: DataTableUpsertRowParams): Promise { + try { + const response = await this.client.post(`/data-tables/${id}/rows/upsert`, params); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + async deleteDataTableRows(id: string, params: DataTableDeleteRowsParams): Promise { + try { + const response = await this.client.delete(`/data-tables/${id}/rows/delete`, { params }); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + /** * Validates and normalizes n8n API list responses. * Handles both modern format {data: [], nextCursor?: string} and legacy array format. diff --git a/src/types/n8n-api.ts b/src/types/n8n-api.ts index 959ad23..6d3d490 100644 --- a/src/types/n8n-api.ts +++ b/src/types/n8n-api.ts @@ -476,4 +476,60 @@ export interface DataTable { projectId?: string; createdAt?: string; updatedAt?: string; +} + +export interface DataTableRow { + id?: number; + createdAt?: string; + updatedAt?: string; + [columnName: string]: unknown; +} + +export interface DataTableFilterCondition { + columnName: string; + condition: 'eq' | 'neq' | 'like' | 'ilike' | 'gt' | 'gte' | 'lt' | 'lte'; + value?: any; +} + +export interface DataTableFilter { + type?: 'and' | 'or'; + filters: DataTableFilterCondition[]; +} + +export interface DataTableListParams { + limit?: number; + cursor?: string; +} + +export interface DataTableRowListParams { + limit?: number; + cursor?: string; + filter?: string; + sortBy?: string; + search?: string; +} + +export interface DataTableInsertRowsParams { + data: Record[]; + returnType?: 'count' | 'id' | 'all'; +} + +export interface DataTableUpdateRowsParams { + filter: DataTableFilter; + data: Record; + returnData?: boolean; + dryRun?: boolean; +} + +export interface DataTableUpsertRowParams { + filter: DataTableFilter; + data: Record; + returnData?: boolean; + dryRun?: boolean; +} + +export interface DataTableDeleteRowsParams { + filter: string; + returnData?: boolean; + dryRun?: boolean; } \ No newline at end of file diff --git a/tests/unit/mcp/handlers-create-data-table.test.ts b/tests/unit/mcp/handlers-create-data-table.test.ts deleted file mode 100644 index 0d5e051..0000000 --- a/tests/unit/mcp/handlers-create-data-table.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { N8nApiClient } from '@/services/n8n-api-client'; -import { N8nApiError } from '@/utils/n8n-errors'; - -// Mock dependencies -vi.mock('@/services/n8n-api-client'); -vi.mock('@/config/n8n-api', () => ({ - getN8nApiConfig: vi.fn(), -})); -vi.mock('@/services/n8n-validation', () => ({ - validateWorkflowStructure: vi.fn(), - hasWebhookTrigger: vi.fn(), - getWebhookUrl: vi.fn(), -})); -vi.mock('@/utils/logger', () => ({ - logger: { - info: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - }, - Logger: vi.fn().mockImplementation(() => ({ - info: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - warn: vi.fn(), - })), - LogLevel: { - ERROR: 0, - WARN: 1, - INFO: 2, - DEBUG: 3, - }, -})); - -describe('handleCreateDataTable', () => { - let mockApiClient: any; - let handlers: any; - let getN8nApiConfig: any; - - beforeEach(async () => { - vi.clearAllMocks(); - - // Setup mock API client - mockApiClient = { - createWorkflow: vi.fn(), - getWorkflow: vi.fn(), - updateWorkflow: vi.fn(), - deleteWorkflow: vi.fn(), - listWorkflows: vi.fn(), - triggerWebhook: vi.fn(), - getExecution: vi.fn(), - listExecutions: vi.fn(), - deleteExecution: vi.fn(), - healthCheck: vi.fn(), - createDataTable: vi.fn(), - }; - - // Import mocked modules - getN8nApiConfig = (await import('@/config/n8n-api')).getN8nApiConfig; - - // Mock the API config - vi.mocked(getN8nApiConfig).mockReturnValue({ - baseUrl: 'https://n8n.test.com', - apiKey: 'test-key', - timeout: 30000, - maxRetries: 3, - }); - - // Mock the N8nApiClient constructor - vi.mocked(N8nApiClient).mockImplementation(() => mockApiClient); - - // Import handlers module after setting up mocks - handlers = await import('@/mcp/handlers-n8n-manager'); - }); - - afterEach(() => { - if (handlers) { - const clientGetter = handlers.getN8nApiClient; - if (clientGetter) { - vi.mocked(getN8nApiConfig).mockReturnValue(null); - clientGetter(); - } - } - }); - - it('should create data table with name and columns successfully', async () => { - const createdTable = { - id: 'dt-123', - name: 'My Data Table', - columns: [ - { id: 'col-1', name: 'email', type: 'string', index: 0 }, - { id: 'col-2', name: 'age', type: 'number', index: 1 }, - ], - }; - - mockApiClient.createDataTable.mockResolvedValue(createdTable); - - const result = await handlers.handleCreateDataTable({ - name: 'My Data Table', - columns: [ - { name: 'email', type: 'string' }, - { name: 'age', type: 'number' }, - ], - }); - - expect(result).toEqual({ - success: true, - data: { id: 'dt-123', name: 'My Data Table' }, - message: 'Data table "My Data Table" created with ID: dt-123', - }); - - expect(mockApiClient.createDataTable).toHaveBeenCalledWith({ - name: 'My Data Table', - columns: [ - { name: 'email', type: 'string' }, - { name: 'age', type: 'number' }, - ], - }); - }); - - it('should create data table with name only (no columns)', async () => { - const createdTable = { - id: 'dt-456', - name: 'Empty Table', - }; - - mockApiClient.createDataTable.mockResolvedValue(createdTable); - - const result = await handlers.handleCreateDataTable({ - name: 'Empty Table', - }); - - expect(result).toEqual({ - success: true, - data: { id: 'dt-456', name: 'Empty Table' }, - message: 'Data table "Empty Table" created with ID: dt-456', - }); - - expect(mockApiClient.createDataTable).toHaveBeenCalledWith({ - name: 'Empty Table', - }); - }); - - it('should return error when n8n API is not configured', async () => { - vi.mocked(getN8nApiConfig).mockReturnValue(null); - - const result = await handlers.handleCreateDataTable({ - name: 'Test Table', - }); - - expect(result).toEqual({ - success: false, - error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.', - }); - }); - - it('should return Zod validation error when name is missing', async () => { - const result = await handlers.handleCreateDataTable({}); - - expect(result.success).toBe(false); - expect(result.error).toBe('Invalid input'); - expect(result.details).toHaveProperty('errors'); - }); - - it('should return Zod validation error when name is empty string', async () => { - const result = await handlers.handleCreateDataTable({ name: '' }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Invalid input'); - expect(result.details).toHaveProperty('errors'); - }); - - it('should return Zod validation error when column name is empty string', async () => { - const result = await handlers.handleCreateDataTable({ - name: 'Valid Table', - columns: [{ name: '' }], - }); - - expect(result.success).toBe(false); - expect(result.error).toBe('Invalid input'); - expect(result.details).toHaveProperty('errors'); - }); - - it('should return error when API call fails', async () => { - const apiError = new Error('Data table creation failed on the server'); - mockApiClient.createDataTable.mockRejectedValue(apiError); - - const result = await handlers.handleCreateDataTable({ - name: 'Duplicate Table', - }); - - expect(result).toEqual({ - success: false, - error: 'Data table creation failed on the server', - }); - }); - - it('should return structured error for N8nApiError', async () => { - const apiError = new N8nApiError('Feature not available', 402, 'PAYMENT_REQUIRED', { plan: 'enterprise' }); - mockApiClient.createDataTable.mockRejectedValue(apiError); - - const result = await handlers.handleCreateDataTable({ - name: 'Enterprise Table', - }); - - expect(result.success).toBe(false); - expect(result.error).toBeDefined(); - expect(result.code).toBe('PAYMENT_REQUIRED'); - expect(result.details).toEqual({ plan: 'enterprise' }); - }); - - it('should return error when API returns empty response (null)', async () => { - mockApiClient.createDataTable.mockResolvedValue(null); - - const result = await handlers.handleCreateDataTable({ - name: 'Ghost Table', - }); - - expect(result).toEqual({ - success: false, - error: 'Data table creation failed: n8n API returned an empty or invalid response', - }); - }); - - it('should return error when API returns response without id', async () => { - mockApiClient.createDataTable.mockResolvedValue({ name: 'No ID Table' }); - - const result = await handlers.handleCreateDataTable({ - name: 'No ID Table', - }); - - expect(result).toEqual({ - success: false, - error: 'Data table creation failed: n8n API returned an empty or invalid response', - }); - }); - - it('should return Unknown error when a non-Error value is thrown', async () => { - mockApiClient.createDataTable.mockRejectedValue('string-error'); - - const result = await handlers.handleCreateDataTable({ - name: 'Error Table', - }); - - expect(result).toEqual({ - success: false, - error: 'Unknown error occurred', - }); - }); -}); diff --git a/tests/unit/mcp/handlers-manage-datatable.test.ts b/tests/unit/mcp/handlers-manage-datatable.test.ts new file mode 100644 index 0000000..fe28414 --- /dev/null +++ b/tests/unit/mcp/handlers-manage-datatable.test.ts @@ -0,0 +1,727 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { N8nApiClient } from '@/services/n8n-api-client'; +import { N8nApiError } from '@/utils/n8n-errors'; + +// Mock dependencies +vi.mock('@/services/n8n-api-client'); +vi.mock('@/config/n8n-api', () => ({ + getN8nApiConfig: vi.fn(), +})); +vi.mock('@/services/n8n-validation', () => ({ + validateWorkflowStructure: vi.fn(), + hasWebhookTrigger: vi.fn(), + getWebhookUrl: vi.fn(), +})); +vi.mock('@/utils/logger', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + }, + Logger: vi.fn().mockImplementation(() => ({ + info: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + })), + LogLevel: { + ERROR: 0, + WARN: 1, + INFO: 2, + DEBUG: 3, + }, +})); + +describe('Data Table Handlers (n8n_manage_datatable)', () => { + let mockApiClient: any; + let handlers: any; + let getN8nApiConfig: any; + + beforeEach(async () => { + vi.clearAllMocks(); + + // Setup mock API client with all data table methods + mockApiClient = { + createWorkflow: vi.fn(), + getWorkflow: vi.fn(), + updateWorkflow: vi.fn(), + deleteWorkflow: vi.fn(), + listWorkflows: vi.fn(), + triggerWebhook: vi.fn(), + getExecution: vi.fn(), + listExecutions: vi.fn(), + deleteExecution: vi.fn(), + healthCheck: vi.fn(), + createDataTable: vi.fn(), + listDataTables: vi.fn(), + getDataTable: vi.fn(), + updateDataTable: vi.fn(), + deleteDataTable: vi.fn(), + getDataTableRows: vi.fn(), + insertDataTableRows: vi.fn(), + updateDataTableRows: vi.fn(), + upsertDataTableRow: vi.fn(), + deleteDataTableRows: vi.fn(), + }; + + // Import mocked modules + getN8nApiConfig = (await import('@/config/n8n-api')).getN8nApiConfig; + + // Mock the API config + vi.mocked(getN8nApiConfig).mockReturnValue({ + baseUrl: 'https://n8n.test.com', + apiKey: 'test-key', + timeout: 30000, + maxRetries: 3, + }); + + // Mock the N8nApiClient constructor + vi.mocked(N8nApiClient).mockImplementation(() => mockApiClient); + + // Import handlers module after setting up mocks + handlers = await import('@/mcp/handlers-n8n-manager'); + }); + + afterEach(() => { + if (handlers) { + const clientGetter = handlers.getN8nApiClient; + if (clientGetter) { + vi.mocked(getN8nApiConfig).mockReturnValue(null); + clientGetter(); + } + } + }); + + // ======================================================================== + // handleCreateTable + // ======================================================================== + describe('handleCreateTable', () => { + it('should create data table with name and columns successfully', async () => { + const createdTable = { + id: 'dt-123', + name: 'My Data Table', + columns: [ + { id: 'col-1', name: 'email', type: 'string', index: 0 }, + { id: 'col-2', name: 'age', type: 'number', index: 1 }, + ], + }; + + mockApiClient.createDataTable.mockResolvedValue(createdTable); + + const result = await handlers.handleCreateTable({ + name: 'My Data Table', + columns: [ + { name: 'email', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + + expect(result).toEqual({ + success: true, + data: { id: 'dt-123', name: 'My Data Table' }, + message: 'Data table "My Data Table" created with ID: dt-123', + }); + + expect(mockApiClient.createDataTable).toHaveBeenCalledWith({ + name: 'My Data Table', + columns: [ + { name: 'email', type: 'string' }, + { name: 'age', type: 'number' }, + ], + }); + }); + + it('should create data table with name only (no columns)', async () => { + const createdTable = { + id: 'dt-456', + name: 'Empty Table', + }; + + mockApiClient.createDataTable.mockResolvedValue(createdTable); + + const result = await handlers.handleCreateTable({ + name: 'Empty Table', + }); + + expect(result).toEqual({ + success: true, + data: { id: 'dt-456', name: 'Empty Table' }, + message: 'Data table "Empty Table" created with ID: dt-456', + }); + + expect(mockApiClient.createDataTable).toHaveBeenCalledWith({ + name: 'Empty Table', + }); + }); + + it('should return error when API returns empty response (null)', async () => { + mockApiClient.createDataTable.mockResolvedValue(null); + + const result = await handlers.handleCreateTable({ + name: 'Ghost Table', + }); + + expect(result).toEqual({ + success: false, + error: 'Data table creation failed: n8n API returned an empty or invalid response', + }); + }); + + it('should return error when API call fails', async () => { + const apiError = new Error('Data table creation failed on the server'); + mockApiClient.createDataTable.mockRejectedValue(apiError); + + const result = await handlers.handleCreateTable({ + name: 'Broken Table', + }); + + expect(result).toEqual({ + success: false, + error: 'Data table creation failed on the server', + }); + }); + + it('should return Zod validation error when name is missing', async () => { + const result = await handlers.handleCreateTable({}); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid input'); + expect(result.details).toHaveProperty('errors'); + }); + + it('should return error when n8n API is not configured', async () => { + vi.mocked(getN8nApiConfig).mockReturnValue(null); + + const result = await handlers.handleCreateTable({ + name: 'Test Table', + }); + + expect(result).toEqual({ + success: false, + error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.', + }); + }); + + it('should return structured error for N8nApiError', async () => { + const apiError = new N8nApiError('Feature not available', 402, 'PAYMENT_REQUIRED', { plan: 'enterprise' }); + mockApiClient.createDataTable.mockRejectedValue(apiError); + + const result = await handlers.handleCreateTable({ + name: 'Enterprise Table', + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.code).toBe('PAYMENT_REQUIRED'); + expect(result.details).toEqual({ plan: 'enterprise' }); + }); + + it('should return Unknown error when a non-Error value is thrown', async () => { + mockApiClient.createDataTable.mockRejectedValue('string-error'); + + const result = await handlers.handleCreateTable({ + name: 'Error Table', + }); + + expect(result).toEqual({ + success: false, + error: 'Unknown error occurred', + }); + }); + }); + + // ======================================================================== + // handleListTables + // ======================================================================== + describe('handleListTables', () => { + it('should list tables successfully', async () => { + const tables = [ + { id: 'dt-1', name: 'Table One' }, + { id: 'dt-2', name: 'Table Two' }, + ]; + mockApiClient.listDataTables.mockResolvedValue({ data: tables, nextCursor: null }); + + const result = await handlers.handleListTables({}); + + expect(result).toEqual({ + success: true, + data: { + tables, + count: 2, + nextCursor: undefined, + }, + }); + }); + + it('should return empty list when no tables exist', async () => { + mockApiClient.listDataTables.mockResolvedValue({ data: [], nextCursor: null }); + + const result = await handlers.handleListTables({}); + + expect(result).toEqual({ + success: true, + data: { + tables: [], + count: 0, + nextCursor: undefined, + }, + }); + }); + + it('should pass pagination params (limit, cursor)', async () => { + mockApiClient.listDataTables.mockResolvedValue({ + data: [{ id: 'dt-3', name: 'Page Two' }], + nextCursor: 'cursor-next', + }); + + const result = await handlers.handleListTables({ limit: 10, cursor: 'cursor-abc' }); + + expect(mockApiClient.listDataTables).toHaveBeenCalledWith({ limit: 10, cursor: 'cursor-abc' }); + expect(result.success).toBe(true); + expect(result.data.nextCursor).toBe('cursor-next'); + }); + + it('should handle API error', async () => { + mockApiClient.listDataTables.mockRejectedValue(new Error('Server down')); + + const result = await handlers.handleListTables({}); + + expect(result.success).toBe(false); + expect(result.error).toBe('Server down'); + }); + }); + + // ======================================================================== + // handleGetTable + // ======================================================================== + describe('handleGetTable', () => { + it('should get table successfully', async () => { + const table = { id: 'dt-1', name: 'My Table', columns: [] }; + mockApiClient.getDataTable.mockResolvedValue(table); + + const result = await handlers.handleGetTable({ tableId: 'dt-1' }); + + expect(result).toEqual({ + success: true, + data: table, + }); + expect(mockApiClient.getDataTable).toHaveBeenCalledWith('dt-1'); + }); + + it('should return error on 404', async () => { + const notFoundError = new N8nApiError('Data table not found', 404, 'NOT_FOUND'); + mockApiClient.getDataTable.mockRejectedValue(notFoundError); + + const result = await handlers.handleGetTable({ tableId: 'dt-nonexistent' }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.code).toBe('NOT_FOUND'); + }); + + it('should return Zod validation error when tableId is missing', async () => { + const result = await handlers.handleGetTable({}); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid input'); + expect(result.details).toHaveProperty('errors'); + }); + }); + + // ======================================================================== + // handleUpdateTable + // ======================================================================== + describe('handleUpdateTable', () => { + it('should rename table successfully', async () => { + const updatedTable = { id: 'dt-1', name: 'Renamed Table' }; + mockApiClient.updateDataTable.mockResolvedValue(updatedTable); + + const result = await handlers.handleUpdateTable({ tableId: 'dt-1', name: 'Renamed Table' }); + + expect(result).toEqual({ + success: true, + data: updatedTable, + message: 'Data table renamed to "Renamed Table"', + }); + expect(mockApiClient.updateDataTable).toHaveBeenCalledWith('dt-1', { name: 'Renamed Table' }); + }); + + it('should return Zod validation error when tableId is missing', async () => { + const result = await handlers.handleUpdateTable({ name: 'New Name' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid input'); + expect(result.details).toHaveProperty('errors'); + }); + + it('should return error when API call fails', async () => { + mockApiClient.updateDataTable.mockRejectedValue(new Error('Update failed')); + + const result = await handlers.handleUpdateTable({ tableId: 'dt-1', name: 'New Name' }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Update failed'); + }); + }); + + // ======================================================================== + // handleDeleteTable + // ======================================================================== + describe('handleDeleteTable', () => { + it('should delete table successfully', async () => { + mockApiClient.deleteDataTable.mockResolvedValue(undefined); + + const result = await handlers.handleDeleteTable({ tableId: 'dt-1' }); + + expect(result).toEqual({ + success: true, + message: 'Data table dt-1 deleted successfully', + }); + expect(mockApiClient.deleteDataTable).toHaveBeenCalledWith('dt-1'); + }); + + it('should return error on 404', async () => { + const notFoundError = new N8nApiError('Data table not found', 404, 'NOT_FOUND'); + mockApiClient.deleteDataTable.mockRejectedValue(notFoundError); + + const result = await handlers.handleDeleteTable({ tableId: 'dt-nonexistent' }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.code).toBe('NOT_FOUND'); + }); + }); + + // ======================================================================== + // handleGetRows + // ======================================================================== + describe('handleGetRows', () => { + it('should get rows with default params', async () => { + const rows = [ + { id: 1, email: 'a@b.com', score: 10 }, + { id: 2, email: 'c@d.com', score: 20 }, + ]; + mockApiClient.getDataTableRows.mockResolvedValue({ data: rows, nextCursor: null }); + + const result = await handlers.handleGetRows({ tableId: 'dt-1' }); + + expect(result).toEqual({ + success: true, + data: { + rows, + count: 2, + nextCursor: undefined, + }, + }); + expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', {}); + }); + + it('should pass filter, sort, and search params', async () => { + mockApiClient.getDataTableRows.mockResolvedValue({ data: [], nextCursor: null }); + + await handlers.handleGetRows({ + tableId: 'dt-1', + limit: 50, + sortBy: 'name:asc', + search: 'john', + }); + + expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', { + limit: 50, + sortBy: 'name:asc', + search: 'john', + }); + }); + + it('should serialize object filter to JSON string', async () => { + mockApiClient.getDataTableRows.mockResolvedValue({ data: [], nextCursor: null }); + + const objectFilter = { + type: 'and' as const, + filters: [{ columnName: 'status', condition: 'eq' as const, value: 'active' }], + }; + + await handlers.handleGetRows({ + tableId: 'dt-1', + filter: objectFilter, + }); + + expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', { + filter: JSON.stringify(objectFilter), + }); + }); + + it('should pass through string filter as-is', async () => { + mockApiClient.getDataTableRows.mockResolvedValue({ data: [], nextCursor: null }); + + await handlers.handleGetRows({ + tableId: 'dt-1', + filter: '{"type":"and","filters":[]}', + }); + + expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', { + filter: '{"type":"and","filters":[]}', + }); + }); + }); + + // ======================================================================== + // handleInsertRows + // ======================================================================== + describe('handleInsertRows', () => { + it('should insert rows successfully', async () => { + const insertResult = { insertedCount: 2, ids: [1, 2] }; + mockApiClient.insertDataTableRows.mockResolvedValue(insertResult); + + const result = await handlers.handleInsertRows({ + tableId: 'dt-1', + data: [ + { email: 'a@b.com', score: 10 }, + { email: 'c@d.com', score: 20 }, + ], + }); + + expect(result).toEqual({ + success: true, + data: insertResult, + message: 'Rows inserted into data table dt-1', + }); + expect(mockApiClient.insertDataTableRows).toHaveBeenCalledWith('dt-1', { + data: [ + { email: 'a@b.com', score: 10 }, + { email: 'c@d.com', score: 20 }, + ], + }); + }); + + it('should pass returnType to the API client', async () => { + const insertResult = [{ id: 1, email: 'a@b.com', score: 10 }]; + mockApiClient.insertDataTableRows.mockResolvedValue(insertResult); + + const result = await handlers.handleInsertRows({ + tableId: 'dt-1', + data: [{ email: 'a@b.com', score: 10 }], + returnType: 'all', + }); + + expect(result.success).toBe(true); + expect(result.data).toEqual(insertResult); + expect(mockApiClient.insertDataTableRows).toHaveBeenCalledWith('dt-1', { + data: [{ email: 'a@b.com', score: 10 }], + returnType: 'all', + }); + }); + + it('should return Zod validation error when data is empty array', async () => { + const result = await handlers.handleInsertRows({ + tableId: 'dt-1', + data: [], + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Invalid input'); + expect(result.details).toHaveProperty('errors'); + }); + }); + + // ======================================================================== + // handleUpdateRows + // ======================================================================== + describe('handleUpdateRows', () => { + it('should update rows successfully', async () => { + const updateResult = { updatedCount: 3 }; + mockApiClient.updateDataTableRows.mockResolvedValue(updateResult); + + const filter = { + type: 'and' as const, + filters: [{ columnName: 'status', condition: 'eq' as const, value: 'inactive' }], + }; + + const result = await handlers.handleUpdateRows({ + tableId: 'dt-1', + filter, + data: { status: 'active' }, + }); + + expect(result).toEqual({ + success: true, + data: updateResult, + message: 'Rows updated successfully', + }); + expect(mockApiClient.updateDataTableRows).toHaveBeenCalledWith('dt-1', { + filter, + data: { status: 'active' }, + }); + }); + + it('should support dryRun mode', async () => { + const dryRunResult = { matchedCount: 5 }; + mockApiClient.updateDataTableRows.mockResolvedValue(dryRunResult); + + const filter = { + filters: [{ columnName: 'score', condition: 'lt' as const, value: 5 }], + }; + + const result = await handlers.handleUpdateRows({ + tableId: 'dt-1', + filter, + data: { status: 'low' }, + dryRun: true, + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Dry run: rows matched (no changes applied)'); + expect(mockApiClient.updateDataTableRows).toHaveBeenCalledWith('dt-1', { + filter: { type: 'and', ...filter }, + data: { status: 'low' }, + dryRun: true, + }); + }); + + it('should return error on API failure', async () => { + mockApiClient.updateDataTableRows.mockRejectedValue(new Error('Conflict')); + + const result = await handlers.handleUpdateRows({ + tableId: 'dt-1', + filter: { filters: [{ columnName: 'id', condition: 'eq' as const, value: 1 }] }, + data: { name: 'test' }, + }); + + expect(result.success).toBe(false); + expect(result.error).toBe('Conflict'); + }); + }); + + // ======================================================================== + // handleUpsertRows + // ======================================================================== + describe('handleUpsertRows', () => { + it('should upsert row successfully', async () => { + const upsertResult = { action: 'updated', row: { id: 1, email: 'a@b.com', score: 15 } }; + mockApiClient.upsertDataTableRow.mockResolvedValue(upsertResult); + + const filter = { + filters: [{ columnName: 'email', condition: 'eq' as const, value: 'a@b.com' }], + }; + + const result = await handlers.handleUpsertRows({ + tableId: 'dt-1', + filter, + data: { score: 15 }, + }); + + expect(result).toEqual({ + success: true, + data: upsertResult, + message: 'Row upserted successfully', + }); + expect(mockApiClient.upsertDataTableRow).toHaveBeenCalledWith('dt-1', { + filter: { type: 'and', ...filter }, + data: { score: 15 }, + }); + }); + + it('should support dryRun mode', async () => { + const dryRunResult = { action: 'would_update', matchedRows: 1 }; + mockApiClient.upsertDataTableRow.mockResolvedValue(dryRunResult); + + const filter = { + filters: [{ columnName: 'email', condition: 'eq' as const, value: 'a@b.com' }], + }; + + const result = await handlers.handleUpsertRows({ + tableId: 'dt-1', + filter, + data: { score: 20 }, + dryRun: true, + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Dry run: upsert previewed (no changes applied)'); + }); + + it('should return error on API failure', async () => { + const apiError = new N8nApiError('Server error', 500, 'INTERNAL_ERROR'); + mockApiClient.upsertDataTableRow.mockRejectedValue(apiError); + + const result = await handlers.handleUpsertRows({ + tableId: 'dt-1', + filter: { filters: [{ columnName: 'id', condition: 'eq' as const, value: 1 }] }, + data: { name: 'test' }, + }); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + expect(result.code).toBe('INTERNAL_ERROR'); + }); + }); + + // ======================================================================== + // handleDeleteRows + // ======================================================================== + describe('handleDeleteRows', () => { + it('should delete rows successfully', async () => { + const deleteResult = { deletedCount: 2 }; + mockApiClient.deleteDataTableRows.mockResolvedValue(deleteResult); + + const filter = { + filters: [{ columnName: 'status', condition: 'eq' as const, value: 'deleted' }], + }; + + const result = await handlers.handleDeleteRows({ + tableId: 'dt-1', + filter, + }); + + expect(result).toEqual({ + success: true, + data: deleteResult, + message: 'Rows deleted successfully', + }); + expect(mockApiClient.deleteDataTableRows).toHaveBeenCalledWith('dt-1', { + filter: JSON.stringify({ type: 'and', ...filter }), + }); + }); + + it('should serialize filter object to JSON string for API call', async () => { + mockApiClient.deleteDataTableRows.mockResolvedValue({ deletedCount: 1 }); + + const filter = { + type: 'or' as const, + filters: [ + { columnName: 'score', condition: 'lt' as const, value: 0 }, + { columnName: 'status', condition: 'eq' as const, value: 'spam' }, + ], + }; + + await handlers.handleDeleteRows({ tableId: 'dt-1', filter }); + + expect(mockApiClient.deleteDataTableRows).toHaveBeenCalledWith('dt-1', { + filter: JSON.stringify(filter), + }); + }); + + it('should support dryRun mode', async () => { + const dryRunResult = { matchedCount: 4 }; + mockApiClient.deleteDataTableRows.mockResolvedValue(dryRunResult); + + const filter = { + filters: [{ columnName: 'active', condition: 'eq' as const, value: false }], + }; + + const result = await handlers.handleDeleteRows({ + tableId: 'dt-1', + filter, + dryRun: true, + }); + + expect(result.success).toBe(true); + expect(result.message).toBe('Dry run: rows matched for deletion (no changes applied)'); + expect(mockApiClient.deleteDataTableRows).toHaveBeenCalledWith('dt-1', { + filter: JSON.stringify({ type: 'and', ...filter }), + dryRun: true, + }); + }); + }); +}); diff --git a/tests/unit/mcp/handlers-n8n-manager.test.ts b/tests/unit/mcp/handlers-n8n-manager.test.ts index 2b6dbf3..1207078 100644 --- a/tests/unit/mcp/handlers-n8n-manager.test.ts +++ b/tests/unit/mcp/handlers-n8n-manager.test.ts @@ -1102,10 +1102,10 @@ describe('handlers-n8n-manager', () => { enabled: true, }, managementTools: { - count: 13, + count: 14, enabled: true, }, - totalAvailable: 20, + totalAvailable: 21, }, }); diff --git a/tests/unit/mcp/parameter-validation.test.ts b/tests/unit/mcp/parameter-validation.test.ts index a76c57b..6b5e034 100644 --- a/tests/unit/mcp/parameter-validation.test.ts +++ b/tests/unit/mcp/parameter-validation.test.ts @@ -542,8 +542,8 @@ describe('Parameter Validation', () => { await expect(server.testExecuteTool('n8n_test_workflow', {})) .rejects.toThrow('Missing required parameters for n8n_test_workflow: workflowId'); - await expect(server.testExecuteTool('n8n_create_data_table', {})) - .rejects.toThrow('Missing required parameters for n8n_create_data_table: name'); + await expect(server.testExecuteTool('n8n_manage_datatable', {})) + .rejects.toThrow('n8n_manage_datatable: Validation failed:\n • action: action is required'); for (const tool of n8nToolsWithRequiredParams) { await expect(server.testExecuteTool(tool.name, tool.args)) diff --git a/tests/unit/services/n8n-api-client.test.ts b/tests/unit/services/n8n-api-client.test.ts index 07ab962..0a87089 100644 --- a/tests/unit/services/n8n-api-client.test.ts +++ b/tests/unit/services/n8n-api-client.test.ts @@ -1353,6 +1353,310 @@ describe('N8nApiClient', () => { }); }); + describe('listDataTables', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should list data tables successfully', async () => { + const response = { data: [{ id: 'dt-1', name: 'Table One' }], nextCursor: 'abc' }; + mockAxiosInstance.get.mockResolvedValue({ data: response }); + + const result = await client.listDataTables({ limit: 10, cursor: 'xyz' }); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/data-tables', { params: { limit: 10, cursor: 'xyz' } }); + expect(result).toEqual(response); + }); + + it('should handle error', async () => { + const error = { + message: 'Request failed', + response: { status: 500, data: { message: 'Internal server error' } }, + }; + await mockAxiosInstance.simulateError('get', error); + + try { + await client.listDataTables(); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nServerError); + expect((err as N8nServerError).statusCode).toBe(500); + } + }); + }); + + describe('getDataTable', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should get data table successfully', async () => { + const table = { id: 'dt-1', name: 'My Table', columns: [] }; + mockAxiosInstance.get.mockResolvedValue({ data: table }); + + const result = await client.getDataTable('dt-1'); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/data-tables/dt-1'); + expect(result).toEqual(table); + }); + + it('should handle 404 error', async () => { + const error = { + message: 'Request failed', + response: { status: 404, data: { message: 'Data table not found' } }, + }; + await mockAxiosInstance.simulateError('get', error); + + try { + await client.getDataTable('dt-nonexistent'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nNotFoundError); + expect((err as N8nNotFoundError).statusCode).toBe(404); + } + }); + }); + + describe('updateDataTable', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should update data table successfully', async () => { + const updated = { id: 'dt-1', name: 'Renamed' }; + mockAxiosInstance.patch.mockResolvedValue({ data: updated }); + + const result = await client.updateDataTable('dt-1', { name: 'Renamed' }); + + expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/data-tables/dt-1', { name: 'Renamed' }); + expect(result).toEqual(updated); + }); + + it('should handle error', async () => { + const error = { + message: 'Request failed', + response: { status: 400, data: { message: 'Invalid name' } }, + }; + await mockAxiosInstance.simulateError('patch', error); + + try { + await client.updateDataTable('dt-1', { name: '' }); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nValidationError); + expect((err as N8nValidationError).statusCode).toBe(400); + } + }); + }); + + describe('deleteDataTable', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should delete data table successfully', async () => { + mockAxiosInstance.delete.mockResolvedValue({ data: {} }); + + await client.deleteDataTable('dt-1'); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/data-tables/dt-1'); + }); + + it('should handle 404 error', async () => { + const error = { + message: 'Request failed', + response: { status: 404, data: { message: 'Data table not found' } }, + }; + await mockAxiosInstance.simulateError('delete', error); + + try { + await client.deleteDataTable('dt-nonexistent'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nNotFoundError); + expect((err as N8nNotFoundError).statusCode).toBe(404); + } + }); + }); + + describe('getDataTableRows', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should get data table rows with params', async () => { + const response = { data: [{ id: 1, email: 'a@b.com' }], nextCursor: null }; + mockAxiosInstance.get.mockResolvedValue({ data: response }); + + const params = { limit: 50, sortBy: 'email:asc', search: 'john' }; + const result = await client.getDataTableRows('dt-1', params); + + expect(mockAxiosInstance.get).toHaveBeenCalledWith('/data-tables/dt-1/rows', { params }); + expect(result).toEqual(response); + }); + + it('should handle error', async () => { + const error = { + message: 'Request failed', + response: { status: 500, data: { message: 'Internal server error' } }, + }; + await mockAxiosInstance.simulateError('get', error); + + try { + await client.getDataTableRows('dt-1'); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nServerError); + expect((err as N8nServerError).statusCode).toBe(500); + } + }); + }); + + describe('insertDataTableRows', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should insert data table rows successfully', async () => { + const insertResult = { insertedCount: 2 }; + mockAxiosInstance.post.mockResolvedValue({ data: insertResult }); + + const params = { data: [{ email: 'a@b.com' }, { email: 'c@d.com' }], returnType: 'count' as const }; + const result = await client.insertDataTableRows('dt-1', params); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/data-tables/dt-1/rows', params); + expect(result).toEqual(insertResult); + }); + + it('should handle 400 error', async () => { + const error = { + message: 'Request failed', + response: { status: 400, data: { message: 'Invalid row data' } }, + }; + await mockAxiosInstance.simulateError('post', error); + + try { + await client.insertDataTableRows('dt-1', { data: [{}] }); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nValidationError); + expect((err as N8nValidationError).message).toBe('Invalid row data'); + expect((err as N8nValidationError).statusCode).toBe(400); + } + }); + }); + + describe('updateDataTableRows', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should update data table rows successfully', async () => { + const updateResult = { updatedCount: 3 }; + mockAxiosInstance.patch.mockResolvedValue({ data: updateResult }); + + const params = { + filter: { type: 'and' as const, filters: [{ columnName: 'status', condition: 'eq' as const, value: 'old' }] }, + data: { status: 'new' }, + }; + const result = await client.updateDataTableRows('dt-1', params); + + expect(mockAxiosInstance.patch).toHaveBeenCalledWith('/data-tables/dt-1/rows/update', params); + expect(result).toEqual(updateResult); + }); + + it('should handle error', async () => { + const error = { + message: 'Request failed', + response: { status: 500, data: { message: 'Internal server error' } }, + }; + await mockAxiosInstance.simulateError('patch', error); + + try { + await client.updateDataTableRows('dt-1', { + filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] }, + data: { name: 'test' }, + }); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nServerError); + expect((err as N8nServerError).statusCode).toBe(500); + } + }); + }); + + describe('upsertDataTableRow', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should upsert data table row successfully', async () => { + const upsertResult = { action: 'updated', row: { id: 1, email: 'a@b.com' } }; + mockAxiosInstance.post.mockResolvedValue({ data: upsertResult }); + + const params = { + filter: { type: 'and' as const, filters: [{ columnName: 'email', condition: 'eq' as const, value: 'a@b.com' }] }, + data: { score: 15 }, + }; + const result = await client.upsertDataTableRow('dt-1', params); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/data-tables/dt-1/rows/upsert', params); + expect(result).toEqual(upsertResult); + }); + + it('should handle error', async () => { + const error = { + message: 'Request failed', + response: { status: 400, data: { message: 'Invalid upsert params' } }, + }; + await mockAxiosInstance.simulateError('post', error); + + try { + await client.upsertDataTableRow('dt-1', { + filter: { type: 'and', filters: [{ columnName: 'id', condition: 'eq', value: 1 }] }, + data: { name: 'test' }, + }); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nValidationError); + expect((err as N8nValidationError).statusCode).toBe(400); + } + }); + }); + + describe('deleteDataTableRows', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should delete data table rows successfully', async () => { + const deleteResult = { deletedCount: 2 }; + mockAxiosInstance.delete.mockResolvedValue({ data: deleteResult }); + + const params = { filter: '{"type":"and","filters":[]}', dryRun: false }; + const result = await client.deleteDataTableRows('dt-1', params); + + expect(mockAxiosInstance.delete).toHaveBeenCalledWith('/data-tables/dt-1/rows/delete', { params }); + expect(result).toEqual(deleteResult); + }); + + it('should handle error', async () => { + const error = { + message: 'Request failed', + response: { status: 500, data: { message: 'Internal server error' } }, + }; + await mockAxiosInstance.simulateError('delete', error); + + try { + await client.deleteDataTableRows('dt-1', { filter: '{}' }); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nServerError); + expect((err as N8nServerError).statusCode).toBe(500); + } + }); + }); + describe('interceptors', () => { let requestInterceptor: any; let responseInterceptor: any;