#!/usr/bin/env node function loadWebSocketImplementation() { if (typeof globalThis.WebSocket === 'function') { return globalThis.WebSocket; } try { const { WebSocket } = require('undici'); if (typeof WebSocket === 'function') { return WebSocket; } } catch { // Fall through to the legacy ws dependency when available. } try { const wsModule = require('ws'); if (typeof wsModule === 'function') { return wsModule; } if (typeof wsModule?.WebSocket === 'function') { return wsModule.WebSocket; } } catch { // Surface a dedicated error below if no implementation is available. } throw new Error( 'Browser MCP could not find a WebSocket implementation. Tried globalThis.WebSocket, undici, and ws.' ); } const WebSocket = loadWebSocketImplementation(); const PROTOCOL_VERSION = '2024-11-05'; const SERVER_NAME = 'ccs-browser'; const SERVER_VERSION = '1.0.0'; const TOOL_SESSION_INFO = 'browser_get_session_info'; const TOOL_URL_TITLE = 'browser_get_url_and_title'; const TOOL_VISIBLE_TEXT = 'browser_get_visible_text'; const TOOL_DOM_SNAPSHOT = 'browser_get_dom_snapshot'; const TOOL_NAVIGATE = 'browser_navigate'; const TOOL_CLICK = 'browser_click'; const TOOL_TYPE = 'browser_type'; const TOOL_PRESS_KEY = 'browser_press_key'; const TOOL_SCROLL = 'browser_scroll'; const TOOL_SELECT_PAGE = 'browser_select_page'; const TOOL_OPEN_PAGE = 'browser_open_page'; const TOOL_CLOSE_PAGE = 'browser_close_page'; const TOOL_ADD_INTERCEPT_RULE = 'browser_add_intercept_rule'; const TOOL_REMOVE_INTERCEPT_RULE = 'browser_remove_intercept_rule'; const TOOL_LIST_INTERCEPT_RULES = 'browser_list_intercept_rules'; const TOOL_LIST_REQUESTS = 'browser_list_requests'; const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; const TOOL_HOVER = 'browser_hover'; const TOOL_QUERY = 'browser_query'; const TOOL_TAKE_ELEMENT_SCREENSHOT = 'browser_take_element_screenshot'; const TOOL_WAIT_FOR_EVENT = 'browser_wait_for_event'; const TOOL_NAMES = [ TOOL_SESSION_INFO, TOOL_URL_TITLE, TOOL_VISIBLE_TEXT, TOOL_DOM_SNAPSHOT, TOOL_NAVIGATE, TOOL_CLICK, TOOL_TYPE, TOOL_PRESS_KEY, TOOL_SCROLL, TOOL_SELECT_PAGE, TOOL_OPEN_PAGE, TOOL_CLOSE_PAGE, TOOL_ADD_INTERCEPT_RULE, TOOL_REMOVE_INTERCEPT_RULE, TOOL_LIST_INTERCEPT_RULES, TOOL_LIST_REQUESTS, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, TOOL_HOVER, TOOL_QUERY, TOOL_TAKE_ELEMENT_SCREENSHOT, TOOL_WAIT_FOR_EVENT, ]; const SUPPORTED_QUERY_FIELDS = [ 'exists', 'count', 'innerText', 'textContent', 'boundingClientRect', 'display', 'visibility', 'opacity', 'href', 'onclick', ]; const DEFAULT_QUERY_FIELDS = [...SUPPORTED_QUERY_FIELDS]; const SUPPORTED_QUERY_FIELD_SET = new Set(SUPPORTED_QUERY_FIELDS); const CDP_TIMEOUT_MS = 5000; const NAVIGATION_POLL_INTERVAL_MS = 100; const DEFAULT_WAIT_TIMEOUT_MS = 2000; const DEFAULT_WAIT_POLL_INTERVAL_MS = 100; let inputBuffer = Buffer.alloc(0); let requestCounter = 0; let selectedPageId = ''; let messageQueue = Promise.resolve(); let nextInterceptRuleCounter = 1; const interceptRules = []; const recentRequests = []; const interceptSessionsByPageId = new Map(); const MAX_RECENT_REQUESTS = 100; const FETCH_FAIL_ERROR_REASON = 'Failed'; function addSocketListener(socket, eventName, handler) { if (typeof socket.addEventListener === 'function') { socket.addEventListener(eventName, handler); return; } if (typeof socket.on === 'function') { socket.on(eventName, handler); } } async function getSocketMessageText(message) { const data = message && typeof message === 'object' && 'data' in message ? message.data : message; if (typeof data === 'string') { return data; } if (Buffer.isBuffer(data)) { return data.toString('utf8'); } if (data instanceof ArrayBuffer) { return Buffer.from(data).toString('utf8'); } if (ArrayBuffer.isView(data)) { return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString('utf8'); } if (data && typeof data.text === 'function') { return await data.text(); } return String(data); } function closeSocket(socket) { if (typeof socket.close === 'function') { socket.close(); } } function abortSocket(socket) { if (typeof socket.terminate === 'function') { socket.terminate(); return; } closeSocket(socket); } function toSocketError(error) { if (error instanceof Error) { return error; } return new Error('Browser MCP lost the DevTools websocket connection.'); } function shouldExposeTools() { return Boolean(process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL); } function writeMessage(message) { process.stdout.write(`${JSON.stringify(message)}\n`); } function writeResponse(id, result) { writeMessage({ jsonrpc: '2.0', id, result }); } function writeError(id, code, message) { writeMessage({ jsonrpc: '2.0', id, error: { code, message } }); } function getTools() { if (!shouldExposeTools()) { return []; } return [ { name: TOOL_SESSION_INFO, description: 'List the current Chrome session pages available through the configured DevTools connection, including page ids, titles, URLs, and websocket endpoints.', inputSchema: { type: 'object', properties: {}, additionalProperties: false, }, }, { name: TOOL_URL_TITLE, description: 'Read the current page URL and title from the configured Chrome session. Optionally choose a page by index.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, }, additionalProperties: false, }, }, { name: TOOL_VISIBLE_TEXT, description: 'Read visible text from the current page via DOM evaluation in the configured Chrome session. Optionally choose a page by index.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, }, additionalProperties: false, }, }, { name: TOOL_DOM_SNAPSHOT, description: 'Read a DOM snapshot from the current page by returning the document outerHTML. Optionally choose a page by index.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, }, additionalProperties: false, }, }, { name: TOOL_NAVIGATE, description: 'Navigate the selected page to an absolute http or https URL and wait until navigation is ready. Optionally choose a page by index.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, url: { type: 'string', description: 'Required absolute http or https URL to navigate to.', }, }, required: ['url'], additionalProperties: false, }, }, { name: TOOL_CLICK, description: 'Click the first element matching a CSS selector in the selected page using a minimal mouse event chain with click fallback. Optionally choose a page by index and match index.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', description: 'Required CSS selector for the element to click.', }, nth: { type: 'integer', minimum: 0, description: 'Optional zero-based match index for selectors returning multiple elements.', }, frameSelector: { type: 'string', description: 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', description: 'When true, search open shadow roots beneath the selected root.', }, offsetX: { type: 'number', description: "Optional horizontal offset in CSS pixels from the target element's left edge.", }, offsetY: { type: 'number', description: "Optional vertical offset in CSS pixels from the target element's top edge.", }, button: { type: 'string', enum: ['left', 'middle', 'right'], description: 'Optional mouse button. Defaults to left.', }, clickCount: { type: 'integer', minimum: 1, description: 'Optional click count. Defaults to 1.', }, }, required: ['selector'], additionalProperties: false, }, }, { name: TOOL_TYPE, description: 'Type text into the first element matching a CSS selector when it is a supported text-editable target. Optionally choose a page by index.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', description: 'Required CSS selector for the target element.', }, text: { type: 'string', description: 'Required text to assign. May be an empty string.', }, clearFirst: { type: 'boolean', description: 'When true, clear the current value or content before assigning text.', }, }, required: ['selector', 'text'], additionalProperties: false, }, }, { name: TOOL_PRESS_KEY, description: 'Press a key or key combination in the selected page using real keyboard-style events. Optionally choose a page by index.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, key: { type: 'string', description: 'Required primary key to press.', }, modifiers: { type: 'array', items: { type: 'string', enum: ['Alt', 'Control', 'Meta', 'Shift'], }, description: 'Optional modifier keys such as Alt, Control, Meta, or Shift.', }, repeat: { type: 'integer', minimum: 1, description: 'Optional repeat count. Defaults to 1.', }, }, required: ['key'], additionalProperties: false, }, }, { name: TOOL_SCROLL, description: 'Scroll the selected page or a matched element. Supports explicit deltas or scrolling an element into view.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', description: 'Optional CSS selector for an element-scoped scroll target.', }, frameSelector: { type: 'string', description: 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', description: 'When true, search open shadow roots beneath the selected root.', }, behavior: { type: 'string', enum: ['into-view', 'by-offset'], description: 'Required scroll behavior.', }, deltaX: { type: 'number', description: 'Optional horizontal scroll delta for by-offset behavior.', }, deltaY: { type: 'number', description: 'Optional vertical scroll delta for by-offset behavior.', }, }, required: ['behavior'], additionalProperties: false, }, }, { name: TOOL_SELECT_PAGE, description: 'Select the current page target by page index or page id for subsequent browser tool calls.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0 }, pageId: { type: 'string' }, }, additionalProperties: false, }, }, { name: TOOL_OPEN_PAGE, description: 'Open a new browser page tab and optionally navigate it to a URL.', inputSchema: { type: 'object', properties: { url: { type: 'string' }, }, additionalProperties: false, }, }, { name: TOOL_CLOSE_PAGE, description: 'Close a browser page target by page index or page id. Defaults to the selected page.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0 }, pageId: { type: 'string' }, }, additionalProperties: false, }, }, { name: TOOL_ADD_INTERCEPT_RULE, description: 'Add a session-local interception rule bound to a concrete page target.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0 }, pageId: { type: 'string' }, urlIncludes: { type: 'string' }, method: { type: 'string' }, action: { type: 'string', enum: ['continue', 'fail', 'fulfill'] }, statusCode: { type: 'integer', minimum: 100, maximum: 599 }, headers: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, value: { type: 'string' }, }, required: ['name', 'value'], additionalProperties: false, }, }, body: { type: 'string' }, contentType: { type: 'string' }, }, required: ['action'], additionalProperties: false, }, }, { name: TOOL_REMOVE_INTERCEPT_RULE, description: 'Remove a session-local interception rule by rule id.', inputSchema: { type: 'object', properties: { ruleId: { type: 'string' }, }, required: ['ruleId'], additionalProperties: false, }, }, { name: TOOL_LIST_INTERCEPT_RULES, description: 'List current session-local interception rules.', inputSchema: { type: 'object', properties: {}, additionalProperties: false, }, }, { name: TOOL_LIST_REQUESTS, description: 'List recent intercepted request summaries for the current session.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0 }, pageId: { type: 'string' }, limit: { type: 'integer', minimum: 1 }, }, additionalProperties: false, }, }, { name: TOOL_TAKE_SCREENSHOT, description: 'Capture a PNG screenshot from the selected page. Optionally choose a page by index or request fullPage capture.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, fullPage: { type: 'boolean', description: 'Optional full-page capture flag.', }, }, additionalProperties: false, }, }, { name: TOOL_WAIT_FOR, description: 'Poll until a selector-scoped or page-level condition is satisfied. Supports selector existence/visibility/text waits and page text waits.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', description: 'Optional CSS selector for selector-scoped waiting.', }, nth: { type: 'integer', minimum: 0, description: 'Optional zero-based match index for selectors returning multiple elements.', }, frameSelector: { type: 'string', description: 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', description: 'When true, search open shadow roots beneath the selected root.', }, timeoutMs: { type: 'integer', minimum: 1, description: 'Optional timeout in milliseconds.', }, pollIntervalMs: { type: 'integer', minimum: 1, description: 'Optional polling interval in milliseconds.', }, condition: { type: 'object', description: 'Required wait condition.', }, }, required: ['condition'], additionalProperties: false, }, }, { name: TOOL_EVAL, description: 'Evaluate page-side JavaScript for inspection or mutation, gated by CCS_BROWSER_EVAL_MODE.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, expression: { type: 'string', description: 'Required JavaScript expression to evaluate in the page.', }, mode: { type: 'string', enum: ['readonly', 'readwrite'], description: 'Optional evaluation mode. Defaults to readonly.', }, }, required: ['expression'], additionalProperties: false, }, }, { name: TOOL_HOVER, description: 'Move the browser mouse pointer onto the first element matching a CSS selector in the selected page to trigger hover state. Optionally choose a page by index.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', description: 'Required CSS selector for the hover target.', }, frameSelector: { type: 'string', description: 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', description: 'When true, search open shadow roots beneath the selected root.', }, }, required: ['selector'], additionalProperties: false, }, }, { name: TOOL_QUERY, description: 'Return diagnostic state for selector-matched elements in the selected page. Optionally choose a page by index, zero-based match index, and a subset of fields.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', description: 'Required CSS selector for the query target.', }, nth: { type: 'integer', minimum: 0, description: 'Optional zero-based match index for selectors returning multiple elements.', }, frameSelector: { type: 'string', description: 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', description: 'When true, search open shadow roots beneath the selected root.', }, fields: { type: 'array', items: { type: 'string' }, description: 'Optional list of diagnostic fields to return.', }, }, required: ['selector'], additionalProperties: false, }, }, { name: TOOL_TAKE_ELEMENT_SCREENSHOT, description: 'Capture a PNG screenshot clipped to the first element matching a CSS selector in the selected page. Optionally choose a page by index.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, selector: { type: 'string', description: 'Required CSS selector for the screenshot target.', }, frameSelector: { type: 'string', description: 'Optional CSS selector for an iframe whose document should be used as the query root.', }, pierceShadow: { type: 'boolean', description: 'When true, search open shadow roots beneath the selected root.', }, }, required: ['selector'], additionalProperties: false, }, }, { name: TOOL_WAIT_FOR_EVENT, description: 'Wait until a page or browser event matching the requested filter is observed.', inputSchema: { type: 'object', properties: { pageIndex: { type: 'integer', minimum: 0, description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.', }, timeoutMs: { type: 'integer', minimum: 1, description: 'Optional timeout in milliseconds.', }, event: { type: 'object', description: 'Required event selector.', }, }, required: ['event'], additionalProperties: false, }, }, ]; } async function fetchJson(url) { const response = await fetch(url); if (!response.ok) { throw new Error(`HTTP ${response.status} for ${url}`); } return await response.json(); } function getHttpUrl() { const value = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL; if (!value) { throw new Error('Browser MCP is unavailable because CCS_BROWSER_DEVTOOLS_HTTP_URL is missing.'); } return value.replace(/\/+$/, ''); } async function listPageTargets() { const targets = await fetchJson(`${getHttpUrl()}/json/list`); if (!Array.isArray(targets)) { throw new Error('Browser MCP received an invalid /json/list response.'); } return targets .filter((target) => target && typeof target === 'object' && target.type === 'page') .map((target) => ({ id: typeof target.id === 'string' ? target.id : '', title: typeof target.title === 'string' ? target.title : '', url: typeof target.url === 'string' ? target.url : '', type: typeof target.type === 'string' ? target.type : 'page', webSocketDebuggerUrl: typeof target.webSocketDebuggerUrl === 'string' ? target.webSocketDebuggerUrl : '', })); } function parsePageIndex(toolArgs) { if (!toolArgs || !Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex')) { return 0; } if (!Number.isInteger(toolArgs.pageIndex) || toolArgs.pageIndex < 0) { throw new Error('pageIndex must be a non-negative integer'); } return toolArgs.pageIndex; } function findPageIndexById(pages, pageId) { return pages.findIndex((page) => page.id === pageId); } function resolveFallbackSelectedPageId(pages, preferredIndex = 0) { if (pages.length === 0) { return ''; } const safeIndex = Math.min(Math.max(preferredIndex, 0), pages.length - 1); return pages[safeIndex]?.id || pages[0]?.id || ''; } function parseOptionalPageId(toolArgs) { return typeof toolArgs?.pageId === 'string' && toolArgs.pageId.trim() !== '' ? toolArgs.pageId.trim() : ''; } function parseInterceptAction(value) { if (value !== 'continue' && value !== 'fail' && value !== 'fulfill') { throw new Error('action must be one of: continue, fail, fulfill'); } return value; } function parseOptionalStatusCode(value) { if (value === undefined) { return 200; } if (!Number.isInteger(value) || value < 100 || value > 599) { throw new Error('statusCode must be an integer between 100 and 599'); } return value; } function parseOptionalHeaders(value, contentType) { const headers = []; if (Array.isArray(value)) { for (const entry of value) { if (!entry || typeof entry !== 'object') { throw new Error('headers entries must be objects with name and value'); } const name = requireNonEmptyString(entry.name, 'headers.name'); const headerValue = String(entry.value ?? ''); headers.push({ name, value: headerValue }); } } else if (value !== undefined) { throw new Error('headers must be an array'); } if (contentType && !headers.some((header) => header.name.toLowerCase() === 'content-type')) { headers.push({ name: 'Content-Type', value: contentType }); } return headers; } function parseOptionalBody(value) { if (value === undefined) { return ''; } if (typeof value !== 'string') { throw new Error('body must be a string'); } return value; } function encodeFulfillBody(body) { return Buffer.from(body, 'utf8').toString('base64'); } function parseOptionalMethod(value) { if (value === undefined) { return ''; } return requireNonEmptyString(value, 'method').toUpperCase(); } function parseOptionalUrlIncludes(value) { if (value === undefined) { return ''; } return requireNonEmptyString(value, 'urlIncludes'); } function createInterceptRuleId() { return `rule-${nextInterceptRuleCounter++}`; } function removeInterceptStateForPage(pageId) { for (let index = interceptRules.length - 1; index >= 0; index -= 1) { if (interceptRules[index].pageId === pageId) { interceptRules.splice(index, 1); } } for (let index = recentRequests.length - 1; index >= 0; index -= 1) { if (recentRequests[index].pageId === pageId) { recentRequests.splice(index, 1); } } const interceptSession = interceptSessionsByPageId.get(pageId); if (interceptSession) { interceptSessionsByPageId.delete(pageId); } } function pushRecentRequest(entry) { recentRequests.push(entry); if (recentRequests.length > MAX_RECENT_REQUESTS) { recentRequests.splice(0, recentRequests.length - MAX_RECENT_REQUESTS); } } function formatInterceptRules(rules) { if (rules.length === 0) { return 'status: empty'; } return rules .map((rule) => [ `ruleId: ${rule.ruleId}`, `pageId: ${rule.pageId}`, `pageTitle: ${rule.pageTitleSnapshot || ''}`, `urlIncludes: ${rule.urlIncludes || ''}`, `method: ${rule.method || ''}`, `action: ${rule.action}`, `statusCode: ${rule.action === 'fulfill' ? rule.statusCode : 'n/a'}`, `contentType: ${rule.action === 'fulfill' ? rule.contentType || '' : 'n/a'}`, ].join('\n') ) .join('\n---\n'); } function formatRecentRequests(entries) { if (entries.length === 0) { return 'status: empty'; } return entries .map((entry) => [ `requestId: ${entry.requestId}`, `pageId: ${entry.pageId}`, `url: ${entry.url}`, `method: ${entry.method}`, `resourceType: ${entry.resourceType || ''}`, `matchedRuleId: ${entry.matchedRuleId || 'none'}`, `action: ${entry.action}`, `statusCode: ${entry.action === 'fulfill' ? entry.statusCode : 'n/a'}`, ].join('\n') ) .join('\n---\n'); } function resolveTargetPage(pages, toolArgs, defaultSelectedId = selectedPageId, options = {}) { const hasPageIndex = toolArgs && Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex'); const pageId = parseOptionalPageId(toolArgs); const allowImplicitFallback = options.allowImplicitFallback !== false; if (hasPageIndex && pageId) { throw new Error('pageIndex and pageId cannot be used together'); } if (hasPageIndex) { const pageIndex = parsePageIndex(toolArgs); const page = pages[pageIndex]; if (!page) { throw new Error(`pageIndex out of range: ${pageIndex}`); } return { page, pageIndex }; } if (pageId) { const pageIndex = findPageIndexById(pages, pageId); if (pageIndex === -1) { throw new Error(`page not found: ${pageId}`); } return { page: pages[pageIndex], pageIndex }; } const selectedIndex = findPageIndexById(pages, defaultSelectedId); if (selectedIndex === -1) { if (!allowImplicitFallback) { throw new Error('Selected page is no longer available; specify pageIndex or pageId explicitly.'); } const fallbackPage = pages[0]; if (!fallbackPage) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } return { page: fallbackPage, pageIndex: 0 }; } return { page: pages[selectedIndex], pageIndex: selectedIndex }; } async function getSelectedPage(toolArgs) { const pages = await listPageTargets(); if (pages.length === 0) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } if (toolArgs && Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex')) { const pageIndex = parsePageIndex(toolArgs); const page = pages[pageIndex]; if (!page) { throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`); } if (!page.webSocketDebuggerUrl) { throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); } return { page, pageIndex, pages }; } let pageIndex = findPageIndexById(pages, selectedPageId); if (pageIndex === -1) { selectedPageId = resolveFallbackSelectedPageId(pages, 0); pageIndex = findPageIndexById(pages, selectedPageId); } const page = pages[pageIndex]; if (!page || !page.webSocketDebuggerUrl) { throw new Error('Browser MCP could not resolve a selected page target.'); } return { page, pageIndex, pages }; } function formatSessionInfo(pages, selectedId) { return [ '[CCS Browser Session]', '', ...pages.map((page, index) => { const selectedSuffix = page.id === selectedId ? ' | selected: true' : ''; return `${index}. ${page.title || ''} | ${page.url || ''}${selectedSuffix}`; }), ].join('\n'); } function createEvaluateExpression(kind) { switch (kind) { case 'url-title': return `JSON.stringify({ title: document.title, url: location.href })`; case 'visible-text': return `document.body ? document.body.innerText : ''`; case 'dom-snapshot': return `document.documentElement ? document.documentElement.outerHTML : ''`; default: throw new Error(`Unknown browser evaluation kind: ${kind}`); } } async function sendCdpCommand(page, method, params = {}) { const ws = new WebSocket(page.webSocketDebuggerUrl); const requestId = ++requestCounter; return await new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { if (!settled) { settled = true; abortSocket(ws); reject(new Error('Browser MCP timed out waiting for a DevTools response.')); } }, CDP_TIMEOUT_MS); function settleError(error) { if (settled) { return; } clearTimeout(timer); settled = true; reject(toSocketError(error)); } addSocketListener(ws, 'open', () => { ws.send( JSON.stringify({ id: requestId, method, params, }) ); }); addSocketListener(ws, 'message', (data) => { void (async () => { const raw = await getSocketMessageText(data); if (settled) { return; } let message; try { message = JSON.parse(raw); } catch { return; } if (message.id !== requestId) { return; } clearTimeout(timer); settled = true; closeSocket(ws); if (message.error) { reject(new Error(message.error.message || 'DevTools request failed.')); return; } resolve(message.result || null); })().catch(settleError); }); addSocketListener(ws, 'error', (error) => { settleError(error); }); addSocketListener(ws, 'close', () => { if (settled) { return; } settleError(new Error('Browser MCP lost the DevTools websocket connection.')); }); }); } async function evaluateInPage(page, kind) { const response = await sendCdpCommand(page, 'Runtime.evaluate', { expression: createEvaluateExpression(kind), returnByValue: true, }); const result = response && response.result ? response.result : null; if (!result) { throw new Error('Browser MCP received an invalid DevTools evaluation response.'); } if (result.subtype === 'error') { throw new Error(result.description || 'DevTools evaluation returned an error.'); } return typeof result.value === 'string' ? result.value : result.value ?? ''; } async function evaluateExpression(page, expression) { const response = await sendCdpCommand(page, 'Runtime.evaluate', { expression, returnByValue: true, }); const result = response && response.result ? response.result : null; if (!result) { throw new Error('Browser MCP received an invalid DevTools evaluation response.'); } if (result.subtype === 'error') { throw new Error(result.description || 'DevTools evaluation returned an error.'); } return typeof result.value === 'string' ? result.value : result.value ?? ''; } function requireNonEmptyString(value, label) { if (typeof value !== 'string' || value.trim() === '') { throw new Error(`${label} is required`); } return value.trim(); } function requireString(value, label) { if (typeof value !== 'string') { throw new Error(`${label} is required`); } return value; } function requireValidHttpUrl(value) { const raw = requireNonEmptyString(value, 'url'); let parsed; try { parsed = new URL(raw); } catch { throw new Error('url must be an absolute http or https URL'); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error('url must be an absolute http or https URL'); } return parsed.toString(); } function parseQueryFields(value) { if (value === undefined) { return DEFAULT_QUERY_FIELDS; } if (!Array.isArray(value) || value.some((field) => typeof field !== 'string')) { throw new Error('fields must be an array of strings'); } const unknownField = value.find((field) => !SUPPORTED_QUERY_FIELD_SET.has(field)); if (unknownField) { throw new Error(`unknown query field: ${unknownField}`); } return value; } function requireOptionalNonNegativeInteger(value, label) { if (value === undefined) { return undefined; } if (!Number.isInteger(value) || value < 0) { throw new Error(`${label} must be a non-negative integer`); } return value; } function requirePositiveIntegerOrDefault(value, label, fallback) { if (value === undefined) { return fallback; } if (!Number.isInteger(value) || value <= 0) { throw new Error(`${label} must be a positive integer`); } return value; } function requirePositiveInteger(value, label) { if (!Number.isInteger(value) || value <= 0) { throw new Error(`${label} must be a positive integer`); } return value; } function requireFiniteNumber(value, label) { if (typeof value !== 'number' || !Number.isFinite(value)) { throw new Error(`${label} must be a finite number`); } return value; } function requireEnumString(value, label, allowedValues) { if (value === undefined) { return undefined; } const normalized = requireNonEmptyString(value, label); if (!allowedValues.includes(normalized)) { throw new Error(`${label} must be one of: ${allowedValues.join(', ')}`); } return normalized; } function requireOptionalStringArray(value, label, allowedValues) { if (value === undefined) { return []; } if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.trim() === '')) { throw new Error(`${label} must be an array of non-empty strings`); } const normalized = value.map((item) => item.trim()); if (Array.isArray(allowedValues) && allowedValues.length > 0) { for (const item of normalized) { if (!allowedValues.includes(item)) { throw new Error(`${label} must only contain: ${allowedValues.join(', ')}`); } } } return normalized; } function getBrowserEvalMode() { const raw = String(process.env.CCS_BROWSER_EVAL_MODE || 'readonly').trim(); if (raw === 'disabled' || raw === 'readonly' || raw === 'readwrite') { return raw; } return 'readonly'; } function parseOptionalNonEmptyString(value) { return typeof value === 'string' && value.trim() !== '' ? value.trim() : ''; } function formatScopedSelectorSuffix(frameSelector, pierceShadow) { return `${frameSelector ? `\nframeSelector: ${frameSelector}` : ''}${pierceShadow ? '\npierceShadow: true' : ''}`; } function buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow) { return `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); const nth = ${nth === undefined ? 'undefined' : String(nth)}; const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; const pierceShadow = ${pierceShadow ? 'true' : 'false'}; const visitRoots = (root) => { const roots = [root]; if (!pierceShadow) { return roots; } const queue = [root]; while (queue.length > 0) { const current = queue.shift(); const elements = Array.from(current.querySelectorAll('*')); for (const element of elements) { if (element.shadowRoot) { roots.push(element.shadowRoot); queue.push(element.shadowRoot); } } } return roots; }; let root = document; if (frameSelector) { const frame = document.querySelector(frameSelector); if (!frame) { throw new Error('frame not found for selector: ' + frameSelector); } const frameDocument = frame.contentDocument; if (!frameDocument) { throw new Error('frame document is unavailable for selector: ' + frameSelector); } root = frameDocument; } const roots = visitRoots(root); const matches = []; for (const currentRoot of roots) { matches.push(...Array.from(currentRoot.querySelectorAll(selector))); } const count = matches.length; const targetIndex = nth ?? 0; const element = matches[targetIndex]; if (!element) { return JSON.stringify({ exists: nth === undefined ? count > 0 : count > targetIndex, count, targetIndex, targetMissing: true, }); } const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); const text = typeof element.innerText === 'string' ? element.innerText : (element.textContent || ''); return JSON.stringify({ exists: true, count, targetIndex, connected: element.isConnected, text, innerText: typeof element.innerText === 'string' ? element.innerText : '', textContent: element.textContent || '', boundingClientRect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height, top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left, }, display: style.display, visibility: style.visibility, opacity: style.opacity, href: typeof element.getAttribute === 'function' ? element.getAttribute('href') || '' : '', onclick: typeof element.getAttribute === 'function' ? element.getAttribute('onclick') || '' : '', interactable: element.isConnected && style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0, centerPoint: { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, }, }); })()`; } async function getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow) { const raw = await evaluateExpression(page, buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow)); return JSON.parse(raw); } function parseEventCondition(value) { if (!value || typeof value !== 'object') { throw new Error('event is required'); } if (value.kind === 'dialog') { return { kind: 'dialog', dialogType: value.dialogType ? String(value.dialogType) : undefined, messageIncludes: value.messageIncludes ? String(value.messageIncludes) : undefined, }; } if (value.kind === 'navigation') { return { kind: 'navigation', urlIncludes: value.urlIncludes ? String(value.urlIncludes) : undefined, }; } if (value.kind === 'request') { return { kind: 'request', urlIncludes: value.urlIncludes ? String(value.urlIncludes) : undefined, method: value.method ? String(value.method) : undefined, }; } if (value.kind === 'download') { return { kind: 'download', urlIncludes: value.urlIncludes ? String(value.urlIncludes) : undefined, suggestedFilenameIncludes: value.suggestedFilenameIncludes ? String(value.suggestedFilenameIncludes) : undefined, }; } throw new Error(`unknown event kind: ${String(value.kind || '')}`); } function matchesObservedEvent(event, observed) { if (event.kind === 'dialog') { return ( (!event.dialogType || observed.type === event.dialogType) && (!event.messageIncludes || String(observed.message || '').includes(event.messageIncludes)) ); } if (event.kind === 'navigation') { return !event.urlIncludes || String(observed.url || '').includes(event.urlIncludes); } if (event.kind === 'request') { return ( (!event.urlIncludes || String(observed.url || '').includes(event.urlIncludes)) && (!event.method || String(observed.method || '').toUpperCase() === event.method.toUpperCase()) ); } if (event.kind === 'download') { return ( (!event.urlIncludes || String(observed.url || '').includes(event.urlIncludes)) && (!event.suggestedFilenameIncludes || String(observed.suggestedFilename || '').includes(event.suggestedFilenameIncludes)) ); } return false; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } async function getNavigationState(page) { const raw = await evaluateExpression(page, `JSON.stringify({ href: location.href, readyState: document.readyState })`); const parsed = JSON.parse(raw); return { href: typeof parsed.href === 'string' ? parsed.href : '', readyState: typeof parsed.readyState === 'string' ? parsed.readyState : '', }; } function isSameDocumentHashNavigation(beforeHref, requestedUrl) { try { const before = new URL(beforeHref); const requested = new URL(requestedUrl); return ( before.origin === requested.origin && before.pathname === requested.pathname && before.search === requested.search && before.hash !== requested.hash ); } catch { return false; } } function isNavigationReady(state, beforeHref, requestedUrl) { if (state.readyState !== 'interactive' && state.readyState !== 'complete') { return false; } if (state.href === requestedUrl) { return true; } if (state.href && state.href !== beforeHref) { return true; } if (isSameDocumentHashNavigation(beforeHref, requestedUrl) && state.href === requestedUrl) { return true; } return false; } async function waitForNavigationReady(page, beforeHref, requestedUrl) { const deadline = Date.now() + CDP_TIMEOUT_MS; while (Date.now() <= deadline) { const state = await getNavigationState(page); if (isNavigationReady(state, beforeHref, requestedUrl)) { return state.href; } if (Date.now() + NAVIGATION_POLL_INTERVAL_MS > deadline) { break; } await sleep(NAVIGATION_POLL_INTERVAL_MS); } throw new Error(`navigation did not complete for URL: ${requestedUrl}`); } async function handleNavigate(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const url = requireValidHttpUrl(toolArgs.url); const before = await getNavigationState(page); const navigateResult = await sendCdpCommand(page, 'Page.navigate', { url }); if (navigateResult && typeof navigateResult.errorText === 'string' && navigateResult.errorText) { throw new Error(`navigation failed for URL: ${url}: ${navigateResult.errorText}`); } const finalUrl = await waitForNavigationReady(page, before.href, url); return `pageIndex: ${pageIndex}\nurl: ${finalUrl}\nstatus: navigated`; } async function handleClick(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const selector = requireNonEmptyString(toolArgs.selector, 'selector'); const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); const targetIndex = nth ?? 0; const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); const pierceShadow = toolArgs.pierceShadow === true; const offsetX = toolArgs.offsetX === undefined ? undefined : requireFiniteNumber(toolArgs.offsetX, 'offsetX'); const offsetY = toolArgs.offsetY === undefined ? undefined : requireFiniteNumber(toolArgs.offsetY, 'offsetY'); const button = requireEnumString(toolArgs.button, 'button', ['left', 'middle', 'right']) || 'left'; const clickCount = toolArgs.clickCount === undefined ? 1 : requirePositiveInteger(toolArgs.clickCount, 'clickCount'); const expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); const nth = ${nth === undefined ? 'undefined' : String(nth)}; const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; const pierceShadow = ${pierceShadow ? 'true' : 'false'}; const offsetX = ${offsetX === undefined ? 'undefined' : String(offsetX)}; const offsetY = ${offsetY === undefined ? 'undefined' : String(offsetY)}; const button = JSON.parse(${JSON.stringify(JSON.stringify(button))}); const clickCount = ${clickCount}; const visitRoots = (root) => { const roots = [root]; if (!pierceShadow) { return roots; } const queue = [root]; while (queue.length > 0) { const current = queue.shift(); const elements = Array.from(current.querySelectorAll('*')); for (const element of elements) { if (element.shadowRoot) { roots.push(element.shadowRoot); queue.push(element.shadowRoot); } } } return roots; }; let root = document; if (frameSelector) { const frame = document.querySelector(frameSelector); if (!frame) { throw new Error('frame not found for selector: ' + frameSelector); } const frameDocument = frame.contentDocument; if (!frameDocument) { throw new Error('frame document is unavailable for selector: ' + frameSelector); } root = frameDocument; } const roots = visitRoots(root); const matches = []; for (const currentRoot of roots) { matches.push(...Array.from(currentRoot.querySelectorAll(selector))); } const element = matches[nth ?? 0]; if (!element) { throw new Error('element index ' + (nth ?? 0) + ' is out of range for selector: ' + selector); } if (!element.isConnected) { throw new Error('element is detached for selector: ' + selector); } if ('disabled' in element && element.disabled) { throw new Error('element is disabled for selector: ' + selector); } const style = window.getComputedStyle(element); const initialRect = element.getBoundingClientRect(); if ( style.display === 'none' || style.visibility === 'hidden' || initialRect.width <= 0 || initialRect.height <= 0 ) { throw new Error('element is hidden or not interactable for selector: ' + selector); } element.scrollIntoView({ block: 'center', inline: 'center' }); const rect = element.getBoundingClientRect(); const resolvedOffsetX = offsetX === undefined ? rect.width / 2 : offsetX; const resolvedOffsetY = offsetY === undefined ? rect.height / 2 : offsetY; const clientX = rect.left + resolvedOffsetX; const clientY = rect.top + resolvedOffsetY; const buttonCode = button === 'middle' ? 1 : button === 'right' ? 2 : 0; const buttonsMask = buttonCode === 1 ? 4 : buttonCode === 2 ? 2 : 1; const dispatchMouseEvent = (type, detail, init) => { const event = new MouseEvent(type, { bubbles: true, cancelable: true, composed: true, view: window, detail, clientX, clientY, button: buttonCode, buttons: type === 'mousedown' ? buttonsMask : 0, ...init, }); return element.dispatchEvent(event); }; try { for (let index = 1; index <= clickCount; index += 1) { const dispatchResult = { shouldActivate: dispatchMouseEvent('mousedown', index, {}) && dispatchMouseEvent('mouseup', index, {}), }; if (!dispatchResult.shouldActivate) { return JSON.stringify({ resolvedOffsetX, resolvedOffsetY, button, clickCount }); } if (!element.isConnected) { return JSON.stringify({ resolvedOffsetX, resolvedOffsetY, button, clickCount }); } if (button === 'left') { element.click(); } else if (button === 'right') { element.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, composed: true, view: window, detail: index, clientX, clientY, button: 2, buttons: 0, })); } else { element.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, cancelable: true, composed: true, view: window, detail: index, clientX, clientY, button: 1, buttons: 0, })); } } if (button === 'left' && clickCount === 2) { element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true, composed: true, view: window, detail: 2, clientX, clientY, button: 0, buttons: 0, })); } } catch (mouseError) { } return JSON.stringify({ resolvedOffsetX, resolvedOffsetY, button, clickCount }); })()`; const raw = await evaluateExpression(page, expression); const parsed = JSON.parse(raw); return `pageIndex: ${pageIndex}\nselector: ${selector}\nnth: ${targetIndex}\noffsetX: ${parsed.resolvedOffsetX}\noffsetY: ${parsed.resolvedOffsetY}\nbutton: ${parsed.button}\nclickCount: ${parsed.clickCount}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: clicked`; } async function handleType(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const selector = requireNonEmptyString(toolArgs.selector, 'selector'); const text = requireString(toolArgs.text, 'text'); const clearFirst = toolArgs.clearFirst === true; const expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); const text = JSON.parse(${JSON.stringify(JSON.stringify(text))}); const clearFirst = ${clearFirst ? 'true' : 'false'}; const element = document.querySelector(selector); if (!element) { throw new Error('element not found for selector: ' + selector); } const dispatchEvents = (target) => { target.dispatchEvent(new Event('input', { bubbles: true })); target.dispatchEvent(new Event('change', { bubbles: true })); }; const focusTarget = (target) => { if (typeof target.focus === 'function') { target.focus(); } }; let readback = ''; let expectedValue = ''; if (element instanceof HTMLTextAreaElement) { focusTarget(element); const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set; expectedValue = (clearFirst ? '' : element.value) + text; if (setter) { setter.call(element, expectedValue); } else { element.value = expectedValue; } dispatchEvents(element); readback = element.value; } else if (element instanceof HTMLInputElement) { const supportedTypes = new Set(['', 'text', 'search', 'email', 'url', 'tel', 'password']); const normalizedType = (element.getAttribute('type') || '').toLowerCase(); if (!supportedTypes.has(normalizedType)) { throw new Error('element is not text-editable for selector: ' + selector); } focusTarget(element); const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; expectedValue = (clearFirst ? '' : element.value) + text; if (setter) { setter.call(element, expectedValue); } else { element.value = expectedValue; } dispatchEvents(element); readback = element.value; } else if (element.isContentEditable === true) { focusTarget(element); expectedValue = (clearFirst ? '' : (element.textContent || '')) + text; element.textContent = expectedValue; dispatchEvents(element); readback = element.textContent || ''; } else { throw new Error('element is not text-editable for selector: ' + selector); } if (readback !== expectedValue) { throw new Error('typed text verification failed for selector: ' + selector); } return JSON.stringify({ value: readback, typedLength: readback.length }); })()`; const raw = await evaluateExpression(page, expression); const parsed = JSON.parse(raw); const typedLength = typeof parsed.typedLength === 'number' ? parsed.typedLength : String(parsed.value || '').length; return `pageIndex: ${pageIndex}\nselector: ${selector}\ntypedLength: ${typedLength}\nstatus: typed`; } async function handlePressKey(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const key = requireNonEmptyString(toolArgs.key, 'key'); const modifiers = requireOptionalStringArray(toolArgs.modifiers, 'modifiers', [ 'Alt', 'Control', 'Meta', 'Shift', ]); const repeat = toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat'); const modifierMask = (modifiers.includes('Alt') ? 1 : 0) | (modifiers.includes('Control') ? 2 : 0) | (modifiers.includes('Meta') ? 4 : 0) | (modifiers.includes('Shift') ? 8 : 0); const specialKeyMap = { Enter: { code: 'Enter', keyCode: 13, text: '\r' }, Tab: { code: 'Tab', keyCode: 9, text: '' }, Escape: { code: 'Escape', keyCode: 27, text: '' }, ArrowUp: { code: 'ArrowUp', keyCode: 38, text: '' }, ArrowDown: { code: 'ArrowDown', keyCode: 40, text: '' }, ArrowLeft: { code: 'ArrowLeft', keyCode: 37, text: '' }, ArrowRight: { code: 'ArrowRight', keyCode: 39, text: '' }, Backspace: { code: 'Backspace', keyCode: 8, text: '' }, Delete: { code: 'Delete', keyCode: 46, text: '' }, Space: { code: 'Space', keyCode: 32, text: ' ' }, }; const keyDescriptor = key.length === 1 ? { code: `Key${key.toUpperCase()}`, keyCode: key.toUpperCase().charCodeAt(0), text: key, } : specialKeyMap[key]; if (!keyDescriptor) { throw new Error(`unsupported key: ${key}`); } const normalizedKey = key; const normalizedText = keyDescriptor.text; const code = keyDescriptor.code; const keyCode = keyDescriptor.keyCode; for (let index = 0; index < repeat; index += 1) { await sendCdpCommand(page, 'Input.dispatchKeyEvent', { type: 'keyDown', key: normalizedKey, code, text: normalizedText, unmodifiedText: normalizedText, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers: modifierMask, autoRepeat: index > 0, }); await sendCdpCommand(page, 'Input.dispatchKeyEvent', { type: 'keyUp', key: normalizedKey, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode, modifiers: modifierMask, }); } const modifierText = modifiers.length > 0 ? modifiers.join(',') : 'none'; return `pageIndex: ${pageIndex}\nkey: ${key}\nmodifiers: ${modifierText}\nrepeat: ${repeat}\nstatus: key-pressed`; } async function handleScroll(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const selector = parseOptionalNonEmptyString(toolArgs.selector); const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); const pierceShadow = toolArgs.pierceShadow === true; const behavior = requireEnumString(toolArgs.behavior, 'behavior', ['into-view', 'by-offset']); const deltaX = toolArgs.deltaX === undefined ? 0 : requireFiniteNumber(toolArgs.deltaX, 'deltaX'); const deltaY = toolArgs.deltaY === undefined ? 0 : requireFiniteNumber(toolArgs.deltaY, 'deltaY'); const expression = `(() => { const selector = ${selector ? `JSON.parse(${JSON.stringify(JSON.stringify(selector))})` : 'undefined'}; const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; const pierceShadow = ${pierceShadow ? 'true' : 'false'}; const behavior = JSON.parse(${JSON.stringify(JSON.stringify(behavior))}); const deltaX = ${deltaX}; const deltaY = ${deltaY}; const visitRoots = (root) => { const roots = [root]; if (!pierceShadow) { return roots; } const queue = [root]; while (queue.length > 0) { const current = queue.shift(); const elements = Array.from(current.querySelectorAll('*')); for (const element of elements) { if (element.shadowRoot) { roots.push(element.shadowRoot); queue.push(element.shadowRoot); } } } return roots; }; let root = document; let scrollWindow = window; if (frameSelector) { const frame = document.querySelector(frameSelector); if (!frame) { throw new Error('frame not found for selector: ' + frameSelector); } const frameDocument = frame.contentDocument; if (!frameDocument) { throw new Error('frame document is unavailable for selector: ' + frameSelector); } if (!frame.contentWindow) { throw new Error('frame window is unavailable for selector: ' + frameSelector); } root = frameDocument; scrollWindow = frame.contentWindow; } if (!selector) { if (behavior !== 'by-offset') { throw new Error('selector is required for behavior: ' + behavior); } scrollWindow.scrollBy(deltaX, deltaY); return JSON.stringify({ scope: 'page', behavior, deltaX, deltaY }); } const roots = visitRoots(root); const matches = []; for (const currentRoot of roots) { matches.push(...Array.from(currentRoot.querySelectorAll(selector))); } const element = matches[0]; if (!element) { throw new Error('element not found for selector: ' + selector); } if (behavior === 'into-view') { element.scrollIntoView({ block: 'center', inline: 'center' }); return JSON.stringify({ scope: 'element', selector, behavior }); } if (typeof element.scrollBy === 'function') { element.scrollBy(deltaX, deltaY); return JSON.stringify({ scope: 'element', selector, behavior, deltaX, deltaY }); } throw new Error('element does not support scrollBy for selector: ' + selector); })()`; const raw = await evaluateExpression(page, expression); const parsed = JSON.parse(raw); const lines = [`pageIndex: ${pageIndex}`]; if (parsed.selector) { lines.push(`selector: ${parsed.selector}`); } lines.push(`behavior: ${parsed.behavior}`); if (typeof parsed.deltaX === 'number') { lines.push(`deltaX: ${parsed.deltaX}`); } if (typeof parsed.deltaY === 'number') { lines.push(`deltaY: ${parsed.deltaY}`); } if (parsed.scope === 'element') { const scopedSuffix = formatScopedSelectorSuffix(frameSelector, pierceShadow); if (scopedSuffix) { lines.push(scopedSuffix.slice(1)); } } lines.push('status: scrolled'); return lines.join('\n'); } async function handleScreenshot(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const fullPage = toolArgs.fullPage === true; const response = await sendCdpCommand(page, 'Page.captureScreenshot', { format: 'png', captureBeyondViewport: fullPage, }); const data = response && typeof response.data === 'string' ? response.data : ''; if (!data) { throw new Error('screenshot capture failed'); } return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`; } async function getElementDiagnostics(page, selector, nth, frameSelector = '', pierceShadow = false) { return await getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow); } async function getScrolledElementState(page, selector, frameSelector = '', pierceShadow = false) { const expression = `(() => { const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))}); const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'}; const pierceShadow = ${pierceShadow ? 'true' : 'false'}; const visitRoots = (root) => { const roots = [root]; if (!pierceShadow) { return roots; } const queue = [root]; while (queue.length > 0) { const current = queue.shift(); const elements = Array.from(current.querySelectorAll('*')); for (const element of elements) { if (element.shadowRoot) { roots.push(element.shadowRoot); queue.push(element.shadowRoot); } } } return roots; }; let root = document; let frameOffset = { left: 0, top: 0 }; if (frameSelector) { const frame = document.querySelector(frameSelector); if (!frame) { throw new Error('frame not found for selector: ' + frameSelector); } const frameDocument = frame.contentDocument; if (!frameDocument) { throw new Error('frame document is unavailable for selector: ' + frameSelector); } const frameRect = frame.getBoundingClientRect(); frameOffset = { left: frameRect.left, top: frameRect.top }; root = frameDocument; } const roots = visitRoots(root); const matches = []; for (const currentRoot of roots) { matches.push(...Array.from(currentRoot.querySelectorAll(selector))); } const element = matches[0]; if (!element) { return JSON.stringify({ exists: false }); } if (!element.isConnected) { return JSON.stringify({ exists: true, connected: false }); } element.scrollIntoView({ block: 'center', inline: 'center' }); const rect = element.getBoundingClientRect(); const absoluteRect = { x: rect.x + frameOffset.left, y: rect.y + frameOffset.top, width: rect.width, height: rect.height, top: rect.top + frameOffset.top, right: rect.right + frameOffset.left, bottom: rect.bottom + frameOffset.top, left: rect.left + frameOffset.left, }; const style = window.getComputedStyle(element); const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0; const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0; const clipX = Math.max(0, absoluteRect.left); const clipY = Math.max(0, absoluteRect.top); const clipRight = Math.min(viewportWidth, absoluteRect.right); const clipBottom = Math.min(viewportHeight, absoluteRect.bottom); return JSON.stringify({ exists: true, connected: true, display: style.display, visibility: style.visibility, opacity: style.opacity, interactable: style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0, boundingClientRect: absoluteRect, centerPoint: { x: absoluteRect.left + absoluteRect.width / 2, y: absoluteRect.top + absoluteRect.height / 2, }, visibleClip: { x: clipX, y: clipY, width: Math.max(0, clipRight - clipX), height: Math.max(0, clipBottom - clipY), scale: 1, }, }); })()`; return JSON.parse(await evaluateExpression(page, expression)); } function formatQueryValue(field, value) { if (field === 'boundingClientRect') { return JSON.stringify(value); } return String(value); } function hasTargetSpecificQueryField(fields) { return fields.some((field) => field !== 'exists' && field !== 'count'); } function parseWaitCondition(value, hasSelector) { if (!value || typeof value !== 'object') { throw new Error('condition is required'); } const condition = value; if (condition.kind === 'existence') { if (!hasSelector) { throw new Error('page-level wait only supports text conditions in Phase 1'); } return { kind: 'existence', exists: condition.exists !== false }; } if (condition.kind === 'visibility') { if (!hasSelector) { throw new Error('page-level wait only supports text conditions in Phase 1'); } return { kind: 'visibility', visibility: condition.visibility === 'hidden' ? 'hidden' : 'visible', opacityGt: typeof condition.opacityGt === 'number' ? condition.opacityGt : undefined, }; } if (condition.kind === 'text') { if (typeof condition.includes !== 'string' || condition.includes === '') { throw new Error('condition.includes is required'); } return { kind: 'text', includes: condition.includes }; } throw new Error(`unknown wait condition kind: ${String(condition.kind || '')}`); } function isVisibleObservation(observation, opacityGt) { if (!observation || observation.targetMissing) { return false; } const opacity = Number.parseFloat(String(observation.opacity ?? '1')); return ( observation.display !== 'none' && observation.visibility === 'visible' && Number(observation.boundingClientRect?.width || 0) > 0 && Number(observation.boundingClientRect?.height || 0) > 0 && (opacityGt === undefined || opacity > opacityGt) ); } function isHiddenObservation(observation) { if (!observation || observation.targetMissing) { return true; } return ( observation.display === 'none' || observation.visibility !== 'visible' || Number(observation.boundingClientRect?.width || 0) <= 0 || Number(observation.boundingClientRect?.height || 0) <= 0 ); } function isWaitConditionSatisfied(observation, condition) { if (condition.kind === 'existence') { return condition.exists ? observation.exists === true : observation.exists === false; } if (condition.kind === 'visibility') { return condition.visibility === 'hidden' ? isHiddenObservation(observation) : isVisibleObservation(observation, condition.opacityGt); } if (condition.kind === 'text') { return String(observation.text || '').includes(condition.includes); } return false; } function formatWaitObservation(observation) { if (!observation) { return 'unavailable'; } if ('exists' in observation || 'count' in observation || 'display' in observation) { return [ `exists=${observation.exists === true ? 'true' : 'false'}`, `count=${String(observation.count ?? 0)}`, `display=${String(observation.display ?? '')}`, `visibility=${String(observation.visibility ?? '')}`, `opacity=${String(observation.opacity ?? '')}`, ].join(', '); } if ('text' in observation) { return `text=${JSON.stringify(observation.text || '')}`; } return 'unavailable'; } function formatQueryResponse(pageIndex, selector, nth, diagnostics, fields) { const lines = [`pageIndex: ${pageIndex}`, `selector: ${selector}`]; if (nth !== undefined) { lines.push(`nth: ${nth}`); } if (diagnostics.targetMissing) { if (hasTargetSpecificQueryField(fields)) { throw new Error(`element index ${diagnostics.targetIndex} is out of range for selector: ${selector}`); } for (const field of fields) { if (field === 'exists') { lines.push(`exists: ${diagnostics.exists ? 'true' : 'false'}`); continue; } if (field === 'count') { lines.push(`count: ${formatQueryValue(field, diagnostics.count)}`); } } return lines.join('\n'); } for (const field of fields) { lines.push(`${field}: ${formatQueryValue(field, diagnostics[field])}`); } return lines.join('\n'); } async function getWaitPageObservation(page) { const text = await evaluateExpression( page, `(() => document.body ? document.body.innerText || '' : '')()` ); return { text }; } async function getWaitSelectorObservation(page, selector, nth, frameSelector, pierceShadow) { return getElementDiagnostics(page, selector, nth, frameSelector, pierceShadow); } async function handleWaitFor(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const selector = typeof toolArgs.selector === 'string' ? toolArgs.selector.trim() : ''; const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); const pierceShadow = toolArgs.pierceShadow === true; const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS); const pollIntervalMs = requirePositiveIntegerOrDefault( toolArgs.pollIntervalMs, 'pollIntervalMs', DEFAULT_WAIT_POLL_INTERVAL_MS ); const condition = parseWaitCondition(toolArgs.condition, selector !== ''); const deadline = Date.now() + timeoutMs; let lastObserved = null; while (Date.now() <= deadline) { lastObserved = selector ? await getWaitSelectorObservation(page, selector, nth, frameSelector, pierceShadow) : await getWaitPageObservation(page); if (isWaitConditionSatisfied(lastObserved, condition)) { return `pageIndex: ${pageIndex}${selector ? `\nselector: ${selector}` : ''}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: satisfied`; } if (Date.now() + pollIntervalMs > deadline) { break; } await sleep(pollIntervalMs); } throw new Error(`wait condition timed out\nlastObserved: ${formatWaitObservation(lastObserved)}`); } async function handleEval(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const expression = requireNonEmptyString(toolArgs.expression, 'expression'); const mode = toolArgs.mode === 'readwrite' ? 'readwrite' : 'readonly'; const evalMode = getBrowserEvalMode(); if (evalMode === 'disabled') { throw new Error('browser_eval is disabled by CCS_BROWSER_EVAL_MODE=disabled'); } if (mode === 'readwrite' && evalMode !== 'readwrite') { throw new Error(`browser_eval readwrite mode is disabled by CCS_BROWSER_EVAL_MODE=${evalMode}`); } const response = await sendCdpCommand(page, 'Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true, ...(mode === 'readonly' ? { throwOnSideEffect: true } : {}), }); if (response?.exceptionDetails?.text) { throw new Error(response.exceptionDetails.text); } if (!response?.result) { throw new Error('evaluation result is not JSON-serializable'); } const result = response.result; const value = Object.prototype.hasOwnProperty.call(result, 'value') ? result.value : undefined; if (value === undefined && result.type !== 'undefined') { throw new Error('evaluation result is not JSON-serializable'); } return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(value)}`; } async function handleHover(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const selector = requireNonEmptyString(toolArgs.selector, 'selector'); const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); const pierceShadow = toolArgs.pierceShadow === true; const state = await getScrolledElementState(page, selector, frameSelector, pierceShadow); if (!state.exists) { throw new Error(`element not found for selector: ${selector}`); } if (state.connected !== true) { throw new Error(`element is detached for selector: ${selector}`); } if (state.interactable !== true || !state.centerPoint) { throw new Error(`element is hidden or not interactable for selector: ${selector}`); } await sendCdpCommand(page, 'Input.dispatchMouseEvent', { type: 'mouseMoved', x: state.centerPoint.x, y: state.centerPoint.y, button: 'none', buttons: 0, pointerType: 'mouse', }); return `pageIndex: ${pageIndex}\nselector: ${selector}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: hovered`; } async function handleQuery(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const selector = requireNonEmptyString(toolArgs.selector, 'selector'); const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth'); const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); const pierceShadow = toolArgs.pierceShadow === true; const fields = parseQueryFields(toolArgs.fields); const diagnostics = await getElementDiagnostics(page, selector, nth, frameSelector, pierceShadow); return formatQueryResponse(pageIndex, selector, nth, diagnostics, fields); } async function handleElementScreenshot(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const selector = requireNonEmptyString(toolArgs.selector, 'selector'); const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector); const pierceShadow = toolArgs.pierceShadow === true; const state = await getScrolledElementState(page, selector, frameSelector, pierceShadow); if (!state.exists) { throw new Error(`element not found for selector: ${selector}`); } if (state.connected !== true) { throw new Error(`element is detached for selector: ${selector}`); } if (state.interactable !== true || !state.visibleClip) { throw new Error(`element has empty bounds for selector: ${selector}`); } if (state.visibleClip.width <= 0 || state.visibleClip.height <= 0) { throw new Error(`element has empty bounds for selector: ${selector}`); } const response = await sendCdpCommand(page, 'Page.captureScreenshot', { format: 'png', clip: state.visibleClip, }); const data = response && typeof response.data === 'string' ? response.data : ''; if (!data) { throw new Error('screenshot capture failed'); } return `pageIndex: ${pageIndex}\nselector: ${selector}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nformat: png\ndata: ${data}`; } async function waitForPageEvent({ page, timeoutMs, event }) { const ws = new WebSocket(page.webSocketDebuggerUrl); return await new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { if (settled) { return; } settled = true; abortSocket(ws); reject(new Error(`event wait timed out for kind: ${event.kind}`)); }, timeoutMs); const settleError = (error) => { if (settled) { return; } settled = true; clearTimeout(timer); abortSocket(ws); reject(toSocketError(error)); }; const settleSuccess = (observed) => { if (settled) { return; } settled = true; clearTimeout(timer); closeSocket(ws); resolve(observed); }; addSocketListener(ws, 'open', () => { if (event.kind === 'dialog' || event.kind === 'navigation') { ws.send(JSON.stringify({ id: ++requestCounter, method: 'Page.enable', params: {} })); } if (event.kind === 'request') { ws.send(JSON.stringify({ id: ++requestCounter, method: 'Network.enable', params: {} })); } }); addSocketListener(ws, 'message', (data) => { void (async () => { const raw = await getSocketMessageText(data); let message; try { message = JSON.parse(raw); } catch { return; } if (!message || typeof message !== 'object' || typeof message.method !== 'string') { return; } let observed = null; if (message.method === 'Page.javascriptDialogOpening') { observed = { type: message.params?.type || '', message: message.params?.message || '' }; } else if (message.method === 'Page.frameNavigated') { const frame = message.params?.frame; if (!frame?.parentId) { observed = { url: frame?.url || '' }; } } else if (message.method === 'Network.requestWillBeSent') { observed = { url: message.params?.request?.url || '', method: message.params?.request?.method || '', }; } if (observed && matchesObservedEvent(event, observed)) { settleSuccess(observed); } })().catch(settleError); }); addSocketListener(ws, 'error', settleError); addSocketListener(ws, 'close', () => { if (!settled) { settleError(new Error('Browser MCP lost the DevTools websocket connection.')); } }); }); } async function waitForBrowserDownloadEvent(timeoutMs, event) { const targets = await fetchJson(`${getHttpUrl()}/json/list`); const browserTarget = Array.isArray(targets) ? targets.find((target) => target && typeof target === 'object' && target.type === 'browser') : null; if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) { throw new Error('browser-level download events are unavailable'); } const ws = new WebSocket(browserTarget.webSocketDebuggerUrl); return await new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { if (settled) { return; } settled = true; abortSocket(ws); reject(new Error('event wait timed out for kind: download')); }, timeoutMs); const settleError = (error) => { if (settled) { return; } settled = true; clearTimeout(timer); abortSocket(ws); reject(toSocketError(error)); }; const settleSuccess = (observed) => { if (settled) { return; } settled = true; clearTimeout(timer); closeSocket(ws); resolve(observed); }; addSocketListener(ws, 'message', (data) => { void (async () => { const raw = await getSocketMessageText(data); let message; try { message = JSON.parse(raw); } catch { return; } if (message?.method !== 'Browser.downloadWillBegin') { return; } const observed = { url: message.params?.url || '', suggestedFilename: message.params?.suggestedFilename || '', }; if (matchesObservedEvent(event, observed)) { settleSuccess(observed); } })().catch(settleError); }); addSocketListener(ws, 'error', settleError); addSocketListener(ws, 'close', () => { if (!settled) { settleError(new Error('Browser MCP lost the DevTools websocket connection.')); } }); }); } async function waitForMatchingEvent({ page, timeoutMs, event }) { if (event.kind === 'download') { return await waitForBrowserDownloadEvent(timeoutMs, event); } return await waitForPageEvent({ page, timeoutMs, event }); } async function handleWaitForEvent(toolArgs) { const { page, pageIndex } = await getSelectedPage(toolArgs); const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS); const event = parseEventCondition(toolArgs.event); const observed = await waitForMatchingEvent({ page, pageIndex, timeoutMs, event }); return `pageIndex: ${pageIndex}\nevent: ${event.kind}\nstatus: observed\ndetail: ${JSON.stringify(observed)}`; } function findInterceptRuleIndex(ruleId) { return interceptRules.findIndex((rule) => rule.ruleId === ruleId); } function getRulesForPage(pageId) { return interceptRules.filter((rule) => rule.pageId === pageId); } function matchesInterceptRule(rule, paused) { if (rule.method && rule.method !== String(paused.request?.method || '').toUpperCase()) { return false; } if (rule.urlIncludes && !String(paused.request?.url || '').includes(rule.urlIncludes)) { return false; } return true; } async function ensureInterceptSession(page) { const existing = interceptSessionsByPageId.get(page.id); if (existing) { return existing; } const ws = new WebSocket(page.webSocketDebuggerUrl); let enableRequestId = 0; let resolveReady; let rejectReady; let activityChain = Promise.resolve(); const pendingCommands = new Map(); const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }); const session = { pageId: page.id, webSocketDebuggerUrl: page.webSocketDebuggerUrl, ws, enabled: false, ready, pendingCommands, activityVersion: 0, getLastActivity() { return activityChain; }, }; interceptSessionsByPageId.set(page.id, session); function settleReadyError(error) { if (rejectReady) { rejectReady(error instanceof Error ? error : new Error(String(error))); rejectReady = null; resolveReady = null; } } function rejectPendingCommands(error) { for (const pending of pendingCommands.values()) { pending.reject(error instanceof Error ? error : new Error(String(error))); } pendingCommands.clear(); } addSocketListener(ws, 'open', () => { enableRequestId = ++requestCounter; ws.send( JSON.stringify({ id: enableRequestId, method: 'Fetch.enable', params: { patterns: [{ urlPattern: '*' }] }, }) ); }); addSocketListener(ws, 'message', (data) => { const activity = (async () => { const raw = await getSocketMessageText(data); let message; try { message = JSON.parse(raw); } catch { return; } session.activityVersion += 1; if (message.id === enableRequestId) { if (message.error) { const error = new Error(message.error.message || 'DevTools request failed.'); settleReadyError(error); rejectPendingCommands(error); removeInterceptStateForPage(page.id); closeSocket(ws); return; } session.enabled = true; if (resolveReady) { resolveReady(); resolveReady = null; rejectReady = null; } return; } if (message.id && pendingCommands.has(message.id)) { const pending = pendingCommands.get(message.id); pendingCommands.delete(message.id); if (message.error) { pending.reject(new Error(message.error.message || 'DevTools request failed.')); return; } pending.resolve(message.result || null); return; } if (message.method !== 'Fetch.requestPaused') { return; } const paused = message.params || {}; const matchedRule = getRulesForPage(page.id).find((rule) => matchesInterceptRule(rule, paused)); const action = matchedRule ? matchedRule.action : 'continue'; if (action === 'fail') { ws.send( JSON.stringify({ id: ++requestCounter, method: 'Fetch.failRequest', params: { requestId: paused.requestId, errorReason: FETCH_FAIL_ERROR_REASON }, }) ); } else if (action === 'fulfill') { ws.send( JSON.stringify({ id: ++requestCounter, method: 'Fetch.fulfillRequest', params: { requestId: paused.requestId, responseCode: matchedRule.statusCode, responseHeaders: matchedRule.headers, body: encodeFulfillBody(matchedRule.body), }, }) ); } else { ws.send( JSON.stringify({ id: ++requestCounter, method: 'Fetch.continueRequest', params: { requestId: paused.requestId }, }) ); } pushRecentRequest({ requestId: String(paused.requestId || ''), pageId: page.id, url: String(paused.request?.url || ''), method: String(paused.request?.method || ''), resourceType: String(paused.resourceType || ''), matchedRuleId: matchedRule ? matchedRule.ruleId : '', action, statusCode: action === 'fulfill' ? matchedRule.statusCode : 0, }); })(); activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {}); void activity.catch(() => {}); }); addSocketListener(ws, 'close', () => { const error = new Error('Browser MCP lost the DevTools websocket connection.'); if (!session.enabled) { settleReadyError(error); } rejectPendingCommands(error); removeInterceptStateForPage(page.id); }); addSocketListener(ws, 'error', (error) => { const socketError = toSocketError(error); if (!session.enabled) { settleReadyError(socketError); } rejectPendingCommands(socketError); removeInterceptStateForPage(page.id); }); await ready; return session; } async function handleSelectPage(toolArgs) { const pages = await listPageTargets(); if (pages.length === 0) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId); if (!page.webSocketDebuggerUrl) { throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`); } selectedPageId = page.id; return `pageIndex: ${pageIndex}\npageId: ${page.id}\ntitle: ${page.title || ''}\nurl: ${page.url || ''}\nstatus: selected`; } async function handleOpenPage(toolArgs) { const query = toolArgs?.url ? `?${new URLSearchParams({ url: requireValidHttpUrl(toolArgs.url) }).toString()}` : ''; const createdTarget = await fetchJson(`${getHttpUrl()}/json/new${query}`); const pageId = typeof createdTarget?.id === 'string' ? createdTarget.id : ''; if (!pageId) { throw new Error('Browser MCP failed to create a new page target.'); } selectedPageId = pageId; const pages = await listPageTargets(); const pageIndex = findPageIndexById(pages, pageId); const selectedPage = pages[pageIndex]; if (!selectedPage) { throw new Error('Browser MCP could not resolve the newly opened page target.'); } return `pageIndex: ${pageIndex}\npageId: ${selectedPage.id}\ntitle: ${selectedPage.title || ''}\nurl: ${selectedPage.url || ''}\nstatus: opened`; } async function handleClosePage(toolArgs) { const pages = await listPageTargets(); if (pages.length === 0) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } const previousSelectedPageId = selectedPageId; const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, { allowImplicitFallback: false, }); await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`); const interceptSession = interceptSessionsByPageId.get(page.id); if (interceptSession) { closeSocket(interceptSession.ws); } removeInterceptStateForPage(page.id); const remainingPages = await listPageTargets(); if (findPageIndexById(remainingPages, previousSelectedPageId) !== -1) { selectedPageId = previousSelectedPageId; } else { selectedPageId = resolveFallbackSelectedPageId(remainingPages, pageIndex > 0 ? pageIndex - 1 : 0); } const selectedIndex = findPageIndexById(remainingPages, selectedPageId); return [ `pageIndex: ${pageIndex}`, `pageId: ${page.id}`, 'status: closed', selectedPageId ? `selectedPageIndex: ${selectedIndex}` : 'selectedPageIndex: none', selectedPageId ? `selectedPageId: ${selectedPageId}` : 'selectedPageId: none', ].join('\n'); } async function handleAddInterceptRule(toolArgs) { const pages = await listPageTargets(); if (pages.length === 0) { throw new Error('Browser MCP did not find any page targets in the current Chrome session.'); } const defaultPageId = selectedPageId || resolveFallbackSelectedPageId(pages, 0); const { page } = resolveTargetPage(pages, toolArgs, defaultPageId, { allowImplicitFallback: false, }); if (!page.webSocketDebuggerUrl) { throw new Error('Target page does not expose a websocket debugger URL.'); } const urlIncludes = parseOptionalUrlIncludes(toolArgs.urlIncludes); const method = parseOptionalMethod(toolArgs.method); const action = parseInterceptAction(toolArgs.action); if (!urlIncludes && !method) { throw new Error('urlIncludes or method is required'); } const statusCode = parseOptionalStatusCode(toolArgs.statusCode); const contentType = toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType'); const body = parseOptionalBody(toolArgs.body); const headers = parseOptionalHeaders(toolArgs.headers, contentType); const rule = { ruleId: createInterceptRuleId(), pageId: page.id, pageTitleSnapshot: page.title || '', urlIncludes, method, action, statusCode: action === 'fulfill' ? statusCode : 0, contentType: action === 'fulfill' ? contentType : '', headers: action === 'fulfill' ? headers : [], body: action === 'fulfill' ? body : '', createdAt: new Date().toISOString(), }; interceptRules.push(rule); await ensureInterceptSession(page); return formatInterceptRules([rule]); } async function handleRemoveInterceptRule(toolArgs) { const ruleId = requireNonEmptyString(toolArgs.ruleId, 'ruleId'); const index = findInterceptRuleIndex(ruleId); if (index === -1) { throw new Error(`rule not found: ${ruleId}`); } const [removed] = interceptRules.splice(index, 1); if (getRulesForPage(removed.pageId).length === 0) { const session = interceptSessionsByPageId.get(removed.pageId); if (session) { closeSocket(session.ws); interceptSessionsByPageId.delete(removed.pageId); } } return `ruleId: ${removed.ruleId}\nstatus: removed`; } async function handleListInterceptRules() { const pages = await listPageTargets(); const livePageIds = new Set(pages.map((page) => page.id)); for (let index = interceptRules.length - 1; index >= 0; index -= 1) { if (!livePageIds.has(interceptRules[index].pageId)) { interceptRules.splice(index, 1); } } return formatInterceptRules(interceptRules); } async function handleListRequests(toolArgs) { const pages = await listPageTargets(); const filteredPage = toolArgs && (Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex') || toolArgs.pageId) ? resolveTargetPage(pages, toolArgs, selectedPageId) : null; const limit = requirePositiveIntegerOrDefault(toolArgs.limit, 'limit', 20); const sessions = Array.from(interceptSessionsByPageId.values()); await Promise.all(sessions.map((session) => session.ready)); await Promise.all( sessions.map(async (session) => { const barrierRequestId = ++requestCounter; const barrierPromise = new Promise((resolve, reject) => { session.pendingCommands.set(barrierRequestId, { resolve, reject }); }); session.ws.send( JSON.stringify({ id: barrierRequestId, method: 'Runtime.evaluate', params: { expression: '0', returnByValue: true, }, }) ); await barrierPromise; await session.getLastActivity(); await new Promise((resolve) => setTimeout(resolve, 35)); await session.getLastActivity(); }) ); const entries = recentRequests .filter((entry) => !filteredPage || entry.pageId === filteredPage.page.id) .slice(-limit) .reverse(); return formatRecentRequests(entries); } async function handleToolCall(message) { const id = message.id; const params = message.params || {}; const toolName = params.name || ''; const toolArgs = params.arguments || {}; if (!TOOL_NAMES.includes(toolName)) { writeError(id, -32602, `Unknown tool: ${toolName}`); return; } if (!shouldExposeTools()) { writeResponse(id, { content: [ { type: 'text', text: 'Browser MCP is unavailable because browser reuse is not configured for this Claude session.', }, ], isError: true, }); return; } try { if (toolName === TOOL_SESSION_INFO) { const pages = await listPageTargets(); if (pages.length > 0 && findPageIndexById(pages, selectedPageId) === -1) { selectedPageId = resolveFallbackSelectedPageId(pages, 0); } writeResponse(id, { content: [{ type: 'text', text: formatSessionInfo(pages, selectedPageId) }], }); return; } if (toolName === TOOL_NAVIGATE) { const text = await handleNavigate(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_CLICK) { const text = await handleClick(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_TYPE) { const text = await handleType(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_PRESS_KEY) { const text = await handlePressKey(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_SCROLL) { const text = await handleScroll(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_SELECT_PAGE) { const text = await handleSelectPage(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_OPEN_PAGE) { const text = await handleOpenPage(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_CLOSE_PAGE) { const text = await handleClosePage(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_ADD_INTERCEPT_RULE) { const text = await handleAddInterceptRule(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_REMOVE_INTERCEPT_RULE) { const text = await handleRemoveInterceptRule(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_LIST_INTERCEPT_RULES) { const text = await handleListInterceptRules(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_LIST_REQUESTS) { const text = await handleListRequests(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_WAIT_FOR) { const text = await handleWaitFor(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_EVAL) { const text = await handleEval(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_HOVER) { const text = await handleHover(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_QUERY) { const text = await handleQuery(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_TAKE_ELEMENT_SCREENSHOT) { const text = await handleElementScreenshot(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } if (toolName === TOOL_WAIT_FOR_EVENT) { const text = await handleWaitForEvent(toolArgs); writeResponse(id, { content: [{ type: 'text', text }], }); return; } const { page, pageIndex } = await getSelectedPage(toolArgs); if (toolName === TOOL_URL_TITLE) { const raw = await evaluateInPage(page, 'url-title'); const parsed = JSON.parse(raw); writeResponse(id, { content: [ { type: 'text', text: `pageIndex: ${pageIndex}\ntitle: ${parsed.title || ''}\nurl: ${parsed.url || ''}`, }, ], }); return; } if (toolName === TOOL_VISIBLE_TEXT) { const text = await evaluateInPage(page, 'visible-text'); writeResponse(id, { content: [{ type: 'text', text: text || '' }], }); return; } const html = await evaluateInPage(page, 'dom-snapshot'); writeResponse(id, { content: [{ type: 'text', text: html || '' }], }); } catch (error) { writeResponse(id, { content: [ { type: 'text', text: `Browser MCP failed: ${(error && error.message) || String(error)}`, }, ], isError: true, }); } } async function handleMessage(message) { if (!message || message.jsonrpc !== '2.0' || typeof message.method !== 'string') { return; } switch (message.method) { case 'initialize': writeResponse(message.id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }, }); return; case 'notifications/initialized': return; case 'ping': writeResponse(message.id, {}); return; case 'tools/list': writeResponse(message.id, { tools: getTools() }); return; case 'tools/call': await handleToolCall(message); return; default: if (message.id !== undefined) { writeError(message.id, -32601, `Method not found: ${message.method}`); } } } function parseMessages() { while (true) { let body; const startsWithLegacyHeaders = inputBuffer .subarray(0, Math.min(inputBuffer.length, 32)) .toString('utf8') .toLowerCase() .startsWith('content-length:'); if (startsWithLegacyHeaders) { const headerEnd = inputBuffer.indexOf('\r\n\r\n'); if (headerEnd === -1) { return; } const headerText = inputBuffer.subarray(0, headerEnd).toString('utf8'); const match = headerText.match(/content-length:\s*(\d+)/i); if (!match) { inputBuffer = Buffer.alloc(0); return; } const contentLength = Number.parseInt(match[1], 10); const messageEnd = headerEnd + 4 + contentLength; if (inputBuffer.length < messageEnd) { return; } body = inputBuffer.subarray(headerEnd + 4, messageEnd).toString('utf8'); inputBuffer = inputBuffer.subarray(messageEnd); } else { const newlineIndex = inputBuffer.indexOf('\n'); if (newlineIndex === -1) { return; } body = inputBuffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim(); inputBuffer = inputBuffer.subarray(newlineIndex + 1); if (!body) { continue; } } let message; try { message = JSON.parse(body); } catch { continue; } messageQueue = messageQueue .then(() => handleMessage(message)) .catch((error) => { if (message && message.id !== undefined) { writeError(message.id, -32603, (error && error.message) || 'Internal error'); } }); } } process.stdin.on('data', (chunk) => { inputBuffer = Buffer.concat([inputBuffer, chunk]); parseMessages(); }); process.stdin.on('error', () => { process.exit(0); }); ['SIGINT', 'SIGTERM', 'SIGHUP'].forEach((signal) => { process.on(signal, () => process.exit(0)); }); process.stdin.resume();