Skip to main content
Glama
index.ts15.1 kB
#!/usr/bin/env node import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import WebSocket from "ws"; import * as net from "net"; // Log entry interface interface LogEntry { timestamp: Date; level: "log" | "warn" | "error" | "info" | "debug"; message: string; args?: unknown[]; } // Device info from /json endpoint interface DeviceInfo { id: string; title: string; description: string; appId: string; type: string; webSocketDebuggerUrl: string; deviceName: string; } // Connected app info interface ConnectedApp { ws: WebSocket; deviceInfo: DeviceInfo; port: number; } // Circular buffer for storing logs class LogBuffer { private logs: LogEntry[] = []; private maxSize: number; constructor(maxSize: number = 1000) { this.maxSize = maxSize; } add(entry: LogEntry): void { this.logs.push(entry); if (this.logs.length > this.maxSize) { this.logs.shift(); } } get(count?: number, level?: string, startFromText?: string): LogEntry[] { let filtered = this.logs; // If startFromText is provided, find the LAST matching line and start from there if (startFromText) { let startIndex = -1; for (let i = filtered.length - 1; i >= 0; i--) { if (filtered[i].message.includes(startFromText)) { startIndex = i; break; } } if (startIndex !== -1) { filtered = filtered.slice(startIndex); } } if (level && level !== "all") { filtered = filtered.filter((log) => log.level === level); } if (count && count > 0) { filtered = filtered.slice(0, count); } return filtered; } search(text: string): LogEntry[] { return this.logs.filter((log) => log.message.toLowerCase().includes(text.toLowerCase()) ); } clear(): void { this.logs = []; } get size(): number { return this.logs.length; } } // Global log buffer const logBuffer = new LogBuffer(1000); // Connected apps const connectedApps: Map<string, ConnectedApp> = new Map(); // Common Metro ports const COMMON_PORTS = [8081, 8082, 19000, 19001, 19002]; // CDP message ID counter let messageId = 1; // Check if a port is open async function isPortOpen(port: number, host: string = "localhost"): Promise<boolean> { return new Promise((resolve) => { const socket = new net.Socket(); socket.setTimeout(1000); socket.on("connect", () => { socket.destroy(); resolve(true); }); socket.on("timeout", () => { socket.destroy(); resolve(false); }); socket.on("error", () => { socket.destroy(); resolve(false); }); socket.connect(port, host); }); } // Scan for running Metro servers async function scanMetroPorts( startPort: number = 8081, endPort: number = 19002 ): Promise<number[]> { const portsToCheck = startPort === 8081 && endPort === 19002 ? COMMON_PORTS : Array.from({ length: endPort - startPort + 1 }, (_, i) => startPort + i); const openPorts: number[] = []; for (const port of portsToCheck) { if (await isPortOpen(port)) { openPorts.push(port); } } return openPorts; } // Fetch connected devices from Metro /json endpoint async function fetchDevices(port: number): Promise<DeviceInfo[]> { try { const response = await fetch(`http://localhost:${port}/json`); if (!response.ok) { return []; } const devices = await response.json() as DeviceInfo[]; return devices.filter(d => d.webSocketDebuggerUrl); } catch { return []; } } // Connect to a device via CDP WebSocket async function connectToDevice(device: DeviceInfo, port: number): Promise<string> { return new Promise((resolve, reject) => { const appKey = `${port}-${device.id}`; if (connectedApps.has(appKey)) { resolve(`Already connected to ${device.title}`); return; } try { const ws = new WebSocket(device.webSocketDebuggerUrl); ws.on("open", () => { connectedApps.set(appKey, { ws, deviceInfo: device, port }); console.error(`[metro-logs-mcp] Connected to ${device.title}`); // Enable Runtime domain to receive console messages ws.send(JSON.stringify({ id: messageId++, method: "Runtime.enable" })); // Also enable Log domain ws.send(JSON.stringify({ id: messageId++, method: "Log.enable" })); resolve(`Connected to ${device.title} (${device.deviceName})`); }); ws.on("message", (data: WebSocket.Data) => { try { const message = JSON.parse(data.toString()); handleCDPMessage(message, device); } catch { // Ignore non-JSON messages } }); ws.on("close", () => { connectedApps.delete(appKey); console.error(`[metro-logs-mcp] Disconnected from ${device.title}`); }); ws.on("error", (error: Error) => { connectedApps.delete(appKey); reject(`Failed to connect to ${device.title}: ${error.message}`); }); // Timeout after 5 seconds setTimeout(() => { if (ws.readyState !== WebSocket.OPEN) { ws.terminate(); reject(`Connection to ${device.title} timed out`); } }, 5000); } catch (error) { reject(`Failed to create WebSocket connection: ${error}`); } }); } // Handle CDP messages function handleCDPMessage(message: Record<string, unknown>, device: DeviceInfo): void { const method = message.method as string; // Handle Runtime.consoleAPICalled if (method === "Runtime.consoleAPICalled") { const params = message.params as { type?: string; args?: Array<{ type?: string; value?: unknown; description?: string; preview?: { properties?: Array<{ name: string; value: string }> }; }>; timestamp?: number; }; const type = params.type || "log"; const level = mapConsoleType(type); const args = params.args || []; const messageText = args .map((arg) => { if (arg.type === "string" || arg.type === "number" || arg.type === "boolean") { return String(arg.value); } if (arg.description) { return arg.description; } if (arg.preview?.properties) { const props = arg.preview.properties .map(p => `${p.name}: ${p.value}`) .join(", "); return `{${props}}`; } if (arg.value !== undefined) { return JSON.stringify(arg.value); } return "[object]"; }) .join(" "); if (messageText.trim()) { logBuffer.add({ timestamp: new Date(), level, message: messageText, args: args.map((a) => a.value), }); } } // Handle Log.entryAdded if (method === "Log.entryAdded") { const params = message.params as { entry?: { level?: string; text?: string; timestamp?: number; }; }; if (params.entry) { const level = mapConsoleType(params.entry.level || "log"); logBuffer.add({ timestamp: new Date(), level, message: params.entry.text || "", }); } } } // Map console type to log level function mapConsoleType(type: string): LogEntry["level"] { switch (type) { case "error": return "error"; case "warning": case "warn": return "warn"; case "info": return "info"; case "debug": return "debug"; default: return "log"; } } // Format logs for output function formatLogs(logs: LogEntry[]): string { if (logs.length === 0) { return "No logs captured yet. Make sure Metro is running and the app is connected."; } return logs .map((log) => { const time = log.timestamp.toLocaleTimeString(); const levelTag = `[${log.level.toUpperCase()}]`; return `${time} ${levelTag} ${log.message}`; }) .join("\n"); } // Create MCP server const server = new McpServer({ name: "metro-logs-mcp", version: "1.0.0", }); // Tool: Scan for Metro servers server.registerTool( "scan_metro", { description: "Scan for running Metro bundler servers on common ports", inputSchema: { startPort: z .number() .optional() .default(8081) .describe("Start port for scanning (default: 8081)"), endPort: z .number() .optional() .default(19002) .describe("End port for scanning (default: 19002)"), }, }, async ({ startPort, endPort }) => { const openPorts = await scanMetroPorts(startPort, endPort); if (openPorts.length === 0) { return { content: [ { type: "text", text: "No Metro servers found. Make sure Metro bundler is running (npm start or expo start).", }, ], }; } // Fetch devices from each port and connect const results: string[] = []; for (const port of openPorts) { const devices = await fetchDevices(port); if (devices.length === 0) { results.push(`Port ${port}: No devices found`); continue; } results.push(`Port ${port}: Found ${devices.length} device(s)`); // Select the main JS runtime device (priority order) const mainDevice = devices.find(d => // SDK 54+ uses "React Native Bridgeless" in description d.description.includes("React Native Bridgeless") ) || devices.find(d => // Hermes runtime (RN 0.70+) d.title === "Hermes React Native" || d.title.includes("Hermes") ) || devices.find(d => // Fallback: any React Native in title, excluding Reanimated/Experimental d.title.includes("React Native") && !d.title.includes("Reanimated") && !d.title.includes("Experimental") ) || devices[0]; try { const connectionResult = await connectToDevice(mainDevice, port); results.push(` - ${connectionResult}`); } catch (error) { results.push(` - Failed: ${error}`); } } return { content: [ { type: "text", text: `Metro scan results:\n${results.join("\n")}`, }, ], }; } ); // Tool: Get connected apps server.registerTool( "get_apps", { description: "List connected React Native apps and Metro server status", inputSchema: {}, }, async () => { const connections = Array.from(connectedApps.entries()); if (connections.length === 0) { return { content: [ { type: "text", text: 'No apps connected. Run "scan_metro" first to discover and connect to running apps.', }, ], }; } const status = connections.map(([key, app]) => { const state = app.ws.readyState === WebSocket.OPEN ? "Connected" : "Disconnected"; return `${app.deviceInfo.title} (${app.deviceInfo.deviceName}): ${state}`; }); return { content: [ { type: "text", text: `Connected apps:\n${status.join("\n")}\n\nTotal logs in buffer: ${logBuffer.size}`, }, ], }; } ); // Tool: Get console logs server.registerTool( "get_logs", { description: "Retrieve console logs from connected React Native app", inputSchema: { maxLogs: z .number() .optional() .default(50) .describe("Maximum number of logs to return (default: 50)"), level: z .enum(["all", "log", "warn", "error", "info", "debug"]) .optional() .default("all") .describe("Filter by log level (default: all)"), startFromText: z .string() .optional() .describe("Start from the first log line containing this text"), }, }, async ({ maxLogs, level, startFromText }) => { const logs = logBuffer.get(maxLogs, level, startFromText); const formatted = formatLogs(logs); const startNote = startFromText ? ` (starting from "${startFromText}")` : ""; return { content: [ { type: "text", text: `React Native Console Logs (${logs.length} entries)${startNote}:\n\n${formatted}`, }, ], }; } ); // Tool: Search logs server.registerTool( "search_logs", { description: "Search console logs for text (case-insensitive)", inputSchema: { text: z .string() .describe("Text to search for in log messages"), maxResults: z .number() .optional() .default(50) .describe("Maximum number of results to return (default: 50)"), }, }, async ({ text, maxResults }) => { const logs = logBuffer.search(text).slice(0, maxResults); const formatted = formatLogs(logs); return { content: [ { type: "text", text: `Search results for "${text}" (${logs.length} matches):\n\n${formatted}`, }, ], }; } ); // Tool: Clear logs server.registerTool( "clear_logs", { description: "Clear the log buffer", inputSchema: {}, }, async () => { const count = logBuffer.size; logBuffer.clear(); return { content: [ { type: "text", text: `Cleared ${count} log entries from buffer.`, }, ], }; } ); // Tool: Connect to specific Metro port server.registerTool( "connect_metro", { description: "Connect to a specific Metro server port", inputSchema: { port: z .number() .default(8081) .describe("Metro server port (default: 8081)"), }, }, async ({ port }) => { try { const devices = await fetchDevices(port); if (devices.length === 0) { return { content: [ { type: "text", text: `No devices found on port ${port}. Make sure the app is running.`, }, ], }; } const results: string[] = [`Found ${devices.length} device(s) on port ${port}:`]; for (const device of devices) { try { const result = await connectToDevice(device, port); results.push(` - ${result}`); } catch (error) { results.push(` - ${device.title}: Failed - ${error}`); } } return { content: [ { type: "text", text: results.join("\n"), }, ], }; } catch (error) { return { content: [ { type: "text", text: `Failed to connect: ${error}`, }, ], }; } } ); // Main function async function main() { const transport = new StdioServerTransport(); await server.connect(transport); console.error("[metro-logs-mcp] Server started on stdio"); } main().catch((error) => { console.error("[metro-logs-mcp] Fatal error:", error); process.exit(1); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/igorzheludkov/metro-logs-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server