@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.
Files changed (29) hide show
  1. package/README.md +3 -1
  2. package/package.json +2 -2
  3. package/packages/agent-client/src/cli.js +45 -16
  4. package/packages/agent-client/src/client.js +74 -20
  5. package/packages/agent-client/src/command-registry.js +2 -3
  6. package/packages/agent-client/src/mcp-config.js +30 -27
  7. package/packages/agent-client/src/runtime.js +2 -10
  8. package/packages/agent-client/src/types.ts +10 -1
  9. package/packages/mcp-server/src/guidance.js +241 -0
  10. package/packages/mcp-server/src/handlers-capture.js +74 -11
  11. package/packages/mcp-server/src/handlers-dom.js +48 -0
  12. package/packages/mcp-server/src/handlers-navigation.js +22 -2
  13. package/packages/mcp-server/src/handlers-page.js +10 -9
  14. package/packages/mcp-server/src/handlers-utils.js +47 -1
  15. package/packages/mcp-server/src/server.js +111 -29
  16. package/packages/native-host/src/auth-token.js +92 -0
  17. package/packages/native-host/src/daemon-process.js +26 -4
  18. package/packages/native-host/src/daemon.js +174 -28
  19. package/packages/native-host/src/framing.js +7 -2
  20. package/packages/native-host/src/native-host.js +18 -2
  21. package/packages/protocol/src/defaults.js +3 -0
  22. package/packages/protocol/src/json-lines.js +29 -1
  23. package/packages/protocol/src/protocol.js +6 -1
  24. package/packages/protocol/src/types.ts +2 -0
  25. package/skills/browser-bridge/SKILL.md +21 -5
  26. package/skills/browser-bridge/agents/openai.yaml +1 -1
  27. package/skills/browser-bridge/references/interaction.md +6 -6
  28. package/skills/browser-bridge/references/protocol.md +57 -54
  29. 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
- 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,
@@ -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.request({
151
- method,
152
- params: call.params || {},
155
+ const response = await requestBridgeWithRetry(client, method, call.params || {}, {
153
156
  tabId,
154
- meta: {
155
- source: REQUEST_SOURCE,
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 requestBridge(
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`