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:
walker1211
2026-04-19 13:46:07 +08:00
co-authored by Claude Opus 4.7
parent 26aa8f4c20
commit 492ee5b083
3 changed files with 1280 additions and 22 deletions
+13 -3
View File
@@ -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:
+673
View File
@@ -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, {
+594 -19
View File
@@ -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'
);
});