Compare commits

...

4 Commits

Author SHA1 Message Date
czlonkowski
fcc423084a fix: add missing datatable methods to handlers-n8n-manager mock client
CI fails because handlers-manage-datatable.test.ts and handlers-n8n-manager.test.ts
share the same Vitest worker and module cache. The manage-datatable test mocks
N8nApiClient with 20 methods but handlers-n8n-manager only had 10. When running
in parallel, the singleton client state leaked between files causing mock mismatches.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 23:26:39 +01:00
czlonkowski
ba1f9be984 fix: re-apply encodeURIComponent for filter/sortBy in handlers
The URL-encoding fixes were reverted by a concurrent agent. Re-applying
encodeURIComponent() for filter and sortBy in handleGetRows and
handleDeleteRows to match the test expectations and n8n API requirements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 22:35:56 +01:00
czlonkowski
a62e5f198c fix: case-insensitive 'not found' assertions in API client tests
N8nNotFoundError now passes through API messages directly instead of
wrapping them. The n8n API returns "Not found" (capital N) but tests
used .toContain('not found') (lowercase). Changed to case-insensitive.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 22:22:02 +01:00
czlonkowski
b0279bd11f fix: resolve 5 bugs in n8n_manage_datatable row operations and error handling
Fix critical issues found during staging QA testing:

1. data parameter serialization: MCP transport sends JSON as strings —
   added z.preprocess coercers (coerceJsonArray, coerceJsonObject,
   coerceJsonFilter) to parse string→JSON before Zod validation

2. filter/sortBy URL encoding: n8n API requires URL-encoded query params —
   added encodeURIComponent() for filter and sortBy in getRows/deleteRows

3. json column type: n8n API only accepts string|number|boolean|date —
   removed json from enum in types, Zod schema, tool definition, and docs

4. 404 error messages: N8nNotFoundError was wrapping API messages in
   "Resource with ID <message> not found" — now passes through cleanly

5. Unit test expectations updated for URL-encoded filter/sortBy values

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 22:03:07 +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);
}
});