mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-20 18:23:23 +00:00
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:
co-authored by
Claude Opus 4.7
parent
e92a2c11bf
commit
ff0e7085b8
@@ -23,7 +23,7 @@ enabled. A recent Chrome update alone is not sufficient.
|
|||||||
The managed `ccs-browser` runtime currently exposes five tool groups:
|
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`
|
- **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`
|
- **Hover diagnostics**: `browser_hover`, `browser_query`, `browser_take_element_screenshot`
|
||||||
- **Readiness and page evaluation**: `browser_wait_for`, `browser_eval`
|
- **Readiness and page evaluation**: `browser_wait_for`, `browser_eval`
|
||||||
- **Event observation**: `browser_wait_for_event`
|
- **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_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
|
- `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:
|
A common hover-debug workflow is:
|
||||||
|
|
||||||
1. call `browser_hover` to move the browser pointer onto the card or trigger
|
1. call `browser_hover` to move the browser pointer onto the card or trigger
|
||||||
|
|||||||
+214
-14
@@ -45,6 +45,9 @@ const TOOL_CLICK = 'browser_click';
|
|||||||
const TOOL_TYPE = 'browser_type';
|
const TOOL_TYPE = 'browser_type';
|
||||||
const TOOL_PRESS_KEY = 'browser_press_key';
|
const TOOL_PRESS_KEY = 'browser_press_key';
|
||||||
const TOOL_SCROLL = 'browser_scroll';
|
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_TAKE_SCREENSHOT = 'browser_take_screenshot';
|
||||||
const TOOL_WAIT_FOR = 'browser_wait_for';
|
const TOOL_WAIT_FOR = 'browser_wait_for';
|
||||||
const TOOL_EVAL = 'browser_eval';
|
const TOOL_EVAL = 'browser_eval';
|
||||||
@@ -62,6 +65,9 @@ const TOOL_NAMES = [
|
|||||||
TOOL_TYPE,
|
TOOL_TYPE,
|
||||||
TOOL_PRESS_KEY,
|
TOOL_PRESS_KEY,
|
||||||
TOOL_SCROLL,
|
TOOL_SCROLL,
|
||||||
|
TOOL_SELECT_PAGE,
|
||||||
|
TOOL_OPEN_PAGE,
|
||||||
|
TOOL_CLOSE_PAGE,
|
||||||
TOOL_TAKE_SCREENSHOT,
|
TOOL_TAKE_SCREENSHOT,
|
||||||
TOOL_WAIT_FOR,
|
TOOL_WAIT_FOR,
|
||||||
TOOL_EVAL,
|
TOOL_EVAL,
|
||||||
@@ -91,6 +97,8 @@ const DEFAULT_WAIT_POLL_INTERVAL_MS = 100;
|
|||||||
|
|
||||||
let inputBuffer = Buffer.alloc(0);
|
let inputBuffer = Buffer.alloc(0);
|
||||||
let requestCounter = 0;
|
let requestCounter = 0;
|
||||||
|
let selectedPageId = '';
|
||||||
|
let messageQueue = Promise.resolve();
|
||||||
|
|
||||||
function addSocketListener(socket, eventName, handler) {
|
function addSocketListener(socket, eventName, handler) {
|
||||||
if (typeof socket.addEventListener === 'function') {
|
if (typeof socket.addEventListener === 'function') {
|
||||||
@@ -410,6 +418,41 @@ function getTools() {
|
|||||||
additionalProperties: false,
|
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,
|
name: TOOL_TAKE_SCREENSHOT,
|
||||||
description:
|
description:
|
||||||
@@ -675,30 +718,100 @@ function parsePageIndex(toolArgs) {
|
|||||||
return toolArgs.pageIndex;
|
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) {
|
async function getSelectedPage(toolArgs) {
|
||||||
const pages = await listPageTargets();
|
const pages = await listPageTargets();
|
||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
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];
|
const page = pages[pageIndex];
|
||||||
if (!page) {
|
if (!page || !page.webSocketDebuggerUrl) {
|
||||||
throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`);
|
throw new Error('Browser MCP could not resolve a selected page target.');
|
||||||
}
|
|
||||||
if (!page.webSocketDebuggerUrl) {
|
|
||||||
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { page, pageIndex, pages };
|
return { page, pageIndex, pages };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSessionInfo(pages) {
|
function formatSessionInfo(pages, selectedId) {
|
||||||
return [
|
return [
|
||||||
'[CCS Browser Session]',
|
'[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');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2186,6 +2299,64 @@ async function handleWaitForEvent(toolArgs) {
|
|||||||
return `pageIndex: ${pageIndex}\nevent: ${event.kind}\nstatus: observed\ndetail: ${JSON.stringify(observed)}`;
|
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) {
|
async function handleToolCall(message) {
|
||||||
const id = message.id;
|
const id = message.id;
|
||||||
const params = message.params || {};
|
const params = message.params || {};
|
||||||
@@ -2213,8 +2384,11 @@ async function handleToolCall(message) {
|
|||||||
try {
|
try {
|
||||||
if (toolName === TOOL_SESSION_INFO) {
|
if (toolName === TOOL_SESSION_INFO) {
|
||||||
const pages = await listPageTargets();
|
const pages = await listPageTargets();
|
||||||
|
if (pages.length > 0 && findPageIndexById(pages, selectedPageId) === -1) {
|
||||||
|
selectedPageId = resolveFallbackSelectedPageId(pages, 0);
|
||||||
|
}
|
||||||
writeResponse(id, {
|
writeResponse(id, {
|
||||||
content: [{ type: 'text', text: formatSessionInfo(pages) }],
|
content: [{ type: 'text', text: formatSessionInfo(pages, selectedPageId) }],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2259,6 +2433,30 @@ async function handleToolCall(message) {
|
|||||||
return;
|
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) {
|
if (toolName === TOOL_TAKE_SCREENSHOT) {
|
||||||
const text = await handleScreenshot(toolArgs);
|
const text = await handleScreenshot(toolArgs);
|
||||||
writeResponse(id, {
|
writeResponse(id, {
|
||||||
@@ -2437,11 +2635,13 @@ function parseMessages() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Promise.resolve(handleMessage(message)).catch((error) => {
|
messageQueue = messageQueue
|
||||||
if (message && message.id !== undefined) {
|
.then(() => handleMessage(message))
|
||||||
writeError(message.id, -32603, (error && error.message) || 'Internal error');
|
.catch((error) => {
|
||||||
}
|
if (message && message.id !== undefined) {
|
||||||
});
|
writeError(message.id, -32603, (error && error.message) || 'Internal error');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
|||||||
let wsServer: WebSocketServer | null = null;
|
let wsServer: WebSocketServer | null = null;
|
||||||
let browserSocketPath = '';
|
let browserSocketPath = '';
|
||||||
const pageStates = new Map<string, MockPageState>();
|
const pageStates = new Map<string, MockPageState>();
|
||||||
|
let nextPageCounter = pagesInput.length + 1;
|
||||||
|
|
||||||
for (const [index, page] of pagesInput.entries()) {
|
for (const [index, page] of pagesInput.entries()) {
|
||||||
pageStates.set(`/devtools/page/${index + 1}`, {
|
pageStates.set(`/devtools/page/${index + 1}`, {
|
||||||
@@ -395,13 +396,15 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
|||||||
|
|
||||||
const port = await new Promise<number>((resolve, reject) => {
|
const port = await new Promise<number>((resolve, reject) => {
|
||||||
httpServer = http.createServer((req, res) => {
|
httpServer = http.createServer((req, res) => {
|
||||||
|
const address = httpServer?.address();
|
||||||
|
const serverPort = address && typeof address !== 'string' ? address.port : 0;
|
||||||
|
|
||||||
if (req.url === '/json/list') {
|
if (req.url === '/json/list') {
|
||||||
const address = httpServer?.address();
|
const pageEntries = Array.from(pageStates.entries());
|
||||||
const serverPort = address && typeof address !== 'string' ? address.port : 0;
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(
|
res.end(
|
||||||
JSON.stringify([
|
JSON.stringify([
|
||||||
...Array.from(pageStates.entries()).map(([wsPath, page]) => ({
|
...pageEntries.map(([wsPath, page]) => ({
|
||||||
id: page.id,
|
id: page.id,
|
||||||
type: 'page',
|
type: 'page',
|
||||||
title: page.title,
|
title: page.title,
|
||||||
@@ -419,6 +422,47 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
|||||||
);
|
);
|
||||||
return;
|
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.writeHead(404);
|
||||||
res.end('not found');
|
res.end('not found');
|
||||||
});
|
});
|
||||||
@@ -1240,6 +1284,9 @@ describe('ccs-browser MCP server', () => {
|
|||||||
'browser_type',
|
'browser_type',
|
||||||
'browser_press_key',
|
'browser_press_key',
|
||||||
'browser_scroll',
|
'browser_scroll',
|
||||||
|
'browser_select_page',
|
||||||
|
'browser_open_page',
|
||||||
|
'browser_close_page',
|
||||||
'browser_take_screenshot',
|
'browser_take_screenshot',
|
||||||
'browser_wait_for',
|
'browser_wait_for',
|
||||||
'browser_eval',
|
'browser_eval',
|
||||||
@@ -1268,6 +1315,17 @@ describe('ccs-browser MCP server', () => {
|
|||||||
expect(scrollTool?.inputSchema?.properties?.deltaX).toMatchObject({ type: 'number' });
|
expect(scrollTool?.inputSchema?.properties?.deltaX).toMatchObject({ type: 'number' });
|
||||||
expect(scrollTool?.inputSchema?.properties?.deltaY).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');
|
const queryTool = tools.find((tool) => tool.name === 'browser_query');
|
||||||
expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({
|
expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({
|
||||||
type: 'array',
|
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 () => {
|
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 installDir = mkdtempSync(join(tmpdir(), 'ccs-browser-installed-copy-'));
|
||||||
const installedServerPath = join(installDir, 'ccs-browser-server.cjs');
|
const installedServerPath = join(installDir, 'ccs-browser-server.cjs');
|
||||||
|
|||||||
Reference in New Issue
Block a user