feat(browser): 完成 browser MCP 第五阶段页面控制

补充 selected page 会话状态与页面选择、打开、关闭能力,
并为 browser_get_session_info 增加 selected 标记与最小多标签文档说明。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
walker1211andClaude Opus 4.7 committed 2026-04-18 16:39:51 +08:00
1 parent e92a2c11bf
commit ff0e7085b8
3 files changed
+559 -18

No files matched your search

+214 -14
View File
@@ -45,6 +45,9 @@ const TOOL_CLICK = 'browser_click';
const TOOL_TYPE = 'browser_type';
const TOOL_PRESS_KEY = 'browser_press_key';
const TOOL_SCROLL = 'browser_scroll';
const TOOL_SELECT_PAGE = 'browser_select_page';
const TOOL_OPEN_PAGE = 'browser_open_page';
const TOOL_CLOSE_PAGE = 'browser_close_page';
const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot';
const TOOL_WAIT_FOR = 'browser_wait_for';
const TOOL_EVAL = 'browser_eval';
@@ -62,6 +65,9 @@ const TOOL_NAMES = [
TOOL_TYPE,
TOOL_PRESS_KEY,
TOOL_SCROLL,
TOOL_SELECT_PAGE,
TOOL_OPEN_PAGE,
TOOL_CLOSE_PAGE,
TOOL_TAKE_SCREENSHOT,
TOOL_WAIT_FOR,
TOOL_EVAL,
@@ -91,6 +97,8 @@ const DEFAULT_WAIT_POLL_INTERVAL_MS = 100;
let inputBuffer = Buffer.alloc(0);
let requestCounter = 0;
let selectedPageId = '';
let messageQueue = Promise.resolve();
function addSocketListener(socket, eventName, handler) {
if (typeof socket.addEventListener === 'function') {
@@ -410,6 +418,41 @@ function getTools() {
additionalProperties: false,
},
},
{
name: TOOL_SELECT_PAGE,
description: 'Select the current page target by page index or page id for subsequent browser tool calls.',
inputSchema: {
type: 'object',
properties: {
pageIndex: { type: 'integer', minimum: 0 },
pageId: { type: 'string' },
},
additionalProperties: false,
},
},
{
name: TOOL_OPEN_PAGE,
description: 'Open a new browser page tab and optionally navigate it to a URL.',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string' },
},
additionalProperties: false,
},
},
{
name: TOOL_CLOSE_PAGE,
description: 'Close a browser page target by page index or page id. Defaults to the selected page.',
inputSchema: {
type: 'object',
properties: {
pageIndex: { type: 'integer', minimum: 0 },
pageId: { type: 'string' },
},
additionalProperties: false,
},
},
{
name: TOOL_TAKE_SCREENSHOT,
description:
@@ -675,30 +718,100 @@ function parsePageIndex(toolArgs) {
return toolArgs.pageIndex;
}
function findPageIndexById(pages, pageId) {
return pages.findIndex((page) => page.id === pageId);
}
function resolveFallbackSelectedPageId(pages, preferredIndex = 0) {
if (pages.length === 0) {
return '';
}
const safeIndex = Math.min(Math.max(preferredIndex, 0), pages.length - 1);
return pages[safeIndex]?.id || pages[0]?.id || '';
}
function parseOptionalPageId(toolArgs) {
return typeof toolArgs?.pageId === 'string' && toolArgs.pageId.trim() !== ''
? toolArgs.pageId.trim()
: '';
}
function resolveTargetPage(pages, toolArgs, defaultSelectedId = selectedPageId, options = {}) {
const hasPageIndex = toolArgs && Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex');
const pageId = parseOptionalPageId(toolArgs);
const allowImplicitFallback = options.allowImplicitFallback !== false;
if (hasPageIndex && pageId) {
throw new Error('pageIndex and pageId cannot be used together');
}
if (hasPageIndex) {
const pageIndex = parsePageIndex(toolArgs);
const page = pages[pageIndex];
if (!page) {
throw new Error(`pageIndex out of range: ${pageIndex}`);
}
return { page, pageIndex };
}
if (pageId) {
const pageIndex = findPageIndexById(pages, pageId);
if (pageIndex === -1) {
throw new Error(`page not found: ${pageId}`);
}
return { page: pages[pageIndex], pageIndex };
}
const selectedIndex = findPageIndexById(pages, defaultSelectedId);
if (selectedIndex === -1) {
if (!allowImplicitFallback) {
throw new Error('Selected page is no longer available; specify pageIndex or pageId explicitly.');
}
const fallbackPage = pages[0];
if (!fallbackPage) {
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
}
return { page: fallbackPage, pageIndex: 0 };
}
return { page: pages[selectedIndex], pageIndex: selectedIndex };
}
async function getSelectedPage(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 pageIndex = parsePageIndex(toolArgs);
if (toolArgs && Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex')) {
const pageIndex = parsePageIndex(toolArgs);
const page = pages[pageIndex];
if (!page) {
throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`);
}
if (!page.webSocketDebuggerUrl) {
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
}
return { page, pageIndex, pages };
}
let pageIndex = findPageIndexById(pages, selectedPageId);
if (pageIndex === -1) {
selectedPageId = resolveFallbackSelectedPageId(pages, 0);
pageIndex = findPageIndexById(pages, selectedPageId);
}
const page = pages[pageIndex];
if (!page) {
throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`);
}
if (!page.webSocketDebuggerUrl) {
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
if (!page || !page.webSocketDebuggerUrl) {
throw new Error('Browser MCP could not resolve a selected page target.');
}
return { page, pageIndex, pages };
}
function formatSessionInfo(pages) {
function formatSessionInfo(pages, selectedId) {
return [
'[CCS Browser Session]',
'',
...pages.map((page, index) => `${index}. ${page.title || '<untitled>'} | ${page.url || '<empty>'}`),
...pages.map((page, index) => {
const selectedSuffix = page.id === selectedId ? ' | selected: true' : '';
return `${index}. ${page.title || '<untitled>'} | ${page.url || '<empty>'}${selectedSuffix}`;
}),
].join('\n');
}
@@ -2186,6 +2299,64 @@ async function handleWaitForEvent(toolArgs) {
return `pageIndex: ${pageIndex}\nevent: ${event.kind}\nstatus: observed\ndetail: ${JSON.stringify(observed)}`;
}
async function handleSelectPage(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);
if (!page.webSocketDebuggerUrl) {
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
}
selectedPageId = page.id;
return `pageIndex: ${pageIndex}\npageId: ${page.id}\ntitle: ${page.title || '<untitled>'}\nurl: ${page.url || '<empty>'}\nstatus: selected`;
}
async function handleOpenPage(toolArgs) {
const query = toolArgs?.url
? `?${new URLSearchParams({ url: requireValidHttpUrl(toolArgs.url) }).toString()}`
: '';
const createdTarget = await fetchJson(`${getHttpUrl()}/json/new${query}`);
const pageId = typeof createdTarget?.id === 'string' ? createdTarget.id : '';
if (!pageId) {
throw new Error('Browser MCP failed to create a new page target.');
}
selectedPageId = pageId;
const pages = await listPageTargets();
const pageIndex = findPageIndexById(pages, pageId);
const selectedPage = pages[pageIndex];
if (!selectedPage) {
throw new Error('Browser MCP could not resolve the newly opened page target.');
}
return `pageIndex: ${pageIndex}\npageId: ${selectedPage.id}\ntitle: ${selectedPage.title || '<untitled>'}\nurl: ${selectedPage.url || '<empty>'}\nstatus: opened`;
}
async function handleClosePage(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 previousSelectedPageId = selectedPageId;
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
allowImplicitFallback: false,
});
await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`);
const remainingPages = await listPageTargets();
if (findPageIndexById(remainingPages, previousSelectedPageId) !== -1) {
selectedPageId = previousSelectedPageId;
} else {
selectedPageId = resolveFallbackSelectedPageId(remainingPages, pageIndex > 0 ? pageIndex - 1 : 0);
}
const selectedIndex = findPageIndexById(remainingPages, selectedPageId);
return [
`pageIndex: ${pageIndex}`,
`pageId: ${page.id}`,
'status: closed',
selectedPageId ? `selectedPageIndex: ${selectedIndex}` : 'selectedPageIndex: none',
selectedPageId ? `selectedPageId: ${selectedPageId}` : 'selectedPageId: none',
].join('\n');
}
async function handleToolCall(message) {
const id = message.id;
const params = message.params || {};
@@ -2213,8 +2384,11 @@ async function handleToolCall(message) {
try {
if (toolName === TOOL_SESSION_INFO) {
const pages = await listPageTargets();
if (pages.length > 0 && findPageIndexById(pages, selectedPageId) === -1) {
selectedPageId = resolveFallbackSelectedPageId(pages, 0);
}
writeResponse(id, {
content: [{ type: 'text', text: formatSessionInfo(pages) }],
content: [{ type: 'text', text: formatSessionInfo(pages, selectedPageId) }],
});
return;
}
@@ -2259,6 +2433,30 @@ async function handleToolCall(message) {
return;
}
if (toolName === TOOL_SELECT_PAGE) {
const text = await handleSelectPage(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_OPEN_PAGE) {
const text = await handleOpenPage(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_CLOSE_PAGE) {
const text = await handleClosePage(toolArgs);
writeResponse(id, {
content: [{ type: 'text', text }],
});
return;
}
if (toolName === TOOL_TAKE_SCREENSHOT) {
const text = await handleScreenshot(toolArgs);
writeResponse(id, {
@@ -2437,11 +2635,13 @@ function parseMessages() {
continue;
}
Promise.resolve(handleMessage(message)).catch((error) => {
if (message && message.id !== undefined) {
writeError(message.id, -32603, (error && error.message) || 'Internal error');
}
});
messageQueue = messageQueue
.then(() => handleMessage(message))
.catch((error) => {
if (message && message.id !== undefined) {
writeError(message.id, -32603, (error && error.message) || 'Internal error');
}
});
}
}