mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +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:
co-authored by
Claude Opus 4.7
parent
26aa8f4c20
commit
492ee5b083
@@ -1,6 +1,6 @@
|
||||
# Browser Automation
|
||||
|
||||
Last Updated: 2026-04-18
|
||||
Last Updated: 2026-04-19
|
||||
|
||||
CCS provides browser automation through two separate runtime paths:
|
||||
|
||||
@@ -20,7 +20,7 @@ that already has useful authenticated state.
|
||||
Claude Browser Attach requires a browser launched in attach mode with remote debugging
|
||||
enabled. A recent Chrome update alone is not sufficient.
|
||||
|
||||
The managed `ccs-browser` runtime currently exposes six tool groups:
|
||||
The managed `ccs-browser` runtime currently exposes seven tool groups:
|
||||
|
||||
- **Session inspection**: `browser_get_session_info`, `browser_get_url_and_title`, `browser_get_visible_text`, `browser_get_dom_snapshot`
|
||||
- **Navigation and interaction**: `browser_navigate`, `browser_click`, `browser_type`, `browser_press_key`, `browser_scroll`, `browser_select_page`, `browser_open_page`, `browser_close_page`, `browser_take_screenshot`
|
||||
@@ -28,6 +28,7 @@ The managed `ccs-browser` runtime currently exposes six tool groups:
|
||||
- **Readiness and page evaluation**: `browser_wait_for`, `browser_eval`
|
||||
- **Event observation**: `browser_wait_for_event`
|
||||
- **Network interception**: `browser_add_intercept_rule`, `browser_remove_intercept_rule`, `browser_list_intercept_rules`, `browser_list_requests`
|
||||
- **File transfer**: `browser_set_download_behavior`, `browser_list_downloads`, `browser_cancel_download`, `browser_set_file_input`
|
||||
|
||||
Notable Phase 1 capability details:
|
||||
|
||||
@@ -74,6 +75,15 @@ Phase 7 capability details:
|
||||
- higher `priority` rules win before lower-priority rules, while equal-priority rules continue to follow creation order
|
||||
- `headerMatchers` are request-matching conditions; `responseHeaders` on `fulfill` rules remain response headers
|
||||
|
||||
Phase 8 capability details:
|
||||
|
||||
- `browser_set_download_behavior` configures browser-scoped download acceptance for the current attach session; `behavior: "accept"` can use an explicit `downloadPath` or a session-local default directory
|
||||
- `browser_list_downloads` returns recent download summaries only; it does not read or return downloaded file contents
|
||||
- `browser_cancel_download` cancels an in-progress download by `downloadId` or `guid`
|
||||
- `browser_set_file_input` sets one or more local files on a matched `<input type="file">` using the existing selected-page, `pageIndex`, `pageId`, `frameSelector`, `nth`, and `pierceShadow` routing semantics
|
||||
- download controls are browser-scoped because they map to Chrome's Browser domain; file input uploads remain page-scoped selector actions
|
||||
- download behavior and recent download summaries are session-local runtime state and are not persisted across runtime restarts
|
||||
|
||||
Minimal multi-tab workflow examples:
|
||||
|
||||
```json
|
||||
@@ -123,7 +133,7 @@ Scoped selector notes:
|
||||
|
||||
- `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, and `browser_take_element_screenshot` accept optional `frameSelector` for same-origin iframes whose `contentDocument` is accessible
|
||||
- the same selector-based tools accept optional `pierceShadow: true` for open shadow-root traversal
|
||||
- closed shadow roots, frame-index routing, cross-page shared rules, request body matching, advanced boolean matcher groups, and download acceptance controls are still out of scope
|
||||
- closed shadow roots, frame-index routing, cross-page shared rules, request body matching, and advanced boolean matcher groups are still out of scope
|
||||
|
||||
Example event wait:
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -73,22 +73,55 @@ type MockWaitPlan = {
|
||||
pageTextSequence?: string[];
|
||||
};
|
||||
|
||||
type MockDownloadProgressState = {
|
||||
receivedBytes: number;
|
||||
totalBytes: number;
|
||||
state: 'inProgress' | 'completed' | 'canceled';
|
||||
filePath?: string;
|
||||
};
|
||||
|
||||
type MockDownloadState = {
|
||||
guid?: string;
|
||||
url: string;
|
||||
suggestedFilename: string;
|
||||
progress?: MockDownloadProgressState[];
|
||||
};
|
||||
|
||||
type MockBrowserState = {
|
||||
setDownloadBehaviorCalls?: Array<{
|
||||
behavior: string;
|
||||
downloadPath?: string;
|
||||
eventsEnabled?: boolean;
|
||||
}>;
|
||||
canceledDownloadGuids?: string[];
|
||||
};
|
||||
|
||||
type MockPageEventPlan = {
|
||||
dialogs?: Array<{ type: string; message: string }>;
|
||||
navigations?: Array<{ url: string; parentId?: string }>;
|
||||
requests?: Array<{ url: string; method: string }>;
|
||||
downloads?: Array<{ url: string; suggestedFilename: string }>;
|
||||
downloads?: MockDownloadState[];
|
||||
};
|
||||
|
||||
type MockFileInputState = {
|
||||
kind: 'file' | 'nonfile';
|
||||
multiple?: boolean;
|
||||
assignedFiles?: string[];
|
||||
};
|
||||
|
||||
type MockFileInputPlan = MockFileInputState | MockFileInputState[];
|
||||
|
||||
type MockFrameState = {
|
||||
selector: string;
|
||||
query?: Record<string, MockQueryPlan>;
|
||||
visibleText?: string;
|
||||
fileInputs?: Record<string, MockFileInputPlan>;
|
||||
};
|
||||
|
||||
type MockShadowRootState = {
|
||||
hostSelector: string;
|
||||
query?: Record<string, MockQueryPlan>;
|
||||
fileInputs?: Record<string, MockFileInputPlan>;
|
||||
};
|
||||
|
||||
type MockEvalPlan = Record<
|
||||
@@ -129,6 +162,8 @@ type MockPageState = {
|
||||
id: string;
|
||||
title: string;
|
||||
currentUrl: string;
|
||||
fileInputs?: Record<string, MockFileInputPlan>;
|
||||
browser?: MockBrowserState;
|
||||
readyStateSequence?: string[];
|
||||
visibleText?: string;
|
||||
domSnapshot?: string;
|
||||
@@ -508,22 +543,73 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
||||
wsServer = new WebSocketServer({ server: httpServer as http.Server });
|
||||
wsServer.on('connection', (socket, request) => {
|
||||
if ((request.url || '') === browserSocketPath) {
|
||||
const downloadPage = pagesInput.find(
|
||||
(candidate) => (candidate.events?.downloads?.length || 0) > 0
|
||||
);
|
||||
if (downloadPage?.events?.downloads?.[0]) {
|
||||
const download = downloadPage.events.downloads[0];
|
||||
setTimeout(() => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
method: 'Browser.downloadWillBegin',
|
||||
params: {
|
||||
url: download.url,
|
||||
suggestedFilename: download.suggestedFilename,
|
||||
},
|
||||
})
|
||||
);
|
||||
}, 10);
|
||||
const browserState = pagesInput[0]?.browser || (pagesInput[0] ? (pagesInput[0].browser = {}) : {});
|
||||
|
||||
socket.on('message', (raw) => {
|
||||
const message = JSON.parse(raw.toString()) as {
|
||||
id: number;
|
||||
method: string;
|
||||
params?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function reply(result: unknown): void {
|
||||
socket.send(JSON.stringify({ id: message.id, result }));
|
||||
}
|
||||
|
||||
if (message.method === 'Browser.setDownloadBehavior') {
|
||||
browserState.setDownloadBehaviorCalls = browserState.setDownloadBehaviorCalls || [];
|
||||
browserState.setDownloadBehaviorCalls.push({
|
||||
behavior: typeof message.params?.behavior === 'string' ? message.params.behavior : '',
|
||||
downloadPath:
|
||||
typeof message.params?.downloadPath === 'string' ? message.params.downloadPath : undefined,
|
||||
eventsEnabled:
|
||||
typeof message.params?.eventsEnabled === 'boolean' ? message.params.eventsEnabled : undefined,
|
||||
});
|
||||
reply({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === 'Browser.cancelDownload') {
|
||||
browserState.canceledDownloadGuids = browserState.canceledDownloadGuids || [];
|
||||
browserState.canceledDownloadGuids.push(String(message.params?.guid || ''));
|
||||
reply({});
|
||||
}
|
||||
});
|
||||
|
||||
for (const page of pagesInput) {
|
||||
for (const [index, download] of (page.events?.downloads || []).entries()) {
|
||||
const guid = download.guid || `${page.id}-download-${index + 1}`;
|
||||
setTimeout(() => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
method: 'Browser.downloadWillBegin',
|
||||
params: {
|
||||
frameId: `frame-${page.id}`,
|
||||
guid,
|
||||
url: download.url,
|
||||
suggestedFilename: download.suggestedFilename,
|
||||
},
|
||||
})
|
||||
);
|
||||
}, 10 + index * 40);
|
||||
|
||||
for (const [progressIndex, progress] of (download.progress || []).entries()) {
|
||||
setTimeout(() => {
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
method: 'Browser.downloadProgress',
|
||||
params: {
|
||||
guid,
|
||||
totalBytes: progress.totalBytes,
|
||||
receivedBytes: progress.receivedBytes,
|
||||
state: progress.state,
|
||||
filePath: progress.filePath,
|
||||
},
|
||||
})
|
||||
);
|
||||
}, 20 + index * 40 + progressIndex * 20);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -534,6 +620,8 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteObjects = new Map<string, MockFileInputState>();
|
||||
|
||||
socket.on('message', (raw) => {
|
||||
const message = JSON.parse(raw.toString()) as {
|
||||
id: number;
|
||||
@@ -760,6 +848,20 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method === 'DOM.setFileInputFiles') {
|
||||
const objectId = typeof message.params?.objectId === 'string' ? message.params.objectId : '';
|
||||
const target = remoteObjects.get(objectId);
|
||||
if (!target) {
|
||||
socket.send(JSON.stringify({ id: message.id, error: { message: 'file input handle not found' } }));
|
||||
return;
|
||||
}
|
||||
target.assignedFiles = Array.isArray(message.params?.files)
|
||||
? (message.params.files as unknown[]).map((entry) => String(entry))
|
||||
: [];
|
||||
reply({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.method !== 'Runtime.evaluate') {
|
||||
return;
|
||||
}
|
||||
@@ -795,6 +897,46 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (expression.includes('element is not a file input for selector:')) {
|
||||
const selector = parseJsonArgument(expression, 'selector') || '';
|
||||
const nth = parseNumberArgument(expression, 'nth') ?? 0;
|
||||
const frameSelector = parseJsonArgument(expression, 'frameSelector') || '';
|
||||
const pierceShadow = expression.includes('const pierceShadow = true');
|
||||
|
||||
const fileInputPlan = frameSelector
|
||||
? getMockFrame(page, frameSelector)?.fileInputs?.[selector]
|
||||
: pierceShadow
|
||||
? getMockShadowRoot(page)?.fileInputs?.[selector]
|
||||
: page.fileInputs?.[selector];
|
||||
|
||||
const { count, target } = pickMockMatch(fileInputPlan, nth);
|
||||
if (!target || count <= nth) {
|
||||
replyError(`element not found for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
if (target.kind !== 'file') {
|
||||
replyError(`element is not a file input for selector: ${selector}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const objectId = `file-input:${page.id}:${selector}:${nth}:${frameSelector || 'root'}:${pierceShadow ? 'shadow' : 'light'}`;
|
||||
remoteObjects.set(objectId, target);
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
id: message.id,
|
||||
result: {
|
||||
result: {
|
||||
type: 'object',
|
||||
subtype: 'node',
|
||||
className: 'HTMLInputElement',
|
||||
objectId,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (expression.includes('document.title') && expression.includes('location.href')) {
|
||||
reply({
|
||||
result: {
|
||||
@@ -1389,6 +1531,10 @@ describe('ccs-browser MCP server', () => {
|
||||
'browser_remove_intercept_rule',
|
||||
'browser_list_intercept_rules',
|
||||
'browser_list_requests',
|
||||
'browser_set_download_behavior',
|
||||
'browser_list_downloads',
|
||||
'browser_cancel_download',
|
||||
'browser_set_file_input',
|
||||
'browser_take_screenshot',
|
||||
'browser_wait_for',
|
||||
'browser_eval',
|
||||
@@ -1456,6 +1602,28 @@ describe('ccs-browser MCP server', () => {
|
||||
expect(listRequestsTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' });
|
||||
expect(listRequestsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' });
|
||||
|
||||
const setDownloadTool = tools.find((tool) => tool.name === 'browser_set_download_behavior');
|
||||
expect(setDownloadTool?.inputSchema?.properties?.behavior).toMatchObject({ type: 'string' });
|
||||
expect(setDownloadTool?.inputSchema?.properties?.downloadPath).toMatchObject({ type: 'string' });
|
||||
expect(setDownloadTool?.inputSchema?.properties?.eventsEnabled).toMatchObject({ type: 'boolean' });
|
||||
|
||||
const listDownloadsTool = tools.find((tool) => tool.name === 'browser_list_downloads');
|
||||
expect(listDownloadsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' });
|
||||
expect(listDownloadsTool?.inputSchema?.properties?.pageId).toBeUndefined();
|
||||
|
||||
const cancelDownloadTool = tools.find((tool) => tool.name === 'browser_cancel_download');
|
||||
expect(cancelDownloadTool?.inputSchema?.properties?.downloadId).toMatchObject({ type: 'string' });
|
||||
expect(cancelDownloadTool?.inputSchema?.properties?.guid).toMatchObject({ type: 'string' });
|
||||
|
||||
const uploadTool = tools.find((tool) => tool.name === 'browser_set_file_input');
|
||||
expect(uploadTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' });
|
||||
expect(uploadTool?.inputSchema?.properties?.files).toMatchObject({ type: 'array' });
|
||||
expect(uploadTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' });
|
||||
expect(uploadTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' });
|
||||
expect(uploadTool?.inputSchema?.properties?.nth).toMatchObject({ type: 'integer' });
|
||||
expect(uploadTool?.inputSchema?.properties?.frameSelector).toMatchObject({ type: 'string' });
|
||||
expect(uploadTool?.inputSchema?.properties?.pierceShadow).toMatchObject({ type: 'boolean' });
|
||||
|
||||
const queryTool = tools.find((tool) => tool.name === 'browser_query');
|
||||
expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({
|
||||
type: 'array',
|
||||
@@ -4702,6 +4870,413 @@ describe('ccs-browser MCP server', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('applies browser-scoped download behavior, records download summaries, and cancels an in-progress download', async () => {
|
||||
const pages: MockPageState[] = [
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Reports',
|
||||
currentUrl: 'https://example.com/reports',
|
||||
browser: {},
|
||||
events: {
|
||||
downloads: [
|
||||
{
|
||||
guid: 'download-guid-1',
|
||||
url: 'https://example.com/files/report.csv',
|
||||
suggestedFilename: 'report.csv',
|
||||
progress: [{ receivedBytes: 5, totalBytes: 10, state: 'inProgress' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const responses = await runMcpRequests(
|
||||
pages,
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 57,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_download_behavior',
|
||||
arguments: { behavior: 'accept', eventsEnabled: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 58,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_list_downloads', arguments: {} },
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 59,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_cancel_download',
|
||||
arguments: { guid: 'download-guid-1' },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 60,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_list_downloads', arguments: {} },
|
||||
},
|
||||
],
|
||||
{ responseTimeoutMs: 12000 }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser');
|
||||
expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv');
|
||||
expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled');
|
||||
expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow');
|
||||
expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1');
|
||||
});
|
||||
|
||||
it('rejects browser_set_download_behavior when behavior is deny and downloadPath is provided', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports' }],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 61,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_download_behavior',
|
||||
arguments: {
|
||||
behavior: 'deny',
|
||||
downloadPath: '/tmp/blocked-downloads',
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
const response = responses.find((message) => message.id === 61);
|
||||
expect((response?.result as { isError?: boolean }).isError).toBe(true);
|
||||
expect(getResponseText(response)).toContain(
|
||||
'Browser MCP failed: downloadPath is only allowed when behavior=accept'
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects browser_cancel_download for completed downloads', async () => {
|
||||
const pages: MockPageState[] = [
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Reports',
|
||||
currentUrl: 'https://example.com/reports',
|
||||
browser: {},
|
||||
events: {
|
||||
downloads: [
|
||||
{
|
||||
guid: 'download-guid-complete',
|
||||
url: 'https://example.com/files/report.csv',
|
||||
suggestedFilename: 'report.csv',
|
||||
progress: [{ receivedBytes: 10, totalBytes: 10, state: 'completed' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const responses = await runMcpRequests(
|
||||
pages,
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 610,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_download_behavior',
|
||||
arguments: { behavior: 'accept', eventsEnabled: true },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 611,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_list_downloads', arguments: {} },
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 612,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_cancel_download',
|
||||
arguments: { guid: 'download-guid-complete' },
|
||||
},
|
||||
},
|
||||
],
|
||||
{ responseTimeoutMs: 12000 }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed');
|
||||
const response = responses.find((message) => message.id === 612);
|
||||
expect((response?.result as { isError?: boolean }).isError).toBe(true);
|
||||
expect(getResponseText(response)).toContain(
|
||||
'Browser MCP failed: download is not cancelable in status: completed'
|
||||
);
|
||||
expect(pages[0]?.browser?.canceledDownloadGuids).toBeUndefined();
|
||||
});
|
||||
|
||||
it('waits for a matching download event with browser_wait_for_event after Phase 8 changes', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Event Page',
|
||||
currentUrl: 'https://example.com/',
|
||||
events: {
|
||||
downloads: [
|
||||
{
|
||||
guid: 'download-guid-2',
|
||||
url: 'https://example.com/files/export.zip',
|
||||
suggestedFilename: 'export.zip',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 62,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_wait_for_event',
|
||||
arguments: {
|
||||
timeoutMs: 1000,
|
||||
event: { kind: 'download', suggestedFilenameIncludes: 'export.zip' },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
{ responseTimeoutMs: 12000 }
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed');
|
||||
});
|
||||
|
||||
it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-'));
|
||||
const invoicePath = join(tempDir, 'invoice.pdf');
|
||||
const receiptPath = join(tempDir, 'receipt.png');
|
||||
writeFileSync(invoicePath, 'invoice');
|
||||
writeFileSync(receiptPath, 'receipt');
|
||||
|
||||
const pages: MockPageState[] = [
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Root Uploads',
|
||||
currentUrl: 'https://example.com/root',
|
||||
fileInputs: {
|
||||
'#root-upload': { kind: 'file', multiple: true },
|
||||
},
|
||||
frames: [
|
||||
{
|
||||
selector: '#upload-frame',
|
||||
fileInputs: {
|
||||
'#frame-upload': { kind: 'file', multiple: true },
|
||||
},
|
||||
},
|
||||
],
|
||||
shadowRoots: [
|
||||
{
|
||||
hostSelector: 'upload-panel',
|
||||
fileInputs: {
|
||||
'#shadow-upload': { kind: 'file' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'page-2',
|
||||
title: 'Selected Uploads',
|
||||
currentUrl: 'https://example.com/selected',
|
||||
fileInputs: {
|
||||
'#selected-upload': { kind: 'file', multiple: true },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const responses = await runMcpRequests(
|
||||
pages,
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 63,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_select_page', arguments: { pageIndex: 1 } },
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 64,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_file_input',
|
||||
arguments: { selector: '#selected-upload', files: [invoicePath, receiptPath] },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 65,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_file_input',
|
||||
arguments: {
|
||||
pageIndex: 0,
|
||||
selector: '#frame-upload',
|
||||
files: [invoicePath],
|
||||
frameSelector: '#upload-frame',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 66,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_file_input',
|
||||
arguments: {
|
||||
pageIndex: 0,
|
||||
selector: '#shadow-upload',
|
||||
files: [receiptPath],
|
||||
pierceShadow: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1');
|
||||
expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame');
|
||||
expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true');
|
||||
|
||||
expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([
|
||||
invoicePath,
|
||||
receiptPath,
|
||||
]);
|
||||
expect(
|
||||
(pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles
|
||||
).toEqual([invoicePath]);
|
||||
expect(
|
||||
(pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles
|
||||
).toEqual([receiptPath]);
|
||||
});
|
||||
|
||||
it('uses pageId for browser_set_file_input when provided', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-'));
|
||||
const assetPath = join(tempDir, 'asset.txt');
|
||||
writeFileSync(assetPath, 'asset');
|
||||
|
||||
const pages: MockPageState[] = [
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Selected Uploads',
|
||||
currentUrl: 'https://example.com/selected',
|
||||
fileInputs: {
|
||||
'#selected-upload': { kind: 'file' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'page-2',
|
||||
title: 'Explicit Uploads',
|
||||
currentUrl: 'https://example.com/explicit',
|
||||
fileInputs: {
|
||||
'#pageid-upload': { kind: 'file' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const responses = await runMcpRequests(
|
||||
pages,
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 67,
|
||||
method: 'tools/call',
|
||||
params: { name: 'browser_select_page', arguments: { pageId: 'page-1' } },
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 68,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_file_input',
|
||||
arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] },
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1');
|
||||
expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([
|
||||
assetPath,
|
||||
]);
|
||||
expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-'));
|
||||
const okPath = join(tempDir, 'ok.txt');
|
||||
const missingPath = join(tempDir, 'missing.txt');
|
||||
writeFileSync(okPath, 'ok');
|
||||
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
{
|
||||
id: 'page-1',
|
||||
title: 'Upload Errors',
|
||||
currentUrl: 'https://example.com/',
|
||||
fileInputs: {
|
||||
'#real-file-input': { kind: 'file' },
|
||||
'#not-file-input': { kind: 'nonfile' },
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 69,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_file_input',
|
||||
arguments: { selector: '#not-file-input', files: [okPath] },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 70,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_file_input',
|
||||
arguments: { selector: '#real-file-input', files: [missingPath] },
|
||||
},
|
||||
},
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 71,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_set_file_input',
|
||||
arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] },
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 69))).toContain(
|
||||
'element is not a file input for selector: #not-file-input'
|
||||
);
|
||||
expect(getResponseText(responses.find((message) => message.id === 70))).toContain(
|
||||
`file does not exist: ${missingPath}`
|
||||
);
|
||||
expect(getResponseText(responses.find((message) => message.id === 71))).toContain(
|
||||
'pageIndex and pageId cannot be used together'
|
||||
);
|
||||
});
|
||||
|
||||
it('waits for a matching navigation event with browser_wait_for_event', async () => {
|
||||
const responses = await runMcpRequests(
|
||||
[
|
||||
@@ -4717,7 +5292,7 @@ describe('ccs-browser MCP server', () => {
|
||||
[
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
id: 57,
|
||||
id: 69,
|
||||
method: 'tools/call',
|
||||
params: {
|
||||
name: 'browser_wait_for_event',
|
||||
@@ -4730,7 +5305,7 @@ describe('ccs-browser MCP server', () => {
|
||||
]
|
||||
);
|
||||
|
||||
expect(getResponseText(responses.find((message) => message.id === 57))).toContain(
|
||||
expect(getResponseText(responses.find((message) => message.id === 69))).toContain(
|
||||
'status: observed'
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user