diff --git a/docs/browser-automation.md b/docs/browser-automation.md index 8c82d592..16f707f2 100644 --- a/docs/browser-automation.md +++ b/docs/browser-automation.md @@ -20,13 +20,14 @@ that already has useful authenticated state. Claude Browser Attach requires a browser launched in attach mode with remote debugging 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 six 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_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` +- **Network interception**: `browser_add_intercept_rule`, `browser_remove_intercept_rule`, `browser_list_intercept_rules`, `browser_list_requests` Notable Phase 1 capability details: @@ -56,6 +57,13 @@ Phase 5 capability details: - 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 +Phase 6A capability details: + +- interception rules are session-local and bind to the concrete page selected when the rule is created +- Phase 6A supports minimal request matching by `urlIncludes` and `method` +- Phase 6A actions are limited to `continue` and `fail` +- `browser_list_requests` returns recent request summaries, not full bodies + Minimal multi-tab workflow examples: ```json @@ -76,6 +84,17 @@ Minimal multi-tab workflow examples: } ``` +```json +{ + "name": "browser_add_intercept_rule", + "arguments": { + "urlIncludes": "/api", + "method": "GET", + "action": "fail" + } +} +``` + A common hover-debug workflow is: 1. call `browser_hover` to move the browser pointer onto the card or trigger @@ -88,7 +107,7 @@ Scoped selector notes: - `browser_click`, `browser_hover`, `browser_query`, `browser_wait_for`, and `browser_take_element_screenshot` accept optional `frameSelector` for same-origin iframes whose `contentDocument` is accessible - the same selector-based tools accept optional `pierceShadow: true` for open shadow-root traversal -- closed shadow roots, frame-index routing, request interception, and download acceptance controls are still out of scope +- closed shadow roots, frame-index routing, response mocking, and download acceptance controls are still out of scope Example event wait: diff --git a/lib/mcp/ccs-browser-server.cjs b/lib/mcp/ccs-browser-server.cjs index 4a6ed61a..a7516eed 100755 --- a/lib/mcp/ccs-browser-server.cjs +++ b/lib/mcp/ccs-browser-server.cjs @@ -48,6 +48,10 @@ 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_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_TAKE_SCREENSHOT = 'browser_take_screenshot'; const TOOL_WAIT_FOR = 'browser_wait_for'; const TOOL_EVAL = 'browser_eval'; @@ -68,6 +72,10 @@ const TOOL_NAMES = [ TOOL_SELECT_PAGE, TOOL_OPEN_PAGE, TOOL_CLOSE_PAGE, + TOOL_ADD_INTERCEPT_RULE, + TOOL_REMOVE_INTERCEPT_RULE, + TOOL_LIST_INTERCEPT_RULES, + TOOL_LIST_REQUESTS, TOOL_TAKE_SCREENSHOT, TOOL_WAIT_FOR, TOOL_EVAL, @@ -99,6 +107,12 @@ let inputBuffer = Buffer.alloc(0); let requestCounter = 0; let selectedPageId = ''; let messageQueue = Promise.resolve(); +let nextInterceptRuleCounter = 1; +const interceptRules = []; +const recentRequests = []; +const interceptSessionsByPageId = new Map(); +const MAX_RECENT_REQUESTS = 100; +const FETCH_FAIL_ERROR_REASON = 'Failed'; function addSocketListener(socket, eventName, handler) { if (typeof socket.addEventListener === 'function') { @@ -453,6 +467,56 @@ function getTools() { additionalProperties: false, }, }, + { + name: TOOL_ADD_INTERCEPT_RULE, + description: 'Add a session-local interception rule bound to a concrete page target.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + urlIncludes: { type: 'string' }, + method: { type: 'string' }, + action: { type: 'string', enum: ['continue', 'fail'] }, + }, + required: ['action'], + additionalProperties: false, + }, + }, + { + name: TOOL_REMOVE_INTERCEPT_RULE, + description: 'Remove a session-local interception rule by rule id.', + inputSchema: { + type: 'object', + properties: { + ruleId: { type: 'string' }, + }, + required: ['ruleId'], + additionalProperties: false, + }, + }, + { + name: TOOL_LIST_INTERCEPT_RULES, + description: 'List current session-local interception rules.', + inputSchema: { + type: 'object', + properties: {}, + additionalProperties: false, + }, + }, + { + name: TOOL_LIST_REQUESTS, + description: 'List recent intercepted request summaries for the current session.', + inputSchema: { + type: 'object', + properties: { + pageIndex: { type: 'integer', minimum: 0 }, + pageId: { type: 'string' }, + limit: { type: 'integer', minimum: 1 }, + }, + additionalProperties: false, + }, + }, { name: TOOL_TAKE_SCREENSHOT, description: @@ -736,6 +800,92 @@ function parseOptionalPageId(toolArgs) { : ''; } +function parseInterceptAction(value) { + if (value !== 'continue' && value !== 'fail') { + throw new Error('action must be one of: continue, fail'); + } + return value; +} + +function parseOptionalMethod(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'method').toUpperCase(); +} + +function parseOptionalUrlIncludes(value) { + if (value === undefined) { + return ''; + } + return requireNonEmptyString(value, 'urlIncludes'); +} + +function createInterceptRuleId() { + return `rule-${nextInterceptRuleCounter++}`; +} + +function removeInterceptStateForPage(pageId) { + for (let index = interceptRules.length - 1; index >= 0; index -= 1) { + if (interceptRules[index].pageId === pageId) { + interceptRules.splice(index, 1); + } + } + for (let index = recentRequests.length - 1; index >= 0; index -= 1) { + if (recentRequests[index].pageId === pageId) { + recentRequests.splice(index, 1); + } + } + const interceptSession = interceptSessionsByPageId.get(pageId); + if (interceptSession) { + interceptSessionsByPageId.delete(pageId); + } +} + +function pushRecentRequest(entry) { + recentRequests.push(entry); + if (recentRequests.length > MAX_RECENT_REQUESTS) { + recentRequests.splice(0, recentRequests.length - MAX_RECENT_REQUESTS); + } +} + +function formatInterceptRules(rules) { + if (rules.length === 0) { + return 'status: empty'; + } + return rules + .map((rule) => + [ + `ruleId: ${rule.ruleId}`, + `pageId: ${rule.pageId}`, + `pageTitle: ${rule.pageTitleSnapshot || ''}`, + `urlIncludes: ${rule.urlIncludes || ''}`, + `method: ${rule.method || ''}`, + `action: ${rule.action}`, + ].join('\n') + ) + .join('\n---\n'); +} + +function formatRecentRequests(entries) { + if (entries.length === 0) { + return 'status: empty'; + } + return entries + .map((entry) => + [ + `requestId: ${entry.requestId}`, + `pageId: ${entry.pageId}`, + `url: ${entry.url}`, + `method: ${entry.method}`, + `resourceType: ${entry.resourceType || ''}`, + `matchedRuleId: ${entry.matchedRuleId || 'none'}`, + `action: ${entry.action}`, + ].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); @@ -2299,6 +2449,176 @@ async function handleWaitForEvent(toolArgs) { return `pageIndex: ${pageIndex}\nevent: ${event.kind}\nstatus: observed\ndetail: ${JSON.stringify(observed)}`; } +function findInterceptRuleIndex(ruleId) { + return interceptRules.findIndex((rule) => rule.ruleId === ruleId); +} + +function getRulesForPage(pageId) { + return interceptRules.filter((rule) => rule.pageId === pageId); +} + +function matchesInterceptRule(rule, paused) { + if (rule.method && rule.method !== String(paused.request?.method || '').toUpperCase()) { + return false; + } + if (rule.urlIncludes && !String(paused.request?.url || '').includes(rule.urlIncludes)) { + return false; + } + return true; +} + +async function ensureInterceptSession(page) { + const existing = interceptSessionsByPageId.get(page.id); + if (existing) { + return existing; + } + + const ws = new WebSocket(page.webSocketDebuggerUrl); + let enableRequestId = 0; + let resolveReady; + let rejectReady; + let activityChain = Promise.resolve(); + const pendingCommands = new Map(); + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + const session = { + pageId: page.id, + webSocketDebuggerUrl: page.webSocketDebuggerUrl, + ws, + enabled: false, + ready, + pendingCommands, + activityVersion: 0, + getLastActivity() { + return activityChain; + }, + }; + interceptSessionsByPageId.set(page.id, session); + + 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', () => { + enableRequestId = ++requestCounter; + ws.send( + JSON.stringify({ + id: enableRequestId, + method: 'Fetch.enable', + params: { patterns: [{ urlPattern: '*' }] }, + }) + ); + }); + + addSocketListener(ws, 'message', (data) => { + const activity = (async () => { + const raw = await getSocketMessageText(data); + let message; + try { + message = JSON.parse(raw); + } catch { + return; + } + session.activityVersion += 1; + if (message.id === enableRequestId) { + if (message.error) { + const error = new Error(message.error.message || 'DevTools request failed.'); + settleReadyError(error); + rejectPendingCommands(error); + removeInterceptStateForPage(page.id); + closeSocket(ws); + return; + } + session.enabled = true; + if (resolveReady) { + resolveReady(); + resolveReady = null; + rejectReady = null; + } + return; + } + if (message.id && pendingCommands.has(message.id)) { + 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); + return; + } + if (message.method !== 'Fetch.requestPaused') { + return; + } + const paused = message.params || {}; + const matchedRule = getRulesForPage(page.id).find((rule) => matchesInterceptRule(rule, paused)); + const action = matchedRule ? matchedRule.action : 'continue'; + if (action === 'fail') { + ws.send( + JSON.stringify({ + id: ++requestCounter, + method: 'Fetch.failRequest', + params: { requestId: paused.requestId, errorReason: FETCH_FAIL_ERROR_REASON }, + }) + ); + } else { + ws.send( + JSON.stringify({ + id: ++requestCounter, + method: 'Fetch.continueRequest', + params: { requestId: paused.requestId }, + }) + ); + } + pushRecentRequest({ + requestId: String(paused.requestId || ''), + pageId: page.id, + url: String(paused.request?.url || ''), + method: String(paused.request?.method || ''), + resourceType: String(paused.resourceType || ''), + matchedRuleId: matchedRule ? matchedRule.ruleId : '', + action, + }); + })(); + activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {}); + void activity.catch(() => {}); + }); + + addSocketListener(ws, 'close', () => { + const error = new Error('Browser MCP lost the DevTools websocket connection.'); + if (!session.enabled) { + settleReadyError(error); + } + rejectPendingCommands(error); + removeInterceptStateForPage(page.id); + }); + + addSocketListener(ws, 'error', (error) => { + const socketError = toSocketError(error); + if (!session.enabled) { + settleReadyError(socketError); + } + rejectPendingCommands(socketError); + removeInterceptStateForPage(page.id); + }); + + await ready; + return session; +} + async function handleSelectPage(toolArgs) { const pages = await listPageTargets(); if (pages.length === 0) { @@ -2341,6 +2661,11 @@ async function handleClosePage(toolArgs) { allowImplicitFallback: false, }); await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`); + const interceptSession = interceptSessionsByPageId.get(page.id); + if (interceptSession) { + closeSocket(interceptSession.ws); + } + removeInterceptStateForPage(page.id); const remainingPages = await listPageTargets(); if (findPageIndexById(remainingPages, previousSelectedPageId) !== -1) { selectedPageId = previousSelectedPageId; @@ -2357,6 +2682,104 @@ async function handleClosePage(toolArgs) { ].join('\n'); } +async function handleAddInterceptRule(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 defaultPageId = selectedPageId || resolveFallbackSelectedPageId(pages, 0); + const { page } = resolveTargetPage(pages, toolArgs, defaultPageId, { + allowImplicitFallback: false, + }); + if (!page.webSocketDebuggerUrl) { + throw new Error('Target page does not expose a websocket debugger URL.'); + } + const urlIncludes = parseOptionalUrlIncludes(toolArgs.urlIncludes); + const method = parseOptionalMethod(toolArgs.method); + if (!urlIncludes && !method) { + throw new Error('urlIncludes or method is required'); + } + const action = parseInterceptAction(toolArgs.action); + const rule = { + ruleId: createInterceptRuleId(), + pageId: page.id, + pageTitleSnapshot: page.title || '', + urlIncludes, + method, + action, + createdAt: new Date().toISOString(), + }; + interceptRules.push(rule); + await ensureInterceptSession(page); + return formatInterceptRules([rule]); +} + +async function handleRemoveInterceptRule(toolArgs) { + const ruleId = requireNonEmptyString(toolArgs.ruleId, 'ruleId'); + const index = findInterceptRuleIndex(ruleId); + if (index === -1) { + throw new Error(`rule not found: ${ruleId}`); + } + const [removed] = interceptRules.splice(index, 1); + if (getRulesForPage(removed.pageId).length === 0) { + const session = interceptSessionsByPageId.get(removed.pageId); + if (session) { + closeSocket(session.ws); + interceptSessionsByPageId.delete(removed.pageId); + } + } + return `ruleId: ${removed.ruleId}\nstatus: removed`; +} + +async function handleListInterceptRules() { + const pages = await listPageTargets(); + const livePageIds = new Set(pages.map((page) => page.id)); + for (let index = interceptRules.length - 1; index >= 0; index -= 1) { + if (!livePageIds.has(interceptRules[index].pageId)) { + interceptRules.splice(index, 1); + } + } + return formatInterceptRules(interceptRules); +} + +async function handleListRequests(toolArgs) { + const pages = await listPageTargets(); + const filteredPage = + toolArgs && (Object.prototype.hasOwnProperty.call(toolArgs, 'pageIndex') || toolArgs.pageId) + ? resolveTargetPage(pages, toolArgs, selectedPageId) + : null; + const limit = requirePositiveIntegerOrDefault(toolArgs.limit, 'limit', 20); + const sessions = Array.from(interceptSessionsByPageId.values()); + await Promise.all(sessions.map((session) => session.ready)); + await Promise.all( + sessions.map(async (session) => { + const barrierRequestId = ++requestCounter; + const barrierPromise = new Promise((resolve, reject) => { + session.pendingCommands.set(barrierRequestId, { resolve, reject }); + }); + session.ws.send( + JSON.stringify({ + id: barrierRequestId, + method: 'Runtime.evaluate', + params: { + expression: '0', + returnByValue: true, + }, + }) + ); + await barrierPromise; + await session.getLastActivity(); + await new Promise((resolve) => setTimeout(resolve, 35)); + await session.getLastActivity(); + }) + ); + const entries = recentRequests + .filter((entry) => !filteredPage || entry.pageId === filteredPage.page.id) + .slice(-limit) + .reverse(); + return formatRecentRequests(entries); +} + async function handleToolCall(message) { const id = message.id; const params = message.params || {}; @@ -2457,6 +2880,38 @@ async function handleToolCall(message) { return; } + if (toolName === TOOL_ADD_INTERCEPT_RULE) { + const text = await handleAddInterceptRule(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_REMOVE_INTERCEPT_RULE) { + const text = await handleRemoveInterceptRule(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_LIST_INTERCEPT_RULES) { + const text = await handleListInterceptRules(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + + if (toolName === TOOL_LIST_REQUESTS) { + const text = await handleListRequests(toolArgs); + writeResponse(id, { + content: [{ type: 'text', text }], + }); + return; + } + if (toolName === TOOL_TAKE_SCREENSHOT) { const text = await handleScreenshot(toolArgs); writeResponse(id, { diff --git a/tests/unit/hooks/ccs-browser-mcp-server.test.ts b/tests/unit/hooks/ccs-browser-mcp-server.test.ts index c814bc03..1be53ae0 100644 --- a/tests/unit/hooks/ccs-browser-mcp-server.test.ts +++ b/tests/unit/hooks/ccs-browser-mcp-server.test.ts @@ -112,6 +112,8 @@ type MockInterceptState = { continuedRequestIds?: string[]; failedRequests?: Array<{ requestId: string; errorReason?: string }>; fetchEnabledPatterns?: unknown[]; + enableError?: string; + pauseDispatchDelayMs?: number; }; type MockPageState = { @@ -687,7 +689,12 @@ function createMockBrowser(pagesInput: MockPageState[]) { page.intercept.fetchEnabledPatterns = Array.isArray(message.params?.patterns) ? (message.params?.patterns as unknown[]) : []; + if (page.intercept.enableError) { + socket.send(JSON.stringify({ id: message.id, error: { message: page.intercept.enableError } })); + return; + } reply({}); + const pauseDispatchDelayMs = page.intercept.pauseDispatchDelayMs ?? 10; for (const [index, paused] of (page.intercept.pausedRequests || []).entries()) { setTimeout(() => { socket.send( @@ -703,7 +710,7 @@ function createMockBrowser(pagesInput: MockPageState[]) { }, }) ); - }, 10 + index * 10); + }, pauseDispatchDelayMs + index * 10); } return; } @@ -757,6 +764,11 @@ function createMockBrowser(pagesInput: MockPageState[]) { return; } + if (expression === '0') { + reply({ result: { type: 'number', value: 0 } }); + return; + } + if (expression.includes('document.title') && expression.includes('location.href')) { reply({ result: { @@ -1856,11 +1868,18 @@ describe('ccs-browser MCP server', () => { expect(listText).toContain('action: continue'); }); - it('removes rules bound to a page after that page is closed', async () => { + it('removes rules and recent requests bound to a page after that page is closed', async () => { const responses = await runMcpRequests( [ { id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }, - { id: 'page-2', title: 'Docs', currentUrl: 'https://example.com/docs' }, + { + id: 'page-2', + title: 'Docs', + currentUrl: 'https://example.com/docs', + intercept: { + pausedRequests: [{ requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' }], + }, + }, ], [ { @@ -1882,19 +1901,175 @@ describe('ccs-browser MCP server', () => { jsonrpc: '2.0', id: 943, method: 'tools/call', - params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, + params: { name: 'browser_list_requests', arguments: {} }, }, { jsonrpc: '2.0', id: 944, method: 'tools/call', + params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } }, + }, + { + jsonrpc: '2.0', + id: 945, + method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} }, }, + { + jsonrpc: '2.0', + id: 946, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const preCloseRequestsText = getResponseText(responses.find((message) => message.id === 943)); + expect(preCloseRequestsText).toContain('requestId: req-closed'); + + const listRulesText = getResponseText(responses.find((message) => message.id === 945)); + expect(listRulesText).not.toContain('pageId: page-2'); + + const postCloseRequestsText = getResponseText(responses.find((message) => message.id === 946)); + expect(postCloseRequestsText).not.toContain('requestId: req-closed'); + expect(postCloseRequestsText).not.toContain('pageId: page-2'); + }); + + it('rejects browser_add_intercept_rule when pageIndex and pageId are both provided', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 951, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + pageIndex: 0, + pageId: 'page-1', + urlIncludes: '/api', + action: 'continue', + }, + }, + }, ] ); - const listText = getResponseText(responses.find((message) => message.id === 944)); - expect(listText).not.toContain('pageId: page-2'); + const response = responses.find((message) => message.id === 951); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: pageIndex and pageId cannot be used together'); + }); + + it('rejects browser_add_intercept_rule when action is invalid', async () => { + const responses = await runMcpRequests( + [{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }], + [ + { + jsonrpc: '2.0', + id: 952, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { + urlIncludes: '/api', + action: 'fulfill', + }, + }, + }, + ] + ); + + const response = responses.find((message) => message.id === 952); + expect((response?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(response)).toContain('Browser MCP failed: action must be one of: continue, fail'); + }); + + it('returns only the requested number of recent requests', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + pausedRequests: [ + { requestId: 'req-1', url: 'https://example.com/api/one', method: 'GET', resourceType: 'XHR' }, + { requestId: 'req-2', url: 'https://example.com/api/two', method: 'GET', resourceType: 'XHR' }, + ], + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 953, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', method: 'GET', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 954, + method: 'tools/call', + params: { name: 'browser_list_requests', arguments: { limit: 1 } }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const listText = getResponseText(responses.find((message) => message.id === 954)); + expect(listText).toContain('requestId: req-2'); + expect(listText).not.toContain('requestId: req-1'); + }); + + it('fails browser_add_intercept_rule when Fetch.enable fails', async () => { + const responses = await runMcpRequests( + [ + { + id: 'page-1', + title: 'Home', + currentUrl: 'https://example.com/', + intercept: { + enableError: 'Fetch.enable blocked', + }, + }, + ], + [ + { + jsonrpc: '2.0', + id: 955, + method: 'tools/call', + params: { + name: 'browser_add_intercept_rule', + arguments: { urlIncludes: '/api', action: 'continue' }, + }, + }, + { + jsonrpc: '2.0', + id: 956, + method: 'tools/call', + params: { name: 'browser_list_intercept_rules', arguments: {} }, + }, + ], + { + responseTimeoutMs: 12000, + } + ); + + const addResponse = responses.find((message) => message.id === 955); + expect((addResponse?.result as { isError?: boolean }).isError).toBe(true); + expect(getResponseText(addResponse)).toContain('Browser MCP failed: Fetch.enable blocked'); + + const listText = getResponseText(responses.find((message) => message.id === 956)); + expect(listText).toBe('status: empty'); }); it('works from an installed copy when global WebSocket is unavailable and NODE_PATH supplies package dependencies', async () => {