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
JavaScript
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');
}