diff --git a/docs/browser-automation.md b/docs/browser-automation.md
index 2d205300..6ef4ea2f 100644
--- a/docs/browser-automation.md
+++ b/docs/browser-automation.md
@@ -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 `` 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:
diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs
index d1a7590f..fee8a06e 100755
--- a/lib/mcp/ccs-browser-server.cjs
+++ b/lib/mcp/ccs-browser-server.cjs
@@ -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 || ''}`,
+ `url: ${entry.url}`,
+ `suggestedFilename: ${entry.suggestedFilename}`,
+ `status: ${entry.status}`,
+ `savedPath: ${entry.savedPath || ''}`,
+ `startedAt: ${entry.startedAt}`,
+ `finishedAt: ${entry.finishedAt || ''}`,
+ ].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 || ''}\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, {
diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts
index 03fe0e44..71a4a490 100644
--- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts
+++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts
@@ -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;
visibleText?: string;
+ fileInputs?: Record;
};
type MockShadowRootState = {
hostSelector: string;
query?: Record;
+ fileInputs?: Record;
};
type MockEvalPlan = Record<
@@ -129,6 +162,8 @@ type MockPageState = {
id: string;
title: string;
currentUrl: string;
+ fileInputs?: Record;
+ 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;
+ };
+
+ 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();
+
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'
);
});