diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d6e4d3..afd47ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.40.1] - 2026-03-21 + +### Fixed + +- **`n8n_manage_datatable` row operations broken by MCP transport serialization**: `data` parameter received as string instead of JSON — added `z.preprocess` coercers for array/object/filter params +- **`n8n_manage_datatable` filter/sortBy URL encoding**: n8n API requires URL-encoded query params — added `encodeURIComponent()` for filter and sortBy in getRows and deleteRows +- **`json` column type rejected by n8n API**: Removed `json` from column type enum (n8n only accepts string/number/boolean/date) +- **Garbled 404 error messages**: Fixed `N8nNotFoundError` constructor — API error messages are now passed through cleanly instead of being wrapped in "Resource with ID ... not found" + +Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en + ## [2.40.0] - 2026-03-21 ### Changed diff --git a/package.json b/package.json index f9271ea..9cde240 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-mcp", - "version": "2.40.0", + "version": "2.40.1", "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 a2dc38a..9b2e508 100644 --- a/src/mcp/handlers-n8n-manager.ts +++ b/src/mcp/handlers-n8n-manager.ts @@ -2716,7 +2716,7 @@ 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(), + type: z.enum(['string', 'number', 'boolean', 'date']).optional(), })).optional(), }); @@ -2729,29 +2729,40 @@ const updateTableSchema = tableIdSchema.extend({ name: z.string().min(1, 'New table name cannot be empty'), }); +// MCP transports may serialize JSON objects/arrays as strings. +// Parse them back, but return the original value on failure so Zod reports a proper type error. +function tryParseJson(val: unknown): unknown { + if (typeof val !== 'string') return val; + try { return JSON.parse(val); } catch { return val; } +} + +const coerceJsonArray = z.preprocess(tryParseJson, z.array(z.record(z.unknown()))); +const coerceJsonObject = z.preprocess(tryParseJson, z.record(z.unknown())); +const coerceJsonFilter = z.preprocess(tryParseJson, dataTableFilterSchema); + const getRowsSchema = tableIdSchema.extend({ limit: z.number().min(1).max(100).optional(), cursor: z.string().optional(), - filter: z.union([dataTableFilterSchema, z.string()]).optional(), + filter: z.union([coerceJsonFilter, 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'), + data: coerceJsonArray.pipe(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()), + filter: coerceJsonFilter, + data: coerceJsonObject, returnData: z.boolean().optional(), dryRun: z.boolean().optional(), }); const deleteRowsSchema = tableIdSchema.extend({ - filter: dataTableFilterSchema, + filter: coerceJsonFilter, returnData: z.boolean().optional(), dryRun: z.boolean().optional(), }); @@ -2847,10 +2858,14 @@ export async function handleDeleteTable(args: unknown, context?: InstanceContext export async function handleGetRows(args: unknown, context?: InstanceContext): Promise { try { const client = ensureApiConfigured(context); - const { tableId, filter, ...params } = getRowsSchema.parse(args); + const { tableId, filter, sortBy, ...params } = getRowsSchema.parse(args); const queryParams: Record = { ...params }; if (filter) { - queryParams.filter = typeof filter === 'string' ? filter : JSON.stringify(filter); + const filterStr = typeof filter === 'string' ? filter : JSON.stringify(filter); + queryParams.filter = encodeURIComponent(filterStr); + } + if (sortBy) { + queryParams.sortBy = encodeURIComponent(sortBy); } const result = await client.getDataTableRows(tableId, queryParams as any); return { @@ -2916,7 +2931,7 @@ export async function handleDeleteRows(args: unknown, context?: InstanceContext) const client = ensureApiConfigured(context); const { tableId, filter, ...params } = deleteRowsSchema.parse(args); const queryParams = { - filter: JSON.stringify(filter), + filter: encodeURIComponent(JSON.stringify(filter)), ...params, }; const result = await client.deleteDataTableRows(tableId, queryParams as any); diff --git a/src/mcp/tool-docs/workflow_management/n8n-manage-datatable.ts b/src/mcp/tool-docs/workflow_management/n8n-manage-datatable.ts index 73214d9..e530cc8 100644 --- a/src/mcp/tool-docs/workflow_management/n8n-manage-datatable.ts +++ b/src/mcp/tool-docs/workflow_management/n8n-manage-datatable.ts @@ -42,7 +42,7 @@ export const n8nManageDatatableDoc: ToolDocumentation = { 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' }, + columns: { type: 'array', required: false, description: 'For createTable: column definitions [{name, type?}]. Types: string, number, boolean, date' }, 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)' }, diff --git a/src/mcp/tools-n8n-manager.ts b/src/mcp/tools-n8n-manager.ts index cf82442..16bea4d 100644 --- a/src/mcp/tools-n8n-manager.ts +++ b/src/mcp/tools-n8n-manager.ts @@ -627,7 +627,7 @@ export const n8nManagementTools: ToolDefinition[] = [ type: 'object', properties: { name: { type: 'string' }, - type: { type: 'string', enum: ['string', 'number', 'boolean', 'date', 'json'] }, + type: { type: 'string', enum: ['string', 'number', 'boolean', 'date'] }, }, required: ['name'], }, diff --git a/src/types/n8n-api.ts b/src/types/n8n-api.ts index 6d3d490..c8ce9bb 100644 --- a/src/types/n8n-api.ts +++ b/src/types/n8n-api.ts @@ -459,13 +459,13 @@ export interface ErrorSuggestion { // Data Table types export interface DataTableColumn { name: string; - type?: 'string' | 'number' | 'boolean' | 'date' | 'json'; + type?: 'string' | 'number' | 'boolean' | 'date'; } export interface DataTableColumnResponse { id: string; name: string; - type: 'string' | 'number' | 'boolean' | 'date' | 'json'; + type: 'string' | 'number' | 'boolean' | 'date'; index: number; } diff --git a/src/utils/n8n-errors.ts b/src/utils/n8n-errors.ts index d8a7c5b..710b1ac 100644 --- a/src/utils/n8n-errors.ts +++ b/src/utils/n8n-errors.ts @@ -22,8 +22,10 @@ export class N8nAuthenticationError extends N8nApiError { } export class N8nNotFoundError extends N8nApiError { - constructor(resource: string, id?: string) { - const message = id ? `${resource} with ID ${id} not found` : `${resource} not found`; + constructor(messageOrResource: string, id?: string) { + // If id is provided, format as "resource with ID id not found" + // Otherwise, use messageOrResource as-is (it's already a complete message from the API) + const message = id ? `${messageOrResource} with ID ${id} not found` : messageOrResource; super(message, 404, 'NOT_FOUND'); this.name = 'N8nNotFoundError'; } @@ -70,7 +72,7 @@ export function handleN8nApiError(error: unknown): N8nApiError { case 401: return new N8nAuthenticationError(message); case 404: - return new N8nNotFoundError('Resource', message); + return new N8nNotFoundError(message || 'Resource'); case 400: return new N8nValidationError(message, data); case 429: diff --git a/tests/unit/mcp/handlers-manage-datatable.test.ts b/tests/unit/mcp/handlers-manage-datatable.test.ts index fe28414..19885ac 100644 --- a/tests/unit/mcp/handlers-manage-datatable.test.ts +++ b/tests/unit/mcp/handlers-manage-datatable.test.ts @@ -429,12 +429,12 @@ describe('Data Table Handlers (n8n_manage_datatable)', () => { expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', { limit: 50, - sortBy: 'name:asc', + sortBy: encodeURIComponent('name:asc'), search: 'john', }); }); - it('should serialize object filter to JSON string', async () => { + it('should serialize object filter to URL-encoded JSON string', async () => { mockApiClient.getDataTableRows.mockResolvedValue({ data: [], nextCursor: null }); const objectFilter = { @@ -448,20 +448,21 @@ describe('Data Table Handlers (n8n_manage_datatable)', () => { }); expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', { - filter: JSON.stringify(objectFilter), + filter: encodeURIComponent(JSON.stringify(objectFilter)), }); }); - it('should pass through string filter as-is', async () => { + it('should URL-encode string filter', async () => { mockApiClient.getDataTableRows.mockResolvedValue({ data: [], nextCursor: null }); + const filterStr = '{"type":"and","filters":[]}'; await handlers.handleGetRows({ tableId: 'dt-1', - filter: '{"type":"and","filters":[]}', + filter: filterStr, }); expect(mockApiClient.getDataTableRows).toHaveBeenCalledWith('dt-1', { - filter: '{"type":"and","filters":[]}', + filter: encodeURIComponent(filterStr), }); }); }); @@ -680,11 +681,11 @@ describe('Data Table Handlers (n8n_manage_datatable)', () => { message: 'Rows deleted successfully', }); expect(mockApiClient.deleteDataTableRows).toHaveBeenCalledWith('dt-1', { - filter: JSON.stringify({ type: 'and', ...filter }), + filter: encodeURIComponent(JSON.stringify({ type: 'and', ...filter })), }); }); - it('should serialize filter object to JSON string for API call', async () => { + it('should URL-encode serialized filter for API call', async () => { mockApiClient.deleteDataTableRows.mockResolvedValue({ deletedCount: 1 }); const filter = { @@ -698,7 +699,7 @@ describe('Data Table Handlers (n8n_manage_datatable)', () => { await handlers.handleDeleteRows({ tableId: 'dt-1', filter }); expect(mockApiClient.deleteDataTableRows).toHaveBeenCalledWith('dt-1', { - filter: JSON.stringify(filter), + filter: encodeURIComponent(JSON.stringify(filter)), }); }); @@ -719,7 +720,7 @@ describe('Data Table Handlers (n8n_manage_datatable)', () => { 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 }), + filter: encodeURIComponent(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 1207078..54dbe0f 100644 --- a/tests/unit/mcp/handlers-n8n-manager.test.ts +++ b/tests/unit/mcp/handlers-n8n-manager.test.ts @@ -100,6 +100,16 @@ describe('handlers-n8n-manager', () => { 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(), }; // Setup mock repository diff --git a/tests/unit/services/n8n-api-client.test.ts b/tests/unit/services/n8n-api-client.test.ts index 0a87089..3ca8eaa 100644 --- a/tests/unit/services/n8n-api-client.test.ts +++ b/tests/unit/services/n8n-api-client.test.ts @@ -297,7 +297,7 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nNotFoundError); - expect((err as N8nNotFoundError).message).toContain('not found'); + expect((err as N8nNotFoundError).message.toLowerCase()).toContain('not found'); expect((err as N8nNotFoundError).statusCode).toBe(404); } }); @@ -380,7 +380,7 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nNotFoundError); - expect((err as N8nNotFoundError).message).toContain('not found'); + expect((err as N8nNotFoundError).message.toLowerCase()).toContain('not found'); expect((err as N8nNotFoundError).statusCode).toBe(404); } }); @@ -432,7 +432,7 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nNotFoundError); - expect((err as N8nNotFoundError).message).toContain('not found'); + expect((err as N8nNotFoundError).message.toLowerCase()).toContain('not found'); expect((err as N8nNotFoundError).statusCode).toBe(404); } }); @@ -501,7 +501,7 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nNotFoundError); - expect((err as N8nNotFoundError).message).toContain('not found'); + expect((err as N8nNotFoundError).message.toLowerCase()).toContain('not found'); expect((err as N8nNotFoundError).statusCode).toBe(404); } }); @@ -1278,7 +1278,7 @@ describe('N8nApiClient', () => { expect.fail('Should have thrown an error'); } catch (err) { expect(err).toBeInstanceOf(N8nNotFoundError); - expect((err as N8nNotFoundError).message).toContain('not found'); + expect((err as N8nNotFoundError).message.toLowerCase()).toContain('not found'); expect((err as N8nNotFoundError).statusCode).toBe(404); } });