@browserbridge/bbx 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/package.json +2 -2
- package/packages/agent-client/src/cli.js +45 -16
- package/packages/agent-client/src/client.js +74 -20
- package/packages/agent-client/src/command-registry.js +2 -3
- package/packages/agent-client/src/mcp-config.js +30 -27
- package/packages/agent-client/src/runtime.js +2 -10
- package/packages/agent-client/src/types.ts +10 -1
- package/packages/mcp-server/src/guidance.js +241 -0
- package/packages/mcp-server/src/handlers-capture.js +74 -11
- package/packages/mcp-server/src/handlers-dom.js +48 -0
- package/packages/mcp-server/src/handlers-navigation.js +22 -2
- package/packages/mcp-server/src/handlers-page.js +10 -9
- package/packages/mcp-server/src/handlers-utils.js +47 -1
- package/packages/mcp-server/src/server.js +111 -29
- package/packages/native-host/src/auth-token.js +92 -0
- package/packages/native-host/src/daemon-process.js +26 -4
- package/packages/native-host/src/daemon.js +174 -28
- package/packages/native-host/src/framing.js +7 -2
- package/packages/native-host/src/native-host.js +18 -2
- package/packages/protocol/src/defaults.js +3 -0
- package/packages/protocol/src/json-lines.js +29 -1
- package/packages/protocol/src/protocol.js +6 -1
- package/packages/protocol/src/types.ts +2 -0
- package/skills/browser-bridge/SKILL.md +21 -5
- package/skills/browser-bridge/agents/openai.yaml +1 -1
- package/skills/browser-bridge/references/interaction.md +6 -6
- package/skills/browser-bridge/references/protocol.md +57 -54
- package/skills/browser-bridge/references/ui-workflows.md +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import * as z from 'zod/v4';
|
|
4
|
+
|
|
5
|
+
/** @typedef {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} McpServer */
|
|
6
|
+
/** @typedef {import('@modelcontextprotocol/sdk/types.js').GetPromptResult} GetPromptResult */
|
|
7
|
+
|
|
8
|
+
export const MCP_SERVER_INSTRUCTIONS = [
|
|
9
|
+
"Browser Bridge MCP inspects and interacts with the user's real Chrome tab through typed MCP tools.",
|
|
10
|
+
'Prefer Browser Bridge MCP tools over shelling out to bbx. Use bbx only for explicit CLI setup, doctor, logs, or raw debugging requests.',
|
|
11
|
+
'Call browser_status first. If window access is disabled, call browser_access once, ask the user to click Enable in the Browser Bridge popup or side panel, then retry once.',
|
|
12
|
+
'Use structured reads first: browser_page, browser_dom, browser_styles_layout, and browser_batch. Keep budgetPreset quick or normal before widening.',
|
|
13
|
+
'Reuse elementRef values returned by DOM tools. Use attribute allowlists for focused DOM reads.',
|
|
14
|
+
'Escalate to browser_capture, accessibility_tree, page evaluate, viewport resize, or CDP only when structured reads cannot answer the question.',
|
|
15
|
+
'Use browser_patch for temporary style or DOM experiments, and rollback patches before finishing unless the user asks to keep them.',
|
|
16
|
+
].join('\n');
|
|
17
|
+
|
|
18
|
+
export const MCP_GUIDANCE_PROMPT_NAMES = Object.freeze([
|
|
19
|
+
'browser_bridge_guide',
|
|
20
|
+
'browser_bridge_investigate',
|
|
21
|
+
'browser_bridge_debug_layout',
|
|
22
|
+
'browser_bridge_verify_flow',
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Register Browser Bridge MCP prompt templates. These are the MCP-mode equivalent
|
|
27
|
+
* of a lightweight skill: discoverable by clients without requiring filesystem
|
|
28
|
+
* skill installation or shell access.
|
|
29
|
+
*
|
|
30
|
+
* @param {McpServer} server
|
|
31
|
+
* @returns {void}
|
|
32
|
+
*/
|
|
33
|
+
export function registerBridgeMcpGuidance(server) {
|
|
34
|
+
server.registerPrompt(
|
|
35
|
+
'browser_bridge_guide',
|
|
36
|
+
{
|
|
37
|
+
title: 'Use Browser Bridge MCP',
|
|
38
|
+
description:
|
|
39
|
+
'General Browser Bridge MCP workflow guidance. Prefer this over CLI skill setup.',
|
|
40
|
+
},
|
|
41
|
+
createGuidePrompt
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
server.registerPrompt(
|
|
45
|
+
'browser_bridge_investigate',
|
|
46
|
+
{
|
|
47
|
+
title: 'Investigate Current Page',
|
|
48
|
+
description:
|
|
49
|
+
'Inspect the current page with structured reads before screenshots or evaluation.',
|
|
50
|
+
argsSchema: {
|
|
51
|
+
objective: z.string().optional().describe('What to find, verify, or explain'),
|
|
52
|
+
selector: z
|
|
53
|
+
.string()
|
|
54
|
+
.optional()
|
|
55
|
+
.describe('Optional CSS selector to scope the first DOM read'),
|
|
56
|
+
scope: z.enum(['quick', 'normal', 'deep']).optional().describe('Investigation depth'),
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
createInvestigatePrompt
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
server.registerPrompt(
|
|
63
|
+
'browser_bridge_debug_layout',
|
|
64
|
+
{
|
|
65
|
+
title: 'Debug Layout Or Styling',
|
|
66
|
+
description: 'Diagnose a visual, spacing, sizing, visibility, or CSS issue in the live tab.',
|
|
67
|
+
argsSchema: {
|
|
68
|
+
target: z.string().optional().describe('Element, component, text, or selector to inspect'),
|
|
69
|
+
symptom: z.string().optional().describe('Observed layout or styling problem'),
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
createDebugLayoutPrompt
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
server.registerPrompt(
|
|
76
|
+
'browser_bridge_verify_flow',
|
|
77
|
+
{
|
|
78
|
+
title: 'Verify User Flow',
|
|
79
|
+
description:
|
|
80
|
+
'Drive a user flow through MCP input tools and verify page, console, and network state.',
|
|
81
|
+
argsSchema: {
|
|
82
|
+
flow: z.string().optional().describe('User flow to exercise'),
|
|
83
|
+
successCriteria: z.string().optional().describe('Expected successful outcome'),
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
createVerifyFlowPrompt
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @returns {GetPromptResult}
|
|
92
|
+
*/
|
|
93
|
+
function createGuidePrompt() {
|
|
94
|
+
return createUserPrompt(
|
|
95
|
+
'Browser Bridge MCP workflow guide.',
|
|
96
|
+
[
|
|
97
|
+
'Use Browser Bridge MCP for this browser task.',
|
|
98
|
+
'',
|
|
99
|
+
'Rules:',
|
|
100
|
+
'1. Prefer MCP tools over `bbx`; do not shell out unless setup, doctor, logs, or raw CLI debugging is explicitly needed.',
|
|
101
|
+
'2. Call `browser_status` first. If access is disabled, call `browser_access` once, ask the user to click Enable, then retry once.',
|
|
102
|
+
'3. Start with structured reads: `browser_page` action `state`, `browser_dom` action `query`/`find_text`/`find_role`, `browser_styles_layout`, and `browser_batch`.',
|
|
103
|
+
'4. Keep budgets tight with `budgetPreset: "quick"` or `"normal"`; widen only when results are truncated.',
|
|
104
|
+
'5. Reuse `elementRef` values returned by DOM tools instead of rescanning.',
|
|
105
|
+
'6. Escalate to `browser_capture`, accessibility tree, `browser_page` evaluate, viewport resize, or CDP only when structured reads cannot answer.',
|
|
106
|
+
'7. Use `browser_patch` for temporary style/DOM experiments and rollback before finishing unless the user asks to keep patches.',
|
|
107
|
+
'',
|
|
108
|
+
'Return concise findings with evidence. Edit source code only after the live page behavior is understood.',
|
|
109
|
+
].join('\n')
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @param {{ objective?: string, selector?: string, scope?: 'quick' | 'normal' | 'deep' }} args
|
|
115
|
+
* @returns {GetPromptResult}
|
|
116
|
+
*/
|
|
117
|
+
function createInvestigatePrompt(args) {
|
|
118
|
+
const objective = normalizeTextArg(
|
|
119
|
+
args.objective,
|
|
120
|
+
'inspect the current page and report findings'
|
|
121
|
+
);
|
|
122
|
+
const selector = normalizeTextArg(
|
|
123
|
+
args.selector,
|
|
124
|
+
'none; start with main, body, or semantic search'
|
|
125
|
+
);
|
|
126
|
+
const scope = normalizeTextArg(args.scope, 'normal');
|
|
127
|
+
|
|
128
|
+
return createUserPrompt(
|
|
129
|
+
'Browser Bridge MCP page investigation workflow.',
|
|
130
|
+
[
|
|
131
|
+
'Investigate the current page with Browser Bridge MCP.',
|
|
132
|
+
'',
|
|
133
|
+
`Objective: ${objective}`,
|
|
134
|
+
`Scope: ${scope}`,
|
|
135
|
+
`Initial selector: ${selector}`,
|
|
136
|
+
'',
|
|
137
|
+
'Workflow:',
|
|
138
|
+
'1. Call `browser_status` to confirm daemon, extension, and access readiness.',
|
|
139
|
+
'2. If access is disabled, call `browser_access` once, ask the user to click Enable, then retry once.',
|
|
140
|
+
'3. Use `browser_batch` for independent structured reads, usually `page.get_state`, a scoped `dom.query`, and `page.get_text` when page copy matters.',
|
|
141
|
+
'4. Use `browser_dom` `find_text` or `find_role` when the target is known by label but not selector.',
|
|
142
|
+
'5. Add `browser_styles_layout`, `browser_page` console, or `browser_page` network only when they directly answer the objective.',
|
|
143
|
+
'6. Escalate to screenshots, accessibility tree, or evaluate only when structured reads are insufficient.',
|
|
144
|
+
'',
|
|
145
|
+
'Return concise findings, relevant evidence, and the next source-code action if a fix is needed.',
|
|
146
|
+
].join('\n')
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* @param {{ target?: string, symptom?: string }} args
|
|
152
|
+
* @returns {GetPromptResult}
|
|
153
|
+
*/
|
|
154
|
+
function createDebugLayoutPrompt(args) {
|
|
155
|
+
const target = normalizeTextArg(args.target, 'the affected element or component');
|
|
156
|
+
const symptom = normalizeTextArg(args.symptom, 'the observed layout or styling problem');
|
|
157
|
+
|
|
158
|
+
return createUserPrompt(
|
|
159
|
+
'Browser Bridge MCP layout debugging workflow.',
|
|
160
|
+
[
|
|
161
|
+
'Debug a layout or styling issue in the live tab with Browser Bridge MCP.',
|
|
162
|
+
'',
|
|
163
|
+
`Target: ${target}`,
|
|
164
|
+
`Symptom: ${symptom}`,
|
|
165
|
+
'',
|
|
166
|
+
'Workflow:',
|
|
167
|
+
'1. Call `browser_status` first and resolve access if needed.',
|
|
168
|
+
'2. Locate the target with `browser_dom` `query`, `find_text`, or `find_role` using a quick budget.',
|
|
169
|
+
'3. Read only relevant computed styles with `browser_styles_layout` action `computed` and specific `properties`.',
|
|
170
|
+
'4. Read dimensions with `browser_styles_layout` action `box_model`; use matched rules only when the cascade is unclear.',
|
|
171
|
+
'5. Prototype the smallest visual fix with `browser_patch` action `apply_styles` and `verify: true` when useful.',
|
|
172
|
+
'6. Check `browser_page` console for new errors after interaction or patching.',
|
|
173
|
+
'7. Roll back temporary patches unless the user explicitly wants them kept, then edit source with the confirmed fix.',
|
|
174
|
+
'',
|
|
175
|
+
'Avoid screenshots until DOM, computed styles, and box model evidence are insufficient.',
|
|
176
|
+
].join('\n')
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {{ flow?: string, successCriteria?: string }} args
|
|
182
|
+
* @returns {GetPromptResult}
|
|
183
|
+
*/
|
|
184
|
+
function createVerifyFlowPrompt(args) {
|
|
185
|
+
const flow = normalizeTextArg(args.flow, 'the requested user flow');
|
|
186
|
+
const successCriteria = normalizeTextArg(
|
|
187
|
+
args.successCriteria,
|
|
188
|
+
'visible success state, no console errors, and expected network behavior'
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
return createUserPrompt(
|
|
192
|
+
'Browser Bridge MCP user-flow verification workflow.',
|
|
193
|
+
[
|
|
194
|
+
'Verify a user flow in the current real browser tab with Browser Bridge MCP.',
|
|
195
|
+
'',
|
|
196
|
+
`Flow: ${flow}`,
|
|
197
|
+
`Success criteria: ${successCriteria}`,
|
|
198
|
+
'',
|
|
199
|
+
'Workflow:',
|
|
200
|
+
'1. Call `browser_status` and resolve access if needed.',
|
|
201
|
+
'2. Read `browser_page` state so you know the current URL and title before interacting.',
|
|
202
|
+
'3. Locate controls semantically with `browser_dom` `find_role` or `find_text`; reuse returned `elementRef` values.',
|
|
203
|
+
'4. Interact with `browser_input` actions such as `click`, `type`, `set_checked`, `select_option`, and `press_key`.',
|
|
204
|
+
'5. After navigation or UI changes, wait with `browser_dom` action `wait` or `browser_page` action `wait_for_load`.',
|
|
205
|
+
'6. Verify final page text/DOM plus `browser_page` console and network if the flow depends on API calls.',
|
|
206
|
+
'7. Do not create new tabs unless the user requested a fresh page or the flow requires one.',
|
|
207
|
+
'',
|
|
208
|
+
'Report the verified result, evidence, and any blocking failures.',
|
|
209
|
+
].join('\n')
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {string} description
|
|
215
|
+
* @param {string} text
|
|
216
|
+
* @returns {GetPromptResult}
|
|
217
|
+
*/
|
|
218
|
+
function createUserPrompt(description, text) {
|
|
219
|
+
return {
|
|
220
|
+
description,
|
|
221
|
+
messages: [
|
|
222
|
+
{
|
|
223
|
+
role: 'user',
|
|
224
|
+
content: {
|
|
225
|
+
type: 'text',
|
|
226
|
+
text,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @param {string | undefined} value
|
|
235
|
+
* @param {string} fallback
|
|
236
|
+
* @returns {string}
|
|
237
|
+
*/
|
|
238
|
+
function normalizeTextArg(value, fallback) {
|
|
239
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
240
|
+
return text || fallback;
|
|
241
|
+
}
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
dispatchToolAction,
|
|
5
5
|
getToolTokenBudget,
|
|
6
6
|
REQUEST_SOURCE,
|
|
7
|
-
|
|
7
|
+
requestBridgeWithRetry,
|
|
8
8
|
resolveToolRef,
|
|
9
9
|
resolveRef,
|
|
10
10
|
summarizeToolError,
|
|
@@ -56,11 +56,40 @@ function isCdpNodeCapture(args) {
|
|
|
56
56
|
return args.action === 'cdp_box_model' || args.action === 'cdp_computed_styles';
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* @param {unknown} value
|
|
61
|
+
* @returns {value is number}
|
|
62
|
+
*/
|
|
63
|
+
function isFiniteNumber(value) {
|
|
64
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @param {unknown} rect */
|
|
68
|
+
function isValidCaptureRegion(rect) {
|
|
69
|
+
if (!rect || typeof rect !== 'object' || Array.isArray(rect)) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const candidate = /** @type {Record<string, unknown>} */ (rect);
|
|
73
|
+
return (
|
|
74
|
+
isFiniteNumber(candidate.x) &&
|
|
75
|
+
isFiniteNumber(candidate.y) &&
|
|
76
|
+
isFiniteNumber(candidate.width) &&
|
|
77
|
+
candidate.width > 0 &&
|
|
78
|
+
isFiniteNumber(candidate.height) &&
|
|
79
|
+
candidate.height > 0
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
59
83
|
/**
|
|
60
84
|
* @param {{ action: string, elementRef?: string, selector?: string, rect?: Record<string, unknown>, nodeId?: number, tabId?: number, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
|
|
61
85
|
* @returns {Promise<ToolResult>}
|
|
62
86
|
*/
|
|
63
87
|
export async function handleCaptureTool(args) {
|
|
88
|
+
if (args.action === 'region' && !isValidCaptureRegion(args.rect)) {
|
|
89
|
+
return summarizeToolError(
|
|
90
|
+
'rect with finite x, y, width, and height is required for region capture.'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
64
93
|
if (
|
|
65
94
|
isCdpNodeCapture(args) &&
|
|
66
95
|
(typeof args.nodeId !== 'number' || !Number.isFinite(args.nodeId))
|
|
@@ -90,6 +119,21 @@ export const INPUT_ACTION_METHODS = {
|
|
|
90
119
|
* @returns {Promise<ToolResult>}
|
|
91
120
|
*/
|
|
92
121
|
export async function handleInputTool(args) {
|
|
122
|
+
if (args.action === 'type' && !hasText(args.text)) {
|
|
123
|
+
return summarizeToolError('text is required for input.type.');
|
|
124
|
+
}
|
|
125
|
+
if ((args.action === 'press_key' || args.action === 'cdp_press_key') && !hasText(args.key)) {
|
|
126
|
+
return summarizeToolError('key is required for key input actions.');
|
|
127
|
+
}
|
|
128
|
+
if (
|
|
129
|
+
args.action === 'select_option' &&
|
|
130
|
+
!hasNonEmptyArray(args.values) &&
|
|
131
|
+
!hasNonEmptyArray(args.labels) &&
|
|
132
|
+
!hasNonEmptyArray(args.indexes)
|
|
133
|
+
) {
|
|
134
|
+
return summarizeToolError('values, labels, or indexes are required for input.select_option.');
|
|
135
|
+
}
|
|
136
|
+
|
|
93
137
|
return withToolClient(async (client) => {
|
|
94
138
|
const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
|
|
95
139
|
const elementTarget = async () => ({
|
|
@@ -98,13 +142,14 @@ export async function handleInputTool(args) {
|
|
|
98
142
|
|
|
99
143
|
switch (args.action) {
|
|
100
144
|
case 'click': {
|
|
101
|
-
const response = await
|
|
145
|
+
const response = await requestBridgeWithRetry(
|
|
102
146
|
client,
|
|
103
147
|
'input.click',
|
|
104
148
|
{
|
|
105
149
|
target: await elementTarget(),
|
|
106
150
|
button: args.button,
|
|
107
151
|
clickCount: args.clickCount,
|
|
152
|
+
modifiers: args.modifiers,
|
|
108
153
|
},
|
|
109
154
|
{
|
|
110
155
|
tabId: requestedTabId,
|
|
@@ -115,7 +160,7 @@ export async function handleInputTool(args) {
|
|
|
115
160
|
return summarizeToolResponse(response, 'input.click');
|
|
116
161
|
}
|
|
117
162
|
case 'focus': {
|
|
118
|
-
const response = await
|
|
163
|
+
const response = await requestBridgeWithRetry(
|
|
119
164
|
client,
|
|
120
165
|
'input.focus',
|
|
121
166
|
{
|
|
@@ -130,7 +175,7 @@ export async function handleInputTool(args) {
|
|
|
130
175
|
return summarizeToolResponse(response, 'input.focus');
|
|
131
176
|
}
|
|
132
177
|
case 'type': {
|
|
133
|
-
const response = await
|
|
178
|
+
const response = await requestBridgeWithRetry(
|
|
134
179
|
client,
|
|
135
180
|
'input.type',
|
|
136
181
|
{
|
|
@@ -138,6 +183,7 @@ export async function handleInputTool(args) {
|
|
|
138
183
|
text: args.text,
|
|
139
184
|
clear: args.clear,
|
|
140
185
|
submit: args.submit,
|
|
186
|
+
modifiers: args.modifiers,
|
|
141
187
|
},
|
|
142
188
|
{
|
|
143
189
|
tabId: requestedTabId,
|
|
@@ -149,7 +195,7 @@ export async function handleInputTool(args) {
|
|
|
149
195
|
}
|
|
150
196
|
case 'press_key': {
|
|
151
197
|
const target = args.elementRef || args.selector ? await elementTarget() : undefined;
|
|
152
|
-
const response = await
|
|
198
|
+
const response = await requestBridgeWithRetry(
|
|
153
199
|
client,
|
|
154
200
|
'input.press_key',
|
|
155
201
|
{
|
|
@@ -166,7 +212,7 @@ export async function handleInputTool(args) {
|
|
|
166
212
|
return summarizeToolResponse(response, 'input.press_key');
|
|
167
213
|
}
|
|
168
214
|
case 'cdp_press_key': {
|
|
169
|
-
const response = await
|
|
215
|
+
const response = await requestBridgeWithRetry(
|
|
170
216
|
client,
|
|
171
217
|
'cdp.dispatch_key_event',
|
|
172
218
|
{
|
|
@@ -183,7 +229,7 @@ export async function handleInputTool(args) {
|
|
|
183
229
|
return summarizeToolResponse(response, 'cdp.dispatch_key_event');
|
|
184
230
|
}
|
|
185
231
|
case 'set_checked': {
|
|
186
|
-
const response = await
|
|
232
|
+
const response = await requestBridgeWithRetry(
|
|
187
233
|
client,
|
|
188
234
|
'input.set_checked',
|
|
189
235
|
{
|
|
@@ -199,7 +245,7 @@ export async function handleInputTool(args) {
|
|
|
199
245
|
return summarizeToolResponse(response, 'input.set_checked');
|
|
200
246
|
}
|
|
201
247
|
case 'select_option': {
|
|
202
|
-
const response = await
|
|
248
|
+
const response = await requestBridgeWithRetry(
|
|
203
249
|
client,
|
|
204
250
|
'input.select_option',
|
|
205
251
|
{
|
|
@@ -217,12 +263,13 @@ export async function handleInputTool(args) {
|
|
|
217
263
|
return summarizeToolResponse(response, 'input.select_option');
|
|
218
264
|
}
|
|
219
265
|
case 'hover': {
|
|
220
|
-
const response = await
|
|
266
|
+
const response = await requestBridgeWithRetry(
|
|
221
267
|
client,
|
|
222
268
|
'input.hover',
|
|
223
269
|
{
|
|
224
270
|
target: await elementTarget(),
|
|
225
271
|
duration: args.duration,
|
|
272
|
+
modifiers: args.modifiers,
|
|
226
273
|
},
|
|
227
274
|
{
|
|
228
275
|
tabId: requestedTabId,
|
|
@@ -252,7 +299,7 @@ export async function handleInputTool(args) {
|
|
|
252
299
|
'sourceElementRef/sourceSelector and destinationElementRef/destinationSelector are required for drag.'
|
|
253
300
|
);
|
|
254
301
|
}
|
|
255
|
-
const response = await
|
|
302
|
+
const response = await requestBridgeWithRetry(
|
|
256
303
|
client,
|
|
257
304
|
'input.drag',
|
|
258
305
|
{
|
|
@@ -270,7 +317,7 @@ export async function handleInputTool(args) {
|
|
|
270
317
|
return summarizeToolResponse(response, 'input.drag');
|
|
271
318
|
}
|
|
272
319
|
case 'scroll_into_view': {
|
|
273
|
-
const response = await
|
|
320
|
+
const response = await requestBridgeWithRetry(
|
|
274
321
|
client,
|
|
275
322
|
'input.scroll_into_view',
|
|
276
323
|
{
|
|
@@ -289,3 +336,19 @@ export async function handleInputTool(args) {
|
|
|
289
336
|
}
|
|
290
337
|
});
|
|
291
338
|
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* @param {unknown} value
|
|
342
|
+
* @returns {boolean}
|
|
343
|
+
*/
|
|
344
|
+
function hasText(value) {
|
|
345
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* @param {unknown} value
|
|
350
|
+
* @returns {boolean}
|
|
351
|
+
*/
|
|
352
|
+
function hasNonEmptyArray(value) {
|
|
353
|
+
return Array.isArray(value) && value.length > 0;
|
|
354
|
+
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
applyTreeBudgetPreset,
|
|
7
7
|
dispatchToolAction,
|
|
8
8
|
inferBudgetFromSelector,
|
|
9
|
+
summarizeToolError,
|
|
9
10
|
} from './handlers-utils.js';
|
|
10
11
|
|
|
11
12
|
/** @typedef {import('../../protocol/src/types.js').BridgeMethod} BridgeMethod */
|
|
@@ -93,6 +94,15 @@ export const DOM_ACTIONS = {
|
|
|
93
94
|
* @returns {Promise<ToolResult>}
|
|
94
95
|
*/
|
|
95
96
|
export async function handleDomTool(args) {
|
|
97
|
+
if (args.action === 'wait' && !hasText(args.selector) && !hasText(args.text)) {
|
|
98
|
+
return summarizeToolError('selector or text is required for dom.wait_for.');
|
|
99
|
+
}
|
|
100
|
+
if (args.action === 'find_text' && !hasText(args.text)) {
|
|
101
|
+
return summarizeToolError('text is required for dom.find_by_text.');
|
|
102
|
+
}
|
|
103
|
+
if (args.action === 'find_role' && !hasText(args.role)) {
|
|
104
|
+
return summarizeToolError('role is required for dom.find_by_role.');
|
|
105
|
+
}
|
|
96
106
|
if (args.action === 'query' || args.action === 'accessibility_tree') {
|
|
97
107
|
const inferred = inferBudgetFromSelector(args);
|
|
98
108
|
const withBudget = inferred ? { ...args, budgetPreset: args.budgetPreset ?? inferred } : args;
|
|
@@ -136,6 +146,15 @@ export const STYLES_LAYOUT_ACTIONS = {
|
|
|
136
146
|
* @returns {Promise<ToolResult>}
|
|
137
147
|
*/
|
|
138
148
|
export async function handleStylesLayoutTool(args) {
|
|
149
|
+
if (
|
|
150
|
+
args.action === 'hit_test' &&
|
|
151
|
+
(typeof args.x !== 'number' ||
|
|
152
|
+
!Number.isFinite(args.x) ||
|
|
153
|
+
typeof args.y !== 'number' ||
|
|
154
|
+
!Number.isFinite(args.y))
|
|
155
|
+
) {
|
|
156
|
+
return summarizeToolError('x and y are required for layout.hit_test.');
|
|
157
|
+
}
|
|
139
158
|
return dispatchToolAction(STYLES_LAYOUT_ACTIONS, args, 'styles/layout');
|
|
140
159
|
}
|
|
141
160
|
|
|
@@ -199,5 +218,34 @@ export const PATCH_ACTIONS = {
|
|
|
199
218
|
* @returns {Promise<ToolResult>}
|
|
200
219
|
*/
|
|
201
220
|
export async function handlePatchTool(args) {
|
|
221
|
+
if (args.action === 'apply_styles' && !hasStringRecord(args.declarations)) {
|
|
222
|
+
return summarizeToolError('declarations are required for patch.apply_styles.');
|
|
223
|
+
}
|
|
224
|
+
if (args.action === 'apply_dom' && !hasText(args.operation)) {
|
|
225
|
+
return summarizeToolError('operation is required for patch.apply_dom.');
|
|
226
|
+
}
|
|
227
|
+
if (args.action === 'rollback' && !hasText(args.patchId)) {
|
|
228
|
+
return summarizeToolError('patchId is required for patch.rollback.');
|
|
229
|
+
}
|
|
202
230
|
return dispatchToolAction(PATCH_ACTIONS, args, 'patch');
|
|
203
231
|
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* @param {unknown} value
|
|
235
|
+
* @returns {boolean}
|
|
236
|
+
*/
|
|
237
|
+
function hasText(value) {
|
|
238
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {unknown} value
|
|
243
|
+
* @returns {value is Record<string, string>}
|
|
244
|
+
*/
|
|
245
|
+
function hasStringRecord(value) {
|
|
246
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
const entries = Object.entries(/** @type {Record<string, unknown>} */ (value));
|
|
250
|
+
return entries.length > 0 && entries.every(([key, val]) => key.trim() && typeof val === 'string');
|
|
251
|
+
}
|
|
@@ -61,17 +61,37 @@ export const NAVIGATION_ACTIONS = {
|
|
|
61
61
|
},
|
|
62
62
|
resize: {
|
|
63
63
|
method: 'viewport.resize',
|
|
64
|
-
params: (a) => ({
|
|
64
|
+
params: (a) => ({
|
|
65
|
+
width: a.width,
|
|
66
|
+
height: a.height,
|
|
67
|
+
deviceScaleFactor: a.deviceScaleFactor,
|
|
68
|
+
reset: a.reset,
|
|
69
|
+
}),
|
|
65
70
|
},
|
|
66
71
|
};
|
|
67
72
|
|
|
68
73
|
/**
|
|
69
|
-
* @param {{ action: string, url?: string, waitForLoad?: boolean, timeoutMs?: number, top?: number, left?: number, behavior?: string, relative?: boolean, width?: number, height?: number, reset?: boolean, tabId?: number, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
|
|
74
|
+
* @param {{ action: string, url?: string, waitForLoad?: boolean, timeoutMs?: number, top?: number, left?: number, behavior?: string, relative?: boolean, width?: number, height?: number, deviceScaleFactor?: number, reset?: boolean, tabId?: number, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
|
|
70
75
|
* @returns {Promise<ToolResult>}
|
|
71
76
|
*/
|
|
72
77
|
export async function handleNavigationTool(args) {
|
|
73
78
|
const entry = NAVIGATION_ACTIONS[args.action];
|
|
74
79
|
if (!entry) return summarizeToolError(`Unsupported navigation action "${args.action}".`);
|
|
80
|
+
if (args.action === 'navigate' && (typeof args.url !== 'string' || !args.url.trim())) {
|
|
81
|
+
return summarizeToolError('url is required for navigation.navigate.');
|
|
82
|
+
}
|
|
83
|
+
if (
|
|
84
|
+
args.action === 'resize' &&
|
|
85
|
+
args.reset !== true &&
|
|
86
|
+
(typeof args.width !== 'number' ||
|
|
87
|
+
!Number.isFinite(args.width) ||
|
|
88
|
+
typeof args.height !== 'number' ||
|
|
89
|
+
!Number.isFinite(args.height))
|
|
90
|
+
) {
|
|
91
|
+
return summarizeToolError(
|
|
92
|
+
'width and height are required for viewport.resize unless reset=true.'
|
|
93
|
+
);
|
|
94
|
+
}
|
|
75
95
|
return callBridgeTool(entry.method, entry.params(args), {
|
|
76
96
|
tabId: typeof args.tabId === 'number' ? args.tabId : null,
|
|
77
97
|
tokenBudget: getToolTokenBudget(args),
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
callBridgeTool,
|
|
14
14
|
createToolResult,
|
|
15
15
|
getToolTokenBudget,
|
|
16
|
-
requestBridge,
|
|
17
16
|
requestBridgeWithRetry,
|
|
18
17
|
summarizeBatchErrorItem,
|
|
19
18
|
summarizeBatchResponseItem,
|
|
@@ -88,6 +87,12 @@ export async function handlePageTool(args) {
|
|
|
88
87
|
}
|
|
89
88
|
const entry = PAGE_ACTIONS[normalizedArgs.action];
|
|
90
89
|
if (!entry) return summarizeToolError(`Unsupported page action "${args.action}".`);
|
|
90
|
+
if (
|
|
91
|
+
normalizedArgs.action === 'evaluate' &&
|
|
92
|
+
(typeof normalizedArgs.expression !== 'string' || !normalizedArgs.expression.trim())
|
|
93
|
+
) {
|
|
94
|
+
return summarizeToolError('expression is required for page evaluate.');
|
|
95
|
+
}
|
|
91
96
|
return callBridgeTool(entry.method, entry.params(normalizedArgs), {
|
|
92
97
|
tabId: typeof normalizedArgs.tabId === 'number' ? normalizedArgs.tabId : null,
|
|
93
98
|
tokenBudget: getToolTokenBudget(normalizedArgs),
|
|
@@ -147,14 +152,10 @@ export async function handleBatchTool(args) {
|
|
|
147
152
|
|
|
148
153
|
const startTime = Date.now();
|
|
149
154
|
try {
|
|
150
|
-
const response = await client.
|
|
151
|
-
method,
|
|
152
|
-
params: call.params || {},
|
|
155
|
+
const response = await requestBridgeWithRetry(client, method, call.params || {}, {
|
|
153
156
|
tabId,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
...(tokenBudget != null ? { token_budget: tokenBudget } : {}),
|
|
157
|
-
},
|
|
157
|
+
source: REQUEST_SOURCE,
|
|
158
|
+
tokenBudget,
|
|
158
159
|
});
|
|
159
160
|
return summarizeBatchResponseItem({
|
|
160
161
|
method,
|
|
@@ -199,7 +200,7 @@ export async function handleRawCallTool(args) {
|
|
|
199
200
|
}
|
|
200
201
|
|
|
201
202
|
return withToolClient(async (client) => {
|
|
202
|
-
const response = await
|
|
203
|
+
const response = await requestBridgeWithRetry(
|
|
203
204
|
client,
|
|
204
205
|
/** @type {BridgeMethod} */ (args.method),
|
|
205
206
|
args.params || {},
|
|
@@ -24,6 +24,52 @@ import { annotateBridgeSummary, summarizeBridgeResponse } from '../../agent-clie
|
|
|
24
24
|
|
|
25
25
|
export const REQUEST_SOURCE = 'mcp';
|
|
26
26
|
|
|
27
|
+
/** @type {ReadonlySet<BridgeMethod>} */
|
|
28
|
+
const RETRY_SAFE_METHODS = new Set([
|
|
29
|
+
'skill.get_runtime_context',
|
|
30
|
+
'setup.get_status',
|
|
31
|
+
'log.tail',
|
|
32
|
+
'health.ping',
|
|
33
|
+
'daemon.metrics',
|
|
34
|
+
'tabs.list',
|
|
35
|
+
'page.get_state',
|
|
36
|
+
'page.get_storage',
|
|
37
|
+
'page.get_text',
|
|
38
|
+
'dom.query',
|
|
39
|
+
'dom.describe',
|
|
40
|
+
'dom.get_text',
|
|
41
|
+
'dom.get_attributes',
|
|
42
|
+
'dom.wait_for',
|
|
43
|
+
'dom.find_by_text',
|
|
44
|
+
'dom.find_by_role',
|
|
45
|
+
'dom.get_html',
|
|
46
|
+
'dom.get_accessibility_tree',
|
|
47
|
+
'layout.get_box_model',
|
|
48
|
+
'layout.hit_test',
|
|
49
|
+
'styles.get_computed',
|
|
50
|
+
'styles.get_matched_rules',
|
|
51
|
+
'screenshot.capture_region',
|
|
52
|
+
'screenshot.capture_element',
|
|
53
|
+
'screenshot.capture_full_page',
|
|
54
|
+
'performance.get_metrics',
|
|
55
|
+
'cdp.get_document',
|
|
56
|
+
'cdp.get_dom_snapshot',
|
|
57
|
+
'cdp.get_box_model',
|
|
58
|
+
'cdp.get_computed_styles_for_node',
|
|
59
|
+
]);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {BridgeMethod} method
|
|
63
|
+
* @param {Record<string, unknown>} params
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
export function isRetrySafeBridgeMethod(method, params) {
|
|
67
|
+
if (method === 'page.get_console' || method === 'page.get_network') {
|
|
68
|
+
return params.clear !== true;
|
|
69
|
+
}
|
|
70
|
+
return RETRY_SAFE_METHODS.has(method);
|
|
71
|
+
}
|
|
72
|
+
|
|
27
73
|
/**
|
|
28
74
|
* @typedef {{
|
|
29
75
|
* content: Array<{ type: 'text', text: string }>,
|
|
@@ -246,7 +292,7 @@ export function applyHtmlBudgetPreset(args) {
|
|
|
246
292
|
export async function requestBridgeWithRetry(client, method, params, options) {
|
|
247
293
|
const response = await requestBridge(client, method, params, options);
|
|
248
294
|
const recovery = !response.ok && response.error ? getErrorRecovery(response.error.code) : null;
|
|
249
|
-
if (!response.ok && recovery?.retry) {
|
|
295
|
+
if (!response.ok && recovery?.retry && isRetrySafeBridgeMethod(method, params)) {
|
|
250
296
|
const delay = recovery.retryAfterMs ?? 1000;
|
|
251
297
|
process.stderr.write(
|
|
252
298
|
`[bbx-mcp] Retrying ${method} after ${delay}ms (${response.error.code})\n`
|