mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-03-22 02:13:09 +00:00
Compare commits
4 Commits
v2.40.0
...
fix/datata
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcc423084a | ||
|
|
ba1f9be984 | ||
|
|
a62e5f198c | ||
|
|
b0279bd11f |
11
CHANGELOG.md
11
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)' },
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user