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' ); });