Files
mcp-sse-wrapper/sse-wrapper.js
Emanuel Almeida 59d699b557 Initial commit: SSE wrapper for MCP stdio servers
- sse-wrapper.js: converte qualquer MCP stdio em SSE server
- .gitignore: Node.js standard
- README.txt: documentacao basica
2026-03-04 19:59:19 +00:00

224 lines
6.0 KiB
JavaScript

#!/usr/bin/env node
/**
* Generic SSE Wrapper for MCP Servers
* Converts any stdio-based MCP to SSE server
*
* Usage: node sse-wrapper.js --port 3101 --name "my-mcp" -- node /path/to/mcp.js
*
* @author Descomplicar | @link descomplicar.pt | @copyright 2026
*/
const http = require('http');
const { spawn } = require('child_process');
const { URL } = require('url');
const { randomUUID } = require('crypto');
// Parse arguments
const args = process.argv.slice(2);
let port = 3101;
let name = 'mcp-server';
let mcpCommand = [];
let mcpEnv = { ...process.env };
// Parse our arguments
let i = 0;
while (i < args.length) {
if (args[i] === '--port' && args[i + 1]) {
port = parseInt(args[i + 1]);
i += 2;
} else if (args[i] === '--name' && args[i + 1]) {
name = args[i + 1];
i += 2;
} else if (args[i] === '--env' && args[i + 1]) {
const [key, value] = args[i + 1].split('=');
mcpEnv[key] = value;
i += 2;
} else if (args[i] === '--') {
mcpCommand = args.slice(i + 1);
break;
} else {
i++;
}
}
if (mcpCommand.length === 0) {
console.error('Usage: node sse-wrapper.js --port 3101 --name "my-mcp" -- <mcp-command> [args...]');
process.exit(1);
}
const HOST = '127.0.0.1';
// Track active sessions
const sessions = new Map();
// Create a new MCP process for a session
function createMCPProcess(sessionId) {
const [cmd, ...cmdArgs] = mcpCommand;
const mcpProcess = spawn(cmd, cmdArgs, {
stdio: ['pipe', 'pipe', 'pipe'],
env: mcpEnv,
cwd: mcpEnv.MCP_WORKING_DIR || process.cwd()
});
mcpProcess.stderr.on('data', (data) => {
// Log stderr but don't send to client (it's usually debug info)
if (process.env.DEBUG) {
console.error(`[${name}:${sessionId.slice(0, 8)}] stderr:`, data.toString());
}
});
return mcpProcess;
}
// HTTP Server
const server = http.createServer((req, res) => {
// CORS
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
const url = new URL(req.url || '/', `http://${HOST}:${port}`);
// Health check
if (url.pathname === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'ok',
name: name,
sessions: sessions.size,
port: port
}));
return;
}
// SSE endpoint - new session
if (url.pathname === '/sse' && req.method === 'GET') {
const sessionId = randomUUID();
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform',
'Connection': 'keep-alive'
});
// Create MCP process for this session
const mcpProcess = createMCPProcess(sessionId);
// Store session
sessions.set(sessionId, { res, mcpProcess, buffer: '' });
// Send endpoint event
res.write(`event: endpoint\ndata: /message?sessionId=${sessionId}\n\n`);
// Handle MCP stdout -> SSE
mcpProcess.stdout.on('data', (data) => {
const session = sessions.get(sessionId);
if (!session) return;
session.buffer += data.toString();
// Process complete JSON messages
const lines = session.buffer.split('\n');
session.buffer = lines.pop() || ''; // Keep incomplete line in buffer
for (const line of lines) {
if (line.trim()) {
try {
JSON.parse(line); // Validate JSON
res.write(`event: message\ndata: ${line}\n\n`);
} catch (e) {
// Not valid JSON, skip
}
}
}
});
// Handle MCP process exit
mcpProcess.on('close', (code) => {
console.log(`[${name}:${sessionId.slice(0, 8)}] MCP process exited with code ${code}`);
sessions.delete(sessionId);
res.end();
});
// Handle client disconnect
req.on('close', () => {
console.log(`[${name}] Session closed: ${sessionId.slice(0, 8)}`);
const session = sessions.get(sessionId);
if (session) {
session.mcpProcess.kill();
sessions.delete(sessionId);
}
});
return;
}
// Message endpoint - POST messages to MCP
if (url.pathname === '/message' && req.method === 'POST') {
const sessionId = url.searchParams.get('sessionId');
if (!sessionId) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing sessionId' }));
return;
}
const session = sessions.get(sessionId);
if (!session) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Session not found' }));
return;
}
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
// Send to MCP stdin
session.mcpProcess.stdin.write(body + '\n');
res.writeHead(202, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'accepted' }));
} catch (error) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: error.message }));
}
});
return;
}
// 404
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
});
server.listen(port, HOST, () => {
console.log(`[${name}] SSE wrapper running at http://${HOST}:${port}/sse`);
console.log(`[${name}] Wrapping command: ${mcpCommand.join(' ')}`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log(`[${name}] SIGTERM received, shutting down...`);
// Kill all MCP processes
for (const [sessionId, session] of sessions) {
session.mcpProcess.kill();
}
server.close(() => process.exit(0));
});
process.on('SIGINT', () => {
console.log(`[${name}] SIGINT received, shutting down...`);
for (const [sessionId, session] of sessions) {
session.mcpProcess.kill();
}
server.close(() => process.exit(0));
});