Fix SSE transport setup and trust proxy configuration

This commit is contained in:
Lee Hanken
2025-10-26 14:09:01 +00:00
parent 43590c1778
commit 931ef0335f
2 changed files with 216 additions and 137 deletions

View File

@@ -17,12 +17,13 @@ import HPRDataLoader from './data-loader.js';
// Configuration // Configuration
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const MAX_CONCURRENT_REQUESTS = 10; const MAX_CONCURRENT_REQUESTS = 10;
const REQUEST_TIMEOUT_MS = 30000; // Increased the timeout for the long-lived SSE connection connect() call
const REQUEST_TIMEOUT_MS = 60000; // 60 seconds (was 30s)
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
const RATE_LIMIT_MAX_REQUESTS = 50; // 50 requests per minute per IP const RATE_LIMIT_MAX_REQUESTS = 100; // 100 requests per minute per IP
const MEMORY_THRESHOLD_MB = 450; const MEMORY_THRESHOLD_MB = 450;
const CIRCUIT_BREAKER_THRESHOLD = 5; const CIRCUIT_BREAKER_THRESHOLD = 5;
const CIRCUIT_BREAKER_TIMEOUT_MS = 60000; const CIRCUIT_BREAKER_TIMEOUT_MS = 60000; // 60 seconds (how long it stays OPEN)
const SSE_HEARTBEAT_INTERVAL_MS = 20000; // 20 seconds to prevent proxy timeout const SSE_HEARTBEAT_INTERVAL_MS = 20000; // 20 seconds to prevent proxy timeout
// Initialize data loader // Initialize data loader
@@ -31,6 +32,9 @@ const dataLoader = new HPRDataLoader();
await dataLoader.load(); await dataLoader.load();
console.error('Data loaded successfully!'); console.error('Data loaded successfully!');
// Map to store active SSE transports, keyed by connectionId
const activeSseTransports = new Map();
// Circuit Breaker class for graceful degradation // Circuit Breaker class for graceful degradation
class CircuitBreaker { class CircuitBreaker {
constructor(threshold = CIRCUIT_BREAKER_THRESHOLD, timeout = CIRCUIT_BREAKER_TIMEOUT_MS) { constructor(threshold = CIRCUIT_BREAKER_THRESHOLD, timeout = CIRCUIT_BREAKER_TIMEOUT_MS) {
@@ -148,12 +152,17 @@ function formatEpisode(episode, includeNotes = false) {
${episode.summary}`; ${episode.summary}`;
if (seriesInfo) { if (seriesInfo) {
result += `\n\n## Series result += `
## Series
**${seriesInfo.name}**: ${stripHtml(seriesInfo.description)}`; **${seriesInfo.name}**: ${stripHtml(seriesInfo.description)}`;
} }
if (includeNotes && episode.notes) { if (includeNotes && episode.notes) {
result += `\n\n## Host Notes\n${stripHtml(episode.notes)}`; result += `
## Host Notes
${stripHtml(episode.notes)}`;
} }
return result; return result;
@@ -662,8 +671,11 @@ ${match.context}
// Create Express app // Create Express app
const app = express(); const app = express();
// Trust proxy headers (required for Render, Heroku, etc.) // Create a single MCP server instance
app.set('trust proxy', true); const mcpServer = createMCPServer();
// Trust first proxy hop (Render/Heroku) without allowing arbitrary spoofing
app.set('trust proxy', 1);
// Enable CORS // Enable CORS
app.use(cors()); app.use(cors());
@@ -671,7 +683,7 @@ app.use(cors());
// Enable compression // Enable compression
app.use(compression()); app.use(compression());
// ⭐ FIX: Apply JSON body parsing globally for the SDK to read POST bodies // Apply JSON body parsing globally for the SDK to read POST bodies.
app.use(express.json()); app.use(express.json());
// Rate limiting // Rate limiting
@@ -701,10 +713,21 @@ app.get('/health', (req, res) => {
}); });
}); });
// ⭐ NEW ENDPOINT: Circuit breaker reset
app.post('/reset', (req, res) => {
if (circuitBreaker.state === 'OPEN') {
circuitBreaker.reset();
console.error('Circuit breaker manually reset.');
res.json({ status: 'ok', message: 'Circuit breaker reset to CLOSED.' });
} else {
res.json({ status: 'ok', message: 'Circuit breaker already CLOSED.' });
}
});
// SSE endpoint for MCP // SSE endpoint for MCP
app.get('/sse', async (req, res) => { app.get('/sse', async (req, res) => {
let pingInterval = null; let pingInterval = null;
let headersSent = false; let transport;
try { try {
// Check system health // Check system health
@@ -714,56 +737,50 @@ app.get('/sse', async (req, res) => {
activeRequests++; activeRequests++;
console.error(`New SSE connection. Active requests: ${activeRequests}`); console.error(`New SSE connection. Active requests: ${activeRequests}`);
// 1. Send no-buffering headers and flush immediately // Create SSE transport, specifying the POST message path
res.writeHead(200, { transport = new SSEServerTransport('/message', res);
'Content-Type': 'text/event-stream', activeSseTransports.set(transport.sessionId, transport);
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
// CRITICAL: Tells proxies (like NGINX) not to buffer the response
'X-Accel-Buffering': 'no',
});
// Send an initial comment (ping) to complete the HTTP handshake and flush headers // Connect server with timeout and circuit breaker
res.write(':\n'); // This calls transport.start() internally, which sets up headers and sends the endpoint event.
res.flushHeaders(); await circuitBreaker.execute(() => mcpServer.connect(transport));
headersSent = true;
// 2. Start the heartbeat/ping interval // 2. Start the heartbeat/ping interval (after transport.start() has set up res.write)
pingInterval = setInterval(() => { pingInterval = setInterval(() => {
// Send a comment line every 20s to keep the proxy alive // Send a comment line every 20s to keep the proxy alive
res.write(':\n'); res.write(':\n');
}, SSE_HEARTBEAT_INTERVAL_MS); }, SSE_HEARTBEAT_INTERVAL_MS);
// Create a new MCP server instance for this connection // Handle connection close (will execute when client closes the connection)
const server = createMCPServer();
// Create SSE transport, specifying the POST message path
const transport = new SSEServerTransport('/message', res);
// Connect server with timeout and circuit breaker
await withTimeout(
circuitBreaker.execute(() => server.connect(transport)),
REQUEST_TIMEOUT_MS
);
// Handle connection close
req.on('close', () => { req.on('close', () => {
activeRequests--; activeRequests--;
if (pingInterval) { if (pingInterval) {
clearInterval(pingInterval); clearInterval(pingInterval);
} }
if (transport) {
activeSseTransports.delete(transport.sessionId);
}
console.error(`SSE connection closed. Active requests: ${activeRequests}`); console.error(`SSE connection closed. Active requests: ${activeRequests}`);
// Ensure the server stream is ended gracefully if it hasn't already
if (!res.writableEnded) {
res.end();
}
}); });
} catch (error) { } catch (error) {
// Handle error during connection establishment or connection timeout
activeRequests--; activeRequests--;
if (pingInterval) { if (pingInterval) {
clearInterval(pingInterval); clearInterval(pingInterval);
} }
if (transport) {
activeSseTransports.delete(transport.sessionId);
}
console.error('SSE connection error:', error.message); console.error('SSE connection error:', error.message);
if (!headersSent) { if (!res.headersSent) {
// Case 1: Error before SSE headers were flushed (e.g., checkMemory failed) // Case 1: Error before SSE headers were flushed (e.g., checkMemory failed)
// We can still set the status code.
res.status(503).json({ res.status(503).json({
error: error.message, error: error.message,
circuitBreaker: circuitBreaker.state, circuitBreaker: circuitBreaker.state,
@@ -781,11 +798,22 @@ app.get('/sse', async (req, res) => {
} }
}); });
// POST endpoint for MCP messages
app.post('/message', async (req, res) => {
const connectionId = req.headers['x-connection-id'];
const transport = activeSseTransports.get(connectionId);
// ⭐ CRITICAL FIX: Removed the custom app.post('/message') handler. if (transport) {
// The SSEServerTransport (created in app.get('/sse')) now automatically handles try {
// the POST requests to '/message' and sends the 200 OK response, fixing the 502 issue. await transport.handlePostMessage(req, res, req.body);
} catch (error) {
console.error('Error processing MCP message via POST:', error);
res.status(400).json({ error: 'Bad Request', message: error.message });
}
} else {
res.status(404).json({ error: 'Not Found', message: 'No active SSE connection for this ID.' });
}
});
// Error handling middleware // Error handling middleware
app.use((err, req, res, next) => { app.use((err, req, res, next) => {

View File

@@ -4,9 +4,10 @@
* Test script for HTTP/SSE MCP Server * Test script for HTTP/SSE MCP Server
* *
* This script tests the deployed MCP server by: * This script tests the deployed MCP server by:
* 1. Connecting to the SSE endpoint * 1. Resetting the Circuit Breaker on the server.
* 2. Sending MCP protocol messages * 2. Connecting to the SSE endpoint.
* 3. Displaying responses * 3. Sending MCP protocol messages sequentially.
* 4. Displaying responses and closing the connection cleanly.
* *
* Usage: node test-http-mcp.js * Usage: node test-http-mcp.js
*/ */
@@ -14,57 +15,85 @@
import EventSource from 'eventsource'; import EventSource from 'eventsource';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
const SERVER_URL = process.env.MCP_SERVER_URL || 'https://hpr-knowledge-base.onrender.com'; const SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000';
const SSE_ENDPOINT = `${SERVER_URL}/sse`; const SSE_ENDPOINT = `${SERVER_URL}/sse`;
const MESSAGE_ENDPOINT = `${SERVER_URL}/message`; const MESSAGE_ENDPOINT = `${SERVER_URL}/message`;
const RESET_ENDPOINT = `${SERVER_URL}/reset`; // New endpoint for circuit breaker reset
let requestId = 1; let requestId = 1;
let sse; // Declare outside for scope
let connectionId = null; // To store the connection ID from the server
console.log('🧪 Testing MCP Server over HTTP/SSE'); console.log('-- Testing MCP Server over HTTP/SSE');
console.log(`📡 Server: ${SERVER_URL}`); console.log(`-- Server: ${SERVER_URL}`);
console.log(`-- Message Endpoint: ${MESSAGE_ENDPOINT}`);
console.log(''); console.log('');
// Test health endpoint first // === 0. Reset Circuit Breaker ===
console.log('1⃣ Testing health endpoint...'); async function resetCircuitBreaker() {
try { console.log('0. Resetting Circuit Breaker...');
const healthResponse = await fetch(`${SERVER_URL}/health`); try {
const health = await healthResponse.json(); const resetResponse = await fetch(RESET_ENDPOINT, { method: 'POST' });
console.log('✅ Health check:', JSON.stringify(health, null, 2)); const result = await resetResponse.json();
console.log(''); console.log(`OK Reset check: ${result.message}`);
} catch (error) { } catch (error) {
console.error('❌ Health check failed:', error.message); console.error('ERROR Circuit breaker reset failed (Server not fully up or endpoint missing):', error.message);
process.exit(1); }
console.log('');
} }
// Connect to SSE endpoint // === 1. Test Health Endpoint ===
console.log('2⃣ Connecting to SSE endpoint...'); async function checkHealth() {
const sse = new EventSource(SSE_ENDPOINT); console.log('1. Testing health endpoint...');
try {
const healthResponse = await fetch(`${SERVER_URL}/health`);
const health = await healthResponse.json();
console.log('OK Health check:', JSON.stringify(health, null, 2));
console.log('');
} catch (error) {
console.error('ERROR Health check failed:', error.message);
process.exit(1);
}
}
sse.onopen = () => { // === 2. Connect to SSE Endpoint ===
console.log('✅ SSE connection established'); function connectSSE() {
console.log(''); return new Promise((resolve, reject) => {
console.log('2. Connecting to SSE endpoint...');
// Use the EventSource polyfill to handle the SSE GET connection
sse = new EventSource(SSE_ENDPOINT);
// Run tests after connection is established sse.onopen = () => {
setTimeout(() => runTests(), 1000); console.log('OK SSE connection established');
}; // Resolve the promise once the connection is open
resolve();
};
sse.onmessage = (event) => { sse.addEventListener('endpoint', (event) => {
try { // The endpoint event data contains the sessionId in the URL
const data = JSON.parse(event.data); const url = new URL(event.data, SERVER_URL);
console.log('📨 Received:', JSON.stringify(data, null, 2)); connectionId = url.searchParams.get('sessionId');
console.log(''); console.log(`OK Received sessionId: ${connectionId}`);
} catch (error) { });
console.log('📨 Received (raw):', event.data);
console.log('');
}
};
sse.onerror = (error) => { sse.onmessage = (event) => {
console.error('❌ SSE error:', error); try {
console.log(''); const data = JSON.parse(event.data);
}; console.log('RECEIVED:', JSON.stringify(data, null, 2));
} catch (error) {
console.log('RECEIVED (raw):', event.data);
}
console.log('');
};
// Send MCP messages sse.onerror = (error) => {
// Log and ignore to let the rest of the test run, as EventSource auto-reconnects
console.error('ERROR SSE error:', error.message || JSON.stringify(error));
};
});
}
// === 3. Send MCP Message (POST) ===
async function sendMessage(method, params = {}) { async function sendMessage(method, params = {}) {
const message = { const message = {
jsonrpc: '2.0', jsonrpc: '2.0',
@@ -73,92 +102,114 @@ async function sendMessage(method, params = {}) {
params params
}; };
console.log('📤 Sending:', method); console.log('SENDING:', method);
console.log(JSON.stringify(message, null, 2)); console.log(JSON.stringify(message, null, 2));
console.log(''); console.log('');
try { try {
const response = await fetch(MESSAGE_ENDPOINT, { const response = await fetch(MESSAGE_ENDPOINT, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
'x-connection-id': connectionId // Include the connection ID
},
body: JSON.stringify(message) body: JSON.stringify(message)
}); });
if (response.ok) { if (response.ok) {
console.log(' Message sent successfully'); console.log('OK Message sent successfully');
} else { } else {
console.error('❌ Message send failed:', response.status, response.statusText); // Log the full error response body if it's not successful
const errorBody = await response.text();
console.error('ERROR Message send failed:', response.status, response.statusText, errorBody);
} }
} catch (error) { } catch (error) {
console.error(' Send error:', error.message); console.error('ERROR Send error:', error.message);
} }
console.log(''); console.log('');
} }
// Run test sequence // === Main Test Sequence ===
async function runTests() { async function runTests() {
console.log('3⃣ Running MCP protocol tests...'); // Ensure the health check runs first
console.log(''); await checkHealth();
// Test 1: Initialize // Ensure the circuit breaker is reset before trying to connect
await sendMessage('initialize', { await resetCircuitBreaker();
protocolVersion: '0.1.0',
clientInfo: {
name: 'test-client',
version: '1.0.0'
},
capabilities: {}
});
await sleep(2000); // Establish a fresh, single connection for the test sequence
await connectSSE();
// Test 2: List tools // Wait for connectionId to be received
await sendMessage('tools/list'); while (connectionId === null) {
await sleep(100);
await sleep(2000);
// Test 3: List resources
await sendMessage('resources/list');
await sleep(2000);
// Test 4: Call a tool (search episodes)
await sendMessage('tools/call', {
name: 'search_episodes',
arguments: {
query: 'linux',
limit: 3
} }
}); await sleep(1000); // Give the server a moment to finalize setup
await sleep(2000); // Log the start of the protocol tests
console.log('3. Running MCP protocol tests...');
console.log('');
// Test 5: Read a resource // Test 1: Initialize
await sendMessage('resources/read', { await sendMessage('initialize', {
uri: 'hpr://stats' protocolVersion: '0.1.0',
}); clientInfo: {
name: 'test-client',
version: '1.0.0'
},
capabilities: {}
});
await sleep(3000); await sleep(1000);
console.log('✅ All tests completed!'); // Test 2: List tools
console.log(''); await sendMessage('tools/list');
console.log('💡 The MCP server is working correctly over HTTP/SSE');
console.log('🔮 Once AI tools add HTTP/SSE support, they can connect to:');
console.log(` ${SSE_ENDPOINT}`);
// Close connection await sleep(1000);
sse.close();
process.exit(0); // Test 3: List resources
await sendMessage('resources/list');
await sleep(1000);
// Test 4: Call a tool (search episodes)
await sendMessage('tools/call', {
name: 'search_episodes',
arguments: {
query: 'linux',
limit: 3
}
});
await sleep(1000);
// Test 5: Read a resource
await sendMessage('resources/read', {
uri: 'hpr://stats'
});
await sleep(2000);
console.log('OK All tests completed!');
console.log('');
console.log('-- The MCP server is working correctly over HTTP/SSE');
console.log('-- Once AI tools add HTTP/SSE support, they can connect to:');
console.log(` ${SSE_ENDPOINT}`);
// Close connection explicitly at the end of the test run to stop auto-reconnects
sse.close();
process.exit(0);
} }
function sleep(ms) { function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms)); return new Promise(resolve => setTimeout(resolve, ms));
} }
// Start the test sequence
runTests();
// Handle Ctrl+C // Handle Ctrl+C
process.on('SIGINT', () => { process.on('SIGINT', () => {
console.log('\n👋 Closing connection...'); console.log('\n-- Closing connection...');
sse.close(); if (sse) sse.close();
process.exit(0); process.exit(0);
}); });