feat: add n8n_manage_datatable MCP tool with full CRUD (#640) (#650)

* feat: add n8n_create_data_table MCP tool and projectId for create workflow (#640)

Add new MCP tool to create n8n data tables via the REST API:
- n8n_create_data_table tool definition with name + columns schema
- handleCreateDataTable handler with Zod validation and N8nApiError handling
- N8nApiClient.createDataTable() calling POST /data-tables
- DataTable, DataTableColumn, DataTableColumnResponse types per OpenAPI spec
- Column types: string | number | boolean | date | json
- Input validation: .min(1) on table name and column names
- Tool documentation with examples, use cases, and pitfalls

Also adds projectId parameter to n8n_create_workflow for enterprise
project support, and fixes stale management tool count in health check.

Based on work by @djakielski in PR #646.
Co-Authored-By: Dominik Jakielski <dominik.jakielski@urlaubsguru.de>

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: replace n8n_create_data_table with n8n_manage_datatable (10 actions)

Replaces the single-purpose n8n_create_data_table tool with a comprehensive
n8n_manage_datatable tool 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
- Dry-run support for updateRows, upsertRows, deleteRows
- Pagination, sorting, and full-text search for row listing
- 9 new N8nApiClient methods for all data table endpoints
- Shared error handler and consolidated Zod schemas
- Comprehensive tool documentation with examples per action
- 36 handler tests + 18 API client tests

BREAKING: n8n_create_data_table removed. Use n8n_manage_datatable with
action="createTable" instead.

Based on work by @djakielski in PR #646.
Co-Authored-By: Dominik Jakielski <dominik.jakielski@urlaubsguru.de>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
This commit is contained in:
Romuald Członkowski
2026-03-21 19:06:22 +01:00
committed by GitHub
parent 47a1cb135d
commit be3d07dbdc
14 changed files with 1741 additions and 8 deletions

View File

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