Files
ccs/lib/mcp/ccs-browser-server.cjs
T
walker1211andClaude Opus 4.7 4af967559c feat(browser): 增强 assert_query 断言与诊断信息
为 assert_query 增加 assertions 数组、更多比较操作和结构化失败 diagnostics。
并补充多断言、类型错误和数值比较的回归覆盖。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 17:39:19 +08:00

5665 lines
176 KiB
JavaScript
Executable File

#!/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 fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
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_SET_DOWNLOAD_BEHAVIOR = 'browser_set_download_behavior';
const TOOL_LIST_DOWNLOADS = 'browser_list_downloads';
const TOOL_CANCEL_DOWNLOAD = 'browser_cancel_download';
const TOOL_SET_FILE_INPUT = 'browser_set_file_input';
const TOOL_DRAG_FILES = 'browser_drag_files';
const TOOL_DRAG_ELEMENT = 'browser_drag_element';
const TOOL_POINTER_ACTION = 'browser_pointer_action';
const TOOL_START_RECORDING = 'browser_start_recording';
const TOOL_STOP_RECORDING = 'browser_stop_recording';
const TOOL_GET_RECORDING = 'browser_get_recording';
const TOOL_CLEAR_RECORDING = 'browser_clear_recording';
const TOOL_START_REPLAY = 'browser_start_replay';
const TOOL_GET_REPLAY = 'browser_get_replay';
const TOOL_CANCEL_REPLAY = 'browser_cancel_replay';
const TOOL_START_ORCHESTRATION = 'browser_start_orchestration';
const TOOL_GET_ORCHESTRATION = 'browser_get_orchestration';
const TOOL_CANCEL_ORCHESTRATION = 'browser_cancel_orchestration';
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_SET_DOWNLOAD_BEHAVIOR,
TOOL_LIST_DOWNLOADS,
TOOL_CANCEL_DOWNLOAD,
TOOL_SET_FILE_INPUT,
TOOL_DRAG_FILES,
TOOL_DRAG_ELEMENT,
TOOL_POINTER_ACTION,
TOOL_START_RECORDING,
TOOL_STOP_RECORDING,
TOOL_GET_RECORDING,
TOOL_CLEAR_RECORDING,
TOOL_START_REPLAY,
TOOL_GET_REPLAY,
TOOL_CANCEL_REPLAY,
TOOL_START_ORCHESTRATION,
TOOL_GET_ORCHESTRATION,
TOOL_CANCEL_ORCHESTRATION,
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;
const DEFAULT_DRAG_STEPS = 5;
const MAX_POINTER_ACTIONS = 25;
let inputBuffer = Buffer.alloc(0);
let requestCounter = 0;
let selectedPageId = '';
let messageQueue = Promise.resolve();
let nextInterceptRuleCounter = 1;
let nextDownloadCounter = 1;
let nextRecordingCounter = 1;
let activeRecordingSession = null;
let latestRecordingSession = null;
let nextReplayCounter = 1;
let activeReplaySession = null;
let latestReplaySession = null;
let nextOrchestrationCounter = 1;
let activeOrchestrationSession = null;
let latestOrchestrationSession = null;
const interceptRules = [];
const recentRequests = [];
const recentDownloads = [];
const interceptSessionsByPageId = new Map();
let browserDownloadSession = null;
let sessionDownloadDir = '';
const MAX_RECENT_REQUESTS = 100;
const MAX_RECENT_DOWNLOADS = 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' },
resourceType: { type: 'string' },
urlPattern: { type: 'string' },
urlRegex: { type: 'string' },
headerMatchers: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
valueIncludes: { type: 'string' },
valueRegex: { type: 'string' },
},
required: ['name'],
additionalProperties: false,
},
},
priority: { type: 'integer' },
action: { type: 'string', enum: ['continue', 'fail', 'fulfill'] },
statusCode: { type: 'integer', minimum: 100, maximum: 599 },
responseHeaders: {
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_SET_DOWNLOAD_BEHAVIOR,
description: 'Set browser-scoped download behavior for the current attach session.',
inputSchema: {
type: 'object',
properties: {
behavior: { type: 'string', enum: ['accept', 'deny'] },
downloadPath: { type: 'string' },
eventsEnabled: { type: 'boolean' },
},
required: ['behavior'],
additionalProperties: false,
},
},
{
name: TOOL_LIST_DOWNLOADS,
description: 'List recent browser-scoped download summaries recorded in this MCP session.',
inputSchema: {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1 },
},
additionalProperties: false,
},
},
{
name: TOOL_CANCEL_DOWNLOAD,
description: 'Cancel an in-progress download by downloadId or guid.',
inputSchema: {
type: 'object',
properties: {
downloadId: { type: 'string' },
guid: { type: 'string' },
},
additionalProperties: false,
},
},
{
name: TOOL_SET_FILE_INPUT,
description:
'Set local files on a file input element in the selected page. Supports selected-page routing plus same-origin frame and open shadow-root scoping.',
inputSchema: {
type: 'object',
properties: {
selector: { type: 'string' },
files: {
type: 'array',
items: { type: 'string' },
},
pageIndex: { type: 'integer', minimum: 0 },
pageId: { type: 'string' },
nth: { type: 'integer', minimum: 0 },
frameSelector: { type: 'string' },
pierceShadow: { type: 'boolean' },
},
required: ['selector', 'files'],
additionalProperties: false,
},
},
{
name: TOOL_DRAG_FILES,
description:
'Drag one or more local files onto a matched page drop target using page-side File and DataTransfer injection.',
inputSchema: {
type: 'object',
properties: {
selector: { type: 'string' },
files: {
type: 'array',
items: { type: 'string' },
},
pageIndex: { type: 'integer', minimum: 0 },
pageId: { type: 'string' },
nth: { type: 'integer', minimum: 0 },
frameSelector: { type: 'string' },
pierceShadow: { type: 'boolean' },
},
required: ['selector', 'files'],
additionalProperties: false,
},
},
{
name: TOOL_DRAG_ELEMENT,
description:
'Drag a matched element to another matched element or explicit coordinates using browser mouse events.',
inputSchema: {
type: 'object',
properties: {
selector: { type: 'string' },
targetSelector: { type: 'string' },
targetX: { type: 'number' },
targetY: { type: 'number' },
pageIndex: { type: 'integer', minimum: 0 },
pageId: { type: 'string' },
nth: { type: 'integer', minimum: 0 },
targetNth: { type: 'integer', minimum: 0 },
frameSelector: { type: 'string' },
pierceShadow: { type: 'boolean' },
steps: { type: 'integer', minimum: 1 },
},
required: ['selector'],
additionalProperties: false,
},
},
{
name: TOOL_POINTER_ACTION,
description: 'Run a limited pointer action sequence using browser mouse events.',
inputSchema: {
type: 'object',
properties: {
pageIndex: { type: 'integer', minimum: 0 },
pageId: { type: 'string' },
actions: {
type: 'array',
items: {
type: 'object',
properties: {
type: { type: 'string', enum: ['move', 'down', 'up', 'pause'] },
selector: { type: 'string' },
nth: { type: 'integer', minimum: 0 },
frameSelector: { type: 'string' },
pierceShadow: { type: 'boolean' },
x: { type: 'number' },
y: { type: 'number' },
button: { type: 'string', enum: ['left', 'middle', 'right'] },
durationMs: { type: 'integer', minimum: 0 },
},
required: ['type'],
additionalProperties: false,
},
},
},
required: ['actions'],
additionalProperties: false,
},
},
{
name: TOOL_START_RECORDING,
description: 'Start recording real browser interactions on the selected page and store them as structured steps.',
inputSchema: {
type: 'object',
properties: {
pageIndex: { type: 'integer', minimum: 0 },
pageId: { type: 'string' },
},
additionalProperties: false,
},
},
{
name: TOOL_STOP_RECORDING,
description: 'Stop the active browser recording session and keep the recorded result in session-local state.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: TOOL_GET_RECORDING,
description: 'Read the current browser recording result from session-local state.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: TOOL_CLEAR_RECORDING,
description: 'Clear the current browser recording result from session-local state.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: TOOL_START_REPLAY,
description: 'Start replaying a sequence of structured Browser MCP steps on the selected page.',
inputSchema: {
type: 'object',
properties: {
steps: { type: 'array', items: { type: 'object' } },
pageIndex: { type: 'integer', minimum: 0 },
pageId: { type: 'string' },
},
required: ['steps'],
additionalProperties: false,
},
},
{
name: TOOL_GET_REPLAY,
description: 'Read the current replay status and result summary from session-local state.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: TOOL_CANCEL_REPLAY,
description: 'Cancel the active replay session and keep the summary in session-local state.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: TOOL_START_ORCHESTRATION,
description: 'Start an orchestration session that runs fixed browser workflow blocks on the selected page.',
inputSchema: {
type: 'object',
properties: {
blocks: { type: 'array', items: { type: 'object' } },
pageIndex: { type: 'integer', minimum: 0 },
pageId: { type: 'string' },
},
required: ['blocks'],
additionalProperties: false,
},
},
{
name: TOOL_GET_ORCHESTRATION,
description: 'Read the current orchestration status and result summary from session-local state.',
inputSchema: {
type: 'object',
properties: {},
additionalProperties: false,
},
},
{
name: TOOL_CANCEL_ORCHESTRATION,
description: 'Cancel the active orchestration session and keep the summary in session-local state.',
inputSchema: {
type: 'object',
properties: {},
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 parseOptionalResponseHeaders(value, contentType) {
const headers = [];
if (Array.isArray(value)) {
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
throw new Error('responseHeaders entries must be objects with name and value');
}
const name = requireNonEmptyString(entry.name, 'responseHeaders.name');
const headerValue = String(entry.value ?? '');
headers.push({ name, value: headerValue });
}
} else if (value !== undefined) {
throw new Error('responseHeaders 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 parseOptionalResourceType(value) {
if (value === undefined) {
return '';
}
return requireNonEmptyString(value, 'resourceType');
}
function parseOptionalUrlPattern(value) {
if (value === undefined) {
return '';
}
return requireNonEmptyString(value, 'urlPattern');
}
function parseOptionalUrlRegex(value) {
if (value === undefined) {
return '';
}
const raw = requireNonEmptyString(value, 'urlRegex');
try {
new RegExp(raw);
} catch {
throw new Error('urlRegex must be a valid regular expression');
}
return raw;
}
function parseOptionalPriority(value) {
if (value === undefined) {
return 0;
}
if (!Number.isInteger(value)) {
throw new Error('priority must be an integer');
}
return value;
}
function parseOptionalHeaderMatchers(value) {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
throw new Error('headerMatchers must be an array');
}
return value.map((entry) => {
if (!entry || typeof entry !== 'object') {
throw new Error('headerMatchers entries must be objects');
}
const name = requireNonEmptyString(entry.name, 'headerMatchers.name');
const valueIncludes =
entry.valueIncludes === undefined ? '' : requireNonEmptyString(entry.valueIncludes, 'headerMatchers.valueIncludes');
const valueRegex =
entry.valueRegex === undefined ? '' : requireNonEmptyString(entry.valueRegex, 'headerMatchers.valueRegex');
if (!valueIncludes && !valueRegex) {
throw new Error('headerMatchers entry must include valueIncludes or valueRegex');
}
if (valueRegex) {
try {
new RegExp(valueRegex);
} catch {
throw new Error('headerMatchers.valueRegex must be a valid regular expression');
}
}
return { name, valueIncludes, valueRegex };
});
}
function validateInterceptMatcherSet({
urlIncludes,
method,
resourceType,
urlPattern,
urlRegex,
headerMatchers,
}) {
if (urlPattern && urlRegex) {
throw new Error('urlPattern and urlRegex cannot be used together');
}
if (!urlIncludes && !method && !resourceType && !urlPattern && !urlRegex && headerMatchers.length === 0) {
throw new Error('at least one matching condition is required');
}
}
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 getSessionDownloadPath() {
if (!sessionDownloadDir) {
sessionDownloadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-downloads-'));
}
return sessionDownloadDir;
}
function ensureWritableDirectory(downloadPath) {
fs.mkdirSync(downloadPath, { recursive: true });
fs.accessSync(downloadPath, fs.constants.W_OK);
return downloadPath;
}
function pushRecentDownload(entry) {
recentDownloads.push(entry);
if (recentDownloads.length > MAX_RECENT_DOWNLOADS) {
recentDownloads.splice(0, recentDownloads.length - MAX_RECENT_DOWNLOADS);
}
}
function updateRecentDownload(guid, patch) {
const record = recentDownloads.find((candidate) => candidate.guid === guid);
if (!record) {
return null;
}
Object.assign(record, patch);
return record;
}
function resolveDownloadStatus(progressState) {
if (progressState === 'completed') {
return 'completed';
}
if (progressState === 'canceled') {
return 'canceled';
}
return 'inProgress';
}
function formatInterceptRules(rules) {
if (rules.length === 0) {
return 'status: empty';
}
return rules
.map((rule) =>
[
`ruleId: ${rule.ruleId}`,
`pageId: ${rule.pageId}`,
`pageTitle: ${rule.pageTitleSnapshot || '<untitled>'}`,
`urlIncludes: ${rule.urlIncludes || '<any>'}`,
`method: ${rule.method || '<any>'}`,
`resourceType: ${rule.resourceType || '<any>'}`,
`urlPattern: ${rule.urlPattern || '<none>'}`,
`urlRegex: ${rule.urlRegex || '<none>'}`,
`headerMatchers: ${Array.isArray(rule.headerMatchers) ? rule.headerMatchers.length : 0}`,
`priority: ${Number.isInteger(rule.priority) ? rule.priority : 0}`,
`action: ${rule.action}`,
`statusCode: ${rule.action === 'fulfill' ? rule.statusCode : 'n/a'}`,
`contentType: ${rule.action === 'fulfill' ? rule.contentType || '<none>' : '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 formatRecentDownloads(entries) {
if (entries.length === 0) {
return 'status: empty';
}
return entries
.map((entry) =>
[
`downloadId: ${entry.downloadId}`,
`guid: ${entry.guid}`,
`pageId: ${entry.pageId || '<unknown>'}`,
`url: ${entry.url}`,
`suggestedFilename: ${entry.suggestedFilename}`,
`status: ${entry.status}`,
`savedPath: ${entry.savedPath || '<none>'}`,
`startedAt: ${entry.startedAt}`,
`finishedAt: ${entry.finishedAt || '<in-progress>'}`,
].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 || '<untitled>'} | ${page.url || '<empty>'}${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 getBrowserTarget() {
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');
}
return browserTarget;
}
async function sendBrowserCdpCommand(method, params = {}) {
const browserTarget = await getBrowserTarget();
const ws = new WebSocket(browserTarget.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', settleError);
addSocketListener(ws, 'close', () => {
if (!settled) {
settleError(new Error('Browser MCP lost the DevTools websocket connection.'));
}
});
});
}
async function ensureBrowserDownloadSession() {
if (browserDownloadSession) {
await browserDownloadSession.ready;
return browserDownloadSession;
}
const browserTarget = await getBrowserTarget();
const ws = new WebSocket(browserTarget.webSocketDebuggerUrl);
let resolveReady;
let rejectReady;
let activityChain = Promise.resolve();
const ready = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
browserDownloadSession = {
ws,
ready,
getLastActivity() {
return activityChain;
},
};
addSocketListener(ws, 'open', () => {
if (resolveReady) {
resolveReady();
resolveReady = null;
rejectReady = null;
}
});
addSocketListener(ws, 'message', (data) => {
const activity = (async () => {
const raw = await getSocketMessageText(data);
let message;
try {
message = JSON.parse(raw);
} catch {
return;
}
if (message?.method === 'Browser.downloadWillBegin') {
pushRecentDownload({
downloadId: `download-${nextDownloadCounter++}`,
guid: String(message.params?.guid || ''),
pageId: '',
url: String(message.params?.url || ''),
suggestedFilename: String(message.params?.suggestedFilename || ''),
status: 'started',
savedPath: '',
receivedBytes: 0,
totalBytes: 0,
startedAt: new Date().toISOString(),
finishedAt: '',
});
return;
}
if (message?.method === 'Browser.downloadProgress') {
const status = resolveDownloadStatus(String(message.params?.state || 'inProgress'));
updateRecentDownload(String(message.params?.guid || ''), {
status,
receivedBytes: Number(message.params?.receivedBytes || 0),
totalBytes: Number(message.params?.totalBytes || 0),
savedPath: typeof message.params?.filePath === 'string' ? message.params.filePath : '',
finishedAt: status === 'completed' || status === 'canceled' ? new Date().toISOString() : '',
});
}
})();
activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {});
void activity.catch(() => {});
});
addSocketListener(ws, 'close', () => {
if (rejectReady) {
rejectReady(new Error('Browser MCP lost the DevTools websocket connection.'));
}
browserDownloadSession = null;
});
addSocketListener(ws, 'error', (error) => {
if (rejectReady) {
rejectReady(toSocketError(error));
}
browserDownloadSession = null;
});
await ready;
return browserDownloadSession;
}
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 ?? '';
}
async function withPageCommandSession(page, callback) {
const ws = new WebSocket(page.webSocketDebuggerUrl);
let resolveReady;
let rejectReady;
let settled = false;
const pendingCommands = new Map();
const ready = new Promise((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
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', () => {
if (resolveReady) {
resolveReady();
resolveReady = null;
rejectReady = null;
}
});
addSocketListener(ws, 'message', (data) => {
void (async () => {
const raw = await getSocketMessageText(data);
let message;
try {
message = JSON.parse(raw);
} catch {
return;
}
if (!message?.id || !pendingCommands.has(message.id)) {
return;
}
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);
})().catch((error) => {
const socketError = toSocketError(error);
if (!settled) {
settleReadyError(socketError);
rejectPendingCommands(socketError);
}
});
});
addSocketListener(ws, 'close', () => {
const error = new Error('Browser MCP lost the DevTools websocket connection.');
if (!settled) {
settleReadyError(error);
rejectPendingCommands(error);
}
});
addSocketListener(ws, 'error', (error) => {
const socketError = toSocketError(error);
if (!settled) {
settleReadyError(socketError);
rejectPendingCommands(socketError);
}
});
await ready;
const session = {
async sendCommand(method, params = {}) {
const requestId = ++requestCounter;
return await new Promise((resolve, reject) => {
const timer = setTimeout(() => {
pendingCommands.delete(requestId);
reject(new Error('Browser MCP timed out waiting for a DevTools response.'));
}, CDP_TIMEOUT_MS);
pendingCommands.set(requestId, {
resolve(result) {
clearTimeout(timer);
resolve(result);
},
reject(error) {
clearTimeout(timer);
reject(error);
},
});
ws.send(JSON.stringify({ id: requestId, method, params }));
});
},
};
try {
return await callback(session);
} finally {
settled = true;
rejectPendingCommands(new Error('Browser MCP closed the page command session.'));
closeSocket(ws);
}
}
async function evaluateObjectHandle(session, expression) {
const response = await session.sendCommand('Runtime.evaluate', {
expression,
returnByValue: false,
});
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.');
}
if (typeof result.objectId !== 'string' || !result.objectId) {
throw new Error('Browser MCP could not resolve a file input handle.');
}
return result.objectId;
}
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 requireNonEmptyStringArray(value, label) {
if (!Array.isArray(value) || value.length === 0) {
throw new Error(`${label} must be a non-empty array of non-empty strings`);
}
if (value.some((item) => typeof item !== 'string' || item.trim() === '')) {
throw new Error(`${label} must be a non-empty array of non-empty strings`);
}
return value.map((item) => item.trim());
}
function requireNonNegativeInteger(value, label) {
if (!Number.isInteger(value) || value < 0) {
throw new Error(`${label} must be a non-negative integer`);
}
return value;
}
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 buildFileInputHandleExpression(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 targetIndex = nth ?? 0;
const element = matches[targetIndex];
if (!element) {
throw new Error('element not found for selector: ' + selector);
}
if (!(element instanceof HTMLInputElement) || element.type !== 'file') {
throw new Error('element is not a file input for selector: ' + selector);
}
return element;
})()`;
}
function validateLocalFiles(files) {
return files.map((filePath) => {
const resolvedPath = path.resolve(filePath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`file does not exist: ${resolvedPath}`);
}
const stat = fs.statSync(resolvedPath);
if (!stat.isFile()) {
throw new Error(`file is not a regular file: ${resolvedPath}`);
}
return resolvedPath;
});
}
function formatFileInputResult({ pageIndex, selector, nth, frameSelector, pierceShadow, files }) {
const lines = [
`pageIndex: ${pageIndex}`,
`selector: ${selector}`,
`nth: ${nth}`,
`fileCount: ${files.length}`,
];
if (frameSelector) {
lines.push(`frameSelector: ${frameSelector}`);
}
if (pierceShadow) {
lines.push('pierceShadow: true');
}
lines.push('status: files-set');
return lines.join('\n');
}
function inferMimeType(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.pdf') return 'application/pdf';
if (ext === '.png') return 'image/png';
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
if (ext === '.txt') return 'text/plain';
if (ext === '.json') return 'application/json';
return 'application/octet-stream';
}
function readLocalFilesForDrop(files) {
return validateLocalFiles(files).map((resolvedPath) => {
const buffer = fs.readFileSync(resolvedPath);
return {
path: resolvedPath,
name: path.basename(resolvedPath),
mimeType: inferMimeType(resolvedPath),
size: buffer.length,
base64: buffer.toString('base64'),
};
});
}
function buildDropFilesExpression(selector, nth, frameSelector, pierceShadow, files) {
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 filePayloads = JSON.parse(${JSON.stringify(JSON.stringify(files))});
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 targetIndex = nth ?? 0;
const element = matches[targetIndex];
if (!element) {
throw new Error('element not found for selector: ' + selector);
}
const files = filePayloads.map((file) => {
const binary = atob(file.base64);
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new File([bytes], file.name, { type: file.mimeType });
});
const dataTransfer = new DataTransfer();
for (const file of files) {
dataTransfer.items.add(file);
}
element.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer }));
element.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer }));
const accepted = element.dispatchEvent(
new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer })
);
return { accepted };
})()`;
}
function formatDragFilesResult({ pageIndex, selector, nth, frameSelector, pierceShadow, files }) {
const lines = [
`pageIndex: ${pageIndex}`,
`selector: ${selector}`,
`nth: ${nth}`,
`fileCount: ${files.length}`,
];
if (frameSelector) {
lines.push(`frameSelector: ${frameSelector}`);
}
if (pierceShadow) {
lines.push('pierceShadow: true');
}
lines.push('status: files-dropped');
return lines.join('\n');
}
function createRecordingId() {
const recordingId = `rec_${String(nextRecordingCounter).padStart(4, '0')}`;
nextRecordingCounter += 1;
return recordingId;
}
function createReplayId() {
const replayId = `rep_${String(nextReplayCounter).padStart(4, '0')}`;
nextReplayCounter += 1;
return replayId;
}
function requireLatestRecording() {
if (!latestRecordingSession) {
throw new Error('no recording available');
}
return latestRecordingSession;
}
function formatRecordingSummary(session) {
return [
`recordingId: ${session.recordingId}`,
`pageId: ${session.pageId}`,
`pageIndex: ${session.pageIndex}`,
`stepCount: ${session.steps.length}`,
`warningCount: ${session.warnings.length}`,
`status: ${session.status}`,
].join('\n');
}
function createOrchestrationId() {
const orchestrationId = `orc_${String(nextOrchestrationCounter).padStart(4, '0')}`;
nextOrchestrationCounter += 1;
return orchestrationId;
}
function requireLatestReplay() {
if (!latestReplaySession) {
throw new Error('no replay available');
}
return latestReplaySession;
}
function requireLatestOrchestration() {
if (!latestOrchestrationSession) {
throw new Error('no orchestration available');
}
return latestOrchestrationSession;
}
function formatReplaySummary(session) {
return [
`replayId: ${session.replayId}`,
`pageId: ${session.pageId}`,
`pageIndex: ${session.pageIndex}`,
`stepCount: ${session.stepCount}`,
`completedSteps: ${session.completedSteps}`,
`currentStepIndex: ${session.currentStepIndex}`,
`failedStepIndex: ${session.failedStepIndex === null ? 'none' : session.failedStepIndex}`,
`error: ${session.error || 'none'}`,
`status: ${session.status}`,
].join('\n');
}
function formatOrchestrationSummary(session) {
const lines = [
`orchestrationId: ${session.orchestrationId}`,
`pageId: ${session.pageId}`,
`pageIndex: ${session.pageIndex}`,
`blockCount: ${session.blockCount}`,
`completedBlocks: ${session.completedBlocks}`,
`currentBlockIndex: ${session.currentBlockIndex}`,
`failedBlockIndex: ${session.failedBlockIndex === null ? 'none' : session.failedBlockIndex}`,
`error: ${session.error || 'none'}`,
`status: ${session.status}`,
];
if (session.errorDetails?.failedAssertionIndex !== undefined) {
lines.push(`failedAssertionIndex: ${session.errorDetails.failedAssertionIndex}`);
lines.push(`field: ${session.errorDetails.field}`);
lines.push(`op: ${session.errorDetails.op}`);
lines.push(`expected: ${JSON.stringify(session.errorDetails.expected)}`);
lines.push(`actual: ${JSON.stringify(session.errorDetails.actual)}`);
}
return lines.join('\n');
}
function formatRecordingDetail(session) {
const lines = [formatRecordingSummary(session), 'steps:'];
if (session.steps.length === 0) {
lines.push('[]');
} else {
session.steps.forEach((step, index) => {
lines.push(`- [${index}] type: ${step.type}`);
lines.push(` pageId: ${step.pageId}`);
lines.push(` timestamp: ${step.timestamp}`);
if (step.selector) {
lines.push(` selector: ${step.selector}`);
}
if (step.frameSelector) {
lines.push(` frameSelector: ${step.frameSelector}`);
}
if (step.pierceShadow === true) {
lines.push(' pierceShadow: true');
}
if (step.args && typeof step.args === 'object') {
for (const [key, value] of Object.entries(step.args)) {
if (value !== undefined) {
lines.push(` ${key}: ${JSON.stringify(value)}`);
}
}
}
});
}
lines.push('warnings:');
if (session.warnings.length === 0) {
lines.push('[]');
} else {
session.warnings.forEach((warning) => {
lines.push(`- ${warning}`);
});
}
return lines.join('\n');
}
const SUPPORTED_REPLAY_STEP_TYPES = new Set([
'click',
'type',
'press_key',
'scroll',
'drag_element',
'pointer_action',
]);
function requireReplaySteps(toolArgs) {
const steps = Array.isArray(toolArgs.steps) ? toolArgs.steps : null;
if (!steps || steps.length === 0) {
throw new Error('steps must be a non-empty array');
}
return steps;
}
function validateReplayStep(step, replayPageId) {
if (!step || typeof step !== 'object') {
throw new Error('invalid replay step payload');
}
if (!SUPPORTED_REPLAY_STEP_TYPES.has(step.type)) {
throw new Error('unsupported replay step type');
}
if (step.pageId && step.pageId !== replayPageId) {
throw new Error('replay step pageId mismatch');
}
}
const SUPPORTED_ORCHESTRATION_BLOCK_TYPES = new Set([
'wait_for_then_click',
'wait_for_then_type',
'wait_for_then_press_key',
'run_replay_sequence',
'assert_query',
]);
const SUPPORTED_ASSERTION_OPERATORS = new Set(['equals', 'contains', 'gt', 'gte', 'lt', 'lte']);
function parseAssertionValue(value) {
return value;
}
function toComparableNumber(field, value) {
const numeric = Number(value);
if (Number.isNaN(numeric)) {
throw new Error(`numeric comparison expects a number-like field: ${field}`);
}
return numeric;
}
function parseAssertions(assertQueryArgs) {
if (Array.isArray(assertQueryArgs.assertions) && assertQueryArgs.assertions.length > 0) {
return assertQueryArgs.assertions.map((entry, index) => {
if (!entry || typeof entry !== 'object') {
throw new Error(`invalid assertion payload at index ${index}`);
}
const field = requireNonEmptyString(entry.field, 'assertion.field');
const op = requireNonEmptyString(entry.op, 'assertion.op');
if (!SUPPORTED_ASSERTION_OPERATORS.has(op)) {
throw new Error('unsupported assertion operator');
}
if (!Object.prototype.hasOwnProperty.call(entry, 'value')) {
throw new Error('assertion value is required');
}
return { field, op, value: parseAssertionValue(entry.value) };
});
}
if (assertQueryArgs.assert && typeof assertQueryArgs.assert === 'object') {
return [
{
field: requireNonEmptyString(assertQueryArgs.assert.field, 'assert.field'),
op: 'equals',
value: assertQueryArgs.assert.equals,
},
];
}
throw new Error('assertions must be a non-empty array');
}
function parseQueryTextToMap(text) {
const result = {};
for (const line of String(text || '').split('\n')) {
const separatorIndex = line.indexOf(': ');
if (separatorIndex === -1) {
continue;
}
const key = line.slice(0, separatorIndex).trim();
const rawValue = line.slice(separatorIndex + 2).trim();
result[key] = rawValue;
}
return result;
}
function evaluateAssertion(assertion, actualValue, assertionIndex) {
const { field, op, value: expected } = assertion;
if (op === 'equals') {
return String(actualValue) === String(expected)
? null
: {
failedAssertionIndex: assertionIndex,
field,
op,
expected,
actual: actualValue,
message: `assert_query failed: ${field} equals ${JSON.stringify(expected)} (actual: ${JSON.stringify(actualValue)})`,
};
}
if (op === 'contains') {
if (typeof actualValue !== 'string') {
throw new Error('contains expects a string field');
}
return actualValue.includes(String(expected))
? null
: {
failedAssertionIndex: assertionIndex,
field,
op,
expected,
actual: actualValue,
message: `assert_query failed: ${field} contains ${JSON.stringify(expected)} (actual: ${JSON.stringify(actualValue)})`,
};
}
const actualNumber = toComparableNumber(field, actualValue);
const expectedNumber = toComparableNumber(field, expected);
const passed =
op === 'gt'
? actualNumber > expectedNumber
: op === 'gte'
? actualNumber >= expectedNumber
: op === 'lt'
? actualNumber < expectedNumber
: actualNumber <= expectedNumber;
return passed
? null
: {
failedAssertionIndex: assertionIndex,
field,
op,
expected: expectedNumber,
actual: actualNumber,
message: `assert_query failed: ${field} ${op} ${expectedNumber} (actual: ${actualNumber})`,
};
}
function formatOrchestrationError(error) {
const message = error instanceof Error ? error.message : String(error);
try {
const parsed = JSON.parse(message);
if (parsed && typeof parsed === 'object' && 'failedAssertionIndex' in parsed) {
return parsed;
}
} catch {
// Keep plain-text error fallback below.
}
return { message };
}
function requireOrchestrationBlocks(toolArgs) {
const blocks = Array.isArray(toolArgs.blocks) ? toolArgs.blocks : null;
if (!blocks || blocks.length === 0) {
throw new Error('blocks must be a non-empty array');
}
return blocks;
}
function validateOrchestrationBlock(block) {
if (!block || typeof block !== 'object') {
throw new Error('invalid orchestration block payload');
}
if (!SUPPORTED_ORCHESTRATION_BLOCK_TYPES.has(block.type)) {
throw new Error('unsupported orchestration block type');
}
}
function normalizeRecordedEvent(pageId, event) {
if (!event || typeof event !== 'object') {
return null;
}
const timestamp = typeof event.timestamp === 'number' ? event.timestamp : Date.now();
if (event.kind === 'click') {
return {
type: 'click',
pageId,
timestamp,
selector: event.selector,
nth: event.nth ?? 0,
frameSelector: event.frameSelector,
pierceShadow: event.pierceShadow === true,
args: {
button: event.button || 'left',
clickCount: event.clickCount || 1,
offsetX: event.offsetX,
offsetY: event.offsetY,
},
};
}
if (event.kind === 'type') {
return {
type: 'type',
pageId,
timestamp,
selector: event.selector,
nth: event.nth ?? 0,
frameSelector: event.frameSelector,
pierceShadow: event.pierceShadow === true,
args: {
text: event.text || '',
},
};
}
if (event.kind === 'press_key') {
return {
type: 'press_key',
pageId,
timestamp,
args: {
key: event.key,
modifiers: Array.isArray(event.modifiers) ? event.modifiers : [],
},
};
}
if (event.kind === 'scroll') {
return {
type: 'scroll',
pageId,
timestamp,
selector: event.selector,
frameSelector: event.frameSelector,
pierceShadow: event.pierceShadow === true,
args: {
deltaX: event.deltaX || 0,
deltaY: event.deltaY || 0,
},
};
}
if (event.kind === 'drag_element') {
return {
type: 'drag_element',
pageId,
timestamp,
selector: event.selector,
nth: event.nth ?? 0,
frameSelector: event.frameSelector,
pierceShadow: event.pierceShadow === true,
args: {
targetSelector: event.targetSelector,
targetX: event.targetX,
targetY: event.targetY,
targetNth: event.targetNth,
},
};
}
if (event.kind === 'pointer_action') {
return {
type: 'pointer_action',
pageId,
timestamp,
args: {
actions: Array.isArray(event.actions) ? event.actions : [],
},
};
}
return null;
}
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 getScrolledElementStateAt(page, selector, nth, frameSelector = '', pierceShadow = false) {
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 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 targetIndex = nth ?? 0;
const element = matches[targetIndex];
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));
}
async function getScrolledElementState(page, selector, frameSelector = '', pierceShadow = false) {
return await getScrolledElementStateAt(page, selector, undefined, frameSelector, pierceShadow);
}
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)}`;
}
function getButtonMask(button) {
if (button === 'right') {
return 2;
}
if (button === 'middle') {
return 4;
}
return 1;
}
async function dispatchMousePointerEvent(page, type, x, y, button = 'left', isPressed = false) {
await sendCdpCommand(page, 'Input.dispatchMouseEvent', {
type,
x,
y,
button: type === 'mouseMoved' && !isPressed ? 'none' : button,
buttons: isPressed ? getButtonMask(button) : 0,
pointerType: 'mouse',
clickCount: 1,
});
}
function interpolatePoints(sourcePoint, targetPoint, steps) {
const points = [];
for (let index = 1; index <= steps; index += 1) {
const ratio = index / steps;
points.push({
x: Math.round(sourcePoint.x + (targetPoint.x - sourcePoint.x) * ratio),
y: Math.round(sourcePoint.y + (targetPoint.y - sourcePoint.y) * ratio),
});
}
return points;
}
async function resolveElementCenterPoint(page, selector, nth, frameSelector, pierceShadow, missingLabel) {
const state = await getScrolledElementStateAt(page, selector, nth, frameSelector, pierceShadow);
if (!state.exists) {
throw new Error(missingLabel);
}
if (state.connected !== true) {
throw new Error(`drag coordinates unavailable for selector: ${selector}`);
}
if (state.interactable !== true || !state.centerPoint) {
throw new Error(`drag coordinates unavailable for selector: ${selector}`);
}
return state.centerPoint;
}
async function handlePointerAction(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.`);
}
const actions = Array.isArray(toolArgs.actions) ? toolArgs.actions : null;
if (!actions || actions.length === 0) {
throw new Error('actions must be a non-empty array');
}
if (actions.length > MAX_POINTER_ACTIONS) {
throw new Error(`actions exceeds maximum of ${MAX_POINTER_ACTIONS}`);
}
let pointerX = null;
let pointerY = null;
let pressedButton = null;
for (const action of actions) {
if (!action || typeof action !== 'object') {
throw new Error('each action must be an object');
}
if (!['move', 'down', 'up', 'pause'].includes(action.type)) {
throw new Error(`unsupported pointer action: ${String(action.type || '')}`);
}
if (action.type === 'pause') {
const durationMs = action.durationMs === undefined ? 0 : requireNonNegativeInteger(action.durationMs, 'durationMs');
if (durationMs > 0) {
await sleep(durationMs);
}
continue;
}
if (action.type === 'move') {
let point = null;
if (typeof action.selector === 'string' && action.selector.trim() !== '') {
point = await resolveElementCenterPoint(
page,
requireNonEmptyString(action.selector, 'actions.selector'),
requireOptionalNonNegativeInteger(action.nth, 'actions.nth'),
parseOptionalNonEmptyString(action.frameSelector),
action.pierceShadow === true,
'drag coordinates unavailable'
);
} else if (typeof action.x === 'number' && typeof action.y === 'number') {
point = {
x: requireFiniteNumber(action.x, 'actions.x'),
y: requireFiniteNumber(action.y, 'actions.y'),
};
} else {
throw new Error('drag coordinates unavailable');
}
pointerX = point.x;
pointerY = point.y;
await dispatchMousePointerEvent(page, 'mouseMoved', pointerX, pointerY, pressedButton || 'left', Boolean(pressedButton));
continue;
}
if (pointerX === null || pointerY === null) {
throw new Error('pointer state error');
}
if (action.type === 'down') {
if (pressedButton) {
throw new Error('pointer state error');
}
pressedButton = requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || 'left';
await dispatchMousePointerEvent(page, 'mousePressed', pointerX, pointerY, pressedButton, true);
continue;
}
if (!pressedButton) {
throw new Error('pointer state error');
}
const releaseButton = requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton;
await dispatchMousePointerEvent(page, 'mouseReleased', pointerX, pointerY, releaseButton, false);
pressedButton = null;
}
if (pressedButton) {
throw new Error('pointer state error');
}
return `pageIndex: ${pageIndex}\nactionCount: ${actions.length}\nstatus: pointer-actions-completed`;
}
async function handleDragElement(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.`);
}
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth');
const targetNth = requireOptionalNonNegativeInteger(toolArgs.targetNth, 'targetNth');
const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector);
const pierceShadow = toolArgs.pierceShadow === true;
const targetSelector = parseOptionalNonEmptyString(toolArgs.targetSelector);
const hasTargetX = Object.prototype.hasOwnProperty.call(toolArgs, 'targetX');
const hasTargetY = Object.prototype.hasOwnProperty.call(toolArgs, 'targetY');
const hasTargetCoordinates = hasTargetX || hasTargetY;
if (targetSelector && hasTargetCoordinates) {
throw new Error('targetSelector and targetX/targetY cannot be used together');
}
if (!targetSelector && !hasTargetCoordinates) {
throw new Error('targetSelector or targetX/targetY is required');
}
if (hasTargetX !== hasTargetY) {
throw new Error('targetX and targetY must be provided together');
}
const sourcePoint = await resolveElementCenterPoint(
page,
selector,
nth,
frameSelector,
pierceShadow,
'source element not found'
);
const targetPoint = targetSelector
? await resolveElementCenterPoint(
page,
targetSelector,
targetNth,
frameSelector,
pierceShadow,
'target element not found'
)
: {
x: requireFiniteNumber(toolArgs.targetX, 'targetX'),
y: requireFiniteNumber(toolArgs.targetY, 'targetY'),
};
const steps = toolArgs.steps === undefined ? DEFAULT_DRAG_STEPS : requirePositiveInteger(toolArgs.steps, 'steps');
await dispatchMousePointerEvent(page, 'mouseMoved', sourcePoint.x, sourcePoint.y, 'left', false);
await dispatchMousePointerEvent(page, 'mousePressed', sourcePoint.x, sourcePoint.y, 'left', true);
for (const point of interpolatePoints(sourcePoint, targetPoint, steps)) {
await dispatchMousePointerEvent(page, 'mouseMoved', point.x, point.y, 'left', true);
}
await dispatchMousePointerEvent(page, 'mouseReleased', targetPoint.x, targetPoint.y, 'left', false);
return [
`pageIndex: ${pageIndex}`,
`selector: ${selector}`,
`nth: ${nth ?? 0}`,
targetSelector ? `targetSelector: ${targetSelector}` : `targetX: ${targetPoint.x}`,
targetSelector ? `targetNth: ${targetNth ?? 0}` : `targetY: ${targetPoint.y}`,
`steps: ${steps}`,
`${frameSelector ? `frameSelector: ${frameSelector}\n` : ''}${pierceShadow ? 'pierceShadow: true\n' : ''}status: dragged`,
].join('\n');
}
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 dispatchMousePointerEvent(page, 'mouseMoved', state.centerPoint.x, state.centerPoint.y, 'left', false);
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 getRulesForMatching(pageId) {
return getRulesForPage(pageId)
.slice()
.sort((left, right) => right.priority - left.priority);
}
function matchesUrlPattern(pattern, url) {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return new RegExp(`^${escaped}$`).test(url);
}
function normalizePausedRequestHeaders(paused) {
const normalized = new Map();
const headers = paused.request?.headers;
if (!headers || typeof headers !== 'object') {
return normalized;
}
for (const [name, value] of Object.entries(headers)) {
normalized.set(name.toLowerCase(), String(value));
}
return normalized;
}
function matchesHeaderMatchers(headerMatchers, paused) {
if (!headerMatchers.length) {
return true;
}
const normalizedHeaders = normalizePausedRequestHeaders(paused);
return headerMatchers.every((matcher) => {
const headerValue = normalizedHeaders.get(matcher.name.toLowerCase());
if (headerValue === undefined) {
return false;
}
if (matcher.valueIncludes && !headerValue.includes(matcher.valueIncludes)) {
return false;
}
if (matcher.valueRegex && !new RegExp(matcher.valueRegex).test(headerValue)) {
return false;
}
return true;
});
}
function matchesInterceptRule(rule, paused) {
const requestMethod = String(paused.request?.method || '').toUpperCase();
const requestUrl = String(paused.request?.url || '');
const requestResourceType = String(paused.resourceType || '');
if (rule.method && rule.method !== requestMethod) {
return false;
}
if (rule.urlIncludes && !requestUrl.includes(rule.urlIncludes)) {
return false;
}
if (rule.urlPattern && !matchesUrlPattern(rule.urlPattern, requestUrl)) {
return false;
}
if (rule.urlRegex && !new RegExp(rule.urlRegex).test(requestUrl)) {
return false;
}
if (rule.resourceType && rule.resourceType.toLowerCase() !== requestResourceType.toLowerCase()) {
return false;
}
if (!matchesHeaderMatchers(rule.headerMatchers || [], paused)) {
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 = getRulesForMatching(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.responseHeaders,
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 || '<untitled>'}\nurl: ${page.url || '<empty>'}\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 || '<untitled>'}\nurl: ${selectedPage.url || '<empty>'}\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,
});
if (activeRecordingSession && activeRecordingSession.pageId === page.id) {
try {
await finalizeRecordingCapture(activeRecordingSession);
} catch {
// Keep the recording session and still report the page-close warning below.
}
activeRecordingSession.status = 'stopped';
activeRecordingSession.stoppedAt = new Date().toISOString();
activeRecordingSession.warnings.push('recording stopped because target page was closed');
latestRecordingSession = activeRecordingSession;
activeRecordingSession = null;
}
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 resourceType = parseOptionalResourceType(toolArgs.resourceType);
const urlPattern = parseOptionalUrlPattern(toolArgs.urlPattern);
const urlRegex = parseOptionalUrlRegex(toolArgs.urlRegex);
const headerMatchers = parseOptionalHeaderMatchers(toolArgs.headerMatchers);
const priority = parseOptionalPriority(toolArgs.priority);
const action = parseInterceptAction(toolArgs.action);
validateInterceptMatcherSet({
urlIncludes,
method,
resourceType,
urlPattern,
urlRegex,
headerMatchers,
});
const statusCode = parseOptionalStatusCode(toolArgs.statusCode);
const contentType =
toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType');
const body = parseOptionalBody(toolArgs.body);
const responseHeaders = parseOptionalResponseHeaders(toolArgs.responseHeaders, contentType);
const rule = {
ruleId: createInterceptRuleId(),
pageId: page.id,
pageTitleSnapshot: page.title || '',
urlIncludes,
method,
resourceType,
urlPattern,
urlRegex,
headerMatchers,
priority,
action,
statusCode: action === 'fulfill' ? statusCode : 0,
contentType: action === 'fulfill' ? contentType : '',
responseHeaders: action === 'fulfill' ? responseHeaders : [],
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 handleSetDownloadBehavior(toolArgs) {
const behavior = requireEnumString(toolArgs.behavior, 'behavior', ['accept', 'deny']);
const downloadPath = parseOptionalNonEmptyString(toolArgs.downloadPath);
const eventsEnabled = toolArgs.eventsEnabled === undefined ? true : toolArgs.eventsEnabled === true;
if (behavior === 'deny' && downloadPath) {
throw new Error('downloadPath is only allowed when behavior=accept');
}
await ensureBrowserDownloadSession();
const resolvedDownloadPath =
behavior === 'accept' ? ensureWritableDirectory(downloadPath || getSessionDownloadPath()) : '';
await sendBrowserCdpCommand('Browser.setDownloadBehavior', {
behavior: behavior === 'accept' ? 'allow' : 'deny',
...(resolvedDownloadPath ? { downloadPath: resolvedDownloadPath } : {}),
eventsEnabled,
});
return `scope: browser\nbehavior: ${behavior}\ndownloadPath: ${resolvedDownloadPath || '<none>'}\neventsEnabled: ${eventsEnabled}\nstatus: configured`;
}
async function handleListDownloads(toolArgs) {
const limit = requirePositiveIntegerOrDefault(toolArgs.limit, 'limit', 20);
if (browserDownloadSession) {
await browserDownloadSession.ready;
await browserDownloadSession.getLastActivity();
await sleep(35);
await browserDownloadSession.getLastActivity();
}
const entries = recentDownloads.slice(-limit).reverse();
return formatRecentDownloads(entries);
}
function resolveDownloadRecord(toolArgs) {
const downloadId = parseOptionalNonEmptyString(toolArgs.downloadId);
const guid = parseOptionalNonEmptyString(toolArgs.guid);
if (!downloadId && !guid) {
throw new Error('downloadId or guid is required');
}
const matched = recentDownloads.filter(
(entry) => (!downloadId || entry.downloadId === downloadId) && (!guid || entry.guid === guid)
);
if (matched.length !== 1) {
throw new Error('download not found');
}
return matched[0];
}
async function handleCancelDownload(toolArgs) {
const entry = resolveDownloadRecord(toolArgs);
if (entry.status === 'completed' || entry.status === 'failed' || entry.status === 'denied') {
throw new Error(`download is not cancelable in status: ${entry.status}`);
}
await sendBrowserCdpCommand('Browser.cancelDownload', { guid: entry.guid });
entry.status = 'canceled';
entry.finishedAt = new Date().toISOString();
return `downloadId: ${entry.downloadId}\nguid: ${entry.guid}\nstatus: canceled`;
}
async function handleSetFileInput(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);
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
const files = validateLocalFiles(requireNonEmptyStringArray(toolArgs.files, 'files'));
const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth');
const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector);
const pierceShadow = toolArgs.pierceShadow === true;
await withPageCommandSession(page, async (session) => {
const objectId = await evaluateObjectHandle(
session,
buildFileInputHandleExpression(selector, nth, frameSelector, pierceShadow)
);
await session.sendCommand('DOM.setFileInputFiles', {
files,
objectId,
});
});
return formatFileInputResult({
pageIndex,
selector,
nth,
frameSelector,
pierceShadow,
files,
});
}
async function handleDragFiles(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);
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
const files = readLocalFilesForDrop(requireNonEmptyStringArray(toolArgs.files, 'files'));
const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth');
const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector);
const pierceShadow = toolArgs.pierceShadow === true;
const response = await sendCdpCommand(page, 'Runtime.evaluate', {
expression: buildDropFilesExpression(selector, nth, frameSelector, pierceShadow, files),
returnByValue: true,
awaitPromise: true,
});
if (response?.exceptionDetails) {
throw new Error(response.exceptionDetails.text || 'drop target evaluation failed');
}
const 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 || 'drop target evaluation failed');
}
if (result.value?.accepted === false) {
throw new Error('drop target rejected files');
}
return formatDragFilesResult({
pageIndex,
selector,
nth,
frameSelector,
pierceShadow,
files,
});
}
function buildRecordingInstallExpression(recordingPayload) {
return `(() => {
const recordingPayload = JSON.parse(${JSON.stringify(JSON.stringify(recordingPayload))});
const existing = globalThis.__CCS_BROWSER_RECORDING_RECORDER__;
if (existing && existing.installed === true) {
return { installed: true };
}
const events = Array.isArray(recordingPayload.events) ? [...recordingPayload.events] : [];
const warnings = Array.isArray(recordingPayload.warnings) ? [...recordingPayload.warnings] : [];
const getSelector = (element) => {
if (!(element instanceof Element)) {
return undefined;
}
if (element.id) {
return '#' + element.id;
}
const attr = typeof element.getAttribute === 'function' ? element.getAttribute('data-testid') : '';
if (attr) {
return '[data-testid="' + attr + '"]';
}
return element.tagName ? element.tagName.toLowerCase() : undefined;
};
const pushEvent = (event) => {
events.push(event);
};
const onClick = (event) => {
pushEvent({
kind: 'click',
selector: getSelector(event.target),
button: event.button === 1 ? 'middle' : event.button === 2 ? 'right' : 'left',
clickCount: event.detail || 1,
offsetX: typeof event.offsetX === 'number' ? event.offsetX : undefined,
offsetY: typeof event.offsetY === 'number' ? event.offsetY : undefined,
timestamp: Date.now(),
});
};
const onInput = (event) => {
const target = event.target;
let text = '';
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
text = target.value;
} else if (target && target.isContentEditable === true) {
text = target.textContent || '';
} else {
return;
}
pushEvent({ kind: 'type', selector: getSelector(target), text, timestamp: Date.now() });
};
const onKeyDown = (event) => {
const modifiers = [];
if (event.altKey) modifiers.push('Alt');
if (event.ctrlKey) modifiers.push('Control');
if (event.metaKey) modifiers.push('Meta');
if (event.shiftKey) modifiers.push('Shift');
pushEvent({ kind: 'press_key', key: event.key, modifiers, timestamp: Date.now() });
};
const onScroll = (event) => {
const target = event.target === document ? document.scrollingElement || document.documentElement : event.target;
pushEvent({
kind: 'scroll',
selector: getSelector(target),
deltaX: 0,
deltaY: 0,
timestamp: Date.now(),
});
};
document.addEventListener('click', onClick, true);
document.addEventListener('input', onInput, true);
document.addEventListener('keydown', onKeyDown, true);
document.addEventListener('scroll', onScroll, true);
globalThis.__CCS_BROWSER_RECORDING_RECORDER__ = {
installed: true,
events,
warnings,
teardown: () => {
document.removeEventListener('click', onClick, true);
document.removeEventListener('input', onInput, true);
document.removeEventListener('keydown', onKeyDown, true);
document.removeEventListener('scroll', onScroll, true);
},
};
return { installed: true };
})()`;
}
async function installRecorderAndCapture(page) {
const response = await sendCdpCommand(page, 'Runtime.evaluate', {
expression: buildRecordingInstallExpression({ events: [], warnings: [] }),
returnByValue: true,
awaitPromise: true,
});
if (response?.exceptionDetails) {
throw new Error(response.exceptionDetails.text || 'recording injection failed');
}
const result = response?.result || null;
if (!result) {
throw new Error('recording injection failed');
}
if (result.subtype === 'error') {
throw new Error(result.description || 'recording injection failed');
}
return result.value || { installed: true };
}
async function finalizeRecordingCapture(session) {
const page = {
id: session.pageId,
webSocketDebuggerUrl: session.pageWebSocketDebuggerUrl,
};
const response = await sendCdpCommand(page, 'Runtime.evaluate', {
expression: `(() => globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] })()`,
returnByValue: true,
awaitPromise: true,
});
if (response?.exceptionDetails) {
throw new Error(response.exceptionDetails.text || 'recording injection failed');
}
const value = response?.result?.value || { events: [], warnings: [] };
const rawEvents = Array.isArray(value.events) ? value.events : [];
const warnings = Array.isArray(value.warnings) ? value.warnings : [];
session.rawEvents = rawEvents;
session.steps = rawEvents.map((event) => normalizeRecordedEvent(session.pageId, event)).filter(Boolean);
session.warnings = warnings.map((warning) => String(warning.message || warning));
}
async function handleStartRecording(toolArgs) {
if (activeRecordingSession) {
throw new Error('recording already active');
}
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.`);
}
const session = {
recordingId: createRecordingId(),
pageId: page.id,
pageIndex,
startedAt: new Date().toISOString(),
stoppedAt: null,
status: 'recording',
steps: [],
warnings: [],
rawEvents: [],
pageWebSocketDebuggerUrl: page.webSocketDebuggerUrl,
captureInstalled: false,
};
await installRecorderAndCapture(page);
session.captureInstalled = true;
activeRecordingSession = session;
latestRecordingSession = session;
return formatRecordingSummary(session);
}
async function handleStopRecording() {
if (!activeRecordingSession) {
throw new Error('no active recording');
}
await finalizeRecordingCapture(activeRecordingSession);
activeRecordingSession.status = 'stopped';
activeRecordingSession.stoppedAt = new Date().toISOString();
const stopped = activeRecordingSession;
activeRecordingSession = null;
latestRecordingSession = stopped;
return formatRecordingSummary(stopped);
}
async function handleGetRecording() {
const session = requireLatestRecording();
return formatRecordingDetail(session);
}
async function handleClearRecording() {
if (!latestRecordingSession && !activeRecordingSession) {
throw new Error('no recording available');
}
activeRecordingSession = null;
latestRecordingSession = null;
return 'status: cleared';
}
function buildReplayToolArgs(step, replayPage) {
const baseArgs = {
pageId: replayPage.pageId,
};
if (step.type === 'click') {
return {
toolName: TOOL_CLICK,
toolArgs: {
...baseArgs,
selector: requireNonEmptyString(step.selector, 'selector'),
nth: step.nth,
frameSelector: step.frameSelector,
pierceShadow: step.pierceShadow,
button: step.args?.button,
clickCount: step.args?.clickCount,
offsetX: step.args?.offsetX,
offsetY: step.args?.offsetY,
},
};
}
if (step.type === 'type') {
return {
toolName: TOOL_TYPE,
toolArgs: {
...baseArgs,
selector: requireNonEmptyString(step.selector, 'selector'),
nth: step.nth,
frameSelector: step.frameSelector,
pierceShadow: step.pierceShadow,
text: requireString(step.args?.text, 'text'),
clearFirst: true,
},
};
}
if (step.type === 'press_key') {
return {
toolName: TOOL_PRESS_KEY,
toolArgs: {
...baseArgs,
key: requireNonEmptyString(step.args?.key, 'key'),
modifiers: Array.isArray(step.args?.modifiers) ? step.args.modifiers : [],
},
};
}
if (step.type === 'scroll') {
return {
toolName: TOOL_SCROLL,
toolArgs: {
...baseArgs,
selector: step.selector,
frameSelector: step.frameSelector,
pierceShadow: step.pierceShadow,
behavior: 'by-offset',
deltaX: step.args?.deltaX ?? 0,
deltaY: step.args?.deltaY ?? 0,
},
};
}
if (step.type === 'drag_element') {
return {
toolName: TOOL_DRAG_ELEMENT,
toolArgs: {
...baseArgs,
selector: requireNonEmptyString(step.selector, 'selector'),
nth: step.nth,
frameSelector: step.frameSelector,
pierceShadow: step.pierceShadow,
...(step.args?.targetSelector !== undefined ? { targetSelector: step.args.targetSelector } : {}),
...(step.args?.targetNth !== undefined ? { targetNth: step.args.targetNth } : {}),
...(step.args?.targetX !== undefined ? { targetX: step.args.targetX } : {}),
...(step.args?.targetY !== undefined ? { targetY: step.args.targetY } : {}),
},
};
}
if (step.type === 'pointer_action') {
return {
toolName: TOOL_POINTER_ACTION,
toolArgs: {
...baseArgs,
actions: Array.isArray(step.args?.actions) ? step.args.actions : [],
},
};
}
return null;
}
async function executeReplaySteps(session) {
for (let index = 0; index < session.steps.length; index += 1) {
const step = session.steps[index];
session.currentStepIndex = index;
validateReplayStep(step, session.pageId);
const mapped = buildReplayToolArgs(step, session);
if (!mapped) {
throw new Error('unsupported replay step type');
}
if (mapped.toolName === TOOL_CLICK) {
await handleClick(mapped.toolArgs);
} else if (mapped.toolName === TOOL_TYPE) {
await handleType(mapped.toolArgs);
} else if (mapped.toolName === TOOL_PRESS_KEY) {
await handlePressKey(mapped.toolArgs);
} else if (mapped.toolName === TOOL_SCROLL) {
await handleScroll(mapped.toolArgs);
} else if (mapped.toolName === TOOL_DRAG_ELEMENT) {
await handleDragElement(mapped.toolArgs);
} else if (mapped.toolName === TOOL_POINTER_ACTION) {
await handlePointerAction(mapped.toolArgs);
} else {
throw new Error('unsupported replay step type');
}
session.completedSteps = index + 1;
}
}
async function handleStartReplay(toolArgs) {
if (activeReplaySession) {
throw new Error('replay already active');
}
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.`);
}
const steps = requireReplaySteps(toolArgs);
const session = {
replayId: createReplayId(),
pageId: page.id,
pageIndex,
status: 'running',
stepCount: steps.length,
completedSteps: 0,
currentStepIndex: 0,
failedStepIndex: null,
error: null,
steps,
};
latestReplaySession = session;
activeReplaySession = session;
try {
await executeReplaySteps(session);
session.status = 'completed';
} catch (error) {
session.status = 'failed';
session.failedStepIndex = session.currentStepIndex;
session.error = error instanceof Error ? error.message : String(error);
}
activeReplaySession = null;
latestReplaySession = session;
return formatReplaySummary(session);
}
async function handleGetReplay() {
const session = requireLatestReplay();
return formatReplaySummary(session);
}
async function handleCancelReplay() {
if (!activeReplaySession) {
throw new Error('no active replay');
}
activeReplaySession.status = 'canceled';
const canceled = activeReplaySession;
activeReplaySession = null;
latestReplaySession = canceled;
return formatReplaySummary(canceled);
}
async function executeOrchestrationBlock(session, block) {
if (block.type === 'wait_for_then_click') {
await handleWaitFor({ pageId: session.pageId, ...block.args.wait });
await handleClick({ pageId: session.pageId, ...block.args.click });
return;
}
if (block.type === 'wait_for_then_type') {
await handleWaitFor({ pageId: session.pageId, ...block.args.wait });
await handleType({ pageId: session.pageId, ...block.args.type });
return;
}
if (block.type === 'wait_for_then_press_key') {
await handleWaitFor({ pageId: session.pageId, ...block.args.wait });
await handlePressKey({ pageId: session.pageId, ...block.args.pressKey });
return;
}
if (block.type === 'run_replay_sequence') {
const replayResultText = await handleStartReplay({ pageId: session.pageId, steps: block.args.steps });
if (replayResultText.includes('status: failed')) {
throw new Error(replayResultText);
}
return;
}
if (block.type === 'assert_query') {
const queryText = await handleQuery({ pageId: session.pageId, ...block.args.query });
const queryMap = parseQueryTextToMap(queryText);
const assertions = parseAssertions(block.args);
for (const [index, assertion] of assertions.entries()) {
const actualValue = queryMap[assertion.field];
if (actualValue === undefined) {
throw new Error(`assert_query field missing from query result: ${assertion.field}`);
}
const failure = evaluateAssertion(assertion, actualValue, index);
if (failure) {
throw new Error(JSON.stringify(failure));
}
}
return;
}
throw new Error('unsupported orchestration block type');
}
async function executeOrchestrationBlocks(session) {
for (let index = 0; index < session.blocks.length; index += 1) {
const block = session.blocks[index];
session.currentBlockIndex = index;
validateOrchestrationBlock(block);
await executeOrchestrationBlock(session, block);
session.completedBlocks = index + 1;
}
}
async function handleStartOrchestration(toolArgs) {
if (activeOrchestrationSession) {
throw new Error('orchestration already active');
}
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.`);
}
const blocks = requireOrchestrationBlocks(toolArgs);
const session = {
orchestrationId: createOrchestrationId(),
pageId: page.id,
pageIndex,
status: 'running',
blockCount: blocks.length,
completedBlocks: 0,
currentBlockIndex: 0,
failedBlockIndex: null,
error: null,
blocks,
};
latestOrchestrationSession = session;
activeOrchestrationSession = session;
try {
await executeOrchestrationBlocks(session);
session.status = 'completed';
} catch (error) {
session.status = 'failed';
session.failedBlockIndex = session.currentBlockIndex;
session.errorDetails = formatOrchestrationError(error);
session.error = session.errorDetails.message || (error instanceof Error ? error.message : String(error));
}
activeOrchestrationSession = null;
latestOrchestrationSession = session;
return formatOrchestrationSummary(session);
}
async function handleGetOrchestration() {
const session = requireLatestOrchestration();
return formatOrchestrationSummary(session);
}
async function handleCancelOrchestration() {
if (!activeOrchestrationSession) {
throw new Error('no active orchestration');
}
activeOrchestrationSession.status = 'canceled';
const canceled = activeOrchestrationSession;
activeOrchestrationSession = null;
latestOrchestrationSession = canceled;
return formatOrchestrationSummary(canceled);
}
async function handleToolCall(message) {
const id = message.id;
const params = message.params || {};
const toolName = params.name || '<missing>';
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_SET_DOWNLOAD_BEHAVIOR) {
const text = await handleSetDownloadBehavior(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_LIST_DOWNLOADS) {
const text = await handleListDownloads(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_CANCEL_DOWNLOAD) {
const text = await handleCancelDownload(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_SET_FILE_INPUT) {
const text = await handleSetFileInput(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_DRAG_FILES) {
const text = await handleDragFiles(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_DRAG_ELEMENT) {
const text = await handleDragElement(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_POINTER_ACTION) {
const text = await handlePointerAction(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_START_RECORDING) {
const text = await handleStartRecording(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_STOP_RECORDING) {
const text = await handleStopRecording(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_GET_RECORDING) {
const text = await handleGetRecording(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_CLEAR_RECORDING) {
const text = await handleClearRecording(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_START_REPLAY) {
const text = await handleStartReplay(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_GET_REPLAY) {
const text = await handleGetReplay(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_CANCEL_REPLAY) {
const text = await handleCancelReplay(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_START_ORCHESTRATION) {
const text = await handleStartOrchestration(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_GET_ORCHESTRATION) {
const text = await handleGetOrchestration(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_CANCEL_ORCHESTRATION) {
const text = await handleCancelOrchestration(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();