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