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:
walker1211
2026-04-18 16:39:51 +08:00
co-authored by Claude Opus 4.7
parent e92a2c11bf
commit ff0e7085b8
3 changed files with 559 additions and 18 deletions
+30 -1
View File
@@ -23,7 +23,7 @@ enabled. A recent Chrome update alone is not sufficient.
The managed `ccs-browser` runtime currently exposes five 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_take_screenshot`
- **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`
- **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot`
- **Readiness and page evaluation**: `browser_wait_for`, `browser_eval`
- **Event observation**: `browser_wait_for_event`
@@ -47,6 +47,35 @@ Phase 4 capability details:
- `browser_press_key` sends real browser-level key events, supports modifier combinations plus repeat counts, and covers a focused set of common special keys such as `Enter`, `Tab`, `Escape`, and arrow keys
- `browser_scroll` supports page-level `by-offset` scrolling and element-scoped `by-offset` or `into-view` scrolling, including same-origin iframe scoping
Phase 5 capability details:
- `browser_get_session_info` marks the currently selected page
- `browser_select_page` chooses the default target page for later tool calls
- `browser_open_page` opens a new tab and makes it selected
- `browser_close_page` closes the selected page by default and deterministically falls back only when the selected page is closed or no longer available
- existing tools still honor explicit `pageIndex`; when omitted, they resolve through the selected page
- selected page state is session-local MCP runtime state and is not persisted across runtime restarts
Minimal multi-tab workflow examples:
```json
{
"name": "browser_select_page",
"arguments": {
"pageIndex": 1
}
}
```
```json
{
"name": "browser_open_page",
"arguments": {
"url": "https://example.com/docs"
}
}
```
A common hover-debug workflow is:
1. call `browser_hover` to move the browser pointer onto the card or trigger
+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');
}
});
}
}
+315 -3
View File
@@ -379,6 +379,7 @@ function createMockBrowser(pagesInput: MockPageState[]) {
let wsServer: WebSocketServer | null = null;
let browserSocketPath = '';
const pageStates = new Map<string, MockPageState>();
let nextPageCounter = pagesInput.length + 1;
for (const [index, page] of pagesInput.entries()) {
pageStates.set(`/devtools/page/${index + 1}`, {
@@ -395,13 +396,15 @@ function createMockBrowser(pagesInput: MockPageState[]) {
const port = await new Promise<number>((resolve, reject) => {
httpServer = http.createServer((req, res) => {
const address = httpServer?.address();
const serverPort = address && typeof address !== 'string' ? address.port : 0;
if (req.url === '/json/list') {
const address = httpServer?.address();
const serverPort = address && typeof address !== 'string' ? address.port : 0;
const pageEntries = Array.from(pageStates.entries());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify([
...Array.from(pageStates.entries()).map(([wsPath, page]) => ({
...pageEntries.map(([wsPath, page]) => ({
id: page.id,
type: 'page',
title: page.title,
@@ -419,6 +422,47 @@ function createMockBrowser(pagesInput: MockPageState[]) {
);
return;
}
if (req.url?.startsWith('/json/new')) {
const parsed = new URL(req.url, `http://127.0.0.1:${serverPort}`);
const requestedUrl = parsed.searchParams.get('url') || 'about:blank';
const wsPath = `/devtools/page/${nextPageCounter}`;
const newPage: MockPageState = {
id: `page-${nextPageCounter}`,
title: requestedUrl === 'about:blank' ? 'about:blank' : requestedUrl,
currentUrl: requestedUrl,
visibleText: 'New page visible text',
domSnapshot: '<html><body>New page</body></html>',
};
pageStates.set(wsPath, newPage);
nextPageCounter += 1;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
id: newPage.id,
type: 'page',
title: newPage.title,
url: newPage.currentUrl,
webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`,
})
);
return;
}
if (req.url?.startsWith('/json/close/')) {
const targetId = decodeURIComponent(req.url.slice('/json/close/'.length));
const entry = Array.from(pageStates.entries()).find(([, page]) => page.id === targetId);
if (!entry) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'page not found' }));
return;
}
pageStates.delete(entry[0]);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: targetId }));
return;
}
res.writeHead(404);
res.end('not found');
});
@@ -1240,6 +1284,9 @@ describe('ccs-browser MCP server', () => {
'browser_type',
'browser_press_key',
'browser_scroll',
'browser_select_page',
'browser_open_page',
'browser_close_page',
'browser_take_screenshot',
'browser_wait_for',
'browser_eval',
@@ -1268,6 +1315,17 @@ describe('ccs-browser MCP server', () => {
expect(scrollTool?.inputSchema?.properties?.deltaX).toMatchObject({ type: 'number' });
expect(scrollTool?.inputSchema?.properties?.deltaY).toMatchObject({ type: 'number' });
const selectTool = tools.find((tool) => tool.name === 'browser_select_page');
expect(selectTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' });
expect(selectTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' });
const openTool = tools.find((tool) => tool.name === 'browser_open_page');
expect(openTool?.inputSchema?.properties?.url).toMatchObject({ type: 'string' });
const closeTool = tools.find((tool) => tool.name === 'browser_close_page');
expect(closeTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' });
expect(closeTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' });
const queryTool = tools.find((tool) => tool.name === 'browser_query');
expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({
type: 'array',
@@ -1281,6 +1339,260 @@ describe('ccs-browser MCP server', () => {
}
});
it('marks the selected page in browser_get_session_info', async () => {
const responses = await runMcpRequests(
[
{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' },
{ id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' },
],
[
{
jsonrpc: '2.0',
id: 801,
method: 'tools/call',
params: { name: 'browser_select_page', arguments: { pageIndex: 1 } },
},
{
jsonrpc: '2.0',
id: 802,
method: 'tools/call',
params: { name: 'browser_get_session_info', arguments: {} },
},
]
);
const text = getResponseText(responses.find((message) => message.id === 802));
expect(text).toContain('selected: true');
expect(text).toContain('1. Docs');
});
it('uses the selected page when pageIndex is omitted', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Home',
currentUrl: 'https://example.com/',
visibleText: 'Home text',
},
{
id: 'page-2',
title: 'Docs',
currentUrl: 'https://example.com/docs',
visibleText: 'Docs text',
},
],
[
{
jsonrpc: '2.0',
id: 811,
method: 'tools/call',
params: { name: 'browser_select_page', arguments: { pageIndex: 1 } },
},
{
jsonrpc: '2.0',
id: 812,
method: 'tools/call',
params: { name: 'browser_get_visible_text', arguments: {} },
},
]
);
const text = getResponseText(responses.find((message) => message.id === 812));
expect(text).toContain('Docs text');
});
it('opens a page and makes it selected', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }],
[
{
jsonrpc: '2.0',
id: 821,
method: 'tools/call',
params: {
name: 'browser_open_page',
arguments: { url: 'https://example.com/new' },
},
},
{
jsonrpc: '2.0',
id: 822,
method: 'tools/call',
params: { name: 'browser_get_session_info', arguments: {} },
},
]
);
const openText = getResponseText(responses.find((message) => message.id === 821));
expect(openText).toContain('status: opened');
expect(openText).toContain('url: https://example.com/new');
const listText = getResponseText(responses.find((message) => message.id === 822));
expect(listText).toContain('https://example.com/new');
expect(listText).toContain('selected: true');
});
it('closes the selected page and falls back deterministically', async () => {
const responses = await runMcpRequests(
[
{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' },
{ id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' },
],
[
{
jsonrpc: '2.0',
id: 831,
method: 'tools/call',
params: { name: 'browser_select_page', arguments: { pageIndex: 1 } },
},
{
jsonrpc: '2.0',
id: 832,
method: 'tools/call',
params: { name: 'browser_close_page', arguments: {} },
},
{
jsonrpc: '2.0',
id: 833,
method: 'tools/call',
params: { name: 'browser_get_session_info', arguments: {} },
},
]
);
const closeText = getResponseText(responses.find((message) => message.id === 832));
expect(closeText).toContain('status: closed');
const listText = getResponseText(responses.find((message) => message.id === 833));
expect(listText).toContain('0. Home');
expect(listText).toContain('selected: true');
expect(listText).not.toContain('Docs');
});
it('keeps the selected page when closing a different page', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Home',
currentUrl: 'https://example.com/',
visibleText: 'Home text',
},
{
id: 'page-2',
title: 'Docs',
currentUrl: 'https://example.com/docs',
visibleText: 'Docs text',
},
{
id: 'page-3',
title: 'Pricing',
currentUrl: 'https://example.com/pricing',
visibleText: 'Pricing text',
},
],
[
{
jsonrpc: '2.0',
id: 834,
method: 'tools/call',
params: { name: 'browser_select_page', arguments: { pageIndex: 0 } },
},
{
jsonrpc: '2.0',
id: 835,
method: 'tools/call',
params: { name: 'browser_close_page', arguments: { pageId: 'page-3' } },
},
{
jsonrpc: '2.0',
id: 836,
method: 'tools/call',
params: { name: 'browser_get_visible_text', arguments: {} },
},
{
jsonrpc: '2.0',
id: 837,
method: 'tools/call',
params: { name: 'browser_get_session_info', arguments: {} },
},
]
);
const visibleText = getResponseText(responses.find((message) => message.id === 836));
expect(visibleText).toContain('Home text');
const listText = getResponseText(responses.find((message) => message.id === 837));
expect(listText).toContain('0. Home');
expect(listText).toContain('selected: true');
expect(listText).not.toContain('Pricing');
});
it('reconciles a stale selected page when browser_get_session_info is called', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }],
[
{
jsonrpc: '2.0',
id: 841,
method: 'tools/call',
params: { name: 'browser_open_page', arguments: { url: 'https://example.com/new' } },
},
{
jsonrpc: '2.0',
id: 842,
method: 'tools/call',
params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } },
},
{
jsonrpc: '2.0',
id: 843,
method: 'tools/call',
params: { name: 'browser_get_session_info', arguments: {} },
},
]
);
const listText = getResponseText(responses.find((message) => message.id === 843));
expect(listText).toContain('0. Home');
expect(listText).toContain('selected: true');
});
it('closes a page by pageId', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }],
[
{
jsonrpc: '2.0',
id: 851,
method: 'tools/call',
params: { name: 'browser_open_page', arguments: { url: 'https://example.com/new' } },
},
{
jsonrpc: '2.0',
id: 852,
method: 'tools/call',
params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } },
},
{
jsonrpc: '2.0',
id: 853,
method: 'tools/call',
params: { name: 'browser_get_session_info', arguments: {} },
},
]
);
const closeText = getResponseText(responses.find((message) => message.id === 852));
expect(closeText).toContain('pageId: page-2');
expect(closeText).toContain('status: closed');
const listText = getResponseText(responses.find((message) => message.id === 853));
expect(listText).toContain('0. Home');
expect(listText).not.toContain('https://example.com/new');
});
it('works from an installed copy when global WebSocket is unavailable and NODE_PATH supplies package dependencies', async () => {
const installDir = mkdtempSync(join(tmpdir(), 'ccs-browser-installed-copy-'));
const installedServerPath = join(installDir, 'ccs-browser-server.cjs');