switchboard-mcp-server
Version:
Model Context Protocol (MCP) server for Switchboard API - enables AI assistants to access broadcast messaging, email campaigns, and contact management for progressive campaigns
217 lines • 8.8 kB
JavaScript
/**
* StreamableHTTP server setup for HTTP-based MCP communication using Hono
*/
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { serve } from '@hono/node-server';
import { v4 as uuid } from 'uuid';
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { InitializeRequestSchema } from "@modelcontextprotocol/sdk/types.js";
import { toReqRes, toFetchResponse } from 'fetch-to-node';
// Import server configuration constants
import { SERVER_NAME, SERVER_VERSION } from './index.js';
// Constants
const SESSION_ID_HEADER_NAME = "mcp-session-id";
const JSON_RPC = "2.0";
/**
* StreamableHTTP MCP Server handler
*/
class MCPStreamableHttpServer {
server;
// Store active transports by session ID
transports = {};
constructor(server) {
this.server = server;
}
/**
* Handle GET requests (typically used for static files)
*/
async handleGetRequest(c) {
console.error("GET request received - StreamableHTTP transport only supports POST");
return c.text('Method Not Allowed', 405, {
'Allow': 'POST'
});
}
/**
* Handle POST requests (all MCP communication)
*/
async handlePostRequest(c) {
const sessionId = c.req.header(SESSION_ID_HEADER_NAME);
console.error(`POST request received ${sessionId ? 'with session ID: ' + sessionId : 'without session ID'}`);
try {
const body = await c.req.json();
// Convert Fetch Request to Node.js req/res
const { req, res } = toReqRes(c.req.raw);
// Reuse existing transport if we have a session ID
if (sessionId && this.transports[sessionId]) {
const transport = this.transports[sessionId];
// Handle the request with the transport
await transport.handleRequest(req, res, body);
// Cleanup when the response ends
res.on('close', () => {
console.error(`Request closed for session ${sessionId}`);
});
// Convert Node.js response back to Fetch Response
return toFetchResponse(res);
}
// Create new transport for initialize requests
if (!sessionId && this.isInitializeRequest(body)) {
console.error("Creating new StreamableHTTP transport for initialize request");
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: () => uuid(),
});
// Add error handler for debug purposes
transport.onerror = (err) => {
console.error('StreamableHTTP transport error:', err);
};
// Connect the transport to the MCP server
await this.server.connect(transport);
// Handle the request with the transport
await transport.handleRequest(req, res, body);
// Store the transport if we have a session ID
const newSessionId = transport.sessionId;
if (newSessionId) {
console.error(`New session established: ${newSessionId}`);
this.transports[newSessionId] = transport;
// Set up clean-up for when the transport is closed
transport.onclose = () => {
console.error(`Session closed: ${newSessionId}`);
delete this.transports[newSessionId];
};
}
// Cleanup when the response ends
res.on('close', () => {
console.error(`Request closed for new session`);
});
// Convert Node.js response back to Fetch Response
return toFetchResponse(res);
}
// Invalid request (no session ID and not initialize)
return c.json(this.createErrorResponse("Bad Request: invalid session ID or method."), 400);
}
catch (error) {
console.error('Error handling MCP request:', error);
return c.json(this.createErrorResponse("Internal server error."), 500);
}
}
/**
* Create a JSON-RPC error response
*/
createErrorResponse(message) {
return {
jsonrpc: JSON_RPC,
error: {
code: -32000,
message: message,
},
id: uuid(),
};
}
/**
* Check if the request is an initialize request
*/
isInitializeRequest(body) {
const isInitial = (data) => {
const result = InitializeRequestSchema.safeParse(data);
return result.success;
};
if (Array.isArray(body)) {
return body.some(request => isInitial(request));
}
return isInitial(body);
}
}
/**
* Sets up a web server for the MCP server using StreamableHTTP transport
*
* @param server The MCP Server instance
* @param port The port to listen on (default: 3000)
* @returns The Hono app instance
*/
export async function setupStreamableHttpServer(server, port = 3000) {
// Create Hono app
const app = new Hono();
// Enable CORS
app.use('*', cors());
// Create MCP handler
const mcpHandler = new MCPStreamableHttpServer(server);
// Add a simple health check endpoint
app.get('/health', (c) => {
return c.json({ status: 'OK', server: SERVER_NAME, version: SERVER_VERSION });
});
// Main MCP endpoint supporting both GET and POST
app.get("/mcp", (c) => mcpHandler.handleGetRequest(c));
app.post("/mcp", (c) => mcpHandler.handlePostRequest(c));
// Static files for the web client (if any)
app.get('/*', async (c) => {
const filePath = c.req.path === '/' ? '/index.html' : c.req.path;
try {
// Use Node.js fs to serve static files
const fs = await import('fs');
const path = await import('path');
const { fileURLToPath } = await import('url');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const publicPath = path.join(__dirname, '..', '..', 'public');
const fullPath = path.join(publicPath, filePath);
// Simple security check to prevent directory traversal
if (!fullPath.startsWith(publicPath)) {
return c.text('Forbidden', 403);
}
try {
const stat = fs.statSync(fullPath);
if (stat.isFile()) {
const content = fs.readFileSync(fullPath);
// Set content type based on file extension
const ext = path.extname(fullPath).toLowerCase();
let contentType = 'text/plain';
switch (ext) {
case '.html':
contentType = 'text/html';
break;
case '.css':
contentType = 'text/css';
break;
case '.js':
contentType = 'text/javascript';
break;
case '.json':
contentType = 'application/json';
break;
case '.png':
contentType = 'image/png';
break;
case '.jpg':
contentType = 'image/jpeg';
break;
case '.svg':
contentType = 'image/svg+xml';
break;
}
return new Response(content, {
headers: { 'Content-Type': contentType }
});
}
}
catch (err) {
// File not found or other error
return c.text('Not Found', 404);
}
}
catch (err) {
console.error('Error serving static file:', err);
return c.text('Internal Server Error', 500);
}
return c.text('Not Found', 404);
});
// Start the server
serve({
fetch: app.fetch,
port
}, (info) => {
console.error(`MCP StreamableHTTP Server running at http://localhost:${info.port}`);
console.error(`- MCP Endpoint: http://localhost:${info.port}/mcp`);
console.error(`- Health Check: http://localhost:${info.port}/health`);
});
return app;
}
//# sourceMappingURL=streamable-http.js.map