Initial commit of n8n MCP Server

A Model Context Protocol (MCP) server that integrates with n8n, providing tools for workflow and execution management via the n8n API.
This commit is contained in:
leonardsellem
2025-03-12 17:12:35 +01:00
commit 2cd565cfa6
79 changed files with 19654 additions and 0 deletions

View File

@@ -0,0 +1,373 @@
/**
* N8nApiClient unit tests
*/
import '@jest/globals';
import axios from 'axios';
import { N8nApiClient } from '../../../src/api/client.js';
import { EnvConfig } from '../../../src/config/environment.js';
import { N8nApiError } from '../../../src/errors/index.js';
import { createMockAxiosInstance, createMockAxiosResponse } from '../../mocks/axios-mock.js';
import { mockApiResponses } from '../../mocks/n8n-fixtures.js';
// Mock axios
jest.mock('axios', () => ({
create: jest.fn(),
}));
describe('N8nApiClient', () => {
// Mock configuration
const mockConfig: EnvConfig = {
n8nApiUrl: 'https://n8n.example.com/api/v1',
n8nApiKey: 'test-api-key',
debug: false,
};
// Mock axios instance
let mockAxios;
beforeEach(() => {
mockAxios = createMockAxiosInstance();
(axios.create as jest.Mock).mockReturnValue(mockAxios);
});
afterEach(() => {
jest.clearAllMocks();
mockAxios.reset();
});
describe('constructor', () => {
it('should create an axios instance with correct config', () => {
// Execute
new N8nApiClient(mockConfig);
// Assert
expect(axios.create).toHaveBeenCalledWith({
baseURL: mockConfig.n8nApiUrl,
headers: {
'X-N8N-API-KEY': mockConfig.n8nApiKey,
'Accept': 'application/json',
},
timeout: 10000,
});
});
it('should set up debug interceptors when debug is true', () => {
// Setup
const debugConfig = { ...mockConfig, debug: true };
// Execute
new N8nApiClient(debugConfig);
// Assert
expect(mockAxios.interceptors.request.use).toHaveBeenCalled();
expect(mockAxios.interceptors.response.use).toHaveBeenCalled();
});
it('should not set up debug interceptors when debug is false', () => {
// Execute
new N8nApiClient(mockConfig);
// Assert
expect(mockAxios.interceptors.request.use).not.toHaveBeenCalled();
expect(mockAxios.interceptors.response.use).not.toHaveBeenCalled();
});
});
describe('checkConnectivity', () => {
it('should resolve when connectivity check succeeds', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
mockAxios.addMockResponse('get', '/workflows', {
status: 200,
data: { data: [] },
});
// Execute & Assert
await expect(client.checkConnectivity()).resolves.not.toThrow();
});
it('should throw an error when response status is not 200', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
mockAxios.addMockResponse('get', '/workflows', {
status: 500,
data: { message: 'Server error' },
});
// Execute & Assert
await expect(client.checkConnectivity()).rejects.toThrow(N8nApiError);
});
it('should throw an error when request fails', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
mockAxios.addMockResponse('get', '/workflows', new Error('Network error'));
// Execute & Assert
await expect(client.checkConnectivity()).rejects.toThrow();
});
});
describe('getWorkflows', () => {
it('should return workflows array on success', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const mockWorkflows = mockApiResponses.workflows.list;
mockAxios.addMockResponse('get', '/workflows', {
status: 200,
data: mockWorkflows,
});
// Execute
const result = await client.getWorkflows();
// Assert
expect(result).toEqual(mockWorkflows.data);
expect(mockAxios.get).toHaveBeenCalledWith('/workflows');
});
it('should handle empty response', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
mockAxios.addMockResponse('get', '/workflows', {
status: 200,
data: {},
});
// Execute
const result = await client.getWorkflows();
// Assert
expect(result).toEqual([]);
});
it('should throw an error when request fails', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
mockAxios.addMockResponse('get', '/workflows', new Error('Network error'));
// Execute & Assert
await expect(client.getWorkflows()).rejects.toThrow();
});
});
describe('getWorkflow', () => {
it('should return a workflow on success', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const workflowId = 'test-workflow-1';
const mockWorkflow = mockApiResponses.workflows.single(workflowId);
mockAxios.addMockResponse('get', `/workflows/${workflowId}`, {
status: 200,
data: mockWorkflow,
});
// Execute
const result = await client.getWorkflow(workflowId);
// Assert
expect(result).toEqual(mockWorkflow);
expect(mockAxios.get).toHaveBeenCalledWith(`/workflows/${workflowId}`);
});
it('should throw an error when request fails', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const workflowId = 'test-workflow-1';
mockAxios.addMockResponse('get', `/workflows/${workflowId}`, new Error('Network error'));
// Execute & Assert
await expect(client.getWorkflow(workflowId)).rejects.toThrow();
});
});
// Additional tests for other API client methods
describe('executeWorkflow', () => {
it('should execute a workflow successfully', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const workflowId = 'test-workflow-1';
const mockData = { inputs: { value: 'test' } };
const mockResponse = { executionId: 'exec-123', success: true };
mockAxios.addMockResponse('post', `/workflows/${workflowId}/execute`, {
status: 200,
data: mockResponse,
});
// Execute
const result = await client.executeWorkflow(workflowId, mockData);
// Assert
expect(result).toEqual(mockResponse);
expect(mockAxios.post).toHaveBeenCalledWith(`/workflows/${workflowId}/execute`, mockData);
});
});
describe('createWorkflow', () => {
it('should create a workflow successfully', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const newWorkflow = { name: 'New Workflow', nodes: [], connections: {} };
const mockResponse = mockApiResponses.workflows.create(newWorkflow);
mockAxios.addMockResponse('post', '/workflows', {
status: 200,
data: mockResponse,
});
// Execute
const result = await client.createWorkflow(newWorkflow);
// Assert
expect(result).toEqual(mockResponse);
expect(mockAxios.post).toHaveBeenCalledWith('/workflows', newWorkflow);
});
});
describe('updateWorkflow', () => {
it('should update a workflow successfully', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const workflowId = 'test-workflow-1';
const updatedWorkflow = { name: 'Updated Workflow', nodes: [], connections: {} };
const mockResponse = mockApiResponses.workflows.update(workflowId, updatedWorkflow);
mockAxios.addMockResponse('put', `/workflows/${workflowId}`, {
status: 200,
data: mockResponse,
});
// Execute
const result = await client.updateWorkflow(workflowId, updatedWorkflow);
// Assert
expect(result).toEqual(mockResponse);
expect(mockAxios.put).toHaveBeenCalledWith(`/workflows/${workflowId}`, updatedWorkflow);
});
});
describe('deleteWorkflow', () => {
it('should delete a workflow successfully', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const workflowId = 'test-workflow-1';
const mockResponse = mockApiResponses.workflows.delete;
mockAxios.addMockResponse('delete', `/workflows/${workflowId}`, {
status: 200,
data: mockResponse,
});
// Execute
const result = await client.deleteWorkflow(workflowId);
// Assert
expect(result).toEqual(mockResponse);
expect(mockAxios.delete).toHaveBeenCalledWith(`/workflows/${workflowId}`);
});
});
describe('activateWorkflow', () => {
it('should activate a workflow successfully', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const workflowId = 'test-workflow-1';
const mockResponse = mockApiResponses.workflows.activate(workflowId);
mockAxios.addMockResponse('post', `/workflows/${workflowId}/activate`, {
status: 200,
data: mockResponse,
});
// Execute
const result = await client.activateWorkflow(workflowId);
// Assert
expect(result).toEqual(mockResponse);
expect(mockAxios.post).toHaveBeenCalledWith(`/workflows/${workflowId}/activate`);
});
});
describe('deactivateWorkflow', () => {
it('should deactivate a workflow successfully', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const workflowId = 'test-workflow-1';
const mockResponse = mockApiResponses.workflows.deactivate(workflowId);
mockAxios.addMockResponse('post', `/workflows/${workflowId}/deactivate`, {
status: 200,
data: mockResponse,
});
// Execute
const result = await client.deactivateWorkflow(workflowId);
// Assert
expect(result).toEqual(mockResponse);
expect(mockAxios.post).toHaveBeenCalledWith(`/workflows/${workflowId}/deactivate`);
});
});
describe('getExecutions', () => {
it('should return executions array on success', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const mockExecutions = mockApiResponses.executions.list;
mockAxios.addMockResponse('get', '/executions', {
status: 200,
data: mockExecutions,
});
// Execute
const result = await client.getExecutions();
// Assert
expect(result).toEqual(mockExecutions.data);
expect(mockAxios.get).toHaveBeenCalledWith('/executions');
});
});
describe('getExecution', () => {
it('should return an execution on success', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const executionId = 'test-execution-1';
const mockExecution = mockApiResponses.executions.single(executionId);
mockAxios.addMockResponse('get', `/executions/${executionId}`, {
status: 200,
data: mockExecution,
});
// Execute
const result = await client.getExecution(executionId);
// Assert
expect(result).toEqual(mockExecution);
expect(mockAxios.get).toHaveBeenCalledWith(`/executions/${executionId}`);
});
});
describe('deleteExecution', () => {
it('should delete an execution successfully', async () => {
// Setup
const client = new N8nApiClient(mockConfig);
const executionId = 'test-execution-1';
const mockResponse = mockApiResponses.executions.delete;
mockAxios.addMockResponse('delete', `/executions/${executionId}`, {
status: 200,
data: mockResponse,
});
// Execute
const result = await client.deleteExecution(executionId);
// Assert
expect(result).toEqual(mockResponse);
expect(mockAxios.delete).toHaveBeenCalledWith(`/executions/${executionId}`);
});
});
});

View File

@@ -0,0 +1,54 @@
/**
* Simple HTTP client tests without complex dependencies
*/
import { describe, it, expect } from '@jest/globals';
// Create a simple HTTP client class to test
class SimpleHttpClient {
constructor(private baseUrl: string, private apiKey: string) {}
getBaseUrl(): string {
return this.baseUrl;
}
getApiKey(): string {
return this.apiKey;
}
buildAuthHeader(): Record<string, string> {
return {
'X-N8N-API-KEY': this.apiKey
};
}
formatUrl(path: string): string {
return `${this.baseUrl}${path.startsWith('/') ? path : '/' + path}`;
}
}
describe('SimpleHttpClient', () => {
it('should store baseUrl and apiKey properly', () => {
const baseUrl = 'https://n8n.example.com/api/v1';
const apiKey = 'test-api-key';
const client = new SimpleHttpClient(baseUrl, apiKey);
expect(client.getBaseUrl()).toBe(baseUrl);
expect(client.getApiKey()).toBe(apiKey);
});
it('should create proper auth headers', () => {
const client = new SimpleHttpClient('https://n8n.example.com/api/v1', 'test-api-key');
const headers = client.buildAuthHeader();
expect(headers).toEqual({ 'X-N8N-API-KEY': 'test-api-key' });
});
it('should format URLs correctly', () => {
const baseUrl = 'https://n8n.example.com/api/v1';
const client = new SimpleHttpClient(baseUrl, 'test-api-key');
expect(client.formatUrl('workflows')).toBe(`${baseUrl}/workflows`);
expect(client.formatUrl('/workflows')).toBe(`${baseUrl}/workflows`);
});
});

View File

@@ -0,0 +1,168 @@
/**
* Environment configuration unit tests
*/
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { getEnvConfig, loadEnvironmentVariables, ENV_VARS } from '../../../src/config/environment.js';
import { McpError } from '@modelcontextprotocol/sdk/types.js';
import { ErrorCode } from '../../../src/errors/error-codes.js';
import { mockEnv } from '../../test-setup.js';
describe('Environment Configuration', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...originalEnv };
// Clear environment variables that might interfere with tests
delete process.env[ENV_VARS.N8N_API_URL];
delete process.env[ENV_VARS.N8N_API_KEY];
delete process.env[ENV_VARS.DEBUG];
});
afterEach(() => {
process.env = originalEnv;
});
describe('loadEnvironmentVariables', () => {
it('should load environment variables from .env file', () => {
// This is mostly a coverage test, as we can't easily verify dotenv behavior
expect(() => loadEnvironmentVariables()).not.toThrow();
});
});
describe('getEnvConfig', () => {
it('should return a valid config when all required variables are present', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'https://n8n.example.com/api/v1';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
// Execute
const config = getEnvConfig();
// Assert
expect(config).toEqual({
n8nApiUrl: 'https://n8n.example.com/api/v1',
n8nApiKey: 'test-api-key',
debug: false,
});
});
it('should set debug to true when DEBUG=true', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'https://n8n.example.com/api/v1';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
process.env[ENV_VARS.DEBUG] = 'true';
// Execute
const config = getEnvConfig();
// Assert
expect(config.debug).toBe(true);
});
it('should handle uppercase true for DEBUG', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'https://n8n.example.com/api/v1';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
process.env[ENV_VARS.DEBUG] = 'TRUE';
// Execute
const config = getEnvConfig();
// Assert
expect(config.debug).toBe(true);
});
it('should set debug to false for non-true values', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'https://n8n.example.com/api/v1';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
process.env[ENV_VARS.DEBUG] = 'yes';
// Execute
const config = getEnvConfig();
// Assert
expect(config.debug).toBe(false);
});
it('should throw an error when N8N_API_URL is missing', () => {
// Setup
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
// Execute & Assert
expect(() => getEnvConfig()).toThrow(
new McpError(
ErrorCode.InitializationError,
`Missing required environment variable: ${ENV_VARS.N8N_API_URL}`
)
);
});
it('should throw an error when N8N_API_KEY is missing', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'https://n8n.example.com/api/v1';
// Execute & Assert
expect(() => getEnvConfig()).toThrow(
new McpError(
ErrorCode.InitializationError,
`Missing required environment variable: ${ENV_VARS.N8N_API_KEY}`
)
);
});
it('should throw an error when N8N_API_URL is not a valid URL', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'invalid-url';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
// Execute & Assert
expect(() => getEnvConfig()).toThrow(
new McpError(
ErrorCode.InitializationError,
`Invalid URL format for ${ENV_VARS.N8N_API_URL}: invalid-url`
)
);
});
it('should allow localhost URLs', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'http://localhost:5678/api/v1';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
// Execute
const config = getEnvConfig();
// Assert
expect(config.n8nApiUrl).toBe('http://localhost:5678/api/v1');
});
it('should accept URLs with trailing slashes', () => {
// Setup
process.env[ENV_VARS.N8N_API_URL] = 'https://n8n.example.com/api/v1/';
process.env[ENV_VARS.N8N_API_KEY] = 'test-api-key';
// Execute
const config = getEnvConfig();
// Assert
expect(config.n8nApiUrl).toBe('https://n8n.example.com/api/v1/');
});
});
describe('with mockEnv helper', () => {
// Using the mockEnv helper from test-setup
mockEnv({
[ENV_VARS.N8N_API_URL]: 'https://n8n.example.com/api/v1',
[ENV_VARS.N8N_API_KEY]: 'test-api-key',
});
it('should use the mocked environment variables', () => {
const config = getEnvConfig();
expect(config.n8nApiUrl).toBe('https://n8n.example.com/api/v1');
expect(config.n8nApiKey).toBe('test-api-key');
});
});
});

View File

@@ -0,0 +1,97 @@
/**
* Simple environment configuration tests
*/
import { describe, it, expect } from '@jest/globals';
// Simple environment validation function to test
function validateEnvironment(env: Record<string, string | undefined>): {
n8nApiUrl: string;
n8nApiKey: string;
debug: boolean;
} {
// Check required variables
if (!env.N8N_API_URL) {
throw new Error('Missing required environment variable: N8N_API_URL');
}
if (!env.N8N_API_KEY) {
throw new Error('Missing required environment variable: N8N_API_KEY');
}
// Validate URL format
try {
new URL(env.N8N_API_URL);
} catch (error) {
throw new Error(`Invalid URL format for N8N_API_URL: ${env.N8N_API_URL}`);
}
// Return parsed config
return {
n8nApiUrl: env.N8N_API_URL,
n8nApiKey: env.N8N_API_KEY,
debug: env.DEBUG?.toLowerCase() === 'true'
};
}
describe('Environment Configuration', () => {
describe('validateEnvironment', () => {
it('should return a valid config when all required variables are present', () => {
const env = {
N8N_API_URL: 'https://n8n.example.com/api/v1',
N8N_API_KEY: 'test-api-key'
};
const config = validateEnvironment(env);
expect(config).toEqual({
n8nApiUrl: 'https://n8n.example.com/api/v1',
n8nApiKey: 'test-api-key',
debug: false
});
});
it('should set debug to true when DEBUG=true', () => {
const env = {
N8N_API_URL: 'https://n8n.example.com/api/v1',
N8N_API_KEY: 'test-api-key',
DEBUG: 'true'
};
const config = validateEnvironment(env);
expect(config.debug).toBe(true);
});
it('should throw an error when N8N_API_URL is missing', () => {
const env = {
N8N_API_KEY: 'test-api-key'
};
expect(() => validateEnvironment(env)).toThrow(
'Missing required environment variable: N8N_API_URL'
);
});
it('should throw an error when N8N_API_KEY is missing', () => {
const env = {
N8N_API_URL: 'https://n8n.example.com/api/v1'
};
expect(() => validateEnvironment(env)).toThrow(
'Missing required environment variable: N8N_API_KEY'
);
});
it('should throw an error when N8N_API_URL is not a valid URL', () => {
const env = {
N8N_API_URL: 'invalid-url',
N8N_API_KEY: 'test-api-key'
};
expect(() => validateEnvironment(env)).toThrow(
'Invalid URL format for N8N_API_URL: invalid-url'
);
});
});
});

View File

@@ -0,0 +1,38 @@
/**
* Simple test for URI Template functionality
*/
import { describe, it, expect } from '@jest/globals';
// Simple functions to test without complex imports
function getWorkflowResourceTemplateUri() {
return 'n8n://workflows/{id}';
}
function extractWorkflowIdFromUri(uri: string): string | null {
const regex = /^n8n:\/\/workflows\/([^/]+)$/;
const match = uri.match(regex);
return match ? match[1] : null;
}
describe('Workflow Resource URI Functions', () => {
describe('getWorkflowResourceTemplateUri', () => {
it('should return the correct URI template', () => {
expect(getWorkflowResourceTemplateUri()).toBe('n8n://workflows/{id}');
});
});
describe('extractWorkflowIdFromUri', () => {
it('should extract workflow ID from valid URI', () => {
expect(extractWorkflowIdFromUri('n8n://workflows/123abc')).toBe('123abc');
expect(extractWorkflowIdFromUri('n8n://workflows/workflow-name-with-dashes')).toBe('workflow-name-with-dashes');
});
it('should return null for invalid URI formats', () => {
expect(extractWorkflowIdFromUri('n8n://workflows/')).toBeNull();
expect(extractWorkflowIdFromUri('n8n://workflows')).toBeNull();
expect(extractWorkflowIdFromUri('n8n://workflow/123')).toBeNull();
expect(extractWorkflowIdFromUri('invalid://workflows/123')).toBeNull();
});
});
});

View File

@@ -0,0 +1,25 @@
/**
* ListWorkflowsHandler unit tests
*/
import { describe, it, expect, jest } from '@jest/globals';
import { getListWorkflowsToolDefinition } from '../../../../src/tools/workflow/list.js';
import { mockApiResponses } from '../../../mocks/n8n-fixtures.js';
// Since this is an integration test, we'll test the definition directly
// rather than mocking the complex handler implementation
jest.mock('../../../../src/tools/workflow/base-handler.js');
describe('getListWorkflowsToolDefinition', () => {
it('should return the correct tool definition', () => {
// Execute
const definition = getListWorkflowsToolDefinition();
// Assert
expect(definition.name).toBe('list_workflows');
expect(definition.description).toBeTruthy();
expect(definition.inputSchema).toBeDefined();
expect(definition.inputSchema.properties).toHaveProperty('active');
expect(definition.inputSchema.required).toEqual([]);
});
});

View File

@@ -0,0 +1,90 @@
/**
* Simple workflow tool tests without complex dependencies
*/
import { describe, it, expect } from '@jest/globals';
// Mock workflow data
const mockWorkflows = [
{
id: '1234abc',
name: 'Test Workflow 1',
active: true,
createdAt: '2025-03-01T12:00:00.000Z',
updatedAt: '2025-03-02T14:30:00.000Z',
nodes: []
},
{
id: '5678def',
name: 'Test Workflow 2',
active: false,
createdAt: '2025-03-01T12:00:00.000Z',
updatedAt: '2025-03-12T10:15:00.000Z',
nodes: []
}
];
// Simple function to test tool definition
function getListWorkflowsToolDefinition() {
return {
name: 'list_workflows',
description: 'List all workflows with optional filtering by status',
inputSchema: {
type: 'object',
properties: {
active: {
type: 'boolean',
description: 'Filter workflows by active status'
}
},
required: []
}
};
}
// Simple function to test workflow filtering
function filterWorkflows(workflows, filter) {
if (filter && typeof filter.active === 'boolean') {
return workflows.filter(workflow => workflow.active === filter.active);
}
return workflows;
}
describe('Workflow Tools', () => {
describe('getListWorkflowsToolDefinition', () => {
it('should return the correct tool definition', () => {
const definition = getListWorkflowsToolDefinition();
expect(definition.name).toBe('list_workflows');
expect(definition.description).toBeTruthy();
expect(definition.inputSchema).toBeDefined();
expect(definition.inputSchema.properties).toHaveProperty('active');
expect(definition.inputSchema.required).toEqual([]);
});
});
describe('filterWorkflows', () => {
it('should return all workflows when no filter is provided', () => {
const result = filterWorkflows(mockWorkflows, {});
expect(result).toHaveLength(2);
expect(result).toEqual(mockWorkflows);
});
it('should filter workflows by active status when active is true', () => {
const result = filterWorkflows(mockWorkflows, { active: true });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('1234abc');
expect(result[0].active).toBe(true);
});
it('should filter workflows by active status when active is false', () => {
const result = filterWorkflows(mockWorkflows, { active: false });
expect(result).toHaveLength(1);
expect(result[0].id).toBe('5678def');
expect(result[0].active).toBe(false);
});
});
});