mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat(browser): 完成 browser MCP 第六阶段 6A 最小拦截闭环
补齐最小 request interception 工具链与会话内状态管理,确保规则绑定具体页面并通过完整验证门禁。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
491e9db51e
commit
a93e0b6494
@@ -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:
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user