feat(browser): 完成 browser MCP 第六阶段 6A 最小拦截闭环

补齐最小 request interception 工具链与会话内状态管理,确保规则绑定具体页面并通过完整验证门禁。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
walker1211
2026-04-18 22:03:17 +08:00
co-authored by Claude Opus 4.7
parent 491e9db51e
commit a93e0b6494
3 changed files with 657 additions and 8 deletions
+21 -2
View File
@@ -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:
+455
View File
@@ -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 || '<untitled>'}`,
`urlIncludes: ${rule.urlIncludes || '<any>'}`,
`method: ${rule.method || '<any>'}`,
`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, {
+181 -6
View File
@@ -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 () => {