@toolprint/hypertool-mcp
Version:
HyperTool MCP proxy server for routing requests between clients and multiple underlying MCP servers
282 lines • 10.3 kB
JavaScript
/**
* Express.js HTTP server for streamable MCP transport
*/
import express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { randomUUID } from "node:crypto";
import { isInitializeRequest, } from "@modelcontextprotocol/sdk/types.js";
import { createChildLogger } from "../utils/logging.js";
import { output } from "../utils/output.js";
// import chalk from "chalk";
import { APP_TECHNICAL_NAME } from "../config/appConfig.js";
const logger = createChildLogger({ module: "server/http-server" });
/**
* Express.js-based HTTP server for MCP protocol with streamable transport
* Implements /mcp endpoint for bidirectional communication
*/
export class McpHttpServer {
app;
httpServer = null;
mcpServer;
port;
host;
transports = {};
connectionString;
sessionTimers = {};
constructor(mcpServer, port = 3000, host = "localhost") {
this.app = express();
this.mcpServer = mcpServer;
this.port = port;
this.host = host;
this.setupMiddleware();
this.setupRoutes();
this.connectionString = `http://${this.host}:${this.port}/mcp`;
}
/**
* Setup Express middleware
*/
setupMiddleware() {
// Enable JSON parsing
this.app.use(express.json());
// Keep connections alive
this.app.use((req, res, next) => {
res.header("Connection", "keep-alive");
res.header("Keep-Alive", "timeout=300, max=1000");
next();
});
// Enable CORS for development with MCP session support
this.app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization, Mcp-Session-Id, mcp-protocol-version");
res.header("Access-Control-Expose-Headers", "Mcp-Session-Id");
if (req.method === "OPTIONS") {
res.sendStatus(200);
return;
}
next();
});
}
/**
* Setup Express routes
*/
setupRoutes() {
// Health check endpoint
this.app.get("/health", (_req, res) => {
res.json({
status: "healthy",
transport: "http",
timestamp: new Date().toISOString(),
});
});
// MCP endpoint for streamable HTTP transport - handle all methods
this.app.all("/mcp", async (req, res) => {
try {
await this.handleMcpRequest(req, res);
}
catch (error) {
logger.error("Error handling MCP request:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: {
code: -32603,
message: "Internal server error",
},
id: null,
});
}
}
});
// Fallback for unsupported routes
this.app.use((req, res) => {
res.status(404).json({
error: "Not found",
message: `Route ${req.method} ${req.path} not found`,
});
});
}
/**
* Handle MCP protocol requests over HTTP using Streamable HTTP Transport
*/
async handleMcpRequest(req, res) {
logger.info(`Received ${req.method} request to /mcp`);
// Check for existing session ID
const sessionId = req.headers["mcp-session-id"];
let transport;
if (sessionId && this.transports[sessionId]) {
// Reuse existing transport and reset session timer
transport = this.transports[sessionId];
this.resetSessionTimer(sessionId);
}
else if (!sessionId &&
req.method === "POST" &&
isInitializeRequest(req.body)) {
// Create new transport for initialization request
transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sessionId) => {
logger.info(`Streamable HTTP session initialized with ID: ${sessionId}`);
this.transports[sessionId] = transport;
// Set up session keep-alive (reset timer on each request)
this.resetSessionTimer(sessionId);
},
});
// Set up cleanup when transport closes
transport.onclose = () => {
const sid = transport.sessionId;
if (sid && this.transports[sid]) {
logger.warn(`Transport closed for session ${sid}, removing from transports map`);
delete this.transports[sid];
this.clearSessionTimer(sid);
}
};
// Add error handling for transport
transport.onerror = (error) => {
const sid = transport.sessionId;
logger.error(`Transport error for session ${sid}:`, error);
};
// Connect the transport to the MCP server
await this.mcpServer.connect(transport);
}
else {
// Invalid request - no session ID or not initialization request
res.status(400).json({
jsonrpc: "2.0",
error: {
code: -32000,
message: "Bad Request: No valid session ID provided or not an initialization request",
},
id: null,
});
return;
}
// Handle the request with the transport
await transport.handleRequest(req, res, req.body);
}
/**
* Start the HTTP server
*/
async start() {
return new Promise((resolve, reject) => {
try {
this.httpServer = this.app.listen(this.port, this.host, () => {
output.displaySeparator();
output.displaySubHeader(`Connect to ${APP_TECHNICAL_NAME} Instructions\n`);
output.displayHelpContext(`Server available on [http]`);
output.displayInstruction(`http://${this.host}:${this.port}/mcp`, true);
output.displaySpaceBuffer();
output.displayHelpContext(`Connect using MCP Inspector`);
output.displayInstruction(`npx @modelcontextprotocol/inspector`, true);
output.displaySpaceBuffer();
output.displayHelpContext(`Add this to your MCP config:`);
output.displayInstruction(JSON.stringify({
hypertool: {
type: "streamable-http",
url: this.connectionString,
},
}, null, 2), true);
resolve();
});
this.httpServer.on("error", (error) => {
reject(error);
});
}
catch (error) {
reject(error);
}
});
}
/**
* Stop the HTTP server
*/
/**
* Reset session timeout timer
*/
resetSessionTimer(sessionId) {
// Clear existing timer
this.clearSessionTimer(sessionId);
// Set new timer (30 minutes session timeout)
this.sessionTimers[sessionId] = setTimeout(() => {
logger.info(`Session ${sessionId} timed out, cleaning up`);
this.cleanupSession(sessionId);
}, 30 * 60 * 1000); // 30 minutes
}
/**
* Clear session timeout timer
*/
clearSessionTimer(sessionId) {
if (this.sessionTimers[sessionId]) {
clearTimeout(this.sessionTimers[sessionId]);
delete this.sessionTimers[sessionId];
}
}
/**
* Clean up session and transport
*/
async cleanupSession(sessionId) {
if (this.transports[sessionId]) {
try {
logger.info(`Cleaning up session ${sessionId}`);
await this.transports[sessionId].close();
delete this.transports[sessionId];
}
catch (error) {
logger.error(`Error cleaning up session ${sessionId}:`, error);
}
}
this.clearSessionTimer(sessionId);
}
async stop() {
// Clear all session timers
for (const sessionId in this.sessionTimers) {
this.clearSessionTimer(sessionId);
}
// Close all active transports
for (const sessionId in this.transports) {
try {
logger.info(`Closing transport for session ${sessionId}`);
await this.transports[sessionId].close();
delete this.transports[sessionId];
}
catch (error) {
logger.error(`Error closing transport for session ${sessionId}:`, error);
}
}
if (this.httpServer) {
return new Promise((resolve) => {
this.httpServer.close(() => {
this.httpServer = null;
resolve();
});
});
}
}
/**
* Get server instance for testing or advanced configuration
*/
getApp() {
return this.app;
}
/**
* Get the HTTP server instance
*/
getHttpServer() {
return this.httpServer;
}
/**
* Check if server is running
*/
isRunning() {
return this.httpServer !== null && this.httpServer.listening;
}
/**
* Broadcast notification to all connected clients
*/
async broadcastNotification(notification) {
const sessionIds = Object.keys(this.transports);
logger.info(`Broadcasting notification to ${sessionIds.length} connected sessions`);
await this.mcpServer.notification(notification);
}
}
//# sourceMappingURL=http-server.js.map