@browserbridge/bbx 1.3.0 → 1.4.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.
@@ -4,7 +4,7 @@ import {
4
4
  dispatchToolAction,
5
5
  getToolTokenBudget,
6
6
  REQUEST_SOURCE,
7
- requestBridge,
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 requestBridge(
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 requestBridge(
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 requestBridge(
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 requestBridge(
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 requestBridge(
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 requestBridge(
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 requestBridge(
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 requestBridge(
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 requestBridge(
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 requestBridge(
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) => ({ width: a.width, height: a.height, reset: a.reset }),
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,
@@ -147,14 +146,10 @@ export async function handleBatchTool(args) {
147
146
 
148
147
  const startTime = Date.now();
149
148
  try {
150
- const response = await client.request({
151
- method,
152
- params: call.params || {},
149
+ const response = await requestBridgeWithRetry(client, method, call.params || {}, {
153
150
  tabId,
154
- meta: {
155
- source: REQUEST_SOURCE,
156
- ...(tokenBudget != null ? { token_budget: tokenBudget } : {}),
157
- },
151
+ source: REQUEST_SOURCE,
152
+ tokenBudget,
158
153
  });
159
154
  return summarizeBatchResponseItem({
160
155
  method,
@@ -199,7 +194,7 @@ export async function handleRawCallTool(args) {
199
194
  }
200
195
 
201
196
  return withToolClient(async (client) => {
202
- const response = await requestBridge(
197
+ const response = await requestBridgeWithRetry(
203
198
  client,
204
199
  /** @type {BridgeMethod} */ (args.method),
205
200
  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`