diff --git a/CHANGELOG.md b/CHANGELOG.md index b0256f1..2d6e4d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.40.0] - 2026-03-21 + +### Changed + +- **`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) + +### Breaking + +- `n8n_create_data_table` tool replaced by `n8n_manage_datatable` with `action: "createTable"` + +Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en + ## [2.38.0] - 2026-03-20 ### Added diff --git a/package.json b/package.json index ed6bc7f..f9271ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.38.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 748b430..a2dc38a 100644 --- a/src/mcp/handlers-n8n-manager.ts +++ b/src/mcp/handlers-n8n-manager.ts @@ -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 | 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 = 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 { + 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 b6993b1..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,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}`); } diff --git a/src/mcp/tool-docs/index.ts b/src/mcp/tool-docs/index.ts index e6e35be..85511d4 100644 --- a/src/mcp/tool-docs/index.ts +++ b/src/mcp/tool-docs/index.ts @@ -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 = { 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 diff --git a/src/mcp/tool-docs/workflow_management/index.ts b/src/mcp/tool-docs/workflow_management/index.ts index 1d2b457..8eb2804 100644 --- a/src/mcp/tool-docs/workflow_management/index.ts +++ b/src/mcp/tool-docs/workflow_management/index.ts @@ -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'; 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 de09a9f..cf82442 100644 --- a/src/mcp/tools-n8n-manager.ts +++ b/src/mcp/tools-n8n-manager.ts @@ -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, + }, + }, ]; diff --git a/src/services/n8n-api-client.ts b/src/services/n8n-api-client.ts index c9808a7..513011c 100644 --- a/src/services/n8n-api-client.ts +++ b/src/services/n8n-api-client.ts @@ -22,6 +22,15 @@ import { SourceControlStatus, SourceControlPullResult, 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'; @@ -582,6 +591,95 @@ export class N8nApiClient { } } + async createDataTable(params: { name: string; columns?: DataTableColumn[] }): Promise { + try { + const response = await this.client.post('/data-tables', params); + return response.data; + } catch (error) { + throw handleN8nApiError(error); + } + } + + 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 e840741..6d3d490 100644 --- a/src/types/n8n-api.ts +++ b/src/types/n8n-api.ts @@ -454,4 +454,82 @@ export interface ErrorSuggestion { title: string; description: string; confidence: 'high' | 'medium' | 'low'; +} + +// Data Table types +export interface DataTableColumn { + name: string; + type?: 'string' | 'number' | 'boolean' | 'date' | 'json'; +} + +export interface DataTableColumnResponse { + id: string; + name: string; + type: 'string' | 'number' | 'boolean' | 'date' | 'json'; + index: number; +} + +export interface DataTable { + id: string; + name: string; + columns?: DataTableColumnResponse[]; + 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-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 16346bd..1207078 100644 --- a/tests/unit/mcp/handlers-n8n-manager.test.ts +++ b/tests/unit/mcp/handlers-n8n-manager.test.ts @@ -631,6 +631,27 @@ describe('handlers-n8n-manager', () => { expect(result.details.errors[0]).toContain('Webhook'); }); }); + + it('should pass projectId to API when provided', async () => { + const testWorkflow = createTestWorkflow(); + const input = { + name: 'Test Workflow', + nodes: testWorkflow.nodes, + connections: testWorkflow.connections, + projectId: 'project-abc-123', + }; + + mockApiClient.createWorkflow.mockResolvedValue(testWorkflow); + + const result = await handlers.handleCreateWorkflow(input); + + expect(result.success).toBe(true); + expect(mockApiClient.createWorkflow).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'project-abc-123', + }) + ); + }); }); describe('handleGetWorkflow', () => { @@ -1081,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 f069593..6b5e034 100644 --- a/tests/unit/mcp/parameter-validation.test.ts +++ b/tests/unit/mcp/parameter-validation.test.ts @@ -542,6 +542,9 @@ 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_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)) .rejects.toThrow(tool.expected); diff --git a/tests/unit/services/n8n-api-client.test.ts b/tests/unit/services/n8n-api-client.test.ts index 0d35d34..0a87089 100644 --- a/tests/unit/services/n8n-api-client.test.ts +++ b/tests/unit/services/n8n-api-client.test.ts @@ -1300,6 +1300,363 @@ describe('N8nApiClient', () => { }); }); + describe('createDataTable', () => { + beforeEach(() => { + client = new N8nApiClient(defaultConfig); + }); + + it('should create data table with name and columns', async () => { + const params = { + name: 'My Table', + columns: [ + { name: 'email', type: 'string' as const }, + { name: 'count', type: 'number' as const }, + ], + }; + const createdTable = { id: 'dt-1', name: 'My Table', columns: [] }; + + mockAxiosInstance.post.mockResolvedValue({ data: createdTable }); + + const result = await client.createDataTable(params); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/data-tables', params); + expect(result).toEqual(createdTable); + }); + + it('should create data table without columns', async () => { + const params = { name: 'Empty Table' }; + const createdTable = { id: 'dt-2', name: 'Empty Table' }; + + mockAxiosInstance.post.mockResolvedValue({ data: createdTable }); + + const result = await client.createDataTable(params); + + expect(mockAxiosInstance.post).toHaveBeenCalledWith('/data-tables', params); + expect(result).toEqual(createdTable); + }); + + it('should handle 400 error', async () => { + const error = { + message: 'Request failed', + response: { status: 400, data: { message: 'Invalid table name' } }, + }; + await mockAxiosInstance.simulateError('post', error); + + try { + await client.createDataTable({ name: '' }); + expect.fail('Should have thrown an error'); + } catch (err) { + expect(err).toBeInstanceOf(N8nValidationError); + expect((err as N8nValidationError).message).toBe('Invalid table name'); + expect((err as N8nValidationError).statusCode).toBe(400); + } + }); + }); + + 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;