@browserbridge/bbx 1.7.3 → 1.7.5

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/package.json +7 -7
  2. package/packages/agent-client/src/atomic-write.js +99 -0
  3. package/packages/agent-client/src/cli-helpers.js +20 -3
  4. package/packages/agent-client/src/cli.js +273 -76
  5. package/packages/agent-client/src/command-registry.js +12 -6
  6. package/packages/agent-client/src/install.js +39 -4
  7. package/packages/agent-client/src/mcp-config.js +28 -14
  8. package/packages/agent-client/src/remotes.js +93 -15
  9. package/packages/agent-client/src/runtime.js +6 -3
  10. package/packages/mcp-server/src/bin.js +10 -1
  11. package/packages/mcp-server/src/guidance.js +2 -2
  12. package/packages/mcp-server/src/handlers-capture.js +62 -32
  13. package/packages/mcp-server/src/handlers-dom.js +2 -2
  14. package/packages/mcp-server/src/handlers-navigation.js +30 -9
  15. package/packages/mcp-server/src/handlers-page.js +170 -143
  16. package/packages/mcp-server/src/handlers-utils.js +277 -8
  17. package/packages/mcp-server/src/handlers.js +31 -10
  18. package/packages/mcp-server/src/lifecycle.js +413 -0
  19. package/packages/mcp-server/src/server.js +59 -30
  20. package/packages/native-host/src/daemon.js +3 -1
  21. package/packages/protocol/src/capabilities.js +1 -1
  22. package/packages/protocol/src/defaults.js +2 -0
  23. package/packages/protocol/src/protocol.js +122 -9
  24. package/packages/protocol/src/registry.js +14 -14
  25. package/packages/protocol/src/summary.js +61 -30
  26. package/packages/protocol/src/types.ts +30 -1
  27. package/skills/browser-bridge/SKILL.md +4 -4
  28. package/skills/browser-bridge/references/interaction.md +1 -1
  29. package/skills/browser-bridge/references/protocol.md +3 -3
@@ -4,6 +4,8 @@ import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
6
 
7
+ import { atomicWriteFile } from './atomic-write.js';
8
+
7
9
  /** @typedef {import('./types.js').McpClientName} McpClientName */
8
10
 
9
11
  /** @type {McpClientName[]} */
@@ -491,6 +493,8 @@ async function installJsonMcpConfig(clientName, configPath, stdout) {
491
493
  const parsed = JSON.parse(raw);
492
494
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
493
495
  existing = parsed;
496
+ } else {
497
+ throw new Error('MCP config root must be a JSON object.');
494
498
  }
495
499
  } catch (error) {
496
500
  if (!isMissingFileError(error)) {
@@ -519,8 +523,9 @@ async function installJsonMcpConfig(clientName, configPath, stdout) {
519
523
  },
520
524
  };
521
525
 
522
- await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
523
- await fs.promises.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
526
+ await atomicWriteFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, {
527
+ encoding: 'utf8',
528
+ });
524
529
  stdout.write(`Wrote ${configPath}\n`);
525
530
  }
526
531
 
@@ -533,13 +538,14 @@ async function installCodexMcpConfig(configPath, stdout) {
533
538
  let raw = '';
534
539
  try {
535
540
  raw = await fs.promises.readFile(configPath, 'utf8');
536
- } catch {
537
- raw = '';
541
+ } catch (error) {
542
+ if (!isMissingFileError(error)) {
543
+ throw error;
544
+ }
538
545
  }
539
546
 
540
547
  const updated = upsertCodexServerBlock(raw);
541
- await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
542
- await fs.promises.writeFile(configPath, updated, 'utf8');
548
+ await atomicWriteFile(configPath, updated, { encoding: 'utf8' });
543
549
  stdout.write(`Wrote ${configPath}\n`);
544
550
  }
545
551
 
@@ -558,10 +564,15 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
558
564
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
559
565
  existing = parsed;
560
566
  } else {
567
+ throw new Error('MCP config root must be a JSON object.');
568
+ }
569
+ } catch (error) {
570
+ if (isMissingFileError(error)) {
561
571
  return false;
562
572
  }
563
- } catch {
564
- return false;
573
+ throw new Error(
574
+ `Cannot remove Browser Bridge from ${configPath}: existing MCP config is not readable valid JSON.`
575
+ );
565
576
  }
566
577
 
567
578
  const shape = getMcpConfigShapeForPath(clientName, configPath);
@@ -584,8 +595,9 @@ async function removeJsonMcpConfig(clientName, configPath, stdout) {
584
595
  merged[topKey] = updatedBlock;
585
596
  }
586
597
 
587
- await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
588
- await fs.promises.writeFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, 'utf8');
598
+ await atomicWriteFile(configPath, `${JSON.stringify(merged, null, 2)}\n`, {
599
+ encoding: 'utf8',
600
+ });
589
601
  stdout.write(`Removed ${configPath}\n`);
590
602
  return true;
591
603
  }
@@ -599,8 +611,11 @@ async function removeCodexMcpConfig(configPath, stdout) {
599
611
  let raw = '';
600
612
  try {
601
613
  raw = await fs.promises.readFile(configPath, 'utf8');
602
- } catch {
603
- return false;
614
+ } catch (error) {
615
+ if (isMissingFileError(error)) {
616
+ return false;
617
+ }
618
+ throw error;
604
619
  }
605
620
 
606
621
  if (!hasCodexServerBlock(raw)) {
@@ -608,8 +623,7 @@ async function removeCodexMcpConfig(configPath, stdout) {
608
623
  }
609
624
 
610
625
  const updated = removeCodexServerBlock(raw);
611
- await fs.promises.mkdir(path.dirname(configPath), { recursive: true });
612
- await fs.promises.writeFile(configPath, updated, 'utf8');
626
+ await atomicWriteFile(configPath, updated, { encoding: 'utf8' });
613
627
  stdout.write(`Removed ${configPath}\n`);
614
628
  return true;
615
629
  }
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
 
6
6
  import { createTcpBridgeTransport, getBridgeDir } from '../../native-host/src/config.js';
7
7
  import { normalizeBridgeAuthToken } from '../../native-host/src/auth-token.js';
8
+ import { atomicWriteFile } from './atomic-write.js';
8
9
  import { BridgeClient } from './client.js';
9
10
 
10
11
  const REMOTES_FILENAME = 'remotes.json';
@@ -121,15 +122,13 @@ export async function readRemoteConfig(options = {}) {
121
122
 
122
123
  /**
123
124
  * @param {RemoteConfig} config
124
- * @param {{ configPath?: string, writeFile?: typeof fs.promises.writeFile, mkdir?: typeof fs.promises.mkdir }} [options={}]
125
+ * @param {{ configPath?: string }} [options={}]
125
126
  * @returns {Promise<void>}
126
127
  */
127
128
  export async function writeRemoteConfig(config, options = {}) {
128
129
  const configPath = options.configPath ?? getRemoteConfigPath();
129
- const writeFile = options.writeFile ?? fs.promises.writeFile.bind(fs.promises);
130
- const mkdir = options.mkdir ?? fs.promises.mkdir.bind(fs.promises);
131
- await mkdir(path.dirname(configPath), { recursive: true });
132
- await writeFile(configPath, `${JSON.stringify({ remotes: config.remotes }, null, 2)}\n`, {
130
+ const remotes = config.remotes.map(validateRemoteDestination);
131
+ await atomicWriteFile(configPath, `${JSON.stringify({ remotes }, null, 2)}\n`, {
133
132
  encoding: 'utf8',
134
133
  mode: 0o600,
135
134
  });
@@ -140,15 +139,16 @@ export async function writeRemoteConfig(config, options = {}) {
140
139
  * @returns {Promise<RemoteDestination>}
141
140
  */
142
141
  export async function addRemoteDestination(remote) {
142
+ const validated = validateRemoteDestination(remote);
143
143
  const config = await readRemoteConfig();
144
- const existing = config.remotes.findIndex((entry) => entry.id === remote.id);
144
+ const existing = config.remotes.findIndex((entry) => entry.id === validated.id);
145
145
  if (existing === -1) {
146
- config.remotes.push(remote);
146
+ config.remotes.push(validated);
147
147
  } else {
148
- config.remotes[existing] = remote;
148
+ config.remotes[existing] = validated;
149
149
  }
150
150
  await writeRemoteConfig(config);
151
- return remote;
151
+ return validated;
152
152
  }
153
153
 
154
154
  /**
@@ -180,7 +180,7 @@ export async function listBridgeDestinations() {
180
180
  }
181
181
 
182
182
  /**
183
- * @typedef {{ port?: number, bindHost?: string, token?: string, rotateToken?: boolean }} ProxyEnableOptions
183
+ * @typedef {{ port?: number, bindHost?: string, token?: string, rotateToken?: boolean, unsafePlaintext?: boolean }} ProxyEnableOptions
184
184
  */
185
185
 
186
186
  /**
@@ -197,14 +197,92 @@ export async function listBridgeDestinations() {
197
197
  */
198
198
  export function resolveProxyEnableSettings(existing, options, generateToken) {
199
199
  const port = options.port ?? existing?.port ?? DEFAULT_REMOTE_PORT;
200
- const bindHost = options.bindHost ?? existing?.bindHost ?? '0.0.0.0';
200
+ const bindHost = options.bindHost ?? existing?.bindHost ?? '127.0.0.1';
201
201
  if (options.token) {
202
- return { port, bindHost, token: options.token, tokenSource: 'explicit' };
202
+ return {
203
+ port,
204
+ bindHost,
205
+ token: validateAuthToken(options.token),
206
+ tokenSource: 'explicit',
207
+ };
203
208
  }
204
- if (!options.rotateToken && existing?.token) {
205
- return { port, bindHost, token: existing.token, tokenSource: 'existing' };
209
+ const existingToken = normalizeBridgeAuthToken(existing?.token);
210
+ if (!options.rotateToken && existingToken) {
211
+ return { port, bindHost, token: existingToken, tokenSource: 'existing' };
206
212
  }
207
- return { port, bindHost, token: generateToken(), tokenSource: 'generated' };
213
+ return {
214
+ port,
215
+ bindHost,
216
+ token: validateAuthToken(generateToken()),
217
+ tokenSource: 'generated',
218
+ };
219
+ }
220
+
221
+ /**
222
+ * Require an explicit acknowledgement before creating or changing a raw TCP
223
+ * listener that is reachable beyond the local machine. A stored non-loopback
224
+ * host may be reused without breaking an existing installation.
225
+ *
226
+ * @param {{ bindHost: string } | null} existing
227
+ * @param {ProxyEnableOptions} options
228
+ * @param {string} bindHost
229
+ * @returns {void}
230
+ */
231
+ export function assertProxyBindSafety(existing, options, bindHost) {
232
+ if (
233
+ isLoopbackHost(bindHost) ||
234
+ options.unsafePlaintext === true ||
235
+ (options.bindHost === undefined && existing?.bindHost === bindHost)
236
+ ) {
237
+ return;
238
+ }
239
+ throw new Error(
240
+ 'Refusing to bind Browser Bridge raw TCP to a non-loopback host without --unsafe-plaintext. This transport is unencrypted; keep the default 127.0.0.1 bind and use an SSH tunnel instead.'
241
+ );
242
+ }
243
+
244
+ /**
245
+ * @param {string} host
246
+ * @returns {boolean}
247
+ */
248
+ export function isLoopbackHost(host) {
249
+ const normalized = host
250
+ .trim()
251
+ .toLowerCase()
252
+ .replace(/^\[|\]$/gu, '');
253
+ const ipv4Parts = normalized.split('.').map(Number);
254
+ const isIpv4Loopback =
255
+ ipv4Parts.length === 4 &&
256
+ ipv4Parts[0] === 127 &&
257
+ ipv4Parts.every((part) => Number.isInteger(part) && part >= 0 && part <= 255);
258
+ return (
259
+ normalized === 'localhost' ||
260
+ normalized === '::1' ||
261
+ normalized === '0:0:0:0:0:0:0:1' ||
262
+ isIpv4Loopback
263
+ );
264
+ }
265
+
266
+ /**
267
+ * @param {RemoteDestination} remote
268
+ * @returns {RemoteDestination}
269
+ */
270
+ function validateRemoteDestination(remote) {
271
+ const id = normalizeDestinationId(remote.id);
272
+ const { host, port } = parseRemoteEndpoint(`${remote.host}:${remote.port}`);
273
+ return { id, host, port, token: validateAuthToken(remote.token) };
274
+ }
275
+
276
+ /**
277
+ * @param {unknown} token
278
+ * @returns {string}
279
+ */
280
+ function validateAuthToken(token) {
281
+ const normalized = normalizeBridgeAuthToken(token);
282
+ if (!normalized) {
283
+ throw new Error('Bridge auth token must be a UUID or 32-256 URL-safe characters.');
284
+ }
285
+ return normalized;
208
286
  }
209
287
 
210
288
  /**
@@ -19,6 +19,7 @@ import {
19
19
  } from '../../native-host/src/daemon-process.js';
20
20
  import { resolveDefaultExtensionId } from '../../native-host/src/install-manifest.js';
21
21
  import {
22
+ BridgeError,
22
23
  CLIENT_REQUEST_TIMEOUT_MARGIN_MS,
23
24
  DEFAULT_CLIENT_REQUEST_TIMEOUT_MS,
24
25
  getBridgeOperationTimeoutMs,
@@ -102,12 +103,14 @@ export async function resolveRef(client, refOrSelector, tabId = null, source) {
102
103
  );
103
104
 
104
105
  if (!response.ok) {
105
- throw new Error(response.error.message);
106
+ throw new BridgeError(response.error.code, response.error.message, response.error.details);
106
107
  }
107
108
 
108
109
  const result = /** @type {{ nodes: Array<{ elementRef: string }> }} */ (response.result);
109
110
  if (!result.nodes || result.nodes.length === 0) {
110
- throw new Error(`No element found for selector "${refOrSelector}".`);
111
+ throw new BridgeError('INVALID_REQUEST', `No element found for selector "${refOrSelector}".`, {
112
+ selector: refOrSelector,
113
+ });
111
114
  }
112
115
  return result.nodes[0].elementRef;
113
116
  }
@@ -350,7 +353,7 @@ export async function checkNativeHostManifestHealth(browserManifests) {
350
353
  */
351
354
  export async function checkUnwritableBridgePaths() {
352
355
  // The root-owned-files scenario comes from sudo installs, which do not exist
353
- // on Windows — and fs.access(W_OK) there only reflects the read-only file
356
+ // on Windows - and fs.access(W_OK) there only reflects the read-only file
354
357
  // attribute, so it would produce false positives.
355
358
  if (os.platform() === 'win32') {
356
359
  return [];
@@ -4,11 +4,13 @@
4
4
  import path from 'node:path';
5
5
  import { pathToFileURL } from 'node:url';
6
6
 
7
+ import { tryStartMcpProcessControl } from './lifecycle.js';
7
8
  import { startBridgeMcpServer } from './server.js';
8
9
 
9
10
  /**
10
11
  * @typedef {{
11
12
  * start?: () => Promise<void>,
13
+ * startControl?: typeof tryStartMcpProcessControl,
12
14
  * argv?: string[],
13
15
  * stdout?: { write: (chunk: string) => unknown },
14
16
  * stderr?: { write: (chunk: string) => unknown },
@@ -33,6 +35,7 @@ const MCP_USAGE = [
33
35
  export async function runBridgeMcpCli(options = {}) {
34
36
  const {
35
37
  start = startBridgeMcpServer,
38
+ startControl = tryStartMcpProcessControl,
36
39
  argv = process.argv.slice(2),
37
40
  stdout = process.stdout,
38
41
  stderr = process.stderr,
@@ -45,7 +48,13 @@ export async function runBridgeMcpCli(options = {}) {
45
48
  }
46
49
 
47
50
  try {
48
- await start();
51
+ const control = await startControl();
52
+ try {
53
+ await start();
54
+ } catch (error) {
55
+ await control?.dispose();
56
+ throw error;
57
+ }
49
58
  return 0;
50
59
  } catch (error) {
51
60
  const message = error instanceof Error ? error.stack || error.message : String(error);
@@ -10,7 +10,7 @@ export const MCP_SERVER_INSTRUCTIONS = [
10
10
  'In permission-ask hosts, use browser_call as the default Browser Bridge MCP tool so the user can approve one BBX tool instead of separate browser_status, browser_page, browser_dom, browser_input, and other tools.',
11
11
  'Prefer Browser Bridge MCP tools over shelling out to bbx. Use bbx only for explicit CLI setup, doctor, logs, or raw debugging requests.',
12
12
  'Start with browser_call method health.ping. If window access is disabled, call browser_call method access.request once, ask the user to click Enable in the Browser Bridge popup or side panel, then retry once.',
13
- 'Use structured reads first through browser_call: page.get_state, dom.query, page.get_text, styles.get_computed, or layout.get_box_model. Use browser_batch for parallel reads and keep limits tight before widening.',
13
+ 'Use structured reads first through browser_call: page.get_state, dom.query, page.get_text, styles.get_computed, or layout.get_box_model. Use browser_batch only for parallel reads; run mutations sequentially and keep limits tight before widening.',
14
14
  'Reuse elementRef values returned by DOM tools. Use attribute allowlists for focused DOM reads.',
15
15
  'Escalate to screenshot.capture_element, screenshot.capture_region, dom.get_accessibility_tree, page.evaluate, viewport.resize, or CDP only when structured reads cannot answer the question.',
16
16
  'Use patch.apply_styles or patch.apply_dom for temporary experiments, and rollback patches before finishing unless the user asks to keep them.',
@@ -103,7 +103,7 @@ function createGuidePrompt() {
103
103
  '2. In permission-ask hosts, use `browser_call` as the default tool so the user can approve one BBX MCP tool instead of separate tools for status, page, DOM, input, and patches.',
104
104
  '3. Start with `browser_call` method `health.ping`. If access is disabled, call `browser_call` method `access.request` once, ask the user to click Enable, then retry once.',
105
105
  '4. Start with structured reads via `browser_call`: `page.get_state`, `dom.query`, `dom.find_by_text`, `dom.find_by_role`, and `styles.get_computed`. Use `browser_batch` for parallel reads.',
106
- '5. `browser_call` accepts explicit method limits, not `budgetPreset`; keep limits tight and widen only when results are truncated.',
106
+ '5. `browser_call` accepts `budgetPreset`; explicit method limits override preset values. Keep limits tight and widen only when results are truncated.',
107
107
  '6. Reuse `elementRef` values returned by DOM tools instead of rescanning.',
108
108
  '7. Escalate to screenshots, accessibility tree, `page.evaluate`, viewport resize, or CDP only when structured reads cannot answer.',
109
109
  '8. Use `patch.apply_styles` or `patch.apply_dom` for temporary experiments and rollback before finishing unless the user asks to keep patches.',
@@ -1,12 +1,12 @@
1
1
  // @ts-check
2
2
 
3
+ import { BridgeError } from '../../protocol/src/index.js';
3
4
  import {
4
5
  createToolResult,
5
6
  getToolTokenBudget,
6
7
  REQUEST_SOURCE,
7
8
  requestBridgeWithRetry,
8
9
  resolveToolRef,
9
- resolveRef,
10
10
  summarizeToolError,
11
11
  summarizeToolResponse,
12
12
  withToolClient,
@@ -110,7 +110,7 @@ export async function handleCaptureTool(args) {
110
110
  const response = await requestBridgeWithRetry(client, entry.method, entry.params(args, ref), {
111
111
  tabId: requestedTabId,
112
112
  source: REQUEST_SOURCE,
113
- tokenBudget: getToolTokenBudget(args),
113
+ tokenBudget: entry.method.startsWith('screenshot.') ? null : getToolTokenBudget(args),
114
114
  });
115
115
  if (!response.ok) {
116
116
  return summarizeToolResponse(response, entry.method);
@@ -128,15 +128,15 @@ export async function handleCaptureTool(args) {
128
128
  * @param {BridgeMethod} method
129
129
  * @returns {ToolResult}
130
130
  */
131
- function createScreenshotResult(response, method) {
131
+ export function createScreenshotResult(response, method) {
132
132
  const result = toRecord(response.result);
133
133
  if (typeof result.image !== 'string') {
134
- return summarizeToolError(`${method} returned no image data.`);
134
+ return summarizeToolError(new Error(`${method} returned no image data.`));
135
135
  }
136
136
 
137
137
  const image = normalizeBase64Image(result.image);
138
138
  if (!image) {
139
- return summarizeToolError(`${method} returned invalid base64 image data.`);
139
+ return summarizeToolError(new Error(`${method} returned invalid base64 image data.`));
140
140
  }
141
141
 
142
142
  const rect = boundedRect(result.rect);
@@ -269,6 +269,7 @@ export const INPUT_ACTION_METHODS = {
269
269
  click: 'input.click',
270
270
  focus: 'input.focus',
271
271
  type: 'input.type',
272
+ fill: 'input.fill',
272
273
  press_key: 'input.press_key',
273
274
  cdp_press_key: 'cdp.dispatch_key_event',
274
275
  set_checked: 'input.set_checked',
@@ -279,13 +280,30 @@ export const INPUT_ACTION_METHODS = {
279
280
  };
280
281
 
281
282
  /**
282
- * @param {{ action: string, elementRef?: string, selector?: string, button?: string, clickCount?: number, text?: string, clear?: boolean, submit?: boolean, key?: string, code?: string, modifiers?: string[], checked?: boolean, values?: string[], labels?: string[], indexes?: number[], duration?: number, sourceElementRef?: string, sourceSelector?: string, destinationElementRef?: string, destinationSelector?: string, offsetX?: number, offsetY?: number, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
283
+ * Keep selector-based actions atomic instead of resolving a ref in a separate
284
+ * bridge request that can race with a page rerender.
285
+ *
286
+ * @param {unknown} elementRef
287
+ * @param {unknown} selector
288
+ * @returns {import('../../protocol/src/types.js').InputTarget | null}
289
+ */
290
+ function createInputTarget(elementRef, selector) {
291
+ if (typeof elementRef === 'string' && elementRef) return { elementRef };
292
+ if (typeof selector === 'string' && selector) return { selector };
293
+ return null;
294
+ }
295
+
296
+ /**
297
+ * @param {{ action: string, elementRef?: string, selector?: string, button?: string, clickCount?: number, text?: string, value?: string, mode?: 'auto' | 'setter' | 'keystrokes', clear?: boolean, submit?: boolean, key?: string, code?: string, modifiers?: string[], checked?: boolean, values?: string[], labels?: string[], indexes?: number[], duration?: number, sourceElementRef?: string, sourceSelector?: string, destinationElementRef?: string, destinationSelector?: string, offsetX?: number, offsetY?: number, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }} args
283
298
  * @returns {Promise<ToolResult>}
284
299
  */
285
300
  export async function handleInputTool(args) {
286
301
  if (args.action === 'type' && !hasText(args.text)) {
287
302
  return summarizeToolError('text is required for input.type.');
288
303
  }
304
+ if (args.action === 'fill' && typeof args.value !== 'string') {
305
+ return summarizeToolError('value is required for input.fill.');
306
+ }
289
307
  if ((args.action === 'press_key' || args.action === 'cdp_press_key') && !hasText(args.key)) {
290
308
  return summarizeToolError('key is required for key input actions.');
291
309
  }
@@ -301,9 +319,13 @@ export async function handleInputTool(args) {
301
319
  return withToolClient(
302
320
  async (client) => {
303
321
  const requestedTabId = typeof args.tabId === 'number' ? args.tabId : null;
304
- const elementTarget = async () => ({
305
- elementRef: await resolveToolRef(client, args, requestedTabId),
306
- });
322
+ const elementTarget = () => {
323
+ const target = createInputTarget(args.elementRef, args.selector);
324
+ if (!target) {
325
+ throw new BridgeError('INVALID_REQUEST', 'Provide either elementRef or selector.');
326
+ }
327
+ return target;
328
+ };
307
329
 
308
330
  switch (args.action) {
309
331
  case 'click': {
@@ -311,7 +333,7 @@ export async function handleInputTool(args) {
311
333
  client,
312
334
  'input.click',
313
335
  {
314
- target: await elementTarget(),
336
+ target: elementTarget(),
315
337
  button: args.button,
316
338
  clickCount: args.clickCount,
317
339
  modifiers: args.modifiers,
@@ -329,7 +351,7 @@ export async function handleInputTool(args) {
329
351
  client,
330
352
  'input.focus',
331
353
  {
332
- target: await elementTarget(),
354
+ target: elementTarget(),
333
355
  },
334
356
  {
335
357
  tabId: requestedTabId,
@@ -344,7 +366,7 @@ export async function handleInputTool(args) {
344
366
  client,
345
367
  'input.type',
346
368
  {
347
- target: await elementTarget(),
369
+ target: elementTarget(),
348
370
  text: args.text,
349
371
  clear: args.clear,
350
372
  submit: args.submit,
@@ -358,8 +380,25 @@ export async function handleInputTool(args) {
358
380
  );
359
381
  return summarizeToolResponse(response, 'input.type');
360
382
  }
383
+ case 'fill': {
384
+ const response = await requestBridgeWithRetry(
385
+ client,
386
+ 'input.fill',
387
+ {
388
+ target: elementTarget(),
389
+ value: args.value,
390
+ mode: args.mode,
391
+ },
392
+ {
393
+ tabId: requestedTabId,
394
+ source: REQUEST_SOURCE,
395
+ tokenBudget: getToolTokenBudget(args),
396
+ }
397
+ );
398
+ return summarizeToolResponse(response, 'input.fill');
399
+ }
361
400
  case 'press_key': {
362
- const target = args.elementRef || args.selector ? await elementTarget() : undefined;
401
+ const target = args.elementRef || args.selector ? elementTarget() : undefined;
363
402
  const response = await requestBridgeWithRetry(
364
403
  client,
365
404
  'input.press_key',
@@ -398,7 +437,7 @@ export async function handleInputTool(args) {
398
437
  client,
399
438
  'input.set_checked',
400
439
  {
401
- target: await elementTarget(),
440
+ target: elementTarget(),
402
441
  checked: args.checked,
403
442
  },
404
443
  {
@@ -414,7 +453,7 @@ export async function handleInputTool(args) {
414
453
  client,
415
454
  'input.select_option',
416
455
  {
417
- target: await elementTarget(),
456
+ target: elementTarget(),
418
457
  values: args.values,
419
458
  labels: args.labels,
420
459
  indexes: args.indexes,
@@ -432,7 +471,7 @@ export async function handleInputTool(args) {
432
471
  client,
433
472
  'input.hover',
434
473
  {
435
- target: await elementTarget(),
474
+ target: elementTarget(),
436
475
  duration: args.duration,
437
476
  modifiers: args.modifiers,
438
477
  },
@@ -445,21 +484,12 @@ export async function handleInputTool(args) {
445
484
  return summarizeToolResponse(response, 'input.hover');
446
485
  }
447
486
  case 'drag': {
448
- const source = {
449
- elementRef:
450
- args.sourceElementRef ||
451
- (args.sourceSelector
452
- ? await resolveRef(client, args.sourceSelector, requestedTabId, REQUEST_SOURCE)
453
- : ''),
454
- };
455
- const destination = {
456
- elementRef:
457
- args.destinationElementRef ||
458
- (args.destinationSelector
459
- ? await resolveRef(client, args.destinationSelector, requestedTabId, REQUEST_SOURCE)
460
- : ''),
461
- };
462
- if (!source.elementRef || !destination.elementRef) {
487
+ const source = createInputTarget(args.sourceElementRef, args.sourceSelector);
488
+ const destination = createInputTarget(
489
+ args.destinationElementRef,
490
+ args.destinationSelector
491
+ );
492
+ if (!source || !destination) {
463
493
  return summarizeToolError(
464
494
  'sourceElementRef/sourceSelector and destinationElementRef/destinationSelector are required for drag.'
465
495
  );
@@ -486,7 +516,7 @@ export async function handleInputTool(args) {
486
516
  client,
487
517
  'input.scroll_into_view',
488
518
  {
489
- target: await elementTarget(),
519
+ target: elementTarget(),
490
520
  },
491
521
  {
492
522
  tabId: requestedTabId,
@@ -104,7 +104,8 @@ export async function handleDomTool(args) {
104
104
  return summarizeToolError('role is required for dom.find_by_role.');
105
105
  }
106
106
  if (args.action === 'query' || args.action === 'accessibility_tree') {
107
- const inferred = inferBudgetFromSelector(args);
107
+ const inferred =
108
+ args.action === 'accessibility_tree' ? 'normal' : inferBudgetFromSelector(args);
108
109
  const withBudget = inferred ? { ...args, budgetPreset: args.budgetPreset ?? inferred } : args;
109
110
  return dispatchToolAction(DOM_ACTIONS, applyTreeBudgetPreset(withBudget), 'DOM');
110
111
  }
@@ -183,7 +184,6 @@ export const PATCH_ACTIONS = {
183
184
  addClass: 'add_class',
184
185
  removeClass: 'remove_class',
185
186
  setTextContent: 'set_text',
186
- setProperty: 'set_attribute',
187
187
  };
188
188
  const normalizedOperation = opMap[operation] || operation;
189
189
  const value =
@@ -6,6 +6,7 @@ import {
6
6
  createToolResult,
7
7
  getBridgeDestinations,
8
8
  getToolTokenBudget,
9
+ requestBridgeWithRetry,
9
10
  summarizeToolError,
10
11
  } from './handlers-utils.js';
11
12
 
@@ -27,15 +28,23 @@ export async function handleTabsTool(args) {
27
28
  }
28
29
  const results = await Promise.all(
29
30
  destinations.map(async (destination) => {
30
- const client = await createBridgeClientForDestination(
31
- destination.local ? null : destination.id
32
- );
31
+ /** @type {import('../../agent-client/src/client.js').BridgeClient | null} */
32
+ let client = null;
33
33
  try {
34
+ client = await createBridgeClientForDestination(
35
+ destination.local ? null : destination.id
36
+ );
34
37
  await client.connect();
35
- const response = await client.request({
36
- method: 'tabs.list',
37
- meta: { source: 'mcp' },
38
- });
38
+ const response = await requestBridgeWithRetry(
39
+ client,
40
+ 'tabs.list',
41
+ {},
42
+ {
43
+ tabId: null,
44
+ source: 'mcp',
45
+ tokenBudget: null,
46
+ }
47
+ );
39
48
  if (!response.ok) {
40
49
  return {
41
50
  destinationId: destination.id,
@@ -58,7 +67,9 @@ export async function handleTabsTool(args) {
58
67
  tabs: [],
59
68
  };
60
69
  } finally {
61
- await client.close();
70
+ if (client) {
71
+ await client.close().catch(() => {});
72
+ }
62
73
  }
63
74
  })
64
75
  );
@@ -73,7 +84,7 @@ export async function handleTabsTool(args) {
73
84
  tabs,
74
85
  destinations: results,
75
86
  },
76
- false
87
+ failures.length === results.length
77
88
  );
78
89
  }
79
90
  if (args.action === 'create') {
@@ -96,6 +107,16 @@ export async function handleTabsTool(args) {
96
107
  { destinationId: args.destinationId ?? null }
97
108
  );
98
109
  }
110
+ if (args.action === 'activate') {
111
+ if (typeof args.tabId !== 'number') {
112
+ return summarizeToolError('tabId is required for tabs.activate.');
113
+ }
114
+ return callBridgeTool(
115
+ 'tabs.activate',
116
+ { tabId: args.tabId },
117
+ { destinationId: args.destinationId ?? null }
118
+ );
119
+ }
99
120
  return summarizeToolError(`Unsupported tabs action "${args.action}".`);
100
121
  }
101
122