Compare commits

..

1 Commits

Author SHA1 Message Date
czlonkowski
4a9e3c7ec0 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>
2026-03-20 18:13:39 +01:00
14 changed files with 541 additions and 6 deletions

View File

@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [2.39.0] - 2026-03-20
### Added
- **`n8n_create_data_table` MCP tool** (Issue #640): Create data tables in n8n via the REST API
- `N8nApiClient.createDataTable()` calling `POST /data-tables`
- Zod-validated handler with `N8nApiError` handling for structured error responses
- TypeScript interfaces matching the n8n OpenAPI spec (`DataTableColumn`, `DataTableColumnResponse`, `DataTable`)
- Column types per spec: `string | number | boolean | date | json`
- Input validation: `.min(1)` on table name and column names
- Tool documentation with examples, use cases, and pitfalls
- Requires n8n enterprise or cloud with data tables feature enabled
- **`projectId` parameter for `n8n_create_workflow`**: Create workflows directly in a specific team project (enterprise feature)
### Fixed
- **Health check management tool count**: Updated from 13 to 14 to include `n8n_create_data_table`
Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
## [2.38.0] - 2026-03-20 ## [2.38.0] - 2026-03-20
### Added ### Added

View File

@@ -1,6 +1,6 @@
{ {
"name": "n8n-mcp", "name": "n8n-mcp",
"version": "2.38.0", "version": "2.39.0",
"description": "Integration between n8n workflow automation and Model Context Protocol (MCP)", "description": "Integration between n8n workflow automation and Model Context Protocol (MCP)",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",

View File

@@ -8,7 +8,7 @@ import {
WebhookRequest, WebhookRequest,
McpToolResponse, McpToolResponse,
ExecutionFilterOptions, ExecutionFilterOptions,
ExecutionMode ExecutionMode,
} from '../types/n8n-api'; } from '../types/n8n-api';
import type { TriggerType, TestWorkflowInput } from '../triggers/types'; import type { TriggerType, TestWorkflowInput } from '../triggers/types';
import { import {
@@ -383,6 +383,7 @@ const createWorkflowSchema = z.object({
executionTimeout: z.number().optional(), executionTimeout: z.number().optional(),
errorWorkflow: z.string().optional(), errorWorkflow: z.string().optional(),
}).optional(), }).optional(),
projectId: z.string().optional(),
}); });
const updateWorkflowSchema = z.object({ const updateWorkflowSchema = z.object({
@@ -1974,7 +1975,7 @@ export async function handleDiagnostic(request: any, context?: InstanceContext):
// Check which tools are available // Check which tools are available
const documentationTools = 7; // Base documentation tools (after v2.26.0 consolidation) const documentationTools = 7; // Base documentation tools (after v2.26.0 consolidation)
const managementTools = apiConfigured ? 13 : 0; // Management tools requiring API (includes n8n_deploy_template) const managementTools = apiConfigured ? 14 : 0; // Management tools requiring API (includes n8n_create_data_table)
const totalTools = documentationTools + managementTools; const totalTools = documentationTools + managementTools;
// Check npm version // Check npm version
@@ -2688,3 +2689,58 @@ export async function handleTriggerWebhookWorkflow(args: unknown, context?: Inst
}; };
} }
} }
// ========================================================================
// Data Table Handler
// ========================================================================
const createDataTableSchema = 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(),
})).optional(),
});
export async function handleCreateDataTable(args: unknown, context?: InstanceContext): Promise<McpToolResponse> {
try {
const client = ensureApiConfigured(context);
const input = createDataTableSchema.parse(args);
const dataTable = await client.createDataTable(input);
if (!dataTable || !dataTable.id) {
return {
success: false,
error: 'Data table creation failed: n8n API returned an empty or invalid response'
};
}
return {
success: true,
data: { id: dataTable.id, name: dataTable.name },
message: `Data table "${dataTable.name}" created with ID: ${dataTable.id}`
};
} catch (error) {
if (error instanceof z.ZodError) {
return {
success: false,
error: 'Invalid input',
details: { errors: error.errors }
};
}
if (error instanceof N8nApiError) {
return {
success: false,
error: getUserFriendlyErrorMessage(error),
code: error.code,
details: error.details as Record<string, unknown> | undefined
};
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}

View File

@@ -1496,6 +1496,10 @@ export class N8NDocumentationMCPServer {
if (!this.repository) throw new Error('Repository not initialized'); if (!this.repository) throw new Error('Repository not initialized');
return n8nHandlers.handleDeployTemplate(args, this.templateService, this.repository, this.instanceContext); return n8nHandlers.handleDeployTemplate(args, this.templateService, this.repository, this.instanceContext);
case 'n8n_create_data_table':
this.validateToolParams(name, args, ['name']);
return n8nHandlers.handleCreateDataTable(args, this.instanceContext);
default: default:
throw new Error(`Unknown tool: ${name}`); throw new Error(`Unknown tool: ${name}`);
} }

View File

@@ -22,7 +22,8 @@ import {
n8nTestWorkflowDoc, n8nTestWorkflowDoc,
n8nExecutionsDoc, n8nExecutionsDoc,
n8nWorkflowVersionsDoc, n8nWorkflowVersionsDoc,
n8nDeployTemplateDoc n8nDeployTemplateDoc,
n8nCreateDataTableDoc
} from './workflow_management'; } from './workflow_management';
// Combine all tool documentations into a single object // Combine all tool documentations into a single object
@@ -60,7 +61,8 @@ export const toolsDocumentation: Record<string, ToolDocumentation> = {
n8n_test_workflow: n8nTestWorkflowDoc, n8n_test_workflow: n8nTestWorkflowDoc,
n8n_executions: n8nExecutionsDoc, n8n_executions: n8nExecutionsDoc,
n8n_workflow_versions: n8nWorkflowVersionsDoc, n8n_workflow_versions: n8nWorkflowVersionsDoc,
n8n_deploy_template: n8nDeployTemplateDoc n8n_deploy_template: n8nDeployTemplateDoc,
n8n_create_data_table: n8nCreateDataTableDoc
}; };
// Re-export types // Re-export types

View File

@@ -10,3 +10,4 @@ export { n8nTestWorkflowDoc } from './n8n-test-workflow';
export { n8nExecutionsDoc } from './n8n-executions'; export { n8nExecutionsDoc } from './n8n-executions';
export { n8nWorkflowVersionsDoc } from './n8n-workflow-versions'; export { n8nWorkflowVersionsDoc } from './n8n-workflow-versions';
export { n8nDeployTemplateDoc } from './n8n-deploy-template'; export { n8nDeployTemplateDoc } from './n8n-deploy-template';
export { n8nCreateDataTableDoc } from './n8n-create-data-table';

View File

@@ -0,0 +1,54 @@
import { ToolDocumentation } from '../types';
export const n8nCreateDataTableDoc: ToolDocumentation = {
name: 'n8n_create_data_table',
category: 'workflow_management',
essentials: {
description: 'Create a new data table in n8n. Requires n8n enterprise or cloud with the data tables feature enabled.',
keyParameters: ['name', 'columns'],
example: 'n8n_create_data_table({name: "Contacts", columns: [{name: "email", type: "string"}]})',
performance: 'Fast (100-300ms)',
tips: [
'Available column types: string, number, boolean, date, json',
'Columns are optional — a table can be created without columns',
'Requires n8n enterprise or cloud plan with data tables feature',
'projectId cannot be set via the public API — use the n8n UI for project assignment'
]
},
full: {
description: 'Creates a new data table in n8n. Data tables are structured, persistent storage for workflow data. Each table can have typed columns (string, number, boolean, date, json). Requires the data tables feature to be enabled on the n8n instance.',
parameters: {
name: { type: 'string', required: true, description: 'Name for the new data table' },
columns: {
type: 'array',
required: false,
description: 'Column definitions. Each column has a name and optional type (string, number, boolean, date, json). Defaults to string if type is omitted.'
}
},
returns: 'Object with id and name of the created data table on success.',
examples: [
'n8n_create_data_table({name: "Orders"}) - Create table without columns',
'n8n_create_data_table({name: "Contacts", columns: [{name: "email", type: "string"}, {name: "score", type: "number"}]}) - Create table with typed columns',
'n8n_create_data_table({name: "Events", columns: [{name: "payload", type: "json"}, {name: "occurred_at", type: "date"}]})'
],
useCases: [
'Persist structured workflow data across executions',
'Store lookup tables for workflow logic',
'Accumulate records from multiple workflow runs',
'Share data between different workflows'
],
performance: 'Fast operation — typically 100-300ms.',
bestPractices: [
'Define columns upfront to enforce schema consistency',
'Use typed columns for numeric or date data to enable proper filtering',
'Use json type for nested or variable-structure data'
],
pitfalls: [
'Requires N8N_API_URL and N8N_API_KEY configured',
'Feature only available on n8n enterprise or cloud plans',
'projectId cannot be set via the public API',
'Column types cannot be changed after table creation via API'
],
relatedTools: ['n8n_create_workflow', 'n8n_list_workflows', 'n8n_health_check']
}
};

View File

@@ -63,6 +63,10 @@ export const n8nManagementTools: ToolDefinition[] = [
executionTimeout: { type: 'number' }, executionTimeout: { type: 'number' },
errorWorkflow: { type: 'string' } errorWorkflow: { type: 'string' }
} }
},
projectId: {
type: 'string',
description: 'Optional project ID to create the workflow in (enterprise feature)'
} }
}, },
required: ['name', 'nodes', 'connections'] required: ['name', 'nodes', 'connections']
@@ -602,5 +606,38 @@ export const n8nManagementTools: ToolDefinition[] = [
destructiveHint: false, destructiveHint: false,
openWorldHint: true, openWorldHint: true,
}, },
} },
{
name: 'n8n_create_data_table',
description: 'Create a new data table in n8n. Requires n8n enterprise or cloud with data tables feature.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name for the data table' },
columns: {
type: 'array',
description: 'Column definitions',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Column name' },
type: {
type: 'string',
enum: ['string', 'number', 'boolean', 'date', 'json'],
description: 'Column data type',
},
},
required: ['name'],
},
},
},
required: ['name'],
},
annotations: {
title: 'Create Data Table',
readOnlyHint: false,
destructiveHint: false,
openWorldHint: true,
},
},
]; ];

View File

@@ -22,6 +22,8 @@ import {
SourceControlStatus, SourceControlStatus,
SourceControlPullResult, SourceControlPullResult,
SourceControlPushResult, SourceControlPushResult,
DataTable,
DataTableColumn,
} from '../types/n8n-api'; } from '../types/n8n-api';
import { handleN8nApiError, logN8nError } from '../utils/n8n-errors'; import { handleN8nApiError, logN8nError } from '../utils/n8n-errors';
import { cleanWorkflowForCreate, cleanWorkflowForUpdate } from './n8n-validation'; import { cleanWorkflowForCreate, cleanWorkflowForUpdate } from './n8n-validation';
@@ -582,6 +584,15 @@ export class N8nApiClient {
} }
} }
async createDataTable(params: { name: string; columns?: DataTableColumn[] }): Promise<DataTable> {
try {
const response = await this.client.post('/data-tables', params);
return response.data;
} catch (error) {
throw handleN8nApiError(error);
}
}
/** /**
* Validates and normalizes n8n API list responses. * Validates and normalizes n8n API list responses.
* Handles both modern format {data: [], nextCursor?: string} and legacy array format. * Handles both modern format {data: [], nextCursor?: string} and legacy array format.

View File

@@ -454,4 +454,26 @@ export interface ErrorSuggestion {
title: string; title: string;
description: string; description: string;
confidence: 'high' | 'medium' | 'low'; confidence: 'high' | 'medium' | 'low';
}
// Data Table types
export interface DataTableColumn {
name: string;
type?: 'string' | 'number' | 'boolean' | 'date' | 'json';
}
export interface DataTableColumnResponse {
id: string;
name: string;
type: 'string' | 'number' | 'boolean' | 'date' | 'json';
index: number;
}
export interface DataTable {
id: string;
name: string;
columns?: DataTableColumnResponse[];
projectId?: string;
createdAt?: string;
updatedAt?: string;
} }

View File

@@ -0,0 +1,251 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { N8nApiClient } from '@/services/n8n-api-client';
import { N8nApiError } from '@/utils/n8n-errors';
// Mock dependencies
vi.mock('@/services/n8n-api-client');
vi.mock('@/config/n8n-api', () => ({
getN8nApiConfig: vi.fn(),
}));
vi.mock('@/services/n8n-validation', () => ({
validateWorkflowStructure: vi.fn(),
hasWebhookTrigger: vi.fn(),
getWebhookUrl: vi.fn(),
}));
vi.mock('@/utils/logger', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
},
Logger: vi.fn().mockImplementation(() => ({
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
})),
LogLevel: {
ERROR: 0,
WARN: 1,
INFO: 2,
DEBUG: 3,
},
}));
describe('handleCreateDataTable', () => {
let mockApiClient: any;
let handlers: any;
let getN8nApiConfig: any;
beforeEach(async () => {
vi.clearAllMocks();
// Setup mock API client
mockApiClient = {
createWorkflow: vi.fn(),
getWorkflow: vi.fn(),
updateWorkflow: vi.fn(),
deleteWorkflow: vi.fn(),
listWorkflows: vi.fn(),
triggerWebhook: vi.fn(),
getExecution: vi.fn(),
listExecutions: vi.fn(),
deleteExecution: vi.fn(),
healthCheck: vi.fn(),
createDataTable: vi.fn(),
};
// Import mocked modules
getN8nApiConfig = (await import('@/config/n8n-api')).getN8nApiConfig;
// Mock the API config
vi.mocked(getN8nApiConfig).mockReturnValue({
baseUrl: 'https://n8n.test.com',
apiKey: 'test-key',
timeout: 30000,
maxRetries: 3,
});
// Mock the N8nApiClient constructor
vi.mocked(N8nApiClient).mockImplementation(() => mockApiClient);
// Import handlers module after setting up mocks
handlers = await import('@/mcp/handlers-n8n-manager');
});
afterEach(() => {
if (handlers) {
const clientGetter = handlers.getN8nApiClient;
if (clientGetter) {
vi.mocked(getN8nApiConfig).mockReturnValue(null);
clientGetter();
}
}
});
it('should create data table with name and columns successfully', async () => {
const createdTable = {
id: 'dt-123',
name: 'My Data Table',
columns: [
{ id: 'col-1', name: 'email', type: 'string', index: 0 },
{ id: 'col-2', name: 'age', type: 'number', index: 1 },
],
};
mockApiClient.createDataTable.mockResolvedValue(createdTable);
const result = await handlers.handleCreateDataTable({
name: 'My Data Table',
columns: [
{ name: 'email', type: 'string' },
{ name: 'age', type: 'number' },
],
});
expect(result).toEqual({
success: true,
data: { id: 'dt-123', name: 'My Data Table' },
message: 'Data table "My Data Table" created with ID: dt-123',
});
expect(mockApiClient.createDataTable).toHaveBeenCalledWith({
name: 'My Data Table',
columns: [
{ name: 'email', type: 'string' },
{ name: 'age', type: 'number' },
],
});
});
it('should create data table with name only (no columns)', async () => {
const createdTable = {
id: 'dt-456',
name: 'Empty Table',
};
mockApiClient.createDataTable.mockResolvedValue(createdTable);
const result = await handlers.handleCreateDataTable({
name: 'Empty Table',
});
expect(result).toEqual({
success: true,
data: { id: 'dt-456', name: 'Empty Table' },
message: 'Data table "Empty Table" created with ID: dt-456',
});
expect(mockApiClient.createDataTable).toHaveBeenCalledWith({
name: 'Empty Table',
});
});
it('should return error when n8n API is not configured', async () => {
vi.mocked(getN8nApiConfig).mockReturnValue(null);
const result = await handlers.handleCreateDataTable({
name: 'Test Table',
});
expect(result).toEqual({
success: false,
error: 'n8n API not configured. Please set N8N_API_URL and N8N_API_KEY environment variables.',
});
});
it('should return Zod validation error when name is missing', async () => {
const result = await handlers.handleCreateDataTable({});
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid input');
expect(result.details).toHaveProperty('errors');
});
it('should return Zod validation error when name is empty string', async () => {
const result = await handlers.handleCreateDataTable({ name: '' });
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid input');
expect(result.details).toHaveProperty('errors');
});
it('should return Zod validation error when column name is empty string', async () => {
const result = await handlers.handleCreateDataTable({
name: 'Valid Table',
columns: [{ name: '' }],
});
expect(result.success).toBe(false);
expect(result.error).toBe('Invalid input');
expect(result.details).toHaveProperty('errors');
});
it('should return error when API call fails', async () => {
const apiError = new Error('Data table creation failed on the server');
mockApiClient.createDataTable.mockRejectedValue(apiError);
const result = await handlers.handleCreateDataTable({
name: 'Duplicate Table',
});
expect(result).toEqual({
success: false,
error: 'Data table creation failed on the server',
});
});
it('should return structured error for N8nApiError', async () => {
const apiError = new N8nApiError('Feature not available', 402, 'PAYMENT_REQUIRED', { plan: 'enterprise' });
mockApiClient.createDataTable.mockRejectedValue(apiError);
const result = await handlers.handleCreateDataTable({
name: 'Enterprise Table',
});
expect(result.success).toBe(false);
expect(result.error).toBeDefined();
expect(result.code).toBe('PAYMENT_REQUIRED');
expect(result.details).toEqual({ plan: 'enterprise' });
});
it('should return error when API returns empty response (null)', async () => {
mockApiClient.createDataTable.mockResolvedValue(null);
const result = await handlers.handleCreateDataTable({
name: 'Ghost Table',
});
expect(result).toEqual({
success: false,
error: 'Data table creation failed: n8n API returned an empty or invalid response',
});
});
it('should return error when API returns response without id', async () => {
mockApiClient.createDataTable.mockResolvedValue({ name: 'No ID Table' });
const result = await handlers.handleCreateDataTable({
name: 'No ID Table',
});
expect(result).toEqual({
success: false,
error: 'Data table creation failed: n8n API returned an empty or invalid response',
});
});
it('should return Unknown error when a non-Error value is thrown', async () => {
mockApiClient.createDataTable.mockRejectedValue('string-error');
const result = await handlers.handleCreateDataTable({
name: 'Error Table',
});
expect(result).toEqual({
success: false,
error: 'Unknown error occurred',
});
});
});

View File

@@ -631,6 +631,27 @@ describe('handlers-n8n-manager', () => {
expect(result.details.errors[0]).toContain('Webhook'); expect(result.details.errors[0]).toContain('Webhook');
}); });
}); });
it('should pass projectId to API when provided', async () => {
const testWorkflow = createTestWorkflow();
const input = {
name: 'Test Workflow',
nodes: testWorkflow.nodes,
connections: testWorkflow.connections,
projectId: 'project-abc-123',
};
mockApiClient.createWorkflow.mockResolvedValue(testWorkflow);
const result = await handlers.handleCreateWorkflow(input);
expect(result.success).toBe(true);
expect(mockApiClient.createWorkflow).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'project-abc-123',
})
);
});
}); });
describe('handleGetWorkflow', () => { describe('handleGetWorkflow', () => {

View File

@@ -542,6 +542,9 @@ describe('Parameter Validation', () => {
await expect(server.testExecuteTool('n8n_test_workflow', {})) await expect(server.testExecuteTool('n8n_test_workflow', {}))
.rejects.toThrow('Missing required parameters for n8n_test_workflow: workflowId'); .rejects.toThrow('Missing required parameters for n8n_test_workflow: workflowId');
await expect(server.testExecuteTool('n8n_create_data_table', {}))
.rejects.toThrow('Missing required parameters for n8n_create_data_table: name');
for (const tool of n8nToolsWithRequiredParams) { for (const tool of n8nToolsWithRequiredParams) {
await expect(server.testExecuteTool(tool.name, tool.args)) await expect(server.testExecuteTool(tool.name, tool.args))
.rejects.toThrow(tool.expected); .rejects.toThrow(tool.expected);

View File

@@ -1300,6 +1300,59 @@ 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('interceptors', () => { describe('interceptors', () => {
let requestInterceptor: any; let requestInterceptor: any;
let responseInterceptor: any; let responseInterceptor: any;