Skip to main content
Glama
devChromeServer.mjs32 kB
#!/usr/bin/env node import puppeteer from "puppeteer"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js"; const DEFAULT_URL = process.env.MCP_DEV_URL ?? "http://localhost:5173/"; const CHROME_PATH = process.env.MCP_CHROME_PATH ?? process.env.PUPPETEER_EXECUTABLE_PATH; const NAV_TIMEOUT = Number(process.env.MCP_NAV_TIMEOUT ?? "45000"); const WAIT_TIMEOUT = Number(process.env.MCP_WAIT_TIMEOUT ?? "45000"); const MAX_LOG_ENTRIES = 500; const MAX_SCREENSHOTS = 8; const state = { consoleLogs: [], screenshots: new Map(), browser: null, page: null, listenersAttached: false, grantedPermissions: new Set(), }; const AVAILABLE_PERMISSIONS = new Map([ ["status", "Read current page metadata and console logs"], ["navigate", "Navigate to arbitrary URLs"], ["reload", "Reload the current page"], ["screenshot", "Capture screenshots of the page"], ["console_clear", "Clear stored console log entries"], ["eval", "Execute custom JavaScript within the page"], ["wait_selector", "Wait for DOM elements matching a selector"], ["click", "Click elements within the page"], ["type", "Type text into elements"], ["scroll", "Scroll the page viewport"], ["set_viewport", "Adjust browser viewport dimensions"], ]); function normalizePermission(name) { return String(name ?? "") .trim() .toLowerCase(); } function expandPermissionInputs(names) { const normalized = new Set(); for (const raw of names ?? []) { const value = normalizePermission(raw); if (!value) { continue; } if (value === "all" || value === "*") { for (const key of AVAILABLE_PERMISSIONS.keys()) { normalized.add(key); } continue; } if (!AVAILABLE_PERMISSIONS.has(value)) { throw new Error(`Unknown permission: ${value}`); } normalized.add(value); } if (normalized.size === 0) { throw new Error("No valid permissions provided"); } return Array.from(normalized.values()).sort(); } function hasPermission(name) { return state.grantedPermissions.has(name); } function requirePermission(name) { if (!hasPermission(name)) { throw new Error(`Permission "${name}" not granted. Grant it via dev_grant_permissions.`); } } function permissionStatusReport() { const lines = []; for (const [name, description] of AVAILABLE_PERMISSIONS) { const marker = hasPermission(name) ? "granted" : "denied"; lines.push(`[${marker}] ${name} - ${description}`); } return lines.join("\\n"); } function serializeForOutput(value) { if (value === undefined) { return "undefined"; } if (value === null) { return "null"; } if (typeof value === "string") { return value; } if (typeof value === "number" && !Number.isFinite(value)) { return value.toString(); } if (typeof value === "function") { return value.toString(); } try { return JSON.stringify(value, null, 2); } catch (_error) { return String(value); } } const server = new Server( { name: "dev-chrome-monitor", version: "0.1.0", }, { capabilities: { resources: {}, tools: {}, }, }, ); function notifyConsoleUpdate() { try { server.notification({ method: "notifications/resources/updated", params: { uri: "console://logs" }, }); } catch (error) { // Swallow notifications issued before transport is ready } } function notifyScreenshotUpdate() { try { server.notification({ method: "notifications/resources/list_changed", params: {}, }); } catch (error) { // Swallow notifications issued before transport is ready } } function pushLog(kind, text) { const entry = `${new Date().toISOString()} [${kind}] ${text}`; state.consoleLogs.push(entry); if (state.consoleLogs.length > MAX_LOG_ENTRIES) { state.consoleLogs.splice(0, state.consoleLogs.length - MAX_LOG_ENTRIES); } notifyConsoleUpdate(); } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function waitForUrl(url, timeoutMs) { const started = Date.now(); while (Date.now() - started < timeoutMs) { try { const response = await fetch(url, { method: "GET" }); if (response.ok || response.status === 404) { return true; } } catch (error) { // Ignore connection failures until timeout } await delay(1500); } return false; } function attachPageListeners(page) { if (state.listenersAttached) { return; } page.on("console", (message) => { pushLog(`console:${message.type()}`, message.text()); }); page.on("pageerror", (error) => { pushLog("pageerror", error.message ?? String(error)); }); page.on("requestfailed", (request) => { const failure = request.failure(); pushLog( "requestfailed", `${request.method()} ${request.url()} ${failure ? failure.errorText : "unknown"}`, ); }); page.on("response", (response) => { if (response.status() >= 400) { pushLog("response", `${response.status()} ${response.url()}`); } }); state.listenersAttached = true; } async function ensurePage() { if (!state.browser) { pushLog("info", "Launching Chrome instance for dev monitoring"); state.browser = await puppeteer.launch({ headless: false, executablePath: CHROME_PATH, defaultViewport: null, args: [ "--disable-web-security", "--disable-features=IsolateOrigins,site-per-process", "--disable-site-isolation-trials", "--no-default-browser-check", "--no-first-run", ], }); const pages = await state.browser.pages(); state.page = pages.length > 0 ? pages[0] : await state.browser.newPage(); attachPageListeners(state.page); } if (!state.page || state.page.isClosed()) { const pages = await state.browser.pages(); state.page = pages.length > 0 ? pages[0] : await state.browser.newPage(); attachPageListeners(state.page); } return state.page; } async function autoNavigate() { const page = await ensurePage(); const targetUrl = DEFAULT_URL; const ready = await waitForUrl(targetUrl, WAIT_TIMEOUT); if (!ready) { pushLog( "warn", `Dev server not reachable at ${targetUrl}. Start it and call dev_navigate when ready.`, ); return; } try { await page.goto(targetUrl, { waitUntil: "networkidle0", timeout: NAV_TIMEOUT }); pushLog("info", `Navigated to ${targetUrl}`); } catch (error) { pushLog("error", `Initial navigation failed: ${error.message ?? error}`); } } function listResources() { const resources = []; if (hasPermission("status")) { resources.push({ uri: "console://logs", mimeType: "text/plain", name: "Dev server console", }); } if (hasPermission("screenshot")) { for (const name of state.screenshots.keys()) { resources.push({ uri: `screenshot://${name}`, mimeType: "image/png", name: `Screenshot: ${name}`, }); } } return { resources }; } function readResource(uri) { if (uri === "console://logs") { if (!hasPermission("status")) { throw new Error('Permission "status" not granted. Grant it via dev_grant_permissions.'); } return { contents: [ { uri, mimeType: "text/plain", text: state.consoleLogs.join("\n"), }, ], }; } if (uri.startsWith("screenshot://")) { if (!hasPermission("screenshot")) { throw new Error('Permission "screenshot" not granted. Grant it via dev_grant_permissions.'); } const name = uri.split("://")[1]; const buffer = state.screenshots.get(name); if (buffer) { return { contents: [ { uri, mimeType: "image/png", blob: buffer, }, ], }; } } throw new Error(`Resource not found: ${uri}`); } function registerHandlers() { server.setRequestHandler(ListResourcesRequestSchema, async () => listResources()); server.setRequestHandler(ReadResourceRequestSchema, async (request) => readResource(request.params.uri.toString()), ); async function recreatePage() { try { if (state.page && !state.page.isClosed()) { await state.page.close(); } } catch (error) { } state.page = null; state.listenersAttached = false; } async function withPage(fn) { const page = await ensurePage(); try { return await fn(page); } catch (error) { const message = error && error.message ? error.message.toLowerCase() : String(error).toLowerCase(); if (message.includes("detached frame") || page.isClosed()) { pushLog("warn", "Page context detached; recreating and retrying once"); await recreatePage(); const page2 = await ensurePage(); return await fn(page2); } throw error; } } const tools = [ { name: "dev_list_permissions", description: "List available permissions and whether they are granted", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "dev_grant_permissions", description: "Grant one or more permissions after confirmation", inputSchema: { type: "object", properties: { permissions: { type: "array", items: { type: "string" }, description: "Permissions to grant (use \"all\" or \"*\" for every permission)", }, confirm: { type: "boolean", description: "Must be true to apply the changes", }, }, required: ["permissions", "confirm"], }, }, { name: "dev_revoke_permissions", description: "Revoke previously granted permissions", inputSchema: { type: "object", properties: { permissions: { type: "array", items: { type: "string" }, description: "Permissions to revoke (use \"all\" or \"*\" for every permission)", }, confirm: { type: "boolean", description: "Must be true to apply the changes", }, }, required: ["permissions", "confirm"], }, }, { name: "dev_status", description: "Report current URL, title, and log counts (requires permission: status)", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "dev_navigate", description: "Navigate the monitored page to the provided URL (requires permission: navigate)", inputSchema: { type: "object", properties: { url: { type: "string", description: "URL to open" }, }, required: ["url"], }, }, { name: "dev_reload", description: "Reload the monitored page and wait for network idle (requires permission: reload)", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "dev_screenshot", description: "Capture a screenshot of the monitored page (requires permission: screenshot)", inputSchema: { type: "object", properties: { name: { type: "string", description: "Identifier for the screenshot" }, fullPage: { type: "boolean", description: "Capture the full scrollable page when true", }, }, required: [], }, }, { name: "dev_clear_console", description: "Clear stored console log entries (requires permission: console_clear)", inputSchema: { type: "object", properties: {}, required: [], }, }, { name: "dev_eval", description: "Evaluate JavaScript in the monitored page (requires permission: eval)", inputSchema: { type: "object", properties: { expression: { type: "string", description: "JavaScript expression to evaluate" }, awaitPromise: { type: "boolean", description: "Await returned promise values when true", }, }, required: ["expression"], }, }, { name: "dev_wait_for_selector", description: "Wait for a selector to appear (requires permission: wait_selector)", inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector to wait for" }, timeout: { type: "number", description: "Timeout in milliseconds (defaults to MCP_WAIT_TIMEOUT)", }, visible: { type: "boolean", description: "Require the element to be visible when true", }, hidden: { type: "boolean", description: "Resolve when the element becomes hidden when true", }, }, required: ["selector"], }, }, { name: "dev_click_selector", description: "Click an element matching a selector (requires permission: click)", inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector of element to click" }, button: { type: "string", enum: ["left", "middle", "right"], description: "Mouse button to use", }, clickCount: { type: "number", description: "Number of click repetitions", }, delay: { type: "number", description: "Delay between mousedown and mouseup in milliseconds", }, timeout: { type: "number", description: "Timeout in milliseconds to find the element", }, }, required: ["selector"], }, }, { name: "dev_type_selector", description: "Type text into an element (requires permission: type)", inputSchema: { type: "object", properties: { selector: { type: "string", description: "CSS selector of input element" }, text: { type: "string", description: "Text to type" }, delay: { type: "number", description: "Delay between key presses in milliseconds", }, clear: { type: "boolean", description: "Clear existing value before typing", }, timeout: { type: "number", description: "Timeout in milliseconds to find the element", }, }, required: ["selector", "text"], }, }, { name: "dev_scroll", description: "Scroll the page viewport (requires permission: scroll)", inputSchema: { type: "object", properties: { x: { type: "number", description: "Horizontal scroll position in pixels", }, y: { type: "number", description: "Vertical scroll position in pixels", }, behavior: { type: "string", enum: ["auto", "smooth"], description: "Scroll behavior", }, }, required: [], }, }, { name: "dev_set_viewport", description: "Adjust the browser viewport (requires permission: set_viewport)", inputSchema: { type: "object", properties: { width: { type: "number", description: "Viewport width in pixels" }, height: { type: "number", description: "Viewport height in pixels" }, deviceScaleFactor: { type: "number", description: "Device scale factor to apply", }, }, required: ["width", "height"], }, }, ]; server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools })); server.setRequestHandler(CallToolRequestSchema, async (request) => { const tool = request.params.name; const args = request.params.arguments ?? {}; try { switch (tool) { case "dev_list_permissions": { const report = permissionStatusReport(); const granted = Array.from(state.grantedPermissions).sort(); const textResult = `${report} Granted: ${granted.length ? granted.join(", ") : "(none)"}`; return { content: [{ type: "text", text: textResult, }], isError: false, }; } case "dev_grant_permissions": { const permissionsInput = args.permissions; if (!Array.isArray(permissionsInput) || permissionsInput.length === 0) { throw new Error('Argument "permissions" must be a non-empty array.'); } if (args.confirm !== true) { throw new Error('Set "confirm" to true to grant permissions.'); } const permissions = expandPermissionInputs(permissionsInput); const granted = []; const already = []; for (const name of permissions) { if (hasPermission(name)) { already.push(name); } else { state.grantedPermissions.add(name); granted.push(name); } } const message = [ granted.length ? `Granted: ${granted.join(", ")}` : null, already.length ? `Already granted: ${already.join(", ")}` : null, ] .filter(Boolean) .join("\n") || "No changes"; return { content: [{ type: "text", text: message }], isError: false, }; } case "dev_revoke_permissions": { const permissionsInput = args.permissions; if (!Array.isArray(permissionsInput) || permissionsInput.length === 0) { throw new Error('Argument "permissions" must be a non-empty array.'); } if (args.confirm !== true) { throw new Error('Set "confirm" to true to revoke permissions.'); } const permissions = expandPermissionInputs(permissionsInput); const revoked = []; const missing = []; for (const name of permissions) { if (hasPermission(name)) { state.grantedPermissions.delete(name); revoked.push(name); } else { missing.push(name); } } const message = [ revoked.length ? `Revoked: ${revoked.join(", ")}` : null, missing.length ? `Not granted: ${missing.join(", ")}` : null, ] .filter(Boolean) .join("\n") || "No changes"; return { content: [{ type: "text", text: message }], isError: false, }; } case "dev_status": { requirePermission("status"); const { title, url } = await withPage(async (page) => ({ title: await page.title(), url: page.url() })); return { content: [{ type: "text", text: `URL: ${url} Title: ${title} Console entries: ${state.consoleLogs.length} Screenshots stored: ${state.screenshots.size}`, }], isError: false, }; } case "dev_navigate": { requirePermission("navigate"); if (!args.url || typeof args.url !== "string") { throw new Error("Missing required argument: url"); } await withPage(async (page) => page.goto(args.url, { waitUntil: "networkidle0", timeout: NAV_TIMEOUT })); pushLog("info", `Navigated to ${args.url}`); return { content: [{ type: "text", text: `Navigated to ${args.url}` }], isError: false, }; } case "dev_reload": { requirePermission("reload"); await withPage(async (page) => page.reload({ waitUntil: "networkidle0", timeout: NAV_TIMEOUT })); pushLog("info", "Reloaded monitored page"); return { content: [{ type: "text", text: "Reloaded page" }], isError: false, }; } case "dev_screenshot": { requirePermission("screenshot"); const name = typeof args.name === "string" && args.name.trim().length > 0 ? args.name.trim() : `screenshot-${Date.now()}`; const buffer = await withPage(async (page) => page.screenshot({ type: "png", fullPage: Boolean(args.fullPage), })); if (state.screenshots.size >= MAX_SCREENSHOTS) { const firstKey = state.screenshots.keys().next().value; state.screenshots.delete(firstKey); } state.screenshots.set(name, buffer); notifyScreenshotUpdate(); pushLog("info", `Captured screenshot ${name}`); return { content: [{ type: "text", text: `Captured screenshot ${name}` }], isError: false, }; } case "dev_clear_console": { requirePermission("console_clear"); state.consoleLogs.length = 0; notifyConsoleUpdate(); return { content: [{ type: "text", text: "Cleared console log buffer" }], isError: false, }; } case "dev_eval": { requirePermission("eval"); if (typeof args.expression !== "string" || !args.expression.trim()) { throw new Error('Argument "expression" must be a non-empty string.'); } const expression = args.expression.trim(); const awaitPromise = Boolean(args.awaitPromise); const result = await withPage(async (page) => page.evaluate( ({ code, wait }) => { const runner = new Function(`return (${code});`); const value = runner(); if (wait && value && typeof value.then === "function") { return value; } if (!wait && value && typeof value.then === "function") { return "[Promise]"; } return value; }, { code: expression, wait: awaitPromise }, ), ); return { content: [{ type: "text", text: serializeForOutput(result) }], isError: false, }; } case "dev_wait_for_selector": { requirePermission("wait_selector"); if (typeof args.selector !== "string" || !args.selector.trim()) { throw new Error('Argument "selector" must be a non-empty string.'); } const selector = args.selector.trim(); const timeout = args.timeout !== undefined ? Number(args.timeout) : WAIT_TIMEOUT; if (!Number.isFinite(timeout) || timeout <= 0) { throw new Error('Argument "timeout" must be a positive number when provided.'); } const visible = args.visible === undefined ? undefined : Boolean(args.visible); const hidden = args.hidden === undefined ? undefined : Boolean(args.hidden); if (visible && hidden) { throw new Error('Arguments "visible" and "hidden" cannot both be true.'); } await withPage(async (page) => { const options = { timeout }; if (visible !== undefined) { options.visible = visible; } if (hidden !== undefined) { options.hidden = hidden; } await page.waitForSelector(selector, options); }); return { content: [{ type: "text", text: `Selector "${selector}" satisfied` }], isError: false, }; } case "dev_click_selector": { requirePermission("click"); if (typeof args.selector !== "string" || !args.selector.trim()) { throw new Error('Argument "selector" must be a non-empty string.'); } const selector = args.selector.trim(); const timeout = args.timeout !== undefined ? Number(args.timeout) : WAIT_TIMEOUT; if (!Number.isFinite(timeout) || timeout <= 0) { throw new Error('Argument "timeout" must be a positive number when provided.'); } const button = typeof args.button === "string" ? args.button : "left"; if (!["left", "middle", "right"].includes(button)) { throw new Error('Argument "button" must be one of: left, middle, right.'); } const clickCount = args.clickCount !== undefined ? Number(args.clickCount) : 1; if (!Number.isFinite(clickCount) || clickCount <= 0) { throw new Error('Argument "clickCount" must be a positive number when provided.'); } const delay = args.delay !== undefined ? Number(args.delay) : undefined; if (delay !== undefined && (!Number.isFinite(delay) || delay < 0)) { throw new Error('Argument "delay" must be a non-negative number when provided.'); } await withPage(async (page) => { await page.waitForSelector(selector, { timeout }); const clickOptions = { button, clickCount: Math.round(clickCount), }; if (delay !== undefined) { clickOptions.delay = delay; } await page.click(selector, clickOptions); }); return { content: [{ type: "text", text: `Clicked selector "${selector}"` }], isError: false, }; } case "dev_type_selector": { requirePermission("type"); if (typeof args.selector !== "string" || !args.selector.trim()) { throw new Error('Argument "selector" must be a non-empty string.'); } if (typeof args.text !== "string") { throw new Error('Argument "text" must be a string.'); } const selector = args.selector.trim(); const textValue = args.text; const timeout = args.timeout !== undefined ? Number(args.timeout) : WAIT_TIMEOUT; if (!Number.isFinite(timeout) || timeout <= 0) { throw new Error('Argument "timeout" must be a positive number when provided.'); } const delay = args.delay !== undefined ? Number(args.delay) : undefined; if (delay !== undefined && (!Number.isFinite(delay) || delay < 0)) { throw new Error('Argument "delay" must be a non-negative number when provided.'); } const clear = Boolean(args.clear); await withPage(async (page) => { await page.waitForSelector(selector, { timeout }); if (clear) { await page.$eval( selector, (element) => { if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) { element.value = ""; } else { element.textContent = ""; } }, ); } if (delay !== undefined) { await page.type(selector, textValue, { delay }); } else { await page.type(selector, textValue); } }); return { content: [{ type: "text", text: `Typed into selector "${selector}"` }], isError: false, }; } case "dev_scroll": { requirePermission("scroll"); const hasX = args.x !== undefined; const hasY = args.y !== undefined; const x = hasX ? Number(args.x) : undefined; const y = hasY ? Number(args.y) : undefined; if (hasX && !Number.isFinite(x)) { throw new Error('Argument "x" must be a finite number when provided.'); } if (hasY && !Number.isFinite(y)) { throw new Error('Argument "y" must be a finite number when provided.'); } const behavior = typeof args.behavior === "string" ? args.behavior : "auto"; if (!["auto", "smooth"].includes(behavior)) { throw new Error('Argument "behavior" must be "auto" or "smooth" when provided.'); } await withPage(async (page) => page.evaluate( ({ left, top, scrollBehavior }) => { const options = { behavior: scrollBehavior }; if (left !== undefined) { options.left = left; } if (top !== undefined) { options.top = top; } window.scrollTo(options); }, { left: hasX ? x : undefined, top: hasY ? y : undefined, scrollBehavior: behavior, }, ), ); return { content: [{ type: "text", text: "Scrolled page" }], isError: false, }; } case "dev_set_viewport": { requirePermission("set_viewport"); const width = Number(args.width); const height = Number(args.height); if (!Number.isFinite(width) || width <= 0) { throw new Error('Argument "width" must be a positive number.'); } if (!Number.isFinite(height) || height <= 0) { throw new Error('Argument "height" must be a positive number.'); } const dsfProvided = args.deviceScaleFactor !== undefined; const deviceScaleFactor = dsfProvided ? Number(args.deviceScaleFactor) : undefined; if (dsfProvided && (!Number.isFinite(deviceScaleFactor) || deviceScaleFactor <= 0)) { throw new Error('Argument "deviceScaleFactor" must be a positive number when provided.'); } await withPage(async (page) => page.setViewport({ width: Math.round(width), height: Math.round(height), deviceScaleFactor: dsfProvided ? deviceScaleFactor : undefined, }), ); const suffix = dsfProvided ? ` @${deviceScaleFactor}x` : ""; return { content: [{ type: "text", text: `Viewport set to ${Math.round(width)}x${Math.round(height)}${suffix}` }], isError: false, }; } default: return { content: [{ type: "text", text: `Unknown tool: ${tool}` }], isError: true, }; } } catch (error) { const message = error instanceof Error ? error.message : String(error); pushLog("error", `Tool ${tool} failed: ${message}`); return { content: [{ type: "text", text: message }], isError: true, }; } }); } async function shutdown() { if (state.browser) { try { await state.browser.close(); } catch (error) { // ignore shutdown errors } state.browser = null; state.page = null; state.listenersAttached = false; } } async function main() { registerHandlers(); process.stdin.on("close", shutdown); process.on("SIGINT", async () => { await shutdown(); process.exit(0); }); process.on("SIGTERM", async () => { await shutdown(); process.exit(0); }); const transport = new StdioServerTransport(); await server.connect(transport); await autoNavigate(); } main().catch(async (error) => { console.error("Failed to start dev chrome monitor server", error); await shutdown(); 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/2sslgetlool/-dev-chrome-monitor'

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