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:
104
tests/auth.test.ts
Normal file
104
tests/auth.test.ts
Normal 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
173
tests/bridge.test.ts
Normal 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
188
tests/error-handler.test.ts
Normal 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
119
tests/logger.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user