From ff0e7085b815e290d61cff39d8099e61df6a793b Mon Sep 17 00:00:00 2001 From: walker1211 <13750528578@163.com> Date: Sat, 18 Apr 2026 16:39:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(browser):=20=E5=AE=8C=E6=88=90=20browser?= =?UTF-8?q?=20MCP=20=E7=AC=AC=E4=BA=94=E9=98=B6=E6=AE=B5=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 selected page 会话状态与页面选择、打开、关闭能力, 并为 browser_get_session_info 增加 selected 标记与最小多标签文档说明。 Co-Authored-By: Claude Opus 4.7 --- docs/browser-automation.md | 31 +- lib/mcp/ccs-browser-server.cjs | 228 ++++++++++++- .../unit/hooks/ccs-browser-mcp-server.test.ts | 318 +++++++++++++++++- 3 files changed, 559 insertions(+), 18 deletions(-) diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 4928b65a..8c82d592 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -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 diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index b39b33f3..4a6ed61a 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -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 || ''} | ${page.url || ''}`), + ...pages.map((page, index) => { + const selectedSuffix = page.id === selectedId ? ' | selected: true' : ''; + return `${index}. ${page.title || ''} | ${page.url || ''}${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 || ''}\nurl: ${page.url || ''}\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 || ''}\nurl: ${selectedPage.url || ''}\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'); + } + }); } } diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index a2740854..cc95834d 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -379,6 +379,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { let wsServer: WebSocketServer | null = null; let browserSocketPath = ''; const pageStates = new Map(); + 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((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: 'New page', + }; + 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');