UNPKG

browser-connect-mcp

Version:

MCP server for browser DevTools and backend debugging - analyze console logs, network requests, and backend logs with AI assistance

305 lines 10.6 kB
import { z } from 'zod'; import { Tail } from 'tail'; import * as fs from 'fs'; import { spawn } from 'child_process'; import { BackendManager } from '../../utils/backend-manager.js'; import logger from '../../utils/logger.js'; export const BackendLogsStreamArgsSchema = z.object({ source: z.enum(['file', 'process', 'docker', 'stdout']).describe('Log source type'), target: z.string().describe('File path, process ID, container ID, or command'), filters: z.object({ level: z.array(z.enum(['error', 'warn', 'info', 'debug', 'trace'])).optional(), pattern: z.string().optional().describe('Text pattern to search for'), regex: z.boolean().default(false).describe('Treat pattern as regex'), since: z.string().optional().describe('ISO timestamp to start from'), limit: z.number().default(1000).describe('Maximum number of logs to return') }).optional(), follow: z.boolean().default(false).describe('Follow log in real-time'), format: z.enum(['json', 'text', 'auto']).default('auto').describe('Log format') }); export async function backendLogsStream(args) { const backendManager = BackendManager.getInstance(); try { logger.info('Starting log stream', { source: args.source, target: args.target }); let logs = []; switch (args.source) { case 'file': logs = await streamFileLog(args.target, args.filters, args.format, args.follow); break; case 'process': logs = await streamProcessLog(args.target, args.filters, args.follow); break; case 'docker': logs = await streamDockerLog(args.target, args.filters, args.follow); break; case 'stdout': logs = await streamStdoutLog(args.target, args.filters, args.follow); break; } // Store logs in backend manager backendManager.addLogs(args.target, logs); // Apply filters let filteredLogs = logs; if (args.filters) { filteredLogs = filterLogs(logs, args.filters); } return { success: true, source: args.source, target: args.target, logCount: filteredLogs.length, logs: filteredLogs.slice(0, args.filters?.limit || 1000), streaming: args.follow }; } catch (error) { logger.error('Failed to stream logs', { error }); return { isError: true, error: error instanceof Error ? error.message : 'Failed to stream logs' }; } } async function streamFileLog(filePath, filters, format, follow) { const logs = []; if (!fs.existsSync(filePath)) { throw new Error(`Log file not found: ${filePath}`); } // Read existing content const content = fs.readFileSync(filePath, 'utf-8'); const lines = content.split('\n').filter(line => line.trim()); for (const line of lines) { const entry = parseLogLine(line, format); if (entry) { logs.push(entry); } } // Follow log if requested if (follow) { const tail = new Tail(filePath); tail.on('line', (data) => { const entry = parseLogLine(data, format); if (entry) { logs.push(entry); // In real implementation, would emit this to connected clients } }); tail.on('error', (error) => { logger.error('Tail error', { error }); }); } return logs; } async function streamProcessLog(processId, filters, follow) { const logs = []; // For Node.js processes, we can use the debug port // This is a simplified implementation const debugUrl = `ws://127.0.0.1:9229/${processId}`; // In a real implementation, would connect to Node.js Inspector // and listen to Runtime.consoleAPICalled events return logs; } async function streamDockerLog(containerId, filters, follow) { const logs = []; return new Promise((resolve, reject) => { const args = ['logs']; if (follow) args.push('-f'); args.push('--timestamps', containerId); const docker = spawn('docker', args); docker.stdout.on('data', (data) => { const lines = data.toString().split('\n').filter(line => line.trim()); for (const line of lines) { const entry = parseDockerLogLine(line); if (entry) { logs.push(entry); } } }); docker.stderr.on('data', (data) => { const lines = data.toString().split('\n').filter(line => line.trim()); for (const line of lines) { const entry = parseDockerLogLine(line); if (entry) { entry.level = 'error'; logs.push(entry); } } }); docker.on('error', (error) => { reject(new Error(`Failed to run docker logs: ${error.message}`)); }); if (!follow) { docker.on('close', () => { resolve(logs); }); } else { // Return current logs immediately when following setTimeout(() => resolve(logs), 1000); } }); } async function streamStdoutLog(command, filters, follow) { const logs = []; return new Promise((resolve, reject) => { const [cmd, ...args] = command.split(' '); const proc = spawn(cmd, args, { shell: true }); proc.stdout.on('data', (data) => { const lines = data.toString().split('\n').filter(line => line.trim()); for (const line of lines) { logs.push({ timestamp: new Date().toISOString(), level: 'info', message: line, source: 'stdout', raw: line }); } }); proc.stderr.on('data', (data) => { const lines = data.toString().split('\n').filter(line => line.trim()); for (const line of lines) { logs.push({ timestamp: new Date().toISOString(), level: 'error', message: line, source: 'stderr', raw: line }); } }); proc.on('error', (error) => { reject(new Error(`Failed to run command: ${error.message}`)); }); if (!follow) { proc.on('close', () => { resolve(logs); }); } else { // Return current logs immediately when following setTimeout(() => resolve(logs), 1000); } }); } function parseLogLine(line, format) { if (!line.trim()) return null; const timestamp = new Date().toISOString(); // Auto-detect format if (format === 'auto') { if (line.startsWith('{') && line.endsWith('}')) { format = 'json'; } else { format = 'text'; } } if (format === 'json') { try { const parsed = JSON.parse(line); return { timestamp: parsed.timestamp || parsed.time || timestamp, level: parsed.level || parsed.severity || 'info', message: parsed.message || parsed.msg || line, metadata: parsed, source: 'file', raw: line }; } catch { // Fall back to text parsing } } // Text format parsing // Common patterns: [LEVEL] message, LEVEL: message, timestamp LEVEL message const patterns = [ /^\[(\w+)\]\s*(.+)$/, // [LEVEL] message /^(\w+):\s*(.+)$/, // LEVEL: message /^(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d{3})?)\s+(\w+)\s+(.+)$/, // timestamp LEVEL message ]; for (const pattern of patterns) { const match = line.match(pattern); if (match) { if (match.length === 4) { // timestamp LEVEL message format return { timestamp: match[1], level: match[2].toLowerCase(), message: match[3], source: 'file', raw: line }; } else { // LEVEL message format return { timestamp, level: match[1].toLowerCase(), message: match[2], source: 'file', raw: line }; } } } // Default: treat entire line as message return { timestamp, message: line, source: 'file', raw: line }; } function parseDockerLogLine(line) { if (!line.trim()) return null; // Docker log format: timestamp stdout/stderr message const match = line.match(/^(\S+)\s+(stdout|stderr)\s+(.+)$/); if (match) { return { timestamp: match[1], level: match[2] === 'stderr' ? 'error' : 'info', message: match[3], source: 'docker', raw: line }; } return { timestamp: new Date().toISOString(), message: line, source: 'docker', raw: line }; } function filterLogs(logs, filters) { let filtered = [...logs]; // Filter by level if (filters.level?.length > 0) { filtered = filtered.filter(log => log.level && filters.level.includes(log.level)); } // Filter by pattern if (filters.pattern) { const regex = filters.regex ? new RegExp(filters.pattern, 'i') : null; filtered = filtered.filter(log => { if (regex) { return regex.test(log.message) || regex.test(log.raw); } else { const pattern = filters.pattern.toLowerCase(); return log.message.toLowerCase().includes(pattern) || log.raw.toLowerCase().includes(pattern); } }); } // Filter by timestamp if (filters.since) { const sinceTime = new Date(filters.since).getTime(); filtered = filtered.filter(log => new Date(log.timestamp).getTime() >= sinceTime); } return filtered; } //# sourceMappingURL=logs-stream.js.map