Implement n8n-MCP integration

This commit adds a complete integration between n8n workflow automation and the Model Context Protocol (MCP):

Features:
- MCP server that exposes n8n workflows as tools, resources, and prompts
- Custom n8n node for connecting to MCP servers from workflows
- Bidirectional bridge for data format conversion
- Token-based authentication and credential management
- Comprehensive error handling and logging
- Full test coverage for core components

Infrastructure:
- TypeScript/Node.js project setup with proper build configuration
- Docker support with multi-stage builds
- Development and production docker-compose configurations
- Installation script for n8n custom node deployment

Documentation:
- Detailed README with usage examples and API reference
- Environment configuration templates
- Troubleshooting guide

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
czlonkowski
2025-06-07 15:43:02 +00:00
parent b51591a87d
commit 1f8140c45c
28 changed files with 17543 additions and 0 deletions

104
tests/auth.test.ts Normal file
View File

@@ -0,0 +1,104 @@
import { AuthManager } from '../src/utils/auth';
describe('AuthManager', () => {
let authManager: AuthManager;
beforeEach(() => {
authManager = new AuthManager();
});
describe('validateToken', () => {
it('should return true when no authentication is required', () => {
expect(authManager.validateToken('any-token')).toBe(true);
expect(authManager.validateToken(undefined)).toBe(true);
});
it('should validate static token correctly', () => {
const expectedToken = 'secret-token';
expect(authManager.validateToken('secret-token', expectedToken)).toBe(true);
expect(authManager.validateToken('wrong-token', expectedToken)).toBe(false);
expect(authManager.validateToken(undefined, expectedToken)).toBe(false);
});
it('should validate generated tokens', () => {
const token = authManager.generateToken(1);
expect(authManager.validateToken(token, 'expected-token')).toBe(true);
});
it('should reject expired tokens', () => {
jest.useFakeTimers();
const token = authManager.generateToken(1); // 1 hour expiry
// Token should be valid initially
expect(authManager.validateToken(token, 'expected-token')).toBe(true);
// Fast forward 2 hours
jest.advanceTimersByTime(2 * 60 * 60 * 1000);
// Token should be expired
expect(authManager.validateToken(token, 'expected-token')).toBe(false);
jest.useRealTimers();
});
});
describe('generateToken', () => {
it('should generate unique tokens', () => {
const token1 = authManager.generateToken();
const token2 = authManager.generateToken();
expect(token1).not.toBe(token2);
expect(token1).toHaveLength(64); // 32 bytes hex = 64 chars
});
it('should set custom expiry time', () => {
jest.useFakeTimers();
const token = authManager.generateToken(24); // 24 hours
// Token should be valid after 23 hours
jest.advanceTimersByTime(23 * 60 * 60 * 1000);
expect(authManager.validateToken(token, 'expected')).toBe(true);
// Token should expire after 25 hours
jest.advanceTimersByTime(2 * 60 * 60 * 1000);
expect(authManager.validateToken(token, 'expected')).toBe(false);
jest.useRealTimers();
});
});
describe('revokeToken', () => {
it('should revoke a generated token', () => {
const token = authManager.generateToken();
expect(authManager.validateToken(token, 'expected')).toBe(true);
authManager.revokeToken(token);
expect(authManager.validateToken(token, 'expected')).toBe(false);
});
});
describe('static methods', () => {
it('should hash tokens consistently', () => {
const token = 'my-secret-token';
const hash1 = AuthManager.hashToken(token);
const hash2 = AuthManager.hashToken(token);
expect(hash1).toBe(hash2);
expect(hash1).toHaveLength(64); // SHA256 hex = 64 chars
});
it('should compare tokens securely', () => {
const token = 'my-secret-token';
const hashedToken = AuthManager.hashToken(token);
expect(AuthManager.compareTokens(token, hashedToken)).toBe(true);
expect(AuthManager.compareTokens('wrong-token', hashedToken)).toBe(false);
});
});
});

173
tests/bridge.test.ts Normal file
View File

@@ -0,0 +1,173 @@
import { N8NMCPBridge } from '../src/utils/bridge';
describe('N8NMCPBridge', () => {
describe('n8nToMCPToolArgs', () => {
it('should extract json from n8n data object', () => {
const n8nData = { json: { foo: 'bar' } };
const result = N8NMCPBridge.n8nToMCPToolArgs(n8nData);
expect(result).toEqual({ foo: 'bar' });
});
it('should remove n8n metadata', () => {
const n8nData = { foo: 'bar', pairedItem: 0 };
const result = N8NMCPBridge.n8nToMCPToolArgs(n8nData);
expect(result).toEqual({ foo: 'bar' });
});
});
describe('mcpToN8NExecutionData', () => {
it('should convert MCP content array to n8n format', () => {
const mcpResponse = {
content: [{ type: 'text', text: '{"result": "success"}' }],
};
const result = N8NMCPBridge.mcpToN8NExecutionData(mcpResponse, 1);
expect(result).toEqual({
json: { result: 'success' },
pairedItem: 1,
});
});
it('should handle non-JSON text content', () => {
const mcpResponse = {
content: [{ type: 'text', text: 'plain text response' }],
};
const result = N8NMCPBridge.mcpToN8NExecutionData(mcpResponse);
expect(result).toEqual({
json: { result: 'plain text response' },
pairedItem: 0,
});
});
it('should handle direct object response', () => {
const mcpResponse = { foo: 'bar' };
const result = N8NMCPBridge.mcpToN8NExecutionData(mcpResponse);
expect(result).toEqual({
json: { foo: 'bar' },
pairedItem: 0,
});
});
});
describe('n8nWorkflowToMCP', () => {
it('should convert n8n workflow to MCP format', () => {
const n8nWorkflow = {
id: '123',
name: 'Test Workflow',
nodes: [
{
id: 'node1',
type: 'n8n-nodes-base.start',
name: 'Start',
parameters: {},
position: [100, 100],
},
],
connections: {},
settings: { executionOrder: 'v1' },
active: true,
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
};
const result = N8NMCPBridge.n8nWorkflowToMCP(n8nWorkflow);
expect(result).toEqual({
id: '123',
name: 'Test Workflow',
description: '',
nodes: [
{
id: 'node1',
type: 'n8n-nodes-base.start',
name: 'Start',
parameters: {},
position: [100, 100],
},
],
connections: {},
settings: { executionOrder: 'v1' },
metadata: {
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-02T00:00:00Z',
active: true,
},
});
});
});
describe('mcpToN8NWorkflow', () => {
it('should convert MCP workflow to n8n format', () => {
const mcpWorkflow = {
name: 'Test Workflow',
nodes: [{ id: 'node1', type: 'n8n-nodes-base.start' }],
connections: { node1: { main: [[]] } },
};
const result = N8NMCPBridge.mcpToN8NWorkflow(mcpWorkflow);
expect(result).toEqual({
name: 'Test Workflow',
nodes: [{ id: 'node1', type: 'n8n-nodes-base.start' }],
connections: { node1: { main: [[]] } },
settings: { executionOrder: 'v1' },
staticData: null,
pinData: {},
});
});
});
describe('sanitizeData', () => {
it('should handle null and undefined', () => {
expect(N8NMCPBridge.sanitizeData(null)).toEqual({});
expect(N8NMCPBridge.sanitizeData(undefined)).toEqual({});
});
it('should wrap non-objects', () => {
expect(N8NMCPBridge.sanitizeData('string')).toEqual({ value: 'string' });
expect(N8NMCPBridge.sanitizeData(123)).toEqual({ value: 123 });
});
it('should handle circular references', () => {
const obj: any = { a: 1 };
obj.circular = obj;
const result = N8NMCPBridge.sanitizeData(obj);
expect(result).toEqual({ a: 1, circular: '[Circular]' });
});
});
describe('formatError', () => {
it('should format standard errors', () => {
const error = new Error('Test error');
error.stack = 'stack trace';
const result = N8NMCPBridge.formatError(error);
expect(result).toEqual({
message: 'Test error',
type: 'Error',
stack: 'stack trace',
details: {
code: undefined,
statusCode: undefined,
data: undefined,
},
});
});
it('should include additional error properties', () => {
const error: any = new Error('API error');
error.code = 'ERR_API';
error.statusCode = 404;
error.data = { field: 'value' };
const result = N8NMCPBridge.formatError(error);
expect(result.details).toEqual({
code: 'ERR_API',
statusCode: 404,
data: { field: 'value' },
});
});
});
});

188
tests/error-handler.test.ts Normal file
View File

@@ -0,0 +1,188 @@
import {
MCPError,
N8NConnectionError,
AuthenticationError,
ValidationError,
ToolNotFoundError,
ResourceNotFoundError,
handleError,
withErrorHandling,
} from '../src/utils/error-handler';
import { logger } from '../src/utils/logger';
// Mock the logger
jest.mock('../src/utils/logger', () => ({
logger: {
error: jest.fn(),
},
}));
describe('Error Classes', () => {
describe('MCPError', () => {
it('should create error with all properties', () => {
const error = new MCPError('Test error', 'TEST_CODE', 400, { field: 'value' });
expect(error.message).toBe('Test error');
expect(error.code).toBe('TEST_CODE');
expect(error.statusCode).toBe(400);
expect(error.data).toEqual({ field: 'value' });
expect(error.name).toBe('MCPError');
});
});
describe('N8NConnectionError', () => {
it('should create connection error with correct code', () => {
const error = new N8NConnectionError('Connection failed');
expect(error.message).toBe('Connection failed');
expect(error.code).toBe('N8N_CONNECTION_ERROR');
expect(error.statusCode).toBe(503);
expect(error.name).toBe('N8NConnectionError');
});
});
describe('AuthenticationError', () => {
it('should create auth error with default message', () => {
const error = new AuthenticationError();
expect(error.message).toBe('Authentication failed');
expect(error.code).toBe('AUTH_ERROR');
expect(error.statusCode).toBe(401);
});
it('should accept custom message', () => {
const error = new AuthenticationError('Invalid token');
expect(error.message).toBe('Invalid token');
});
});
describe('ValidationError', () => {
it('should create validation error', () => {
const error = new ValidationError('Invalid input', { field: 'email' });
expect(error.message).toBe('Invalid input');
expect(error.code).toBe('VALIDATION_ERROR');
expect(error.statusCode).toBe(400);
expect(error.data).toEqual({ field: 'email' });
});
});
describe('ToolNotFoundError', () => {
it('should create tool not found error', () => {
const error = new ToolNotFoundError('myTool');
expect(error.message).toBe("Tool 'myTool' not found");
expect(error.code).toBe('TOOL_NOT_FOUND');
expect(error.statusCode).toBe(404);
});
});
describe('ResourceNotFoundError', () => {
it('should create resource not found error', () => {
const error = new ResourceNotFoundError('workflow://123');
expect(error.message).toBe("Resource 'workflow://123' not found");
expect(error.code).toBe('RESOURCE_NOT_FOUND');
expect(error.statusCode).toBe(404);
});
});
});
describe('handleError', () => {
it('should return MCPError instances as-is', () => {
const mcpError = new ValidationError('Test');
const result = handleError(mcpError);
expect(result).toBe(mcpError);
});
it('should handle HTTP 401 errors', () => {
const httpError = {
response: { status: 401, data: { message: 'Unauthorized' } },
};
const result = handleError(httpError);
expect(result).toBeInstanceOf(AuthenticationError);
expect(result.message).toBe('Unauthorized');
});
it('should handle HTTP 404 errors', () => {
const httpError = {
response: { status: 404, data: { message: 'Not found' } },
};
const result = handleError(httpError);
expect(result.code).toBe('NOT_FOUND');
expect(result.statusCode).toBe(404);
});
it('should handle HTTP 5xx errors', () => {
const httpError = {
response: { status: 503, data: { message: 'Service unavailable' } },
};
const result = handleError(httpError);
expect(result).toBeInstanceOf(N8NConnectionError);
});
it('should handle connection refused errors', () => {
const connError = { code: 'ECONNREFUSED' };
const result = handleError(connError);
expect(result).toBeInstanceOf(N8NConnectionError);
expect(result.message).toBe('Cannot connect to n8n API');
});
it('should handle generic errors', () => {
const error = new Error('Something went wrong');
const result = handleError(error);
expect(result.message).toBe('Something went wrong');
expect(result.code).toBe('UNKNOWN_ERROR');
expect(result.statusCode).toBe(500);
});
it('should handle errors without message', () => {
const error = {};
const result = handleError(error);
expect(result.message).toBe('An unexpected error occurred');
});
});
describe('withErrorHandling', () => {
it('should execute operation successfully', async () => {
const operation = jest.fn().mockResolvedValue('success');
const result = await withErrorHandling(operation, 'test operation');
expect(result).toBe('success');
expect(logger.error).not.toHaveBeenCalled();
});
it('should handle and log errors', async () => {
const error = new Error('Operation failed');
const operation = jest.fn().mockRejectedValue(error);
await expect(withErrorHandling(operation, 'test operation')).rejects.toThrow();
expect(logger.error).toHaveBeenCalledWith('Error in test operation:', error);
});
it('should transform errors using handleError', async () => {
const error = { code: 'ECONNREFUSED' };
const operation = jest.fn().mockRejectedValue(error);
try {
await withErrorHandling(operation, 'test operation');
} catch (err) {
expect(err).toBeInstanceOf(N8NConnectionError);
}
});
});

119
tests/logger.test.ts Normal file
View File

@@ -0,0 +1,119 @@
import { Logger, LogLevel } from '../src/utils/logger';
describe('Logger', () => {
let logger: Logger;
let consoleErrorSpy: jest.SpyInstance;
let consoleWarnSpy: jest.SpyInstance;
let consoleLogSpy: jest.SpyInstance;
beforeEach(() => {
logger = new Logger({ timestamp: false, prefix: 'test' });
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation();
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('log levels', () => {
it('should only log errors when level is ERROR', () => {
logger.setLevel(LogLevel.ERROR);
logger.error('error message');
logger.warn('warn message');
logger.info('info message');
logger.debug('debug message');
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleWarnSpy).toHaveBeenCalledTimes(0);
expect(consoleLogSpy).toHaveBeenCalledTimes(0);
});
it('should log errors and warnings when level is WARN', () => {
logger.setLevel(LogLevel.WARN);
logger.error('error message');
logger.warn('warn message');
logger.info('info message');
logger.debug('debug message');
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleLogSpy).toHaveBeenCalledTimes(0);
});
it('should log all except debug when level is INFO', () => {
logger.setLevel(LogLevel.INFO);
logger.error('error message');
logger.warn('warn message');
logger.info('info message');
logger.debug('debug message');
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleLogSpy).toHaveBeenCalledTimes(1);
});
it('should log everything when level is DEBUG', () => {
logger.setLevel(LogLevel.DEBUG);
logger.error('error message');
logger.warn('warn message');
logger.info('info message');
logger.debug('debug message');
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
expect(consoleWarnSpy).toHaveBeenCalledTimes(1);
expect(consoleLogSpy).toHaveBeenCalledTimes(2); // info + debug
});
});
describe('message formatting', () => {
it('should include prefix in messages', () => {
logger.info('test message');
expect(consoleLogSpy).toHaveBeenCalledWith('[test] [INFO] test message');
});
it('should include timestamp when enabled', () => {
const timestampLogger = new Logger({ timestamp: true, prefix: 'test' });
const dateSpy = jest.spyOn(Date.prototype, 'toISOString').mockReturnValue('2024-01-01T00:00:00.000Z');
timestampLogger.info('test message');
expect(consoleLogSpy).toHaveBeenCalledWith('[2024-01-01T00:00:00.000Z] [test] [INFO] test message');
dateSpy.mockRestore();
});
it('should pass additional arguments', () => {
const obj = { foo: 'bar' };
logger.info('test message', obj, 123);
expect(consoleLogSpy).toHaveBeenCalledWith('[test] [INFO] test message', obj, 123);
});
});
describe('parseLogLevel', () => {
it('should parse log level strings correctly', () => {
expect(Logger.parseLogLevel('error')).toBe(LogLevel.ERROR);
expect(Logger.parseLogLevel('ERROR')).toBe(LogLevel.ERROR);
expect(Logger.parseLogLevel('warn')).toBe(LogLevel.WARN);
expect(Logger.parseLogLevel('info')).toBe(LogLevel.INFO);
expect(Logger.parseLogLevel('debug')).toBe(LogLevel.DEBUG);
expect(Logger.parseLogLevel('unknown')).toBe(LogLevel.INFO);
});
});
describe('singleton instance', () => {
it('should return the same instance', () => {
const instance1 = Logger.getInstance();
const instance2 = Logger.getInstance();
expect(instance1).toBe(instance2);
});
});
});