mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
feat(browser): 完成 Phase 8 文件传输能力
增加 browser-scoped 下载控制与 page-scoped file input 上传,补齐 Phase 8 文档与回归覆盖,并去掉下载列表的 pageId 假能力以保持语义一致。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
26aa8f4c20
commit
492ee5b083
3 files changed
+1280
-22
No files matched your search
@@ -32,6 +32,9 @@ function loadWebSocketImplementation() {
|
||||
}
|
||||
|
||||
const WebSocket = loadWebSocketImplementation();
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
|
||||
const PROTOCOL_VERSION = '2024-11-05';
|
||||
const SERVER_NAME = 'ccs-browser';
|
||||
@@ -52,6 +55,10 @@ const TOOL_ADD_INTERCEPT_RULE = 'browser_add_intercept_rule';
|
||||
const TOOL_REMOVE_INTERCEPT_RULE = 'browser_remove_intercept_rule';
|
||||
const TOOL_LIST_INTERCEPT_RULES = 'browser_list_intercept_rules';
|
||||
const TOOL_LIST_REQUESTS = 'browser_list_requests';
|
||||
const TOOL_SET_DOWNLOAD_BEHAVIOR = 'browser_set_download_behavior';
|
||||
const TOOL_LIST_DOWNLOADS = 'browser_list_downloads';
|
||||
const TOOL_CANCEL_DOWNLOAD = 'browser_cancel_download';
|
||||
const TOOL_SET_FILE_INPUT = 'browser_set_file_input';
|
||||
const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot';
|
||||
const TOOL_WAIT_FOR = 'browser_wait_for';
|
||||
const TOOL_EVAL = 'browser_eval';
|
||||
@@ -76,6 +83,10 @@ const TOOL_NAMES = [
|
||||
TOOL_REMOVE_INTERCEPT_RULE,
|
||||
TOOL_LIST_INTERCEPT_RULES,
|
||||
TOOL_LIST_REQUESTS,
|
||||
TOOL_SET_DOWNLOAD_BEHAVIOR,
|
||||
TOOL_LIST_DOWNLOADS,
|
||||
TOOL_CANCEL_DOWNLOAD,
|
||||
TOOL_SET_FILE_INPUT,
|
||||
TOOL_TAKE_SCREENSHOT,
|
||||
TOOL_WAIT_FOR,
|
||||
TOOL_EVAL,
|
||||
@@ -108,10 +119,15 @@ let requestCounter = 0;
|
||||
let selectedPageId = '';
|
||||
let messageQueue = Promise.resolve();
|
||||
let nextInterceptRuleCounter = 1;
|
||||
let nextDownloadCounter = 1;
|
||||
const interceptRules = [];
|
||||
const recentRequests = [];
|
||||
const recentDownloads = [];
|
||||
const interceptSessionsByPageId = new Map();
|
||||
let browserDownloadSession = null;
|
||||
let sessionDownloadDir = '';
|
||||
const MAX_RECENT_REQUESTS = 100;
|
||||
const MAX_RECENT_DOWNLOADS = 100;
|
||||
const FETCH_FAIL_ERROR_REASON = 'Failed';
|
||||
|
||||
function addSocketListener(socket, eventName, handler) {
|
||||
@@ -549,6 +565,65 @@ function getTools() {
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_SET_DOWNLOAD_BEHAVIOR,
|
||||
description: 'Set browser-scoped download behavior for the current attach session.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
behavior: { type: 'string', enum: ['accept', 'deny'] },
|
||||
downloadPath: { type: 'string' },
|
||||
eventsEnabled: { type: 'boolean' },
|
||||
},
|
||||
required: ['behavior'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_LIST_DOWNLOADS,
|
||||
description: 'List recent browser-scoped download summaries recorded in this MCP session.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
limit: { type: 'integer', minimum: 1 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_CANCEL_DOWNLOAD,
|
||||
description: 'Cancel an in-progress download by downloadId or guid.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
downloadId: { type: 'string' },
|
||||
guid: { type: 'string' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_SET_FILE_INPUT,
|
||||
description:
|
||||
'Set local files on a file input element in the selected page. Supports selected-page routing plus same-origin frame and open shadow-root scoping.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
selector: { type: 'string' },
|
||||
files: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
},
|
||||
pageIndex: { type: 'integer', minimum: 0 },
|
||||
pageId: { type: 'string' },
|
||||
nth: { type: 'integer', minimum: 0 },
|
||||
frameSelector: { type: 'string' },
|
||||
pierceShadow: { type: 'boolean' },
|
||||
},
|
||||
required: ['selector', 'files'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_TAKE_SCREENSHOT,
|
||||
description:
|
||||
@@ -1008,6 +1083,45 @@ function pushRecentRequest(entry) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSessionDownloadPath() {
|
||||
if (!sessionDownloadDir) {
|
||||
sessionDownloadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-downloads-'));
|
||||
}
|
||||
return sessionDownloadDir;
|
||||
}
|
||||
|
||||
function ensureWritableDirectory(downloadPath) {
|
||||
fs.mkdirSync(downloadPath, { recursive: true });
|
||||
fs.accessSync(downloadPath, fs.constants.W_OK);
|
||||
return downloadPath;
|
||||
}
|
||||
|
||||
function pushRecentDownload(entry) {
|
||||
recentDownloads.push(entry);
|
||||
if (recentDownloads.length > MAX_RECENT_DOWNLOADS) {
|
||||
recentDownloads.splice(0, recentDownloads.length - MAX_RECENT_DOWNLOADS);
|
||||
}
|
||||
}
|
||||
|
||||
function updateRecentDownload(guid, patch) {
|
||||
const record = recentDownloads.find((candidate) => candidate.guid === guid);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
Object.assign(record, patch);
|
||||
return record;
|
||||
}
|
||||
|
||||
function resolveDownloadStatus(progressState) {
|
||||
if (progressState === 'completed') {
|
||||
return 'completed';
|
||||
}
|
||||
if (progressState === 'canceled') {
|
||||
return 'canceled';
|
||||
}
|
||||
return 'inProgress';
|
||||
}
|
||||
|
||||
function formatInterceptRules(rules) {
|
||||
if (rules.length === 0) {
|
||||
return 'status: empty';
|
||||
@@ -1053,6 +1167,27 @@ function formatRecentRequests(entries) {
|
||||
.join('\n---\n');
|
||||
}
|
||||
|
||||
function formatRecentDownloads(entries) {
|
||||
if (entries.length === 0) {
|
||||
return 'status: empty';
|
||||
}
|
||||
return entries
|
||||
.map((entry) =>
|
||||
[
|
||||
`downloadId: ${entry.downloadId}`,
|
||||
`guid: ${entry.guid}`,
|
||||
`pageId: ${entry.pageId || '<unknown>'}`,
|
||||
`url: ${entry.url}`,
|
||||
`suggestedFilename: ${entry.suggestedFilename}`,
|
||||
`status: ${entry.status}`,
|
||||
`savedPath: ${entry.savedPath || '<none>'}`,
|
||||
`startedAt: ${entry.startedAt}`,
|
||||
`finishedAt: ${entry.finishedAt || '<in-progress>'}`,
|
||||
].join('\n')
|
||||
)
|
||||
.join('\n---\n');
|
||||
}
|
||||
|
||||
function resolveTargetPage(pages, toolArgs, defaultSelectedId = selectedPageId, options = {}) {
|
||||
const hasPageIndex = toolArgs && Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex');
|
||||
const pageId = parseOptionalPageId(toolArgs);
|
||||
@@ -1223,6 +1358,177 @@ async function sendCdpCommand(page, method, params = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
async function getBrowserTarget() {
|
||||
const targets = await fetchJson(`${getHttpUrl()}/json/list`);
|
||||
const browserTarget = Array.isArray(targets)
|
||||
? targets.find((target) => target && typeof target === 'object' && target.type === 'browser')
|
||||
: null;
|
||||
if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) {
|
||||
throw new Error('browser-level download events are unavailable');
|
||||
}
|
||||
return browserTarget;
|
||||
}
|
||||
|
||||
async function sendBrowserCdpCommand(method, params = {}) {
|
||||
const browserTarget = await getBrowserTarget();
|
||||
const ws = new WebSocket(browserTarget.webSocketDebuggerUrl);
|
||||
const requestId = ++requestCounter;
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
abortSocket(ws);
|
||||
reject(new Error('Browser MCP timed out waiting for a DevTools response.'));
|
||||
}
|
||||
}, CDP_TIMEOUT_MS);
|
||||
|
||||
function settleError(error) {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
settled = true;
|
||||
reject(toSocketError(error));
|
||||
}
|
||||
|
||||
addSocketListener(ws, 'open', () => {
|
||||
ws.send(JSON.stringify({ id: requestId, method, params }));
|
||||
});
|
||||
|
||||
addSocketListener(ws, 'message', (data) => {
|
||||
void (async () => {
|
||||
const raw = await getSocketMessageText(data);
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.id !== requestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer);
|
||||
settled = true;
|
||||
closeSocket(ws);
|
||||
|
||||
if (message.error) {
|
||||
reject(new Error(message.error.message || 'DevTools request failed.'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(message.result || null);
|
||||
})().catch(settleError);
|
||||
});
|
||||
|
||||
addSocketListener(ws, 'error', settleError);
|
||||
addSocketListener(ws, 'close', () => {
|
||||
if (!settled) {
|
||||
settleError(new Error('Browser MCP lost the DevTools websocket connection.'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureBrowserDownloadSession() {
|
||||
if (browserDownloadSession) {
|
||||
await browserDownloadSession.ready;
|
||||
return browserDownloadSession;
|
||||
}
|
||||
|
||||
const browserTarget = await getBrowserTarget();
|
||||
const ws = new WebSocket(browserTarget.webSocketDebuggerUrl);
|
||||
let resolveReady;
|
||||
let rejectReady;
|
||||
let activityChain = Promise.resolve();
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
browserDownloadSession = {
|
||||
ws,
|
||||
ready,
|
||||
getLastActivity() {
|
||||
return activityChain;
|
||||
},
|
||||
};
|
||||
|
||||
addSocketListener(ws, 'open', () => {
|
||||
if (resolveReady) {
|
||||
resolveReady();
|
||||
resolveReady = null;
|
||||
rejectReady = null;
|
||||
}
|
||||
});
|
||||
|
||||
addSocketListener(ws, 'message', (data) => {
|
||||
const activity = (async () => {
|
||||
const raw = await getSocketMessageText(data);
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.method === 'Browser.downloadWillBegin') {
|
||||
pushRecentDownload({
|
||||
downloadId: `download-${nextDownloadCounter++}`,
|
||||
guid: String(message.params?.guid || ''),
|
||||
pageId: '',
|
||||
url: String(message.params?.url || ''),
|
||||
suggestedFilename: String(message.params?.suggestedFilename || ''),
|
||||
status: 'started',
|
||||
savedPath: '',
|
||||
receivedBytes: 0,
|
||||
totalBytes: 0,
|
||||
startedAt: new Date().toISOString(),
|
||||
finishedAt: '',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message?.method === 'Browser.downloadProgress') {
|
||||
const status = resolveDownloadStatus(String(message.params?.state || 'inProgress'));
|
||||
updateRecentDownload(String(message.params?.guid || ''), {
|
||||
status,
|
||||
receivedBytes: Number(message.params?.receivedBytes || 0),
|
||||
totalBytes: Number(message.params?.totalBytes || 0),
|
||||
savedPath: typeof message.params?.filePath === 'string' ? message.params.filePath : '',
|
||||
finishedAt: status === 'completed' || status === 'canceled' ? new Date().toISOString() : '',
|
||||
});
|
||||
}
|
||||
})();
|
||||
activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {});
|
||||
void activity.catch(() => {});
|
||||
});
|
||||
|
||||
addSocketListener(ws, 'close', () => {
|
||||
if (rejectReady) {
|
||||
rejectReady(new Error('Browser MCP lost the DevTools websocket connection.'));
|
||||
}
|
||||
browserDownloadSession = null;
|
||||
});
|
||||
|
||||
addSocketListener(ws, 'error', (error) => {
|
||||
if (rejectReady) {
|
||||
rejectReady(toSocketError(error));
|
||||
}
|
||||
browserDownloadSession = null;
|
||||
});
|
||||
|
||||
await ready;
|
||||
return browserDownloadSession;
|
||||
}
|
||||
|
||||
async function evaluateInPage(page, kind) {
|
||||
const response = await sendCdpCommand(page, 'Runtime.evaluate', {
|
||||
expression: createEvaluateExpression(kind),
|
||||
@@ -1259,6 +1565,140 @@ async function evaluateExpression(page, expression) {
|
||||
return typeof result.value === 'string' ? result.value : result.value ?? '';
|
||||
}
|
||||
|
||||
async function withPageCommandSession(page, callback) {
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let resolveReady;
|
||||
let rejectReady;
|
||||
let settled = false;
|
||||
const pendingCommands = new Map();
|
||||
const ready = new Promise((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
function settleReadyError(error) {
|
||||
if (rejectReady) {
|
||||
rejectReady(error instanceof Error ? error : new Error(String(error)));
|
||||
rejectReady = null;
|
||||
resolveReady = null;
|
||||
}
|
||||
}
|
||||
|
||||
function rejectPendingCommands(error) {
|
||||
for (const pending of pendingCommands.values()) {
|
||||
pending.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
pendingCommands.clear();
|
||||
}
|
||||
|
||||
addSocketListener(ws, 'open', () => {
|
||||
if (resolveReady) {
|
||||
resolveReady();
|
||||
resolveReady = null;
|
||||
rejectReady = null;
|
||||
}
|
||||
});
|
||||
|
||||
addSocketListener(ws, 'message', (data) => {
|
||||
void (async () => {
|
||||
const raw = await getSocketMessageText(data);
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(raw);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (!message?.id || !pendingCommands.has(message.id)) {
|
||||
return;
|
||||
}
|
||||
const pending = pendingCommands.get(message.id);
|
||||
pendingCommands.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(new Error(message.error.message || 'DevTools request failed.'));
|
||||
return;
|
||||
}
|
||||
pending.resolve(message.result || null);
|
||||
})().catch((error) => {
|
||||
const socketError = toSocketError(error);
|
||||
if (!settled) {
|
||||
settleReadyError(socketError);
|
||||
rejectPendingCommands(socketError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
addSocketListener(ws, 'close', () => {
|
||||
const error = new Error('Browser MCP lost the DevTools websocket connection.');
|
||||
if (!settled) {
|
||||
settleReadyError(error);
|
||||
rejectPendingCommands(error);
|
||||
}
|
||||
});
|
||||
|
||||
addSocketListener(ws, 'error', (error) => {
|
||||
const socketError = toSocketError(error);
|
||||
if (!settled) {
|
||||
settleReadyError(socketError);
|
||||
rejectPendingCommands(socketError);
|
||||
}
|
||||
});
|
||||
|
||||
await ready;
|
||||
|
||||
const session = {
|
||||
async sendCommand(method, params = {}) {
|
||||
const requestId = ++requestCounter;
|
||||
return await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
pendingCommands.delete(requestId);
|
||||
reject(new Error('Browser MCP timed out waiting for a DevTools response.'));
|
||||
}, CDP_TIMEOUT_MS);
|
||||
pendingCommands.set(requestId, {
|
||||
resolve(result) {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
},
|
||||
reject(error) {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
ws.send(JSON.stringify({ id: requestId, method, params }));
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
return await callback(session);
|
||||
} finally {
|
||||
settled = true;
|
||||
rejectPendingCommands(new Error('Browser MCP closed the page command session.'));
|
||||
closeSocket(ws);
|
||||
}
|
||||
}
|
||||
|
||||
async function evaluateObjectHandle(session, expression) {
|
||||
const response = await session.sendCommand('Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: false,
|
||||
});
|
||||
|
||||
const result = response && response.result ? response.result : null;
|
||||
if (!result) {
|
||||
throw new Error('Browser MCP received an invalid DevTools evaluation response.');
|
||||
}
|
||||
|
||||
if (result.subtype === 'error') {
|
||||
throw new Error(result.description || 'DevTools evaluation returned an error.');
|
||||
}
|
||||
|
||||
if (typeof result.objectId !== 'string' || !result.objectId) {
|
||||
throw new Error('Browser MCP could not resolve a file input handle.');
|
||||
}
|
||||
|
||||
return result.objectId;
|
||||
}
|
||||
|
||||
function requireNonEmptyString(value, label) {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${label} is required`);
|
||||
@@ -1366,6 +1806,23 @@ function requireOptionalStringArray(value, label, allowedValues) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function requireNonEmptyStringArray(value, label) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
throw new Error(`${label} must be a non-empty array of non-empty strings`);
|
||||
}
|
||||
if (value.some((item) => typeof item !== 'string' || item.trim() === '')) {
|
||||
throw new Error(`${label} must be a non-empty array of non-empty strings`);
|
||||
}
|
||||
return value.map((item) => item.trim());
|
||||
}
|
||||
|
||||
function requireNonNegativeInteger(value, label) {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`${label} must be a non-negative integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getBrowserEvalMode() {
|
||||
const raw = String(process.env.CCS_BROWSER_EVAL_MODE || 'readonly').trim();
|
||||
if (raw === 'disabled' || raw === 'readonly' || raw === 'readwrite') {
|
||||
@@ -1484,6 +1941,94 @@ async function getScopedDiagnostics(page, selector, nth, frameSelector, pierceSh
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
function buildFileInputHandleExpression(selector, nth, frameSelector, pierceShadow) {
|
||||
return `(() => {
|
||||
const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))});
|
||||
const nth = ${nth === undefined ? 'undefined' : String(nth)};
|
||||
const frameSelector = ${frameSelector ? `JSON.parse(${JSON.stringify(JSON.stringify(frameSelector))})` : 'undefined'};
|
||||
const pierceShadow = ${pierceShadow ? 'true' : 'false'};
|
||||
|
||||
const visitRoots = (root) => {
|
||||
const roots = [root];
|
||||
if (!pierceShadow) {
|
||||
return roots;
|
||||
}
|
||||
const queue = [root];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
const elements = Array.from(current.querySelectorAll('*'));
|
||||
for (const element of elements) {
|
||||
if (element.shadowRoot) {
|
||||
roots.push(element.shadowRoot);
|
||||
queue.push(element.shadowRoot);
|
||||
}
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
};
|
||||
|
||||
let root = document;
|
||||
if (frameSelector) {
|
||||
const frame = document.querySelector(frameSelector);
|
||||
if (!frame) {
|
||||
throw new Error('frame not found for selector: ' + frameSelector);
|
||||
}
|
||||
const frameDocument = frame.contentDocument;
|
||||
if (!frameDocument) {
|
||||
throw new Error('frame document is unavailable for selector: ' + frameSelector);
|
||||
}
|
||||
root = frameDocument;
|
||||
}
|
||||
|
||||
const roots = visitRoots(root);
|
||||
const matches = [];
|
||||
for (const currentRoot of roots) {
|
||||
matches.push(...Array.from(currentRoot.querySelectorAll(selector)));
|
||||
}
|
||||
|
||||
const targetIndex = nth ?? 0;
|
||||
const element = matches[targetIndex];
|
||||
if (!element) {
|
||||
throw new Error('element not found for selector: ' + selector);
|
||||
}
|
||||
if (!(element instanceof HTMLInputElement) || element.type !== 'file') {
|
||||
throw new Error('element is not a file input for selector: ' + selector);
|
||||
}
|
||||
return element;
|
||||
})()`;
|
||||
}
|
||||
|
||||
function validateLocalFiles(files) {
|
||||
return files.map((filePath) => {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`file does not exist: ${resolvedPath}`);
|
||||
}
|
||||
const stat = fs.statSync(resolvedPath);
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`file is not a regular file: ${resolvedPath}`);
|
||||
}
|
||||
return resolvedPath;
|
||||
});
|
||||
}
|
||||
|
||||
function formatFileInputResult({ pageIndex, selector, nth, frameSelector, pierceShadow, files }) {
|
||||
const lines = [
|
||||
`pageIndex: ${pageIndex}`,
|
||||
`selector: ${selector}`,
|
||||
`nth: ${nth}`,
|
||||
`fileCount: ${files.length}`,
|
||||
];
|
||||
if (frameSelector) {
|
||||
lines.push(`frameSelector: ${frameSelector}`);
|
||||
}
|
||||
if (pierceShadow) {
|
||||
lines.push('pierceShadow: true');
|
||||
}
|
||||
lines.push('status: files-set');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function parseEventCondition(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('event is required');
|
||||
@@ -3043,6 +3588,102 @@ async function handleListRequests(toolArgs) {
|
||||
return formatRecentRequests(entries);
|
||||
}
|
||||
|
||||
async function handleSetDownloadBehavior(toolArgs) {
|
||||
const behavior = requireEnumString(toolArgs.behavior, 'behavior', ['accept', 'deny']);
|
||||
const downloadPath = parseOptionalNonEmptyString(toolArgs.downloadPath);
|
||||
const eventsEnabled = toolArgs.eventsEnabled === undefined ? true : toolArgs.eventsEnabled === true;
|
||||
|
||||
if (behavior === 'deny' && downloadPath) {
|
||||
throw new Error('downloadPath is only allowed when behavior=accept');
|
||||
}
|
||||
|
||||
await ensureBrowserDownloadSession();
|
||||
|
||||
const resolvedDownloadPath =
|
||||
behavior === 'accept' ? ensureWritableDirectory(downloadPath || getSessionDownloadPath()) : '';
|
||||
await sendBrowserCdpCommand('Browser.setDownloadBehavior', {
|
||||
behavior: behavior === 'accept' ? 'allow' : 'deny',
|
||||
...(resolvedDownloadPath ? { downloadPath: resolvedDownloadPath } : {}),
|
||||
eventsEnabled,
|
||||
});
|
||||
|
||||
return `scope: browser\nbehavior: ${behavior}\ndownloadPath: ${resolvedDownloadPath || '<none>'}\neventsEnabled: ${eventsEnabled}\nstatus: configured`;
|
||||
}
|
||||
|
||||
async function handleListDownloads(toolArgs) {
|
||||
const limit = requirePositiveIntegerOrDefault(toolArgs.limit, 'limit', 20);
|
||||
if (browserDownloadSession) {
|
||||
await browserDownloadSession.ready;
|
||||
await browserDownloadSession.getLastActivity();
|
||||
await sleep(35);
|
||||
await browserDownloadSession.getLastActivity();
|
||||
}
|
||||
const entries = recentDownloads.slice(-limit).reverse();
|
||||
return formatRecentDownloads(entries);
|
||||
}
|
||||
|
||||
function resolveDownloadRecord(toolArgs) {
|
||||
const downloadId = parseOptionalNonEmptyString(toolArgs.downloadId);
|
||||
const guid = parseOptionalNonEmptyString(toolArgs.guid);
|
||||
if (!downloadId && !guid) {
|
||||
throw new Error('downloadId or guid is required');
|
||||
}
|
||||
|
||||
const matched = recentDownloads.filter(
|
||||
(entry) => (!downloadId || entry.downloadId === downloadId) && (!guid || entry.guid === guid)
|
||||
);
|
||||
if (matched.length !== 1) {
|
||||
throw new Error('download not found');
|
||||
}
|
||||
return matched[0];
|
||||
}
|
||||
|
||||
async function handleCancelDownload(toolArgs) {
|
||||
const entry = resolveDownloadRecord(toolArgs);
|
||||
if (entry.status === 'completed' || entry.status === 'failed' || entry.status === 'denied') {
|
||||
throw new Error(`download is not cancelable in status: ${entry.status}`);
|
||||
}
|
||||
|
||||
await sendBrowserCdpCommand('Browser.cancelDownload', { guid: entry.guid });
|
||||
entry.status = 'canceled';
|
||||
entry.finishedAt = new Date().toISOString();
|
||||
return `downloadId: ${entry.downloadId}\nguid: ${entry.guid}\nstatus: canceled`;
|
||||
}
|
||||
|
||||
async function handleSetFileInput(toolArgs) {
|
||||
const pages = await listPageTargets();
|
||||
if (pages.length === 0) {
|
||||
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||
}
|
||||
|
||||
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId);
|
||||
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
|
||||
const files = validateLocalFiles(requireNonEmptyStringArray(toolArgs.files, 'files'));
|
||||
const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth');
|
||||
const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector);
|
||||
const pierceShadow = toolArgs.pierceShadow === true;
|
||||
|
||||
await withPageCommandSession(page, async (session) => {
|
||||
const objectId = await evaluateObjectHandle(
|
||||
session,
|
||||
buildFileInputHandleExpression(selector, nth, frameSelector, pierceShadow)
|
||||
);
|
||||
await session.sendCommand('DOM.setFileInputFiles', {
|
||||
files,
|
||||
objectId,
|
||||
});
|
||||
});
|
||||
|
||||
return formatFileInputResult({
|
||||
pageIndex,
|
||||
selector,
|
||||
nth,
|
||||
frameSelector,
|
||||
pierceShadow,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleToolCall(message) {
|
||||
const id = message.id;
|
||||
const params = message.params || {};
|
||||
@@ -3175,6 +3816,38 @@ async function handleToolCall(message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_SET_DOWNLOAD_BEHAVIOR) {
|
||||
const text = await handleSetDownloadBehavior(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_LIST_DOWNLOADS) {
|
||||
const text = await handleListDownloads(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_CANCEL_DOWNLOAD) {
|
||||
const text = await handleCancelDownload(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_SET_FILE_INPUT) {
|
||||
const text = await handleSetFileInput(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_TAKE_SCREENSHOT) {
|
||||
const text = await handleScreenshot(toolArgs);
|
||||
writeResponse(id, {
|
||||
|
||||
Reference in new issue
Block a user