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:
walker1211andClaude Opus 4.7 committed 2026-04-19 13:46:07 +08:00
1 parent 26aa8f4c20
commit 492ee5b083
3 files changed
+1280 -22

No files matched your search

+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, {