UNPKG

openapi-mcp-generator

Version:

Generates MCP server code from OpenAPI specifications

672 lines (585 loc) 20.6 kB
/** * Generator for StreamableHTTP server code for the MCP server using Hono */ /** * Generates StreamableHTTP server code for the MCP server * * @param port Server port (default: 3000) * @returns Generated code for the StreamableHTTP server */ export function generateStreamableHttpCode(port = 3000) { return ` /** * 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 { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { InitializeRequestSchema, JSONRPCError } 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: Server; // Store active transports by session ID transports: {[sessionId: string]: StreamableHTTPServerTransport} = {}; constructor(server: Server) { this.server = server; } /** * Handle GET requests (typically used for static files) */ async handleGetRequest(c: any) { 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: any) { 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 */ private createErrorResponse(message: string): JSONRPCError { return { jsonrpc: JSON_RPC, error: { code: -32000, message: message, }, id: uuid(), }; } /** * Check if the request is an initialize request */ private isInitializeRequest(body: any): boolean { const isInitial = (data: any) => { 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: ${port}) * @returns The Hono app instance */ export async function setupStreamableHttpServer(server: Server, port = ${port}) { // 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; } `; } /** * Generates HTML client page for testing the MCP StreamableHTTP server * * @param serverName The name of the MCP server * @returns HTML content for the test client */ export function generateStreamableHttpClientHtml(serverName) { // HTML client remains the same return `<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>${serverName} MCP StreamableHTTP Test Client</title> <style> body { font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.5; } h1 { margin-bottom: 10px; } .container { display: flex; flex-direction: column; height: calc(100vh - 150px); } #conversation { flex: 1; border: 1px solid #ccc; overflow-y: auto; margin-bottom: 10px; padding: 10px; border-radius: 5px; } .input-area { display: flex; margin-bottom: 20px; } #userInput { flex: 1; padding: 8px; font-size: 16px; border: 1px solid #ccc; border-radius: 5px 0 0 5px; } #sendButton { padding: 8px 16px; background-color: #4CAF50; color: white; border: none; cursor: pointer; border-radius: 0 5px 5px 0; } #sendButton:hover { background-color: #45a049; } .message { margin-bottom: 10px; padding: 8px 12px; border-radius: 5px; } .user { background-color: #e7f4ff; align-self: flex-end; } .server { background-color: #f1f1f1; } .system { background-color: #fffde7; color: #795548; font-style: italic; } pre { white-space: pre-wrap; word-wrap: break-word; } code { background-color: #f8f8f8; padding: 2px 4px; border-radius: 3px; } .status { color: #666; font-style: italic; margin-bottom: 10px; } #debug { margin-top: 20px; background-color: #f8f8f8; padding: 10px; border-radius: 5px; display: none; } .debug-controls { display: flex; justify-content: space-between; align-items: center; } #showDebug { margin-top: 10px; padding: 5px 10px; cursor: pointer; background-color: #f1f1f1; border: 1px solid #ccc; border-radius: 3px; } #debugLog { max-height: 200px; overflow-y: auto; background-color: #111; color: #0f0; font-family: monospace; padding: 5px; margin-top: 10px; } .clear-debug { padding: 3px 8px; background-color: #f44336; color: white; border: none; border-radius: 3px; cursor: pointer; } </style> </head> <body> <h1>${serverName} MCP StreamableHTTP Test Client</h1> <p class="status" id="status">Disconnected</p> <div class="container"> <div id="conversation"></div> <div class="input-area"> <input type="text" id="userInput" placeholder="Type a message..." disabled> <button id="sendButton" disabled>Send</button> </div> </div> <button id="showDebug">Show Debug Console</button> <div id="debug"> <div class="debug-controls"> <h3>Debug Console</h3> <button class="clear-debug" id="clearDebug">Clear</button> </div> <div id="debugLog"></div> </div> <script> const conversation = document.getElementById('conversation'); const userInput = document.getElementById('userInput'); const sendButton = document.getElementById('sendButton'); const statusEl = document.getElementById('status'); const showDebugBtn = document.getElementById('showDebug'); const debugDiv = document.getElementById('debug'); const debugLog = document.getElementById('debugLog'); const clearDebugBtn = document.getElementById('clearDebug'); let sessionId = null; let messageId = 1; // Debug logging function log(type, message) { const timestamp = new Date().toISOString().split('T')[1].slice(0, -1); const entry = document.createElement('div'); entry.innerHTML = \`<span style="color:#aaa;">\${timestamp}</span> <span style="color:#58a6ff;">\${type}:</span> \${message}\`; debugLog.appendChild(entry); debugLog.scrollTop = debugLog.scrollHeight; console.log(\`\${type}: \${message}\`); } // Toggle debug console showDebugBtn.addEventListener('click', () => { if (debugDiv.style.display === 'block') { debugDiv.style.display = 'none'; showDebugBtn.textContent = 'Show Debug Console'; } else { debugDiv.style.display = 'block'; showDebugBtn.textContent = 'Hide Debug Console'; } }); // Clear debug logs clearDebugBtn.addEventListener('click', () => { debugLog.innerHTML = ''; }); // Initialize the MCP connection async function initialize() { statusEl.textContent = 'Connecting...'; log('INFO', 'Initializing MCP connection...'); try { const requestBody = { jsonrpc: '2.0', id: messageId++, method: 'initialize', params: { clientName: 'MCP StreamableHTTP Test Client', clientVersion: '1.0.0', capabilities: {} } }; log('REQUEST', JSON.stringify(requestBody)); const response = await fetch('/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); if (!response.ok) { const errorText = await response.text(); log('ERROR', \`Error response: \${response.status} \${response.statusText} \${errorText}\`); appendMessage('system', \`Error: \${response.status} \${response.statusText}\\n\${errorText}\`); statusEl.textContent = 'Connection error. Try again.'; return; } // Get session ID from response headers sessionId = response.headers.get('mcp-session-id'); if (!sessionId) { log('ERROR', 'No session ID in response headers'); appendMessage('system', 'Error: No session ID in response headers'); statusEl.textContent = 'Connection error. Try again.'; return; } // Process response body const data = await response.json(); log('RESPONSE', JSON.stringify(data)); if (data.result) { appendMessage('server', JSON.stringify(data.result, null, 2)); } // Enable UI statusEl.textContent = \`Connected (Session ID: \${sessionId})\`; userInput.disabled = false; sendButton.disabled = false; userInput.focus(); appendMessage('system', \`Connected with session ID: \${sessionId}\`); // Get list of tools await listTools(); } catch (error) { log('ERROR', \`Error during initialization: \${error.message}\`); appendMessage('system', \`Error during initialization: \${error.message}\`); statusEl.textContent = 'Connection error. Try again.'; } } // Get list of available tools async function listTools() { try { const requestBody = { jsonrpc: '2.0', id: messageId++, method: 'listTools', params: {} }; log('REQUEST', JSON.stringify(requestBody)); const response = await fetch('/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json', 'mcp-session-id': sessionId }, body: JSON.stringify(requestBody) }); if (!response.ok) { const errorText = await response.text(); log('ERROR', \`Error listing tools: \${response.status} \${response.statusText} \${errorText}\`); return; } const data = await response.json(); log('TOOLS', JSON.stringify(data)); if (data.result?.tools && Array.isArray(data.result.tools)) { appendMessage('system', \`Available tools: \${data.result.tools.map(t => t.name).join(', ')}\`); } } catch (error) { log('ERROR', \`Error listing tools: \${error.message}\`); } } // Send a message to the server async function sendMessage() { const text = userInput.value.trim(); if (!text || !sessionId) return; appendMessage('user', text); userInput.value = ''; log('INFO', \`Sending message: \${text}\`); try { const parts = text.split(' '); const toolName = parts[0]; const requestBody = { jsonrpc: '2.0', id: messageId++, method: 'callTool', params: { name: toolName, arguments: parseArguments(text) } }; log('REQUEST', JSON.stringify(requestBody)); const response = await fetch('/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json', 'mcp-session-id': sessionId }, body: JSON.stringify(requestBody) }); if (!response.ok) { const errorText = await response.text(); log('ERROR', \`Error response: \${response.status} \${response.statusText} \${errorText}\`); appendMessage('system', \`Error: \${response.status} \${response.statusText}\\n\${errorText}\`); return; } const data = await response.json(); log('RESPONSE', JSON.stringify(data)); if (data.error) { appendMessage('system', \`Error: \${data.error.code} - \${data.error.message}\`); } else if (data.result) { appendMessage('server', JSON.stringify(data.result, null, 2)); } } catch (error) { log('ERROR', \`Error sending message: \${error.message}\`); appendMessage('system', \`Error sending message: \${error.message}\`); } } // Try to parse arguments from user input // Format: toolName param1=value1 param2=value2 function parseArguments(text) { const parts = text.split(' '); if (parts.length <= 1) return {}; const args = {}; // Skip the first part (tool name) and process the rest for (let i = 1; i < parts.length; i++) { const part = parts[i]; const equalsIndex = part.indexOf('='); if (equalsIndex > 0) { const key = part.substring(0, equalsIndex); const value = part.substring(equalsIndex + 1); // Try to parse as number or boolean if possible if (value === 'true') args[key] = true; else if (value === 'false') args[key] = false; else if (!isNaN(Number(value))) args[key] = Number(value); else args[key] = value; } } return args; } // Add a message to the conversation function appendMessage(sender, text) { const messageDiv = document.createElement('div'); messageDiv.className = \`message \${sender}\`; // Format as code block if it looks like JSON if (text.trim().startsWith('{') || text.trim().startsWith('[')) { const pre = document.createElement('pre'); const code = document.createElement('code'); code.textContent = text; pre.appendChild(code); messageDiv.appendChild(pre); } else { messageDiv.textContent = text; } conversation.appendChild(messageDiv); conversation.scrollTop = conversation.scrollHeight; } // Event listeners sendButton.addEventListener('click', sendMessage); userInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') sendMessage(); }); // Initialize on page load appendMessage('system', 'Initializing MCP connection...'); initialize(); </script> </body> </html>`; } //# sourceMappingURL=streamable-http.js.map