Compare commits

..

1 Commits

Author SHA1 Message Date
Romuald Członkowski
c5665632af fix: resolve 5 bugs in n8n_manage_datatable (#651) 2026-03-22 00:12:39 +01:00
10 changed files with 71 additions and 32 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const { tableId, filter, ...params } = getRowsSchema.parse(args);
const { tableId, filter, sortBy, ...params } = getRowsSchema.parse(args);
const queryParams: Record<string, unknown> = { ...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);

View File

@@ -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)' },

View File

@@ -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'],
},

View File

@@ -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;
}

View File

@@ -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:

View File

@@ -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,
});
});

View File

@@ -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

View File

@@ -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);
}
});