UNPKG

ble-mcp-test

Version:

Complete BLE testing stack: WebSocket bridge server, MCP observability layer, and Web Bluetooth API mock. Test real BLE devices in Playwright/E2E tests without browser support.

246 lines (245 loc) 9.87 kB
import express from 'express'; import cors from 'cors'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { randomUUID } from 'crypto'; import { Logger } from './logger.js'; import { getPackageMetadata } from './utils.js'; import { toolRegistry } from './mcp-tools.js'; // Transport storage for sessions const transports = {}; const logger = new Logger('MCP HTTP'); export function createHttpApp(server, token) { const app = express(); // Middleware app.use(express.json()); // Permissive CORS for local network usage app.use(cors({ origin: '*', // Allow all origins on local network credentials: true, methods: ['GET', 'POST', 'OPTIONS', 'DELETE'], allowedHeaders: ['Content-Type', 'Authorization', 'Mcp-Session-Id'], exposedHeaders: ['Mcp-Session-Id'] })); // Optional authentication middleware const authenticate = (req, res, next) => { // If no token configured, allow all requests if (!token) { return next(); } const authHeader = req.headers.authorization; if (!authHeader || authHeader !== `Bearer ${token}`) { logger.warn(`Unauthorized access attempt to ${req.method} ${req.path}`); return res.status(401).json({ error: 'Unauthorized' }); } next(); }; // MCP INFO endpoint - public discovery app.get('/mcp/info', (req, res) => { logger.debug('GET /mcp/info accessed'); try { // Validate server is initialized if (!server || !toolRegistry) { return res.status(500).json({ error: 'Internal server error', message: 'MCP server not initialized' }); } const metadata = getPackageMetadata(); // Set headers res.set('Content-Type', 'application/json'); res.set('Cache-Control', 'public, max-age=3600'); // Cache for 1 hour res.json({ name: metadata.name, version: metadata.version, description: metadata.description, tools: toolRegistry }); } catch (error) { logger.error('Error in /mcp/info:', error); res.status(500).json({ error: 'Internal server error', message: error.message }); } }); // MCP REGISTER endpoint - requires authentication app.post('/mcp/register', authenticate, async (req, res) => { logger.info('POST /mcp/register - Client registration attempt'); try { // Validate server is initialized if (!server) { return res.status(500).json({ error: 'Internal server error', message: 'MCP server not initialized' }); } const metadata = getPackageMetadata(); // Set headers // Create a new session for this registration const sessionId = randomUUID(); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId, onsessioninitialized: (id) => { transports[id] = transport; logger.info(`New session initialized: ${id}`); }, enableJsonResponse: true }); // Connect the transport to the server await server.connect(transport); transports[sessionId] = transport; res.set('Content-Type', 'application/json'); res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); res.set('Mcp-Session-Id', sessionId); logger.info(`New session initialized: ${sessionId}`); res.json({ name: metadata.name, version: metadata.version, capabilities: { tools: true, resources: false, prompts: false } }); } catch (error) { logger.error('Error in /mcp/register:', error); res.status(500).json({ error: 'Internal server error', message: error.message }); } }); // MCP POST endpoint - main message handling app.post('/mcp', authenticate, async (req, res) => { // Debug: Log all headers from Claude client logger.info('Claude MCP request headers:', JSON.stringify(req.headers, null, 2)); // Fix Accept header for Claude client compatibility if (!req.headers.accept || !req.headers.accept.includes('text/event-stream')) { logger.debug('Adding required Accept header for MCP transport'); req.headers.accept = 'application/json, text/event-stream'; } try { const sessionId = req.headers['mcp-session-id'] || randomUUID(); let transport = transports[sessionId]; if (!transport) { logger.debug(`Creating new transport for session: ${sessionId}`); transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId, onsessioninitialized: (id) => { transports[id] = transport; logger.debug(`New session initialized: ${id}`); }, enableJsonResponse: true // Allow JSON responses for simple testing }); // Store transport BEFORE connecting to prevent race conditions transports[sessionId] = transport; try { await server.connect(transport); logger.debug(`Transport connected for session: ${sessionId}`); // Give the transport a moment to fully initialize // This is a workaround for the MCP SDK's internal state management await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { // Clean up on connection failure delete transports[sessionId]; throw error; } } // handleRequest will handle the response internally await transport.handleRequest(req, res, req.body); } catch (error) { logger.error('Error handling request:', error); res.status(500).json({ error: 'Internal server error', message: error.message }); } }); // MCP GET endpoint - SSE streaming support app.get('/mcp', authenticate, async (req, res) => { try { const sessionId = req.headers['mcp-session-id']; const transport = transports[sessionId]; if (!transport) { return res.status(404).json({ error: 'Session not found' }); } // For GET requests, handleRequest will set up SSE streaming await transport.handleRequest(req, res); } catch (error) { logger.error('Error handling SSE request:', error); res.status(500).json({ error: 'Internal server error', message: error.message }); } }); // MCP DELETE endpoint - session termination app.delete('/mcp', authenticate, (req, res) => { const sessionId = req.headers['mcp-session-id']; const transport = transports[sessionId]; if (transport) { transport.close(); delete transports[sessionId]; logger.debug(`Session terminated: ${sessionId}`); } res.status(204).send(); }); // Health check endpoint app.get('/health', (req, res) => { const hasTty = process.stdin.isTTY && process.stdout.isTTY; const stdioDisabled = process.env.BLE_MCP_STDIO_DISABLED === 'true'; const stdioEnabled = hasTty && !stdioDisabled; res.json({ status: 'ok', mcp: { transports: { stdio: stdioEnabled, http: true, // Always true if this endpoint is accessible httpPort: parseInt(process.env.BLE_MCP_HTTP_PORT || '8081'), httpAuth: !!token }, sessions: Object.keys(transports).length }, timestamp: new Date().toISOString() }); }); return app; } // Helper to start HTTP server export function startHttpServer(app, port) { const actualPort = port || parseInt(process.env.BLE_MCP_HTTP_PORT || '8081', 10); const server = app.listen(actualPort, '0.0.0.0', () => { logger.info(`Server listening on 0.0.0.0:${actualPort}`); if (process.env.BLE_MCP_HTTP_TOKEN) { logger.info('Authentication enabled (Bearer token required)'); } else { logger.warn('⚠️ Running without authentication - local network only!'); } }); // Handle server errors server.on('error', (error) => { if (error.code === 'EADDRINUSE') { logger.error(`Port ${actualPort} is already in use. Is another instance running?`); } else { logger.error('HTTP server error:', error); } process.exit(1); }); } // Cleanup function for graceful shutdown export function cleanupHttpTransports() { Object.keys(transports).forEach(sessionId => { const transport = transports[sessionId]; if (transport) { transport.close(); } }); logger.info('All sessions closed'); }