mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
为 assert_query 增加 assertions 数组、更多比较操作和结构化失败 diagnostics。 并补充多断言、类型错误和数值比较的回归覆盖。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5665 lines
176 KiB
JavaScript
Executable File
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();
|