/**
* Browser Bridge
*
* HTTP server that provides communication bridge between the MCP server
* and the web UI running in the browser.
*/
import * as http from 'http';
import { exec } from 'child_process';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'fs';
import * as net from 'net';
/**
* Set of active Server-Sent Events connections
*/
const sseConnections = new Set<http.ServerResponse>();
/**
* Store the actual port being used by the server
*/
let actualPort: number = 0;
/**
* Find an available port starting from the given port
*/
function findAvailablePort(startPort: number): Promise<number> {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.listen(startPort, () => {
const port = (server.address() as net.AddressInfo).port;
server.close(() => resolve(port));
});
server.on('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
// Port is in use, try next one
findAvailablePort(startPort + 1).then(resolve).catch(reject);
} else {
reject(err);
}
});
});
}
/**
* Check if any UI connections are active
*/
export function hasActiveUIConnections(): boolean {
return sseConnections.size > 0;
}
/**
* Attempt to open the browser with the UI URL
*/
function openBrowser(url: string): Promise<void> {
return new Promise((resolve, reject) => {
const platform = os.platform();
let command: string;
switch (platform) {
case 'darwin': // macOS
command = `open "${url}"`;
break;
case 'win32': // Windows
command = `start "" "${url}"`;
break;
case 'linux': // Linux
command = `xdg-open "${url}"`;
break;
default:
reject(new Error(`Unsupported platform: ${platform}`));
return;
}
if (process.env.ASK_ME_MCP_DEBUG) {
console.error(`[browser-bridge] Attempting to open browser with: ${command}`);
}
exec(command, (error) => {
if (error) {
if (process.env.ASK_ME_MCP_DEBUG) {
console.error(`[browser-bridge] Failed to open browser: ${error.message}`);
}
reject(error);
} else {
if (process.env.ASK_ME_MCP_DEBUG) {
console.error(`[browser-bridge] Successfully opened browser`);
}
resolve();
}
});
});
}
/**
* Ensure UI is available for user interaction
* Attempts to open browser if no UI connections are active
*/
export async function ensureUIAvailable(): Promise<string | null> {
if (hasActiveUIConnections()) {
return null; // UI is already available
}
const uiUrl = `http://localhost:${actualPort}`;
try {
if (process.env.ASK_ME_MCP_DEBUG) {
console.error(`[browser-bridge] No UI connections detected, opening browser...`);
}
await openBrowser(uiUrl);
// Give the browser a moment to start and connect
await new Promise(resolve => setTimeout(resolve, 2000));
if (hasActiveUIConnections()) {
return null; // Successfully opened and connected
} else {
// Browser opened but UI hasn't connected yet, provide manual instructions
return `🌐 I've attempted to open your browser to ${uiUrl} but the UI hasn't connected yet. Please:\n\n` +
`1. Open your browser to: ${uiUrl}\n` +
`2. Try your request again once the UI is loaded\n\n` +
`The UI is served by the MCP server and should be available at the URL above.`;
}
} catch (error) {
// Failed to open browser, provide manual instructions
return `❌ I couldn't automatically open your browser. Please manually:\n\n` +
`1. Open your browser to: ${uiUrl}\n` +
`2. Try your request again once the UI is loaded\n\n` +
`Error: ${error instanceof Error ? error.message : String(error)}\n\n` +
`The UI is served by the MCP server and should be available at the URL above.`;
}
}
/**
* Notify all browser connections of new messages
*/
export function notifyBrowser(message: any): void {
const data = `data: ${JSON.stringify(message)}\n\n`;
sseConnections.forEach(res => {
try {
res.write(data);
} catch (error) {
// Remove dead connections
sseConnections.delete(res);
}
});
}
/**
* Get the path to the Angular build output directory
*/
function getAngularBuildPath(): string {
// Try multiple paths to find the Angular build
const possiblePaths = [
// When installed from npm (main.js is in root of package)
path.resolve(__dirname, 'askme-ui', 'browser'),
// When running from dist during development
path.resolve(__dirname, '..', '..', 'askme-ui', 'browser'),
// Legacy path without browser subdirectory
path.resolve(__dirname, 'askme-ui'),
path.resolve(__dirname, '..', '..', 'askme-ui')
];
// Find the first path that contains index.html
for (const testPath of possiblePaths) {
if (fs.existsSync(path.join(testPath, 'index.html'))) {
return testPath;
}
}
// Default to the first path if none found
return possiblePaths[0];
}
/**
* Serve static files with proper MIME types
*/
function serveStaticFile(filePath: string, res: http.ServerResponse): void {
const ext = path.extname(filePath).toLowerCase();
const mimeTypes: { [key: string]: string } = {
'.html': 'text/html',
'.js': 'application/javascript',
'.css': 'text/css',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.ttf': 'font/ttf',
'.eot': 'application/vnd.ms-fontobject'
};
const contentType = mimeTypes[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('File not found');
return;
}
res.writeHead(200, {
'Content-Type': contentType,
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600'
});
res.end(data);
});
}
/**
* Start the HTTP server that bridges communication with the browser
*/
export async function startBrowserBridge(
requestStorage: Map<string, any>,
port: number = 3000,
forceExactPort: boolean = false
): Promise<http.Server> {
// Either use exact port or find available port
if (forceExactPort) {
actualPort = port;
} else {
actualPort = await findAvailablePort(port);
}
const server = http.createServer((req, res) => {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
const url = new URL(req.url || '', `http://${req.headers.host}`);
// Browser SSE endpoint
if (url.pathname === '/mcp/browser-events' && req.method === 'GET') {
handleSSEConnection(req, res);
}
// Response endpoint
else if (url.pathname === '/mcp/response' && req.method === 'POST') {
handleResponseSubmission(req, res, requestStorage);
}
// Test endpoint for simulating MCP requests (E2E testing only)
else if (url.pathname === '/mcp/simulate-request' && req.method === 'POST') {
handleSimulateRequest(req, res);
}
// Test endpoint for simulating request timeout (E2E testing only)
else if (url.pathname === '/mcp/simulate-timeout' && req.method === 'POST') {
handleSimulateTimeout(req, res);
}
// Test endpoint for simulating request cancellation (E2E testing only)
else if (url.pathname === '/mcp/simulate-cancellation' && req.method === 'POST') {
handleSimulateCancellation(req, res);
} else {
// Serve static Angular files
const angularBuildPath = getAngularBuildPath();
let filePath = path.join(angularBuildPath, url.pathname);
// Default to index.html for root path
if (url.pathname === '/') {
filePath = path.join(angularBuildPath, 'index.html');
}
// Check if file exists
fs.access(filePath, fs.constants.F_OK, (err) => {
if (err) {
// For Angular routes, serve index.html (SPA routing)
const indexPath = path.join(angularBuildPath, 'index.html');
fs.access(indexPath, fs.constants.F_OK, (indexErr) => {
if (indexErr) {
res.writeHead(404);
res.end('Angular build not found. Please run: npx nx build askme-ui');
} else {
serveStaticFile(indexPath, res);
}
});
} else {
serveStaticFile(filePath, res);
}
});
}
});
server.listen(actualPort, () => {
if (process.env.ASK_ME_MCP_DEBUG) {
console.error(`[ask-me-mcp] Browser bridge listening on http://localhost:${actualPort}`);
console.error(`[ask-me-mcp] Angular UI served at http://localhost:${actualPort}`);
// Check if Angular build exists
const angularBuildPath = getAngularBuildPath();
if (!fs.existsSync(path.join(angularBuildPath, 'index.html'))) {
console.error(`[ask-me-mcp] ⚠️ WARNING: Angular build not found at ${angularBuildPath}`);
console.error(`[ask-me-mcp] Please build the UI first: npx nx build askme-ui`);
}
}
});
return server;
}
/**
* Handle Server-Sent Events connection for real-time browser communication
*/
function handleSSEConnection(req: http.IncomingMessage, res: http.ServerResponse): void {
// Set SSE headers
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
// Send initial connection
res.write('data: {"type":"connected"}\n\n');
// Add to connections
sseConnections.add(res);
// Cleanup on disconnect
req.on('close', () => {
sseConnections.delete(res);
});
// Heartbeat to keep connection alive
const heartbeat = setInterval(() => {
try {
res.write(':heartbeat\n\n');
} catch {
clearInterval(heartbeat);
sseConnections.delete(res);
}
}, 30000);
req.on('close', () => {
clearInterval(heartbeat);
});
}
/**
* Handle simulated request for E2E testing
* This endpoint allows tests to inject requests directly into the browser UI
*/
function handleSimulateRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const requestData = JSON.parse(body);
console.error('[browser-bridge] Simulating request:', JSON.stringify(requestData, null, 2));
// Transform the test request data to match the exact format that real tools send
// Include the question field required by QueuedRequest interface
let question = requestData.question || 'Test request';
// For choose-next requests, use the challenge title as the question
if (requestData.type === 'choose-next' && requestData.context?.challenge?.title) {
question = requestData.context.challenge.title;
}
const mcpMessage = {
id: requestData.id,
sessionId: 'demo', // Match the sessionId used by real tools
question: question, // Required by HumanRequest interface
type: requestData.type,
timestamp: new Date().toISOString(), // Use ISO string format
context: requestData.context
};
// Send the simulated request to all browser connections
notifyBrowser({
type: 'new_request',
data: mcpMessage
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Request simulated successfully' }));
} catch (error) {
console.error('[browser-bridge] Error simulating request:', error);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request data' }));
}
});
}
/**
* Handle simulated timeout for E2E testing
* This endpoint allows tests to trigger timeout messages in the browser UI
*/
function handleSimulateTimeout(req: http.IncomingMessage, res: http.ServerResponse): void {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const timeoutData = JSON.parse(body);
console.error('[browser-bridge] Simulating timeout:', JSON.stringify(timeoutData, null, 2));
// Send timeout message to all browser connections
notifyBrowser({
type: 'request_timeout',
data: {
requestId: timeoutData.requestId || 'test-timeout',
message: timeoutData.message || 'Test timeout message'
}
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Timeout simulated successfully' }));
} catch (error) {
console.error('[browser-bridge] Error simulating timeout:', error);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid timeout data' }));
}
});
}
/**
* Handle simulated cancellation for E2E testing
* This endpoint allows tests to trigger cancellation messages in the browser UI
*/
function handleSimulateCancellation(req: http.IncomingMessage, res: http.ServerResponse): void {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const cancellationData = JSON.parse(body);
console.error('[browser-bridge] Simulating cancellation:', JSON.stringify(cancellationData, null, 2));
// Send cancellation message to all browser connections
notifyBrowser({
type: 'request_cancelled',
data: {
requestId: cancellationData.requestId || 'test-cancelled',
message: cancellationData.message || 'Test cancellation message'
}
});
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true, message: 'Cancellation simulated successfully' }));
} catch (error) {
console.error('[browser-bridge] Error simulating cancellation:', error);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid cancellation data' }));
}
});
}
/**
* Handle response submission from the browser UI
*/
function handleResponseSubmission(
req: http.IncomingMessage,
res: http.ServerResponse,
requestStorage: Map<string, any>
): void {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const data = JSON.parse(body);
console.error('[browser-bridge] Received response:', JSON.stringify(data, null, 2));
const pendingRequest = requestStorage.get(data.requestId);
if (pendingRequest) {
requestStorage.delete(data.requestId);
if (data.type === 'multiple-choice') {
// Handle multiple choice response
pendingRequest.resolve({
type: 'multiple-choice',
questions: data.questions,
completionStatus: data.completionStatus
});
} else if (data.type === 'hypothesis-challenge') {
// Handle hypothesis challenge response
pendingRequest.resolve({
type: 'hypothesis-challenge',
challenge: data.challenge,
completionStatus: data.completionStatus
});
} else if (data.type === 'choose-next') {
// Handle choose-next response
pendingRequest.resolve({
type: 'choose-next',
action: data.action,
selectedOption: data.selectedOption,
message: data.message
});
} else {
// Handle single question response
let responseText = data.response;
// Add completion status instructions for single questions
if (data.completionStatus) {
responseText += '\n\n--- COMPLETION STATUS ---\n';
if (data.completionStatus === 'done') {
responseText += '✅ User indicated they are DONE with answering questions on this topic.\n';
responseText += 'INSTRUCTION: Do not ask additional questions. Proceed with implementation based on the answers provided.\n';
} else if (data.completionStatus === 'drill-deeper') {
responseText += '🔍 User wants to DRILL DEEPER with more questions on this topic.\n';
responseText += 'INSTRUCTION: Ask more detailed follow-up questions using the ask-one-question tool to get more specific information on this topic.\n';
}
}
pendingRequest.resolve({
content: [
{
type: 'text',
text: responseText,
},
],
});
}
} else {
console.error('[browser-bridge] No pending request found for ID:', data.requestId);
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: true }));
} catch (error) {
console.error('[browser-bridge] Error processing response:', error);
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid request' }));
}
});
}