feat: add n8n integration with MCP Client Tool support
- Add N8N_MODE environment variable for n8n-specific behavior - Implement HTTP Streamable transport with multiple session support - Add protocol version endpoint (GET /mcp) for n8n compatibility - Support multiple initialize requests for stateless n8n clients - Add Docker configuration for n8n deployment - Add test script with persistent volume support - Add comprehensive unit tests for n8n mode - Fix session management to handle per-request transport pattern BREAKING CHANGE: Server now creates new transport for each initialize request when running in n8n mode to support n8n's stateless client architecture 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
540
tests/unit/http-server-n8n-mode.test.ts
Normal file
540
tests/unit/http-server-n8n-mode.test.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi, MockedFunction } from 'vitest';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { SingleSessionHTTPServer } from '../../src/http-server-single-session';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../../src/utils/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('dotenv');
|
||||
|
||||
vi.mock('../../src/mcp/server', () => ({
|
||||
N8NDocumentationMCPServer: vi.fn().mockImplementation(() => ({
|
||||
connect: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}));
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/server/streamableHttp.js', () => ({
|
||||
StreamableHTTPServerTransport: vi.fn().mockImplementation(() => ({
|
||||
handleRequest: vi.fn().mockImplementation(async (req: any, res: any) => {
|
||||
// Simulate successful MCP response
|
||||
if (process.env.N8N_MODE === 'true') {
|
||||
res.setHeader('Mcp-Session-Id', 'single-session');
|
||||
}
|
||||
res.status(200).json({
|
||||
jsonrpc: '2.0',
|
||||
result: { success: true },
|
||||
id: 1
|
||||
});
|
||||
}),
|
||||
close: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
}));
|
||||
|
||||
// Create a mock console manager instance
|
||||
const mockConsoleManager = {
|
||||
wrapOperation: vi.fn().mockImplementation(async (fn: () => Promise<any>) => {
|
||||
return await fn();
|
||||
})
|
||||
};
|
||||
|
||||
vi.mock('../../src/utils/console-manager', () => ({
|
||||
ConsoleManager: vi.fn(() => mockConsoleManager)
|
||||
}));
|
||||
|
||||
vi.mock('../../src/utils/url-detector', () => ({
|
||||
getStartupBaseUrl: vi.fn((host: string, port: number) => `http://localhost:${port || 3000}`),
|
||||
formatEndpointUrls: vi.fn((baseUrl: string) => ({
|
||||
health: `${baseUrl}/health`,
|
||||
mcp: `${baseUrl}/mcp`
|
||||
})),
|
||||
detectBaseUrl: vi.fn((req: any, host: string, port: number) => `http://localhost:${port || 3000}`)
|
||||
}));
|
||||
|
||||
vi.mock('../../src/utils/version', () => ({
|
||||
PROJECT_VERSION: '2.8.1'
|
||||
}));
|
||||
|
||||
// Create Express app mock
|
||||
const mockHandlers: { [key: string]: any[] } = {
|
||||
get: [],
|
||||
post: [],
|
||||
use: []
|
||||
};
|
||||
|
||||
const mockExpressApp = {
|
||||
get: vi.fn((path: string, ...handlers: any[]) => {
|
||||
mockHandlers.get.push({ path, handlers });
|
||||
return mockExpressApp;
|
||||
}),
|
||||
post: vi.fn((path: string, ...handlers: any[]) => {
|
||||
mockHandlers.post.push({ path, handlers });
|
||||
return mockExpressApp;
|
||||
}),
|
||||
use: vi.fn((handler: any) => {
|
||||
mockHandlers.use.push(handler);
|
||||
return mockExpressApp;
|
||||
}),
|
||||
set: vi.fn(),
|
||||
listen: vi.fn((port: number, host: string, callback?: () => void) => {
|
||||
if (callback) callback();
|
||||
return {
|
||||
on: vi.fn(),
|
||||
close: vi.fn((cb: () => void) => cb()),
|
||||
address: () => ({ port: 3000 })
|
||||
};
|
||||
})
|
||||
};
|
||||
|
||||
vi.mock('express', () => ({
|
||||
default: vi.fn(() => mockExpressApp),
|
||||
Request: {},
|
||||
Response: {},
|
||||
NextFunction: {}
|
||||
}));
|
||||
|
||||
describe('HTTP Server n8n Mode', () => {
|
||||
const originalEnv = process.env;
|
||||
const TEST_AUTH_TOKEN = 'test-auth-token-with-more-than-32-characters';
|
||||
let server: SingleSessionHTTPServer;
|
||||
let consoleLogSpy: any;
|
||||
let consoleWarnSpy: any;
|
||||
let consoleErrorSpy: any;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset environment
|
||||
process.env = { ...originalEnv };
|
||||
process.env.AUTH_TOKEN = TEST_AUTH_TOKEN;
|
||||
process.env.PORT = '0'; // Use random port for tests
|
||||
|
||||
// Mock console methods to prevent output during tests
|
||||
consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
// Clear all mocks and handlers
|
||||
vi.clearAllMocks();
|
||||
mockHandlers.get = [];
|
||||
mockHandlers.post = [];
|
||||
mockHandlers.use = [];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Restore environment
|
||||
process.env = originalEnv;
|
||||
|
||||
// Restore console methods
|
||||
consoleLogSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
|
||||
// Shutdown server if running
|
||||
if (server) {
|
||||
await server.shutdown();
|
||||
server = null as any;
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to find a route handler
|
||||
function findHandler(method: 'get' | 'post', path: string) {
|
||||
const routes = mockHandlers[method];
|
||||
const route = routes.find(r => r.path === path);
|
||||
return route ? route.handlers[route.handlers.length - 1] : null;
|
||||
}
|
||||
|
||||
// Helper to create mock request/response
|
||||
function createMockReqRes() {
|
||||
const headers: { [key: string]: string } = {};
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis(),
|
||||
send: vi.fn().mockReturnThis(),
|
||||
setHeader: vi.fn((key: string, value: string) => {
|
||||
headers[key.toLowerCase()] = value;
|
||||
}),
|
||||
sendStatus: vi.fn().mockReturnThis(),
|
||||
headersSent: false,
|
||||
getHeader: (key: string) => headers[key.toLowerCase()],
|
||||
headers
|
||||
};
|
||||
|
||||
const req = {
|
||||
method: 'GET',
|
||||
path: '/',
|
||||
headers: {} as Record<string, string>,
|
||||
body: {},
|
||||
ip: '127.0.0.1',
|
||||
get: vi.fn((header: string) => (req.headers as Record<string, string>)[header.toLowerCase()])
|
||||
};
|
||||
|
||||
return { req, res };
|
||||
}
|
||||
|
||||
describe('Protocol Version Endpoint (GET /mcp)', () => {
|
||||
it('should return standard response when N8N_MODE is not set', async () => {
|
||||
delete process.env.N8N_MODE;
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
const handler = findHandler('get', '/mcp');
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const { req, res } = createMockReqRes();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
description: 'n8n Documentation MCP Server',
|
||||
version: '2.8.1',
|
||||
endpoints: {
|
||||
mcp: {
|
||||
method: 'POST',
|
||||
path: '/mcp',
|
||||
description: 'Main MCP JSON-RPC endpoint',
|
||||
authentication: 'Bearer token required'
|
||||
},
|
||||
health: {
|
||||
method: 'GET',
|
||||
path: '/health',
|
||||
description: 'Health check endpoint',
|
||||
authentication: 'None'
|
||||
},
|
||||
root: {
|
||||
method: 'GET',
|
||||
path: '/',
|
||||
description: 'API information',
|
||||
authentication: 'None'
|
||||
}
|
||||
},
|
||||
documentation: 'https://github.com/czlonkowski/n8n-mcp'
|
||||
});
|
||||
});
|
||||
|
||||
it('should return protocol version when N8N_MODE=true', async () => {
|
||||
process.env.N8N_MODE = 'true';
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
const handler = findHandler('get', '/mcp');
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const { req, res } = createMockReqRes();
|
||||
await handler(req, res);
|
||||
|
||||
// When N8N_MODE is true, should return protocol version and server info
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: {
|
||||
name: 'n8n-mcp',
|
||||
version: '2.8.1',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session ID Header (POST /mcp)', () => {
|
||||
it('should handle POST request when N8N_MODE is not set', async () => {
|
||||
delete process.env.N8N_MODE;
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
const handler = findHandler('post', '/mcp');
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const { req, res } = createMockReqRes();
|
||||
req.headers = { authorization: `Bearer ${TEST_AUTH_TOKEN}` };
|
||||
req.method = 'POST';
|
||||
req.body = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'test',
|
||||
params: {},
|
||||
id: 1
|
||||
};
|
||||
|
||||
// The handler should call handleRequest which wraps the operation
|
||||
await handler(req, res);
|
||||
|
||||
// Verify the ConsoleManager's wrapOperation was called
|
||||
expect(mockConsoleManager.wrapOperation).toHaveBeenCalled();
|
||||
|
||||
// In normal mode, no special headers should be set by our code
|
||||
// The transport handles the actual response
|
||||
});
|
||||
|
||||
it('should handle POST request when N8N_MODE=true', async () => {
|
||||
process.env.N8N_MODE = 'true';
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
const handler = findHandler('post', '/mcp');
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const { req, res } = createMockReqRes();
|
||||
req.headers = { authorization: `Bearer ${TEST_AUTH_TOKEN}` };
|
||||
req.method = 'POST';
|
||||
req.body = {
|
||||
jsonrpc: '2.0',
|
||||
method: 'test',
|
||||
params: {},
|
||||
id: 1
|
||||
};
|
||||
|
||||
await handler(req, res);
|
||||
|
||||
// Verify the ConsoleManager's wrapOperation was called
|
||||
expect(mockConsoleManager.wrapOperation).toHaveBeenCalled();
|
||||
|
||||
// In N8N_MODE, the transport mock is configured to set the Mcp-Session-Id header
|
||||
// This is testing that the environment variable is properly passed through
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Response Format', () => {
|
||||
it('should use JSON-RPC error format for auth errors', async () => {
|
||||
delete process.env.N8N_MODE;
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
const handler = findHandler('post', '/mcp');
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
// Test missing auth header
|
||||
const { req, res } = createMockReqRes();
|
||||
req.method = 'POST';
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32001,
|
||||
message: 'Unauthorized'
|
||||
},
|
||||
id: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid auth token', async () => {
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
const handler = findHandler('post', '/mcp');
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const { req, res } = createMockReqRes();
|
||||
req.headers = { authorization: 'Bearer invalid-token' };
|
||||
req.method = 'POST';
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32001,
|
||||
message: 'Unauthorized'
|
||||
},
|
||||
id: null
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle invalid auth header format', async () => {
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
const handler = findHandler('post', '/mcp');
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const { req, res } = createMockReqRes();
|
||||
req.headers = { authorization: 'Basic sometoken' }; // Wrong format
|
||||
req.method = 'POST';
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32001,
|
||||
message: 'Unauthorized'
|
||||
},
|
||||
id: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Normal Mode Behavior', () => {
|
||||
it('should maintain standard behavior for health endpoint', async () => {
|
||||
// Test both with and without N8N_MODE
|
||||
for (const n8nMode of [undefined, 'true', 'false']) {
|
||||
if (n8nMode === undefined) {
|
||||
delete process.env.N8N_MODE;
|
||||
} else {
|
||||
process.env.N8N_MODE = n8nMode;
|
||||
}
|
||||
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
const handler = findHandler('get', '/health');
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const { req, res } = createMockReqRes();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: 'ok',
|
||||
mode: 'single-session',
|
||||
version: '2.8.1',
|
||||
sessionActive: expect.any(Boolean)
|
||||
}));
|
||||
|
||||
await server.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('should maintain standard behavior for root endpoint', async () => {
|
||||
// Test both with and without N8N_MODE
|
||||
for (const n8nMode of [undefined, 'true', 'false']) {
|
||||
if (n8nMode === undefined) {
|
||||
delete process.env.N8N_MODE;
|
||||
} else {
|
||||
process.env.N8N_MODE = n8nMode;
|
||||
}
|
||||
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
const handler = findHandler('get', '/');
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const { req, res } = createMockReqRes();
|
||||
await handler(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
name: 'n8n Documentation MCP Server',
|
||||
version: '2.8.1',
|
||||
endpoints: expect.any(Object),
|
||||
authentication: expect.any(Object)
|
||||
}));
|
||||
|
||||
await server.shutdown();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge Cases', () => {
|
||||
it('should handle N8N_MODE with various values', async () => {
|
||||
const testValues = ['true', 'TRUE', '1', 'yes', 'false', ''];
|
||||
|
||||
for (const value of testValues) {
|
||||
process.env.N8N_MODE = value;
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
const handler = findHandler('get', '/mcp');
|
||||
expect(handler).toBeTruthy();
|
||||
|
||||
const { req, res } = createMockReqRes();
|
||||
await handler(req, res);
|
||||
|
||||
// Only exactly 'true' should enable n8n mode
|
||||
if (value === 'true') {
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
protocolVersion: '2024-11-05',
|
||||
serverInfo: {
|
||||
name: 'n8n-mcp',
|
||||
version: '2.8.1',
|
||||
capabilities: {
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: 'n8n Documentation MCP Server'
|
||||
}));
|
||||
}
|
||||
|
||||
await server.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle OPTIONS requests for CORS', async () => {
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
const { req, res } = createMockReqRes();
|
||||
req.method = 'OPTIONS';
|
||||
|
||||
// Call each middleware to find the CORS one
|
||||
for (const middleware of mockHandlers.use) {
|
||||
if (typeof middleware === 'function') {
|
||||
const next = vi.fn();
|
||||
await middleware(req, res, next);
|
||||
|
||||
if (res.sendStatus.mock.calls.length > 0) {
|
||||
// Found the CORS middleware - verify it was called
|
||||
expect(res.sendStatus).toHaveBeenCalledWith(204);
|
||||
|
||||
// Check that CORS headers were set (order doesn't matter)
|
||||
const setHeaderCalls = (res.setHeader as any).mock.calls;
|
||||
const headerMap = new Map(setHeaderCalls);
|
||||
|
||||
expect(headerMap.has('Access-Control-Allow-Origin')).toBe(true);
|
||||
expect(headerMap.has('Access-Control-Allow-Methods')).toBe(true);
|
||||
expect(headerMap.has('Access-Control-Allow-Headers')).toBe(true);
|
||||
expect(headerMap.get('Access-Control-Allow-Methods')).toBe('POST, GET, OPTIONS');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate session info methods', async () => {
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
// Initially no session
|
||||
let sessionInfo = server.getSessionInfo();
|
||||
expect(sessionInfo.active).toBe(false);
|
||||
|
||||
// The getSessionInfo method should return proper structure
|
||||
expect(sessionInfo).toHaveProperty('active');
|
||||
|
||||
// Test that the server instance has the expected methods
|
||||
expect(typeof server.getSessionInfo).toBe('function');
|
||||
expect(typeof server.start).toBe('function');
|
||||
expect(typeof server.shutdown).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('404 Handler', () => {
|
||||
it('should handle 404 errors correctly', async () => {
|
||||
server = new SingleSessionHTTPServer();
|
||||
await server.start();
|
||||
|
||||
// The 404 handler is added with app.use() without a path
|
||||
// Find the last middleware that looks like a 404 handler
|
||||
const notFoundHandler = mockHandlers.use[mockHandlers.use.length - 2]; // Second to last (before error handler)
|
||||
|
||||
const { req, res } = createMockReqRes();
|
||||
req.method = 'POST';
|
||||
req.path = '/nonexistent';
|
||||
|
||||
await notFoundHandler(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Not found',
|
||||
message: 'Cannot POST /nonexistent'
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user