feat: SSE (Server-Sent Events) support for n8n integration

- Added SSE server implementation for real-time event streaming
- Created n8n compatibility mode with strict schema validation
- Implemented session management for concurrent connections
- Added comprehensive SSE documentation and examples
- Enhanced MCP tools with async execution support
- Added Docker Compose configuration for SSE deployment
- Included test scripts and integration tests

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
czlonkowski
2025-07-09 08:24:44 +02:00
parent 87f0cfc4dc
commit 54e09c9673
23 changed files with 3981 additions and 20 deletions

View File

@@ -0,0 +1,487 @@
/**
* SSE Integration Tests for n8n MCP
* Tests the enhanced SSE server functionality
*/
import { describe, test, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
import request from 'supertest';
import { EventSource } from 'eventsource';
import { v4 as uuidv4 } from 'uuid';
// Test configuration
const TEST_PORT = 3001;
const TEST_AUTH_TOKEN = 'test-token-' + uuidv4();
const TEST_URL = `http://localhost:${TEST_PORT}`;
// SSE server instance
let server: any;
let app: any;
describe('SSE Integration Tests', () => {
beforeAll(async () => {
// Set test environment
process.env.AUTH_TOKEN = TEST_AUTH_TOKEN;
process.env.PORT = String(TEST_PORT);
process.env.MCP_MODE = 'sse';
// Import and start SSE server
const { startSSEServer } = await import('../src/sse-server');
// Note: We'd need to modify startSSEServer to return the express app for testing
// For now, we'll test against the running server
});
afterAll(async () => {
// Clean up
if (server) {
await new Promise((resolve) => server.close(resolve));
}
});
describe('Health Check', () => {
test('should return server status', async () => {
const response = await request(TEST_URL)
.get('/health')
.expect(200);
expect(response.body).toMatchObject({
status: 'ok',
mode: 'sse',
activeSessions: expect.any(Number),
memory: expect.any(Object),
timestamp: expect.any(String)
});
});
});
describe('Authentication', () => {
test('should authenticate with Bearer token', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
.send({
jsonrpc: '2.0',
method: 'initialize',
id: 1
})
.expect(200);
expect(response.body).toHaveProperty('jsonrpc', '2.0');
expect(response.body).toHaveProperty('result');
});
test('should authenticate with custom header', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('x-auth-token', TEST_AUTH_TOKEN)
.send({
jsonrpc: '2.0',
method: 'initialize',
id: 1
})
.expect(200);
expect(response.body).toHaveProperty('jsonrpc', '2.0');
});
test('should authenticate with API key header', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('x-api-key', TEST_AUTH_TOKEN)
.send({
jsonrpc: '2.0',
method: 'initialize',
id: 1
})
.expect(200);
expect(response.body).toHaveProperty('jsonrpc', '2.0');
});
test('should reject invalid token', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('Authorization', 'Bearer invalid-token')
.send({
jsonrpc: '2.0',
method: 'initialize',
id: 1
})
.expect(401);
expect(response.body).toHaveProperty('error', 'Unauthorized');
});
test('should reject missing token', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.send({
jsonrpc: '2.0',
method: 'initialize',
id: 1
})
.expect(401);
expect(response.body).toHaveProperty('error', 'Unauthorized');
});
});
describe('Endpoint Patterns', () => {
test('should support legacy /sse endpoint', async () => {
const response = await new Promise((resolve, reject) => {
const es = new EventSource(`${TEST_URL}/sse?token=${TEST_AUTH_TOKEN}`);
es.onopen = () => {
es.close();
resolve({ connected: true });
};
es.onerror = (error) => {
es.close();
reject(error);
};
});
expect(response).toEqual({ connected: true });
});
test('should support n8n pattern /mcp/:path/sse', async () => {
const response = await new Promise((resolve, reject) => {
const es = new EventSource(`${TEST_URL}/mcp/workflow-123/sse?token=${TEST_AUTH_TOKEN}`);
es.onopen = () => {
es.close();
resolve({ connected: true });
};
es.onerror = (error) => {
es.close();
reject(error);
};
});
expect(response).toEqual({ connected: true });
});
test('should support legacy /mcp/message endpoint', async () => {
// First establish SSE connection to get client ID
// This is simplified - in real test we'd extract the client ID from SSE messages
const response = await request(TEST_URL)
.post('/mcp/message')
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
.set('X-Client-ID', 'test-client-id')
.send({
jsonrpc: '2.0',
method: 'tools/list',
id: 1
});
// The real response would come via SSE, here we just check acknowledgment
expect(response.status).toBeLessThan(500);
});
test('should support n8n pattern /mcp/:path/message', async () => {
const response = await request(TEST_URL)
.post('/mcp/workflow-123/message')
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
.set('X-Client-ID', 'test-client-id')
.send({
jsonrpc: '2.0',
method: 'tools/list',
id: 1
});
expect(response.status).toBeLessThan(500);
});
});
describe('MCP Protocol Methods', () => {
test('should handle initialize method', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
.send({
jsonrpc: '2.0',
method: 'initialize',
id: 1
})
.expect(200);
expect(response.body).toMatchObject({
jsonrpc: '2.0',
id: 1,
result: {
protocolVersion: '2024-11-05',
capabilities: {
tools: {},
resources: {},
prompts: {}
},
serverInfo: {
name: 'n8n-documentation-mcp'
}
}
});
});
test('should handle tools/list method', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
.send({
jsonrpc: '2.0',
method: 'tools/list',
id: 2
})
.expect(200);
expect(response.body).toMatchObject({
jsonrpc: '2.0',
id: 2,
result: {
tools: expect.any(Array)
}
});
});
test('should handle resources/list method', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
.send({
jsonrpc: '2.0',
method: 'resources/list',
id: 3
})
.expect(200);
expect(response.body).toMatchObject({
jsonrpc: '2.0',
id: 3,
result: {
resources: []
}
});
});
test('should handle prompts/list method', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
.send({
jsonrpc: '2.0',
method: 'prompts/list',
id: 4
})
.expect(200);
expect(response.body).toMatchObject({
jsonrpc: '2.0',
id: 4,
result: {
prompts: []
}
});
});
test('should return error for resources/read', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
.send({
jsonrpc: '2.0',
method: 'resources/read',
params: { uri: 'test://resource' },
id: 5
})
.expect(200);
expect(response.body).toMatchObject({
jsonrpc: '2.0',
id: 5,
error: {
code: -32601,
message: 'Resource reading not implemented'
}
});
});
test('should return error for prompts/get', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
.send({
jsonrpc: '2.0',
method: 'prompts/get',
params: { name: 'test-prompt' },
id: 6
})
.expect(200);
expect(response.body).toMatchObject({
jsonrpc: '2.0',
id: 6,
error: {
code: -32601,
message: 'Prompt retrieval not implemented'
}
});
});
test('should return error for unknown method', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
.send({
jsonrpc: '2.0',
method: 'unknown/method',
id: 7
})
.expect(200);
expect(response.body).toMatchObject({
jsonrpc: '2.0',
id: 7,
error: {
code: -32601,
message: expect.stringContaining('Method not found')
}
});
});
});
describe('Workflow Context', () => {
test('should accept workflow context headers', async () => {
const workflowId = 'workflow-' + uuidv4();
const executionId = 'execution-' + uuidv4();
const nodeId = 'node-' + uuidv4();
// Test SSE connection with workflow context
const url = `${TEST_URL}/sse?token=${TEST_AUTH_TOKEN}&workflowId=${workflowId}&executionId=${executionId}&nodeId=${nodeId}`;
const response = await new Promise((resolve, reject) => {
const es = new EventSource(url);
es.onopen = () => {
es.close();
resolve({ connected: true });
};
es.onerror = (error) => {
es.close();
reject(error);
};
});
expect(response).toEqual({ connected: true });
});
});
describe('SSE Message Flow', () => {
test('should receive connected event on SSE connection', async () => {
const messages: any[] = [];
await new Promise<void>((resolve, reject) => {
const es = new EventSource(`${TEST_URL}/sse?token=${TEST_AUTH_TOKEN}`);
es.addEventListener('connected', (event: any) => {
messages.push({
type: event.type,
data: JSON.parse(event.data)
});
es.close();
resolve();
});
es.onerror = (error) => {
es.close();
reject(error);
};
// Timeout after 5 seconds
setTimeout(() => {
es.close();
reject(new Error('Timeout waiting for connected event'));
}, 5000);
});
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
type: 'connected',
data: {
clientId: expect.any(String),
timestamp: expect.any(String)
}
});
});
test('should receive mcp-response for initialization', async () => {
const messages: any[] = [];
await new Promise<void>((resolve, reject) => {
const es = new EventSource(`${TEST_URL}/sse?token=${TEST_AUTH_TOKEN}`);
es.addEventListener('mcp-response', (event: any) => {
const data = JSON.parse(event.data);
if (data.method === 'mcp/ready') {
messages.push({
type: event.type,
data
});
es.close();
resolve();
}
});
es.onerror = (error) => {
es.close();
reject(error);
};
// Timeout after 5 seconds
setTimeout(() => {
es.close();
reject(new Error('Timeout waiting for mcp/ready'));
}, 5000);
});
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
type: 'mcp-response',
data: {
jsonrpc: '2.0',
method: 'mcp/ready',
params: {
protocolVersion: '2024-11-05',
serverInfo: {
name: 'n8n-documentation-mcp'
}
}
}
});
});
});
describe('Error Handling', () => {
test('should handle 404 for unknown endpoints', async () => {
const response = await request(TEST_URL)
.get('/unknown-endpoint')
.expect(404);
expect(response.body).toMatchObject({
error: 'Not found',
message: expect.stringContaining('Cannot GET /unknown-endpoint')
});
});
test('should handle invalid JSON in request body', async () => {
const response = await request(TEST_URL)
.post('/mcp')
.set('Authorization', `Bearer ${TEST_AUTH_TOKEN}`)
.set('Content-Type', 'application/json')
.send('invalid-json')
.expect(400);
expect(response.status).toBeGreaterThanOrEqual(400);
});
});
});

View File

@@ -0,0 +1,313 @@
/**
* Unit tests for SSE Session Manager
*/
import { describe, test, expect, beforeEach, afterEach, jest } from '@jest/globals';
import { SSESessionManager } from '../src/utils/sse-session-manager';
import { EventEmitter } from 'events';
// Mock Express response
class MockResponse extends EventEmitter {
public headers: any = {};
public statusCode?: number;
public writtenData: string[] = [];
writeHead(status: number, headers: any) {
this.statusCode = status;
this.headers = headers;
}
write(data: string) {
this.writtenData.push(data);
return true;
}
end() {
this.emit('close');
}
}
describe('SSE Session Manager', () => {
let sessionManager: SSESessionManager;
beforeEach(() => {
sessionManager = new SSESessionManager();
});
afterEach(() => {
sessionManager.shutdown();
});
describe('Client Registration', () => {
test('should register a new client', () => {
const mockResponse = new MockResponse();
const clientId = sessionManager.registerClient(mockResponse);
expect(clientId).toBeTruthy();
expect(sessionManager.hasClient(clientId)).toBe(true);
expect(sessionManager.getActiveClientCount()).toBe(1);
});
test('should set SSE headers correctly', () => {
const mockResponse = new MockResponse();
sessionManager.registerClient(mockResponse);
expect(mockResponse.statusCode).toBe(200);
expect(mockResponse.headers['Content-Type']).toBe('text/event-stream');
expect(mockResponse.headers['Cache-Control']).toBe('no-cache');
expect(mockResponse.headers['Connection']).toBe('keep-alive');
});
test('should send connected event on registration', () => {
const mockResponse = new MockResponse();
const clientId = sessionManager.registerClient(mockResponse);
// Check that connected event was sent
expect(mockResponse.writtenData.length).toBeGreaterThan(0);
const sentData = mockResponse.writtenData[0];
expect(sentData).toContain('event: connected');
expect(sentData).toContain(`"clientId":"${clientId}"`);
});
test('should handle client disconnect', () => {
const mockResponse = new MockResponse();
const clientId = sessionManager.registerClient(mockResponse);
expect(sessionManager.hasClient(clientId)).toBe(true);
// Simulate disconnect
mockResponse.emit('close');
expect(sessionManager.hasClient(clientId)).toBe(false);
expect(sessionManager.getActiveClientCount()).toBe(0);
});
});
describe('Message Sending', () => {
test('should send message to client', () => {
const mockResponse = new MockResponse();
const clientId = sessionManager.registerClient(mockResponse);
const result = sessionManager.sendToClient(clientId, {
event: 'test-event',
data: { message: 'Hello' }
});
expect(result).toBe(true);
expect(mockResponse.writtenData.length).toBe(2); // connected + test message
const lastMessage = mockResponse.writtenData[1];
expect(lastMessage).toContain('event: test-event');
expect(lastMessage).toContain('data: {"message":"Hello"}');
});
test('should handle message with ID and retry', () => {
const mockResponse = new MockResponse();
const clientId = sessionManager.registerClient(mockResponse);
sessionManager.sendToClient(clientId, {
id: '123',
event: 'test',
data: 'test data',
retry: 5000
});
const lastMessage = mockResponse.writtenData[1];
expect(lastMessage).toContain('id: 123');
expect(lastMessage).toContain('retry: 5000');
});
test('should send MCP message correctly', () => {
const mockResponse = new MockResponse();
const clientId = sessionManager.registerClient(mockResponse);
const mcpMessage = {
jsonrpc: '2.0' as const,
id: 1,
result: { test: 'data' }
};
const result = sessionManager.sendMCPMessage(clientId, mcpMessage);
expect(result).toBe(true);
const lastMessage = mockResponse.writtenData[1];
expect(lastMessage).toContain('event: mcp-response');
expect(lastMessage).toContain('"jsonrpc":"2.0"');
});
test('should return false for invalid client', () => {
const result = sessionManager.sendToClient('invalid-id', {
data: 'test'
});
expect(result).toBe(false);
});
});
describe('Workflow Context', () => {
test('should update workflow context', () => {
const mockResponse = new MockResponse();
const clientId = sessionManager.registerClient(mockResponse);
const context = {
workflowId: 'workflow-123',
executionId: 'execution-456',
nodeId: 'node-789'
};
const result = sessionManager.updateWorkflowContext(clientId, context);
expect(result).toBe(true);
expect(sessionManager.getWorkflowContext(clientId)).toEqual(context);
});
test('should merge workflow context updates', () => {
const mockResponse = new MockResponse();
const clientId = sessionManager.registerClient(mockResponse);
sessionManager.updateWorkflowContext(clientId, {
workflowId: 'workflow-123'
});
sessionManager.updateWorkflowContext(clientId, {
executionId: 'execution-456'
});
const context = sessionManager.getWorkflowContext(clientId);
expect(context).toEqual({
workflowId: 'workflow-123',
executionId: 'execution-456'
});
});
test('should return false for invalid client context update', () => {
const result = sessionManager.updateWorkflowContext('invalid-id', {
workflowId: 'test'
});
expect(result).toBe(false);
});
});
describe('Broadcast', () => {
test('should broadcast to all active clients', () => {
const mockResponse1 = new MockResponse();
const mockResponse2 = new MockResponse();
sessionManager.registerClient(mockResponse1);
sessionManager.registerClient(mockResponse2);
sessionManager.broadcast({
event: 'broadcast-test',
data: { message: 'Hello all' }
});
// Both clients should receive the message
expect(mockResponse1.writtenData.length).toBe(2);
expect(mockResponse2.writtenData.length).toBe(2);
const message1 = mockResponse1.writtenData[1];
const message2 = mockResponse2.writtenData[1];
expect(message1).toContain('event: broadcast-test');
expect(message2).toContain('event: broadcast-test');
});
});
describe('Ping', () => {
test('should send ping to client', () => {
const mockResponse = new MockResponse();
const clientId = sessionManager.registerClient(mockResponse);
const result = sessionManager.sendPing(clientId);
expect(result).toBe(true);
const lastMessage = mockResponse.writtenData[1];
expect(lastMessage).toContain('event: ping');
expect(lastMessage).toContain('"timestamp"');
});
test('should ping all clients', () => {
const mockResponse1 = new MockResponse();
const mockResponse2 = new MockResponse();
sessionManager.registerClient(mockResponse1);
sessionManager.registerClient(mockResponse2);
sessionManager.pingAllClients();
// Both clients should receive ping
expect(mockResponse1.writtenData.length).toBe(2);
expect(mockResponse2.writtenData.length).toBe(2);
expect(mockResponse1.writtenData[1]).toContain('event: ping');
expect(mockResponse2.writtenData[1]).toContain('event: ping');
});
});
describe('Session Cleanup', () => {
test('should clean up inactive sessions', async () => {
// Mock the session timeout to a short value for testing
jest.useFakeTimers();
const mockResponse = new MockResponse();
const clientId = sessionManager.registerClient(mockResponse);
expect(sessionManager.hasClient(clientId)).toBe(true);
// Fast forward past session timeout
jest.advanceTimersByTime(6 * 60 * 1000); // 6 minutes
// The cleanup interval should have run and removed the inactive session
// Note: This test might need adjustment based on actual implementation
jest.useRealTimers();
});
});
describe('Client Limits', () => {
test('should enforce maximum client limit', () => {
// Temporarily set a lower limit for testing
const maxClients = 5;
(sessionManager as any).MAX_CLIENTS = maxClients;
// Register clients up to the limit
const responses: MockResponse[] = [];
for (let i = 0; i < maxClients; i++) {
const mockResponse = new MockResponse();
responses.push(mockResponse);
sessionManager.registerClient(mockResponse);
}
expect(sessionManager.getActiveClientCount()).toBe(maxClients);
// Try to register one more client
const extraResponse = new MockResponse();
expect(() => {
sessionManager.registerClient(extraResponse);
}).toThrow('Maximum concurrent connections exceeded');
// Clean up
responses.forEach(r => r.emit('close'));
});
});
describe('Shutdown', () => {
test('should close all connections on shutdown', () => {
const mockResponse1 = new MockResponse();
const mockResponse2 = new MockResponse();
const endSpy1 = jest.spyOn(mockResponse1, 'end');
const endSpy2 = jest.spyOn(mockResponse2, 'end');
sessionManager.registerClient(mockResponse1);
sessionManager.registerClient(mockResponse2);
sessionManager.shutdown();
expect(endSpy1).toHaveBeenCalled();
expect(endSpy2).toHaveBeenCalled();
expect(sessionManager.getActiveClientCount()).toBe(0);
});
});
});

289
tests/test-sse-endpoints.ts Normal file
View File

@@ -0,0 +1,289 @@
#!/usr/bin/env ts-node
/**
* Manual test script for SSE endpoints
* Usage: npm run build && npx ts-node tests/test-sse-endpoints.ts
*/
import { EventSource } from 'eventsource';
import fetch from 'node-fetch';
const BASE_URL = process.env.SSE_TEST_URL || 'http://localhost:3000';
const AUTH_TOKEN = process.env.AUTH_TOKEN || 'test-token';
interface TestResult {
test: string;
status: 'PASS' | 'FAIL';
message?: string;
error?: any;
}
const results: TestResult[] = [];
function logResult(test: string, status: 'PASS' | 'FAIL', message?: string, error?: any) {
results.push({ test, status, message, error });
console.log(`[${status}] ${test}${message ? ': ' + message : ''}`);
if (error) {
console.error(' Error:', error);
}
}
async function testHealthEndpoint() {
console.log('\n=== Testing Health Endpoint ===');
try {
const response = await fetch(`${BASE_URL}/health`);
const data = await response.json();
if (response.ok && data.status === 'ok' && data.mode === 'sse') {
logResult('Health Check', 'PASS', `Server is healthy (mode: ${data.mode})`);
} else {
logResult('Health Check', 'FAIL', 'Unexpected response', data);
}
} catch (error) {
logResult('Health Check', 'FAIL', 'Failed to connect', error);
}
}
async function testAuthentication() {
console.log('\n=== Testing Authentication Methods ===');
// Test Bearer token
try {
const response = await fetch(`${BASE_URL}/mcp`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${AUTH_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
id: 1
})
});
if (response.ok) {
logResult('Bearer Token Auth', 'PASS');
} else {
logResult('Bearer Token Auth', 'FAIL', `Status: ${response.status}`);
}
} catch (error) {
logResult('Bearer Token Auth', 'FAIL', 'Request failed', error);
}
// Test custom header
try {
const response = await fetch(`${BASE_URL}/mcp`, {
method: 'POST',
headers: {
'x-auth-token': AUTH_TOKEN,
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
id: 2
})
});
if (response.ok) {
logResult('Custom Header Auth', 'PASS');
} else {
logResult('Custom Header Auth', 'FAIL', `Status: ${response.status}`);
}
} catch (error) {
logResult('Custom Header Auth', 'FAIL', 'Request failed', error);
}
// Test API key header
try {
const response = await fetch(`${BASE_URL}/mcp`, {
method: 'POST',
headers: {
'x-api-key': AUTH_TOKEN,
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'initialize',
id: 3
})
});
if (response.ok) {
logResult('API Key Auth', 'PASS');
} else {
logResult('API Key Auth', 'FAIL', `Status: ${response.status}`);
}
} catch (error) {
logResult('API Key Auth', 'FAIL', 'Request failed', error);
}
}
async function testSSEEndpoints() {
console.log('\n=== Testing SSE Endpoints ===');
// Test legacy /sse endpoint
await testSSEConnection('/sse', 'Legacy SSE endpoint');
// Test n8n pattern endpoint
await testSSEConnection('/mcp/test-workflow/sse', 'n8n pattern SSE endpoint');
}
async function testSSEConnection(endpoint: string, testName: string): Promise<void> {
return new Promise((resolve) => {
const url = `${BASE_URL}${endpoint}?token=${AUTH_TOKEN}`;
console.log(`Testing ${testName}: ${url}`);
const es = new EventSource(url);
let receivedConnected = false;
let receivedReady = false;
const timeout = setTimeout(() => {
es.close();
if (!receivedConnected) {
logResult(testName, 'FAIL', 'Timeout - no connected event');
} else if (!receivedReady) {
logResult(testName, 'FAIL', 'Timeout - no mcp/ready event');
}
resolve();
}, 5000);
es.addEventListener('connected', (event: any) => {
receivedConnected = true;
const data = JSON.parse(event.data);
console.log(` Received connected event: clientId=${data.clientId}`);
});
es.addEventListener('mcp-response', (event: any) => {
const data = JSON.parse(event.data);
if (data.method === 'mcp/ready') {
receivedReady = true;
console.log(` Received mcp/ready event`);
clearTimeout(timeout);
es.close();
logResult(testName, 'PASS', 'Connected and ready');
resolve();
}
});
es.onerror = (error: any) => {
clearTimeout(timeout);
es.close();
logResult(testName, 'FAIL', 'Connection error', error);
resolve();
};
});
}
async function testMCPMethods() {
console.log('\n=== Testing MCP Protocol Methods ===');
const methods = [
{ method: 'initialize', expectedResult: true },
{ method: 'tools/list', expectedResult: true },
{ method: 'resources/list', expectedResult: true },
{ method: 'prompts/list', expectedResult: true },
{ method: 'resources/read', expectedResult: false },
{ method: 'prompts/get', expectedResult: false },
];
for (const { method, expectedResult } of methods) {
try {
const response = await fetch(`${BASE_URL}/mcp`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${AUTH_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
jsonrpc: '2.0',
method,
params: method.includes('read') ? { uri: 'test://resource' } :
method.includes('get') ? { name: 'test-prompt' } : undefined,
id: Math.random()
})
});
const data = await response.json();
if (expectedResult && data.result !== undefined) {
logResult(`MCP ${method}`, 'PASS', 'Returned result');
} else if (!expectedResult && data.error !== undefined) {
logResult(`MCP ${method}`, 'PASS', 'Returned expected error');
} else {
logResult(`MCP ${method}`, 'FAIL', 'Unexpected response', data);
}
} catch (error) {
logResult(`MCP ${method}`, 'FAIL', 'Request failed', error);
}
}
}
async function testWorkflowContext() {
console.log('\n=== Testing Workflow Context ===');
const workflowId = 'test-workflow-123';
const executionId = 'test-execution-456';
const nodeId = 'test-node-789';
const url = `${BASE_URL}/mcp/${workflowId}/sse?token=${AUTH_TOKEN}&workflowId=${workflowId}&executionId=${executionId}&nodeId=${nodeId}`;
return new Promise<void>((resolve) => {
const es = new EventSource(url);
const timeout = setTimeout(() => {
es.close();
logResult('Workflow Context', 'FAIL', 'Timeout');
resolve();
}, 5000);
es.addEventListener('connected', (event: any) => {
clearTimeout(timeout);
es.close();
logResult('Workflow Context', 'PASS', 'Connected with context parameters');
resolve();
});
es.onerror = (error: any) => {
clearTimeout(timeout);
es.close();
logResult('Workflow Context', 'FAIL', 'Connection error', error);
resolve();
};
});
}
async function runAllTests() {
console.log(`Testing SSE Server at ${BASE_URL}`);
console.log(`Using auth token: ${AUTH_TOKEN.substring(0, 8)}...`);
await testHealthEndpoint();
await testAuthentication();
await testSSEEndpoints();
await testMCPMethods();
await testWorkflowContext();
// Summary
console.log('\n=== Test Summary ===');
const passed = results.filter(r => r.status === 'PASS').length;
const failed = results.filter(r => r.status === 'FAIL').length;
console.log(`Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
if (failed > 0) {
console.log('\nFailed tests:');
results.filter(r => r.status === 'FAIL').forEach(r => {
console.log(`- ${r.test}: ${r.message || 'No message'}`);
});
process.exit(1);
} else {
console.log('\nAll tests passed!');
process.exit(0);
}
}
// Run tests
runAllTests().catch(error => {
console.error('Test runner error:', error);
process.exit(1);
});