@browserbridge/bbx 1.12.0 → 1.12.2

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/PRIVACY.md CHANGED
@@ -82,6 +82,7 @@ You should review the privacy and retention practices of any connected agent, ID
82
82
  Browser Bridge keeps data only as long as needed for the current local session and feature behavior.
83
83
 
84
84
  - Window enablement state is stored in session storage and cleared when disabled or when the browser session ends.
85
+ - To warn about access enabled elsewhere, the daemon shares enabled-window IDs, bounded titles, enablement timestamps, browser names, and session profile labels with connected local Browser Bridge extensions. The side panel uses this in-memory metadata for its other-enabled-windows counter. It does not include page contents or disabled-window details, and disconnecting an extension removes its entry.
85
86
  - Action log entries are stored for the current browser session only.
86
87
  - Incidental action/log URLs remove credentials and fragments and redact query values; sensitive structured fields and local path prefixes are reduced before persistence.
87
88
  - Console and fetch/XHR network buffers are bounded in memory and cleared when disabled or when the page state is reset.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserbridge/bbx",
3
- "version": "1.12.0",
3
+ "version": "1.12.2",
4
4
  "private": false,
5
5
  "keywords": [
6
6
  "agent-tools",
@@ -6,7 +6,8 @@ import { methodNeedsTab, parseIntArg, parseJsonObject } from './cli-helpers.js';
6
6
  /** @typedef {import('./types.js').BridgeMethod} BridgeMethod */
7
7
  /** @typedef {import('../../protocol/src/defaults.js').BudgetPresetName} BudgetPresetName */
8
8
 
9
- const CALL_USAGE = 'Usage: call [--tab <tabId>] [--preset quick|normal|deep] <method> [paramsJson]';
9
+ const CALL_USAGE =
10
+ 'Usage: call [--extension <connectionId>] [--browser <name>] [--profile <label>] [--tab <tabId>] [--preset quick|normal|deep] <method> [paramsJson]';
10
11
 
11
12
  /**
12
13
  * Read all of stdin as UTF-8 text. Resolves once stdin closes.
@@ -149,10 +150,27 @@ export function extractPresetFlag(args) {
149
150
 
150
151
  /**
151
152
  * @param {string[]} args
152
- * @returns {Promise<{ tabId: number | null, method: BridgeMethod, params: Record<string, unknown> }>}
153
+ * @returns {Promise<import('../../protocol/src/types.js').BrowserTarget & { tabId: number | null, method: BridgeMethod, params: Record<string, unknown> }>}
153
154
  */
154
155
  export async function parseCallCommand(args) {
155
- const presetParsed = extractPresetFlag(args);
156
+ const remaining = [...args];
157
+ /** @type {import('../../protocol/src/types.js').BrowserTarget} */
158
+ const target = {};
159
+ /** @type {Array<[string, keyof import('../../protocol/src/types.js').BrowserTarget]>} */
160
+ const selectors = [
161
+ ['--extension', 'extensionId'],
162
+ ['--browser', 'targetBrowser'],
163
+ ['--profile', 'targetProfile'],
164
+ ];
165
+ for (const [flag, field] of selectors) {
166
+ const index = remaining.indexOf(flag);
167
+ if (index === -1) continue;
168
+ const value = remaining[index + 1];
169
+ if (!value?.trim() || value.startsWith('--')) throw new Error(`${flag} requires a value.`);
170
+ target[field] = value;
171
+ remaining.splice(index, 2);
172
+ }
173
+ const presetParsed = extractPresetFlag(remaining);
156
174
  const parsed = extractTabFlag(presetParsed.rest);
157
175
  const [first, second, ...extra] = parsed.rest;
158
176
  if (!first) {
@@ -173,6 +191,7 @@ export async function parseCallCommand(args) {
173
191
  rawParams = await readStdin();
174
192
  }
175
193
  return {
194
+ ...target,
176
195
  method,
177
196
  tabId: methodNeedsTab(method) ? parsed.tabId : null,
178
197
  params: applyMethodBudgetPreset(method, parseJsonObject(rawParams), presetParsed.preset),
@@ -33,7 +33,7 @@ import { requestBridge } from './runtime.js';
33
33
  */
34
34
 
35
35
  /**
36
- * @typedef {{ method: BridgeMethod, params: Record<string, unknown>, tabId: number | null }} PreparedBatchCall
36
+ * @typedef {import('../../protocol/src/types.js').BrowserTarget & { method: BridgeMethod, params: Record<string, unknown>, tabId: number | null }} PreparedBatchCall
37
37
  */
38
38
 
39
39
  /**
@@ -107,10 +107,11 @@ export async function runBatchCalls(client, input, source, options = {}) {
107
107
  const validPrepared = /** @type {Array<{ value: PreparedBatchCall }>} */ (prepared);
108
108
  if (!client.connected) await client.connect();
109
109
  return mapWithConcurrency(validPrepared, MAX_BATCH_CONCURRENCY, async (item) => {
110
- const { method, params, tabId } = item.value;
110
+ const { method, params, tabId, ...target } = item.value;
111
111
  const startTime = Date.now();
112
112
  try {
113
113
  const response = await requestBridge(client, method, params, {
114
+ ...target,
114
115
  tabId,
115
116
  source,
116
117
  });
@@ -150,6 +151,16 @@ function prepareBatchCall(call, preset) {
150
151
  };
151
152
  }
152
153
  const method = /** @type {BridgeMethod} */ (batchCall.method);
154
+ /** @type {import('../../protocol/src/types.js').BrowserTarget} */
155
+ const target = {};
156
+ for (const field of /** @type {const} */ (['extensionId', 'targetBrowser', 'targetProfile'])) {
157
+ const value = batchCall[field];
158
+ if (value === undefined) continue;
159
+ if (typeof value !== 'string' || !value.trim()) {
160
+ return { error: invalidBatchItem(method, `${field} must be a non-empty string.`) };
161
+ }
162
+ target[field] = value;
163
+ }
153
164
  if (method === 'sensitive.read') {
154
165
  return {
155
166
  error: invalidBatchItem(
@@ -182,7 +193,7 @@ function prepareBatchCall(call, preset) {
182
193
  batchCall.tabId > 0
183
194
  ? batchCall.tabId
184
195
  : null;
185
- return { value: { method, params: mergedParams, tabId } };
196
+ return { value: { ...target, method, params: mergedParams, tabId } };
186
197
  }
187
198
 
188
199
  /**
@@ -393,8 +393,9 @@ async function main() {
393
393
  }
394
394
 
395
395
  if (command === 'call') {
396
- const { tabId, method, params } = await parseCallCommand(rest);
396
+ const { tabId, method, params, ...target } = await parseCallCommand(rest);
397
397
  const response = await requestBridge(client, method, params, {
398
+ ...target,
398
399
  tabId,
399
400
  source: REQUEST_SOURCE,
400
401
  });
@@ -413,8 +414,9 @@ async function main() {
413
414
  }
414
415
 
415
416
  if (command.includes('.') && METHODS.includes(/** @type {BridgeMethod} */ (command))) {
416
- const { tabId, method, params } = await parseCallCommand([command, ...rest]);
417
+ const { tabId, method, params, ...target } = await parseCallCommand([command, ...rest]);
417
418
  const response = await requestBridge(client, method, params, {
419
+ ...target,
418
420
  tabId,
419
421
  source: REQUEST_SOURCE,
420
422
  });
@@ -294,7 +294,7 @@ export const CLI_HELP_SECTIONS = Object.freeze([
294
294
  {
295
295
  title: 'Generic RPC',
296
296
  lines: [
297
- 'bbx call [--tab <tabId>] [--preset quick|normal|deep] <method> [paramsJson|-] Call any bridge method (- reads JSON from stdin)',
297
+ 'bbx call [--extension <connectionId>] [--browser <name>] [--profile <label>] [--tab <tabId>] [--preset quick|normal|deep] <method> [paramsJson|-] Call any bridge method (- reads JSON from stdin)',
298
298
  'bbx <method> [--tab <tabId>] [paramsJson|-] Direct alias for exact bridge methods such as page.get_state',
299
299
  "bbx batch [--preset quick|normal|deep] '[{method,params,tabId?},...]' Up to 20 parallel read-only calls",
300
300
  '--preset fills budget gaps (maxNodes, maxDepth, textBudget, limit) from the shared quick|normal|deep presets; explicit params always win.',
@@ -107,7 +107,7 @@ export async function ensureClientConnected(client) {
107
107
  * @param {BridgeClient} client
108
108
  * @param {BridgeMethod} method
109
109
  * @param {Record<string, unknown>} [params={}]
110
- * @param {{ tabId?: number | null, source?: BridgeRequestSource, mcpEra?: import('./types.js').McpProtocolEra, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} [options]
110
+ * @param {import('../../protocol/src/types.js').BrowserTarget & { tabId?: number | null, source?: BridgeRequestSource, mcpEra?: import('./types.js').McpProtocolEra, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} [options]
111
111
  * @returns {Promise<BridgeResponse>}
112
112
  */
113
113
  export async function requestBridge(client, method, params = {}, options = {}) {
@@ -128,12 +128,17 @@ export async function requestBridge(client, method, params = {}, options = {}) {
128
128
  method,
129
129
  params,
130
130
  tabId: methodNeedsTab(method) ? (options.tabId ?? null) : null,
131
- meta: withRequestMeta(
132
- options.source,
133
- options.mcpEra,
134
- options.tokenBudget,
135
- options.automaticRetry
136
- ),
131
+ meta: {
132
+ ...withRequestMeta(
133
+ options.source,
134
+ options.mcpEra,
135
+ options.tokenBudget,
136
+ options.automaticRetry
137
+ ),
138
+ ...(options.extensionId !== undefined ? { target_extension: options.extensionId } : {}),
139
+ ...(options.targetBrowser !== undefined ? { target_browser: options.targetBrowser } : {}),
140
+ ...(options.targetProfile !== undefined ? { target_profile: options.targetProfile } : {}),
141
+ },
137
142
  ...(timeoutMs === undefined ? {} : { timeoutMs }),
138
143
  });
139
144
  }
@@ -7,7 +7,8 @@
7
7
  */
8
8
  const SHARED_INSTRUCTIONS = Object.freeze([
9
9
  'Prefer Browser Bridge MCP tools over shelling out to bbx. Use bbx only for explicit CLI setup, doctor, logs, or raw debugging requests.',
10
- '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.',
10
+ 'Start with browser_call method health.ping. Its access status describes only the routed profile, not every window. Use connectedExtensions or unscoped tabs.list to discover profiles and existing tabs. Reuse the selected extensionId, destinationId when remote, and tabId on every follow-up call, including each browser_batch item. tabId alone does not select a profile. Never disable unrelated windows to fix routing. If a connection disappears, rediscover it instead of switching profiles or using guessed IDs.',
11
+ "If the selected profile has window access disabled, call browser_call method access.request once with its extensionId, ask the user to click Enable in that profile's Browser Bridge popup or side panel, then retry once with the same target. Restricted pages and ambiguous_browser_target are routing problems, not evidence that every window needs enabling.",
11
12
  'Use structured reads first through browser_call: page.get_state, dom.query, page.get_text, page.extract_content, styles.get_computed, or layout.get_box_model. Prefer semantic extraction for articles and documentation. Use browser_batch only for parallel reads; run mutations sequentially and keep limits tight before widening.',
12
13
  'Reuse elementRef values returned by DOM reads. Use attribute allowlists for focused DOM reads.',
13
14
  '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.',
@@ -5,6 +5,7 @@ import {
5
5
  createToolResult,
6
6
  getBridgeDestinations,
7
7
  getToolTokenBudget,
8
+ hasMcpBrowserTarget,
8
9
  requestBridgeWithRetry,
9
10
  summarizeToolError,
10
11
  withMcpRequestClient,
@@ -19,7 +20,7 @@ import {
19
20
  */
20
21
  export async function handleTabsTool(args) {
21
22
  if (args.action === 'list') {
22
- if (typeof args.destinationId === 'string') {
23
+ if (typeof args.destinationId === 'string' || hasMcpBrowserTarget()) {
23
24
  return callBridgeTool('tabs.list', {}, { destinationId: args.destinationId });
24
25
  }
25
26
  const destinations = await getBridgeDestinations();
@@ -51,10 +52,14 @@ export async function handleTabsTool(args) {
51
52
  tabs: [],
52
53
  };
53
54
  }
54
- const result = /** @type {{ tabs?: Array<Record<string, unknown>> }} */ (response.result);
55
+ const result =
56
+ /** @type {{ tabs?: Array<Record<string, unknown>>, profiles?: unknown[], partial?: boolean }} */ (
57
+ response.result
58
+ );
55
59
  return {
56
60
  destinationId: destination.id,
57
- ok: true,
61
+ ok: result.partial !== true,
62
+ ...(result.profiles ? { profiles: result.profiles } : {}),
58
63
  tabs: (result.tabs ?? []).map((tab) => ({ destinationId: destination.id, ...tab })),
59
64
  };
60
65
  } catch (error) {
@@ -78,7 +83,7 @@ export async function handleTabsTool(args) {
78
83
  tabs,
79
84
  destinations: results,
80
85
  },
81
- failures.length === results.length
86
+ failures.length === results.length && tabs.length === 0
82
87
  );
83
88
  }
84
89
  if (args.action === 'create') {
@@ -347,7 +347,7 @@ export async function handleSensitiveReadTool(args) {
347
347
  }
348
348
 
349
349
  /**
350
- * @param {{ calls?: Array<{ method?: string, params?: Record<string, unknown>, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }> }} args
350
+ * @param {{ calls?: Array<import('../../protocol/src/types.js').BrowserTarget & { method?: string, params?: Record<string, unknown>, tabId?: number, destinationId?: string, budgetPreset?: 'quick' | 'normal' | 'deep' }> }} args
351
351
  * @returns {Promise<ToolResult>}
352
352
  */
353
353
  export async function handleBatchTool(args) {
@@ -393,6 +393,9 @@ export async function handleBatchTool(args) {
393
393
  response = await withMcpRequestClient(
394
394
  (client) =>
395
395
  requestBridgeWithRetry(client, method, params, {
396
+ ...(call.extensionId ? { extensionId: call.extensionId } : {}),
397
+ ...(call.targetBrowser ? { targetBrowser: call.targetBrowser } : {}),
398
+ ...(call.targetProfile ? { targetProfile: call.targetProfile } : {}),
396
399
  tabId,
397
400
  source: REQUEST_SOURCE,
398
401
  tokenBudget,
@@ -405,15 +408,25 @@ export async function handleBatchTool(args) {
405
408
  if (callError || !response) {
406
409
  return {
407
410
  destinationId,
411
+ ...(call.extensionId ? { extensionId: call.extensionId } : {}),
408
412
  ...summarizeThrownBatchError(method, tabId, callError, Date.now() - startTime),
409
413
  };
410
414
  }
415
+ const discovery = method === 'tabs.list' ? summarizeToolResponse(response, method) : null;
411
416
  return {
412
417
  destinationId,
418
+ ...(call.extensionId ? { extensionId: call.extensionId } : {}),
413
419
  ...summarizeBatchResponseItem(
414
420
  { method, tabId, response, durationMs: Date.now() - startTime },
415
421
  { compact: true }
416
422
  ),
423
+ ...(discovery
424
+ ? {
425
+ summary: discovery.content[0].text,
426
+ evidence: discovery.structuredContent.evidence,
427
+ ...(discovery.structuredContent.outputTruncated ? { outputTruncated: true } : {}),
428
+ }
429
+ : {}),
417
430
  };
418
431
  });
419
432
 
@@ -5,6 +5,8 @@ import { randomUUID } from 'node:crypto';
5
5
 
6
6
  import {
7
7
  BridgeError,
8
+ createSuccess,
9
+ createFailure,
8
10
  bridgeMethodNeedsTab,
9
11
  DEFAULT_CONSOLE_LIMIT,
10
12
  DEFAULT_LOG_TAIL_LIMIT,
@@ -51,6 +53,33 @@ const MCP_CLIENT_ID = `mcp_${randomUUID()}`;
51
53
  const MCP_AGENT_SESSION = process.env.BBX_SESSION?.trim() || MCP_CLIENT_ID;
52
54
  /** @type {AsyncLocalStorage<{ era: import('../../protocol/src/types.js').McpProtocolEra, signal?: AbortSignal }>} */
53
55
  const MCP_REQUEST_ERA = new AsyncLocalStorage();
56
+ /** @type {AsyncLocalStorage<import('../../protocol/src/types.js').BrowserTarget>} */
57
+ const MCP_REQUEST_TARGET = new AsyncLocalStorage();
58
+
59
+ /**
60
+ * Scope routing to one tool invocation, including selector resolution and retries.
61
+ * @template T
62
+ * @param {unknown} args
63
+ * @param {() => T} callback
64
+ * @returns {T}
65
+ */
66
+ export function runWithMcpRequestTarget(args, callback) {
67
+ const record =
68
+ args && typeof args === 'object' ? /** @type {Record<string, unknown>} */ (args) : {};
69
+ const target = {
70
+ ...(MCP_REQUEST_TARGET.getStore() ?? {}),
71
+ ...(typeof record.extensionId === 'string' ? { extensionId: record.extensionId } : {}),
72
+ ...(typeof record.targetBrowser === 'string' ? { targetBrowser: record.targetBrowser } : {}),
73
+ ...(typeof record.targetProfile === 'string' ? { targetProfile: record.targetProfile } : {}),
74
+ };
75
+ return MCP_REQUEST_TARGET.run(target, callback);
76
+ }
77
+
78
+ /** @returns {boolean} */
79
+ export function hasMcpBrowserTarget() {
80
+ const target = MCP_REQUEST_TARGET.getStore();
81
+ return Boolean(target?.extensionId || target?.targetBrowser || target?.targetProfile);
82
+ }
54
83
 
55
84
  /**
56
85
  * @template T
@@ -275,6 +304,16 @@ export function summarizeToolResponse(response, method, params = {}) {
275
304
  const summary = annotateBridgeSummary(summarizeBridgeResponse(response, method), response);
276
305
  const summaryRecord = /** @type {Record<string, unknown>} */ (summary);
277
306
  if (response.ok) {
307
+ if (
308
+ method === 'tabs.list' &&
309
+ response.result &&
310
+ typeof response.result === 'object' &&
311
+ 'partial' in response.result &&
312
+ response.result.partial === true
313
+ ) {
314
+ summary.summary +=
315
+ ' Some browser profiles could not be listed; inspect evidence.profiles before assuming a tab is absent.';
316
+ }
278
317
  const evidence = getRequestAwareEvidence(response.result, method, params, summary.evidence);
279
318
  summary.evidence = evidence.value;
280
319
  if (evidence.metadata) summaryRecord.evidenceMeta = evidence.metadata;
@@ -505,6 +544,35 @@ function getRequestAwareEvidence(rawResult, method, params, fallback) {
505
544
  rawResult && typeof rawResult === 'object' && !Array.isArray(rawResult)
506
545
  ? /** @type {Record<string, unknown>} */ (rawResult)
507
546
  : {};
547
+ if (method === 'tabs.list' && Array.isArray(result.tabs)) {
548
+ const tabs = /** @type {unknown[]} */ (result.tabs)
549
+ .filter((tab) => tab && typeof tab === 'object' && !Array.isArray(tab))
550
+ .map((tab) => /** @type {Record<string, unknown>} */ (tab))
551
+ .sort(
552
+ (left, right) =>
553
+ Number(right.working === true || right.active === true) -
554
+ Number(left.working === true || left.active === true)
555
+ )
556
+ .map((tab) => ({
557
+ ...(typeof tab.extensionId === 'string' ? { extensionId: tab.extensionId } : {}),
558
+ tabId: tab.tabId,
559
+ windowId: tab.windowId,
560
+ active: tab.active,
561
+ ...(tab.working === true ? { working: true } : {}),
562
+ ...(tab.browserName !== undefined ? { browserName: tab.browserName } : {}),
563
+ ...(tab.profileLabel !== undefined ? { profileLabel: tab.profileLabel } : {}),
564
+ title: typeof tab.title === 'string' ? tab.title.slice(0, 200) : tab.title,
565
+ origin: tab.origin,
566
+ ...(typeof tab.url === 'string'
567
+ ? { url: tab.url.slice(0, 512), ...(tab.url.length > 512 ? { urlTruncated: true } : {}) }
568
+ : {}),
569
+ }));
570
+ const metadata = Object.fromEntries(Object.entries(result).filter(([key]) => key !== 'tabs'));
571
+ return boundToolValue(
572
+ { tabCount: tabs.length, ...metadata, tabs },
573
+ { maxEntries: 2000, maxCharacters: 30_000, maxStringLength: 1000 }
574
+ );
575
+ }
508
576
  if (method === 'page.extract_content' && typeof result.content === 'string') {
509
577
  const requested = positiveInteger(params.textBudget) ?? DEFAULT_PAGE_TEXT_BUDGET;
510
578
  const maxCharacters = Math.min(requested, 16_000);
@@ -757,14 +825,24 @@ export async function waitForClientReconnect(client, timeoutMs = RECONNECT_WAIT_
757
825
  * @param {import('../../agent-client/src/client.js').BridgeClient} client
758
826
  * @param {BridgeMethod} method
759
827
  * @param {Record<string, unknown>} params
760
- * @param {{ tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, mcpEra?: import('../../protocol/src/types.js').McpProtocolEra, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt' }} options
828
+ * @param {import('../../protocol/src/types.js').BrowserTarget & { tabId?: number | null, source?: import('../../protocol/src/types.js').BridgeRequestSource, mcpEra?: import('../../protocol/src/types.js').McpProtocolEra, tokenBudget?: number | null, automaticRetry?: 'mcp_second_attempt', skipProfileDiscovery?: boolean }} options
761
829
  * @returns {Promise<BridgeResponse>}
762
830
  */
763
831
  export async function requestBridgeWithRetry(client, method, params, options) {
764
832
  const requestOptions = {
833
+ ...(MCP_REQUEST_TARGET.getStore() ?? {}),
765
834
  ...options,
766
835
  mcpEra: options.mcpEra ?? MCP_REQUEST_ERA.getStore()?.era,
767
836
  };
837
+ if (
838
+ method === 'tabs.list' &&
839
+ !options.skipProfileDiscovery &&
840
+ !requestOptions.extensionId &&
841
+ !requestOptions.targetBrowser &&
842
+ !requestOptions.targetProfile
843
+ ) {
844
+ return listBrowserProfileTabs(client, params, requestOptions);
845
+ }
768
846
  /** @param {typeof requestOptions} attemptOptions */
769
847
  const attempt = (attemptOptions) =>
770
848
  awaitMcpOperation(async () => {
@@ -811,6 +889,102 @@ export async function requestBridgeWithRetry(client, method, params, options) {
811
889
  return response;
812
890
  }
813
891
 
892
+ /**
893
+ * Discover only enabled profiles. Reading tabs never chooses a working tab.
894
+ * @param {import('../../agent-client/src/client.js').BridgeClient} client
895
+ * @param {Record<string, unknown>} params
896
+ * @param {Parameters<typeof requestBridgeWithRetry>[3]} options
897
+ * @returns {Promise<BridgeResponse>}
898
+ */
899
+ async function listBrowserProfileTabs(client, params, options) {
900
+ const health = await requestBridgeWithRetry(client, 'health.ping', {}, options);
901
+ if (!health.ok) return health;
902
+ const result =
903
+ health.result && typeof health.result === 'object'
904
+ ? /** @type {Record<string, unknown>} */ (health.result)
905
+ : {};
906
+ const profiles = Array.isArray(result.connectedExtensions)
907
+ ? /** @type {unknown[]} */ (result.connectedExtensions)
908
+ .filter((value) => value && typeof value === 'object' && !Array.isArray(value))
909
+ .map((value) => /** @type {Record<string, unknown>} */ (value))
910
+ .filter((value) => typeof value.extensionId === 'string' && value.accessEnabled === true)
911
+ .map((value) => /** @type {Record<string, unknown> & { extensionId: string }} */ (value))
912
+ : [];
913
+ // Older daemons and a disabled single profile keep the usual access response.
914
+ if (
915
+ profiles.length === 0 &&
916
+ (!Array.isArray(result.connectedExtensions) || result.connectedExtensions.length <= 1)
917
+ ) {
918
+ return requestBridgeWithRetry(client, 'tabs.list', params, {
919
+ ...options,
920
+ skipProfileDiscovery: true,
921
+ });
922
+ }
923
+ if (profiles.length === 0) {
924
+ return createFailure(
925
+ health.id,
926
+ 'ACCESS_DENIED',
927
+ 'No connected profile has an enabled window. Select an extensionId from connectedExtensions and request access only for that profile.',
928
+ {
929
+ reason: 'no_enabled_browser_profiles',
930
+ connectedExtensions: result.connectedExtensions,
931
+ },
932
+ { method: 'tabs.list' }
933
+ );
934
+ }
935
+ const results = [];
936
+ for (const profile of profiles) {
937
+ /** @type {BridgeResponse} */
938
+ let response;
939
+ try {
940
+ response = await requestBridgeWithRetry(client, 'tabs.list', params, {
941
+ ...options,
942
+ extensionId: profile.extensionId,
943
+ });
944
+ } catch (error) {
945
+ throwIfMcpRequestCancelled();
946
+ response = createFailure(
947
+ health.id,
948
+ error instanceof BridgeError ? error.code : 'INTERNAL_ERROR',
949
+ error instanceof Error ? error.message : String(error),
950
+ null,
951
+ { method: 'tabs.list' }
952
+ );
953
+ }
954
+ const listed =
955
+ response.ok && response.result && typeof response.result === 'object'
956
+ ? /** @type {Record<string, unknown>} */ (response.result)
957
+ : {};
958
+ results.push({
959
+ extensionId: profile.extensionId,
960
+ browserName: profile.browserName ?? null,
961
+ profileLabel: profile.profileLabel ?? null,
962
+ ok: response.ok,
963
+ ...(response.ok ? {} : { error: response.error }),
964
+ tabs: Array.isArray(listed.tabs)
965
+ ? /** @type {unknown[]} */ (listed.tabs)
966
+ .filter((tab) => tab && typeof tab === 'object' && !Array.isArray(tab))
967
+ .map((tab) => ({
968
+ .../** @type {Record<string, unknown>} */ (tab),
969
+ extensionId: profile.extensionId,
970
+ browserName: profile.browserName ?? null,
971
+ profileLabel: profile.profileLabel ?? null,
972
+ }))
973
+ : [],
974
+ });
975
+ }
976
+ return createSuccess(
977
+ health.id,
978
+ {
979
+ tabs: results.flatMap((profile) => profile.tabs),
980
+ profiles: results.map(({ tabs, ...profile }) => ({ ...profile, tabCount: tabs.length })),
981
+ connectedExtensions: result.connectedExtensions,
982
+ partial: results.some((profile) => !profile.ok),
983
+ },
984
+ { method: 'tabs.list' }
985
+ );
986
+ }
987
+
814
988
  /**
815
989
  * @param {BridgeMethod} method
816
990
  * @param {Record<string, unknown>} [params={}]
@@ -16,6 +16,7 @@ import {
16
16
  summarizeToolResponse,
17
17
  withToolClient,
18
18
  REQUEST_SOURCE,
19
+ hasMcpBrowserTarget,
19
20
  } from './handlers-utils.js';
20
21
 
21
22
  export {
@@ -81,6 +82,9 @@ const HOME_DIR = os.homedir();
81
82
  */
82
83
  export async function handleStatusTool(args = {}) {
83
84
  try {
85
+ if (hasMcpBrowserTarget()) {
86
+ return callBridgeTool('health.ping', {}, { destinationId: args.destinationId });
87
+ }
84
88
  const requestedDestinationId =
85
89
  typeof args.destinationId === 'string' ? args.destinationId : null;
86
90
  if (requestedDestinationId && requestedDestinationId !== 'local') {
@@ -172,7 +176,7 @@ async function callRemoteHealth(destinationId) {
172
176
  */
173
177
  export async function handleSkillTool(args = {}) {
174
178
  try {
175
- if (typeof args.destinationId === 'string') {
179
+ if (typeof args.destinationId === 'string' || hasMcpBrowserTarget()) {
176
180
  return callBridgeTool('skill.get_runtime_context', {}, { destinationId: args.destinationId });
177
181
  }
178
182
  const ctx = createRuntimeContext();
@@ -54,13 +54,44 @@ import {
54
54
  import { applyWindowsTcpTransportDefaults } from '../../native-host/src/config.js';
55
55
  import { getMcpServerInstructions } from './guidance.js';
56
56
  import { isToolEnabledForEra, LOADABLE_TOOLSET_TOOLS } from './toolset.js';
57
+ import { runWithMcpRequestTarget } from './handlers-utils.js';
57
58
 
58
59
  export const BUDGET_PRESET_DESCRIPTION = `Budget preset: "quick", "normal", or "deep" (defaults: query ${BUDGET_PRESETS.normal.maxNodes} nodes / depth ${BUDGET_PRESETS.normal.maxDepth} / text ${BUDGET_PRESETS.normal.textBudget}). Numeric fields override the preset when both are provided.`;
59
60
  export const TAB_ID_DESCRIPTION =
60
- 'Target a specific tab instead of the active tab in the enabled window.';
61
+ 'Tab ID within the selected browser profile. With multiple profiles, also pass extensionId from tabs.list or health.ping.';
61
62
  export const DESTINATION_ID_DESCRIPTION =
62
63
  'Optional Browser Bridge destination ID from browser_status/browser_tabs; omit for local.';
63
64
 
65
+ const BROWSER_TARGET_SCHEMA = {
66
+ extensionId: z
67
+ .string()
68
+ .trim()
69
+ .min(1)
70
+ .max(256)
71
+ .optional()
72
+ .describe(
73
+ 'Exact browser connection ID from health.ping connectedExtensions or tabs.list. Reuse with tabId on every call; IDs are scoped to destinationId and change on reconnect.'
74
+ ),
75
+ targetBrowser: z
76
+ .string()
77
+ .trim()
78
+ .min(1)
79
+ .max(256)
80
+ .optional()
81
+ .describe(
82
+ 'Browser name from health.ping connectedExtensions. Prefer extensionId when names are ambiguous.'
83
+ ),
84
+ targetProfile: z
85
+ .string()
86
+ .trim()
87
+ .min(1)
88
+ .max(256)
89
+ .optional()
90
+ .describe(
91
+ 'Profile label from health.ping connectedExtensions. Prefer extensionId when labels are missing or duplicated.'
92
+ ),
93
+ };
94
+
64
95
  const MCP_SERVER_VERSION = loadPackageVersion();
65
96
 
66
97
  /**
@@ -173,9 +204,24 @@ export function createBridgeMcpServer(options = {}) {
173
204
  const registerTool = (name, config, handler) => {
174
205
  const wrappedHandler = /** @type {typeof handler} */ (
175
206
  (args, context) =>
176
- runWithMcpRequestEra(era, () => handler(args, context), context.mcpReq.signal)
207
+ runWithMcpRequestEra(
208
+ era,
209
+ () => runWithMcpRequestTarget(args, () => handler(args, context)),
210
+ context.mcpReq.signal
211
+ )
177
212
  );
178
- const registration = server.registerTool(name, config, wrappedHandler);
213
+ const routedConfig =
214
+ name === 'browser_setup' || name === 'browser_toolset'
215
+ ? config
216
+ : {
217
+ ...config,
218
+ // Added optional routing fields are consumed by the wrapper; handlers keep
219
+ // their original, SDK-checked argument contracts.
220
+ inputSchema: /** @type {InputSchema} */ (
221
+ /** @type {unknown} */ (config.inputSchema.extend(BROWSER_TARGET_SCHEMA))
222
+ ),
223
+ };
224
+ const registration = server.registerTool(name, routedConfig, wrappedHandler);
179
225
  registrations.set(name, registration);
180
226
  if (!isToolEnabledForEra(name, era)) {
181
227
  registration.disable();
@@ -257,7 +303,7 @@ export function createBridgeMcpServer(options = {}) {
257
303
  {
258
304
  title: 'Browser Tabs',
259
305
  description:
260
- 'List, create, close, or activate browser tabs. List without destinationId aggregates configured destinations; other actions default to local. Only create a page when the user explicitly requests it.',
306
+ "List, create, close, or activate browser tabs. Unscoped list discovers tabs across enabled browser profiles and configured destinations. Reuse each tab's extensionId, destinationId, and tabId for subsequent calls. Other actions default to local. Only create a page when the user explicitly requests it.",
261
307
  inputSchema: z.object({
262
308
  action: z
263
309
  .enum(['list', 'create', 'close', 'activate'])
@@ -1051,6 +1097,7 @@ export function createBridgeMcpServer(options = {}) {
1051
1097
  .describe('Method params for this call'),
1052
1098
  tabId: z.number().int().positive().optional().describe(TAB_ID_DESCRIPTION),
1053
1099
  destinationId: z.string().optional().describe(DESTINATION_ID_DESCRIPTION),
1100
+ ...BROWSER_TARGET_SCHEMA,
1054
1101
  budgetPreset: z
1055
1102
  .enum(['quick', 'normal', 'deep'])
1056
1103
  .optional()