@browserbridge/bbx 1.12.1 → 1.12.3

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.
@@ -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,13 +303,26 @@ 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'])
264
310
  .describe('"list" (preferred), "create" (only when needed), "close", or "activate"'),
265
311
  url: z.string().optional().describe('URL for create action'),
266
312
  active: z.boolean().optional().describe('Focus the new tab (default: true)'),
313
+ limit: z
314
+ .number()
315
+ .int()
316
+ .min(1)
317
+ .max(100)
318
+ .optional()
319
+ .describe('Requested tab page size. Omit for discovery of all tabs.'),
320
+ offset: z
321
+ .number()
322
+ .int()
323
+ .nonnegative()
324
+ .optional()
325
+ .describe('Tab page offset. With offset alone the page size is 10.'),
267
326
  tabId: z
268
327
  .number()
269
328
  .int()
@@ -1051,6 +1110,7 @@ export function createBridgeMcpServer(options = {}) {
1051
1110
  .describe('Method params for this call'),
1052
1111
  tabId: z.number().int().positive().optional().describe(TAB_ID_DESCRIPTION),
1053
1112
  destinationId: z.string().optional().describe(DESTINATION_ID_DESCRIPTION),
1113
+ ...BROWSER_TARGET_SCHEMA,
1054
1114
  budgetPreset: z
1055
1115
  .enum(['quick', 'normal', 'deep'])
1056
1116
  .optional()
@@ -1282,7 +1282,9 @@ export class BridgeDaemon {
1282
1282
  typeof request.meta?.target_browser === 'string' ? request.meta.target_browser : null;
1283
1283
  const targetProfile =
1284
1284
  typeof request.meta?.target_profile === 'string' ? request.meta.target_profile : null;
1285
- const hasExplicitTarget = Boolean(targetBrowser || targetProfile);
1285
+ const targetExtension =
1286
+ typeof request.meta?.target_extension === 'string' ? request.meta.target_extension : null;
1287
+ const hasExplicitTarget = Boolean(targetBrowser || targetProfile || targetExtension);
1286
1288
 
1287
1289
  const ownerPendingCount = this.pendingRequestsByOwnerSocket.get(socket)?.size ?? 0;
1288
1290
  if (
@@ -1311,13 +1313,42 @@ export class BridgeDaemon {
1311
1313
  request.method === 'dom.baseline.describe' ||
1312
1314
  request.method === 'dom.baseline.release';
1313
1315
  const baselineOwner = usesBaselineOwner ? this.domBaselines.get(baselineId ?? '') : null;
1316
+ const candidates = [...this.extensionSockets.entries()].filter(
1317
+ ([id, extension]) =>
1318
+ (!targetExtension || id === targetExtension) &&
1319
+ (!targetBrowser || extension.__browserName === targetBrowser) &&
1320
+ (!targetProfile || extension.__profileLabel === targetProfile)
1321
+ );
1322
+ if (
1323
+ !usesBaselineOwner &&
1324
+ candidates.length > 1 &&
1325
+ request.method !== 'health.ping' &&
1326
+ request.method !== 'skill.get_runtime_context'
1327
+ ) {
1328
+ await writeJsonLine(socket, {
1329
+ type: 'agent.response',
1330
+ response: createFailure(
1331
+ request.id,
1332
+ ERROR_CODES.TAB_MISMATCH,
1333
+ 'Multiple browser profiles match this call. Select a connection with extensionId in MCP or meta.target_extension in raw requests, then list its tabs. A tabId alone does not select a profile. Do not disable other windows.',
1334
+ {
1335
+ reason: 'ambiguous_browser_target',
1336
+ connectedExtensions: this.getConnectedExtensionsSnapshot(),
1337
+ },
1338
+ { method: request.method }
1339
+ ),
1340
+ });
1341
+ this.recordDaemonRequestOutcome(request, false);
1342
+ return;
1343
+ }
1314
1344
  const target = usesBaselineOwner
1315
1345
  ? baselineOwner &&
1346
+ (!targetExtension || this.extensionSockets.get(targetExtension) === baselineOwner) &&
1316
1347
  (!targetBrowser || baselineOwner.__browserName === targetBrowser) &&
1317
1348
  (!targetProfile || baselineOwner.__profileLabel === targetProfile)
1318
1349
  ? baselineOwner
1319
1350
  : null
1320
- : this.selectExtensionTarget(targetBrowser, targetProfile);
1351
+ : this.selectExtensionTarget(targetBrowser, targetProfile, targetExtension);
1321
1352
 
1322
1353
  if (!target) {
1323
1354
  if (usesBaselineOwner) {
@@ -1339,9 +1370,12 @@ export class BridgeDaemon {
1339
1370
  request.id,
1340
1371
  ERROR_CODES.EXTENSION_DISCONNECTED,
1341
1372
  hasExplicitTarget
1342
- ? `No connected extension matches target_browser="${targetBrowser ?? '*'}" target_profile="${targetProfile ?? '*'}".`
1373
+ ? `No connected extension matches target_browser="${targetBrowser ?? '*'}" target_profile="${targetProfile ?? '*'}" target_extension="${targetExtension ?? '*'}".`
1343
1374
  : 'The Chrome extension is not connected to the local bridge daemon.',
1344
- null,
1375
+ {
1376
+ reason: hasExplicitTarget ? 'browser_target_disconnected' : 'extension_disconnected',
1377
+ connectedExtensions: this.getConnectedExtensionsSnapshot(),
1378
+ },
1345
1379
  { method: request.method }
1346
1380
  );
1347
1381
  await writeJsonLine(socket, { type: 'agent.response', response });
@@ -1418,11 +1452,13 @@ export class BridgeDaemon {
1418
1452
  *
1419
1453
  * @param {string | null} targetBrowser
1420
1454
  * @param {string | null} targetProfile
1455
+ * @param {string | null} [targetExtension]
1421
1456
  * @returns {ClientSocket | null}
1422
1457
  */
1423
- selectExtensionTarget(targetBrowser, targetProfile) {
1424
- const candidates = Array.from(this.extensionSockets.entries()).filter(([, extSocket]) => {
1458
+ selectExtensionTarget(targetBrowser, targetProfile, targetExtension = null) {
1459
+ const candidates = Array.from(this.extensionSockets.entries()).filter(([id, extSocket]) => {
1425
1460
  return (
1461
+ (!targetExtension || id === targetExtension) &&
1426
1462
  (!targetBrowser || extSocket.__browserName === targetBrowser) &&
1427
1463
  (!targetProfile || extSocket.__profileLabel === targetProfile)
1428
1464
  );
@@ -133,7 +133,9 @@ export function applyMethodBudgetPreset(method, params, budgetPreset) {
133
133
  const args = { ...params, budgetPreset };
134
134
  /** @type {Record<string, unknown>} */
135
135
  let normalized = args;
136
- if (
136
+ if (method === 'webmcp.list_tools') {
137
+ normalized = applyLimitBudgetPreset(args, { quick: 5, normal: 20, deep: 100 });
138
+ } else if (
137
139
  method === 'dom.query' ||
138
140
  method === 'dom.get_accessibility_tree' ||
139
141
  method === 'dom.baseline.create'
@@ -3,6 +3,11 @@
3
3
  /** @typedef {import('./types.js').ErrorCode} ErrorCode */
4
4
 
5
5
  export const ERROR_CODES = Object.freeze({
6
+ WEBMCP_UNAVAILABLE: 'WEBMCP_UNAVAILABLE',
7
+ WEBMCP_TOOL_STALE: 'WEBMCP_TOOL_STALE',
8
+ WEBMCP_BUSY: 'WEBMCP_BUSY',
9
+ WEBMCP_APPROVAL_DENIED: 'WEBMCP_APPROVAL_DENIED',
10
+ WEBMCP_EXECUTION_UNCERTAIN: 'WEBMCP_EXECUTION_UNCERTAIN',
6
11
  ACCESS_DENIED: 'ACCESS_DENIED',
7
12
  TAB_MISMATCH: 'TAB_MISMATCH',
8
13
  ELEMENT_STALE: 'ELEMENT_STALE',
@@ -38,6 +43,27 @@ export const ERROR_CODES = Object.freeze({
38
43
  * @type {Readonly<Record<string, { retry: boolean, retryAfterMs?: number, alternativeMethod?: string, hint: string }>>}
39
44
  */
40
45
  export const ERROR_RECOVERY = Object.freeze({
46
+ [ERROR_CODES.WEBMCP_UNAVAILABLE]: {
47
+ retry: false,
48
+ hint: 'This document does not expose the current WebMCP consumer API. Use structured DOM/input methods; do not enable flags or inject a polyfill without user permission.',
49
+ },
50
+ [ERROR_CODES.WEBMCP_TOOL_STALE]: {
51
+ retry: false,
52
+ alternativeMethod: 'webmcp.list_tools',
53
+ hint: 'The tool reference expired or its document, catalog, or access session changed. Discover and inspect a fresh tool before a new deliberate action.',
54
+ },
55
+ [ERROR_CODES.WEBMCP_BUSY]: {
56
+ retry: false,
57
+ hint: 'Another WebMCP execution or approval is pending. Wait and inspect postconditions; a timed-out callback may still be running.',
58
+ },
59
+ [ERROR_CODES.WEBMCP_APPROVAL_DENIED]: {
60
+ retry: false,
61
+ hint: 'User approval was denied, closed, expired, or revoked. No tool was dispatched. Do not repeat the request unless the user asks.',
62
+ },
63
+ [ERROR_CODES.WEBMCP_EXECUTION_UNCERTAIN]: {
64
+ retry: false,
65
+ hint: 'The tool may have executed. Inspect DOM/page postconditions before another deliberate action. Never automatically retry or repeat the action through DOM input.',
66
+ },
41
67
  [ERROR_CODES.ACCESS_DENIED]: {
42
68
  retry: false,
43
69
  hint: 'Access is off for this window. Ask the user to click Enable in the Browser Bridge popup or side panel. Do not request access again until that window is enabled.',
@@ -17,3 +17,5 @@ export * from './protocol.js';
17
17
  export * from './registry.js';
18
18
  export * from './recovery-telemetry.js';
19
19
  export * from './summary.js';
20
+ export * from './tab-list.js';
21
+ export * from './webmcp.js';
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import { applyBudget } from './budget.js';
4
+ import { normalizeWebMcpParams } from './webmcp.js';
4
5
  import {
5
6
  ARTIFACT_CHUNK_BYTES,
6
7
  BUDGET_PRESETS,
@@ -296,15 +297,22 @@ export function createSuccess(id, result, meta = {}) {
296
297
  * @returns {BridgeFailureResponse}
297
298
  */
298
299
  export function createFailure(id, code, message, details = null, meta = {}) {
299
- const recovery = getErrorRecovery(code);
300
+ const recovery = getRoutingRecovery(code, details) ?? getErrorRecovery(code);
300
301
  const effectiveRecovery =
301
- recovery && meta.method === 'sensitive.read'
302
+ meta.method === 'webmcp.execute_tool'
302
303
  ? {
303
304
  retry: false,
304
- ...(recovery.alternativeMethod ? { alternativeMethod: recovery.alternativeMethod } : {}),
305
- hint: `Sensitive reads are never retried automatically. ${recovery.hint}`,
305
+ hint: 'WebMCP executions are never retried automatically. Inspect postconditions if dispatch may have started; never replay through DOM fallback.',
306
306
  }
307
- : recovery;
307
+ : recovery && meta.method === 'sensitive.read'
308
+ ? {
309
+ retry: false,
310
+ ...(recovery.alternativeMethod
311
+ ? { alternativeMethod: recovery.alternativeMethod }
312
+ : {}),
313
+ hint: `Sensitive reads are never retried automatically. ${recovery.hint}`,
314
+ }
315
+ : recovery;
308
316
  return {
309
317
  id,
310
318
  ok: false,
@@ -322,6 +330,35 @@ export function createFailure(id, code, message, details = null, meta = {}) {
322
330
  };
323
331
  }
324
332
 
333
+ /**
334
+ * Routing errors need selection guidance, not closed-tab or global enable hints.
335
+ * @param {import('./types.js').ErrorCode} code
336
+ * @param {unknown} details
337
+ * @returns {import('./types.js').BridgeRecovery | null}
338
+ */
339
+ function getRoutingRecovery(code, details) {
340
+ const reason =
341
+ details && typeof details === 'object' && 'reason' in details ? details.reason : null;
342
+ if (
343
+ (code === ERROR_CODES.TAB_MISMATCH && reason === 'ambiguous_browser_target') ||
344
+ (code === ERROR_CODES.EXTENSION_DISCONNECTED && reason === 'browser_target_disconnected')
345
+ ) {
346
+ return {
347
+ retry: false,
348
+ alternativeMethod: 'health.ping',
349
+ hint: 'Discover connectedExtensions with health.ping or list enabled profiles with MCP tabs.list. Select extensionId and tabId for the intended page. Do not disable unrelated windows or fall back to another profile.',
350
+ };
351
+ }
352
+ if (code === ERROR_CODES.ACCESS_DENIED && reason === 'no_enabled_browser_profiles') {
353
+ return {
354
+ retry: false,
355
+ alternativeMethod: 'health.ping',
356
+ hint: "Choose the intended extensionId from connectedExtensions before requesting access. Ask the user to enable only that profile's window.",
357
+ };
358
+ }
359
+ return null;
360
+ }
361
+
325
362
  /**
326
363
  * @param {unknown} request
327
364
  * @returns {BridgeRequest}
@@ -409,6 +446,7 @@ export function validateBridgeRequest(request) {
409
446
  params: normalizeRequestParams(method, params),
410
447
  meta: {
411
448
  ...meta,
449
+ ...normalizeBrowserTargetMeta(meta),
412
450
  protocol_version:
413
451
  typeof meta.protocol_version === 'string' ? meta.protocol_version : getProtocolVersion(),
414
452
  token_budget: typeof meta.token_budget === 'number' ? meta.token_budget : null,
@@ -424,6 +462,37 @@ export function validateBridgeRequest(request) {
424
462
  };
425
463
  }
426
464
 
465
+ /**
466
+ * Never turn a malformed explicit selector into implicit routing.
467
+ * @param {Record<string, unknown>} meta
468
+ * @returns {Pick<BridgeMeta, 'target_extension' | 'target_browser' | 'target_profile'>}
469
+ */
470
+ function normalizeBrowserTargetMeta(meta) {
471
+ /** @type {Pick<BridgeMeta, 'target_extension' | 'target_browser' | 'target_profile'>} */
472
+ const target = {};
473
+ for (const field of /** @type {const} */ ([
474
+ 'target_extension',
475
+ 'target_browser',
476
+ 'target_profile',
477
+ ])) {
478
+ const value = meta[field];
479
+ if (value === undefined) continue;
480
+ if (
481
+ typeof value !== 'string' ||
482
+ !value.trim() ||
483
+ value.length > 256 ||
484
+ hasAsciiControlCharacters(value)
485
+ ) {
486
+ throw new BridgeError(
487
+ ERROR_CODES.INVALID_REQUEST,
488
+ `meta.${field} must be a non-empty string of at most 256 characters without control characters.`
489
+ );
490
+ }
491
+ target[field] = value.trim();
492
+ }
493
+ return target;
494
+ }
495
+
427
496
  /** @param {string} value @returns {boolean} */
428
497
  function hasAsciiControlCharacters(value) {
429
498
  for (let index = 0; index < value.length; index += 1) {
@@ -470,6 +539,15 @@ function normalizeAutomaticRetryMeta(source, value) {
470
539
  */
471
540
  export function normalizeRequestParams(method, params) {
472
541
  switch (method) {
542
+ case 'webmcp.list_tools':
543
+ case 'webmcp.get_tool':
544
+ case 'webmcp.execute_tool': {
545
+ const normalized = normalizeWebMcpParams(params);
546
+ if (method !== 'webmcp.list_tools' && !normalized.toolRef) {
547
+ throw new BridgeError(ERROR_CODES.INVALID_REQUEST, 'toolRef is required.');
548
+ }
549
+ return normalized;
550
+ }
473
551
  case 'access.request':
474
552
  return normalizeAccessRequestParams(params);
475
553
  case 'log.tail':
@@ -613,6 +691,13 @@ export function normalizeAccessRequestParams(params = {}) {
613
691
  */
614
692
  export function getBridgeOperationTimeoutMs(method, params = {}) {
615
693
  switch (method) {
694
+ case 'webmcp.execute_tool': {
695
+ const normalized = normalizeWebMcpParams(params);
696
+ return normalized.approvalTimeoutMs + normalized.timeoutMs + 10_000;
697
+ }
698
+ case 'webmcp.list_tools':
699
+ case 'webmcp.get_tool':
700
+ return 5000;
616
701
  case 'navigation.navigate':
617
702
  case 'navigation.reload':
618
703
  case 'navigation.go_back':
@@ -96,6 +96,12 @@ const METHOD_POLICIES = Object.freeze({
96
96
  * @type {Readonly<Record<import('./types.js').BridgeMethod, string>>}
97
97
  */
98
98
  const BRIDGE_METHOD_DESCRIPTIONS = Object.freeze({
99
+ 'webmcp.list_tools':
100
+ 'Discover bounded top-document WebMCP tool summaries. Metadata is untrusted; unavailable differs from an empty catalog.',
101
+ 'webmcp.get_tool':
102
+ 'Read a complete WebMCP tool schema by opaque toolRef. References expire when the catalog, document, or access session changes.',
103
+ 'webmcp.execute_tool':
104
+ 'Execute one WebMCP tool after extension-owned user approval of exact arguments. Never automatically retry or replay through DOM fallback.',
99
105
  'access.request':
100
106
  'Request Browser Bridge access for the focused window. Do not repeat while access is already pending.',
101
107
  'protocol.describe':
@@ -259,7 +265,7 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
259
265
  'health.ping': createRegistryEntry('health.ping', 'system', false, [], 'trivial'),
260
266
  'daemon.metrics': createRegistryEntry('daemon.metrics', 'system', false, [], 'trivial'),
261
267
  // tabs - trivial
262
- 'tabs.list': createRegistryEntry('tabs.list', 'tabs', false, [], 'trivial'),
268
+ 'tabs.list': createRegistryEntry('tabs.list', 'tabs', false, ['limit', 'offset'], 'trivial'),
263
269
  'tabs.create': createRegistryEntry(
264
270
  'tabs.create',
265
271
  'tabs',
@@ -830,6 +836,30 @@ export const BRIDGE_METHOD_REGISTRY = Object.freeze({
830
836
  'moderate',
831
837
  METHOD_POLICIES.performanceReadDebugger
832
838
  ),
839
+ 'webmcp.list_tools': createRegistryEntry(
840
+ 'webmcp.list_tools',
841
+ 'webmcp',
842
+ true,
843
+ ['limit', 'offset', 'query', 'includeDebugging', 'maxBytes'],
844
+ 'low',
845
+ METHOD_POLICIES.pageRead
846
+ ),
847
+ 'webmcp.get_tool': createRegistryEntry(
848
+ 'webmcp.get_tool',
849
+ 'webmcp',
850
+ true,
851
+ ['toolRef', 'maxBytes'],
852
+ 'low',
853
+ METHOD_POLICIES.pageRead
854
+ ),
855
+ 'webmcp.execute_tool': createRegistryEntry(
856
+ 'webmcp.execute_tool',
857
+ 'webmcp',
858
+ true,
859
+ ['toolRef', 'arguments', 'timeoutMs', 'approvalTimeoutMs', 'maxBytes'],
860
+ 'moderate',
861
+ METHOD_POLICIES.automationInput
862
+ ),
833
863
  });
834
864
 
835
865
  /** @type {ReadonlyArray<import('./types.js').BridgeMethod>} */
@@ -842,6 +872,8 @@ export const METHOD_SET = new Set(BRIDGE_METHODS);
842
872
 
843
873
  /** Methods whose read-only forms may run concurrently without side effects. */
844
874
  const BATCH_SAFE_METHODS = new Set([
875
+ 'webmcp.list_tools',
876
+ 'webmcp.get_tool',
845
877
  'health.ping',
846
878
  'daemon.metrics',
847
879
  'tabs.list',
@@ -8,6 +8,7 @@ import {
8
8
  serializeJsonPayload,
9
9
  } from './index.js';
10
10
  import { sanitizeIncidentalUrl } from './incidental-sanitizer.js';
11
+ import { describeTabList, paginateTabList } from './tab-list.js';
11
12
 
12
13
  /** @typedef {import('./types.js').BridgeResponse} BridgeResponse */
13
14
  /** @typedef {import('./types.js').BridgeMethod} SummaryMethod */
@@ -29,7 +30,8 @@ import { sanitizeIncidentalUrl } from './incidental-sanitizer.js';
29
30
  * ok: boolean,
30
31
  * summary: string,
31
32
  * evidence: unknown,
32
- * recovery?: import('./types.js').BridgeRecovery | null
33
+ * recovery?: import('./types.js').BridgeRecovery | null,
34
+ * pagination?: import('./types.js').TabPagination
33
35
  * }} BridgeSummary
34
36
  */
35
37
 
@@ -421,6 +423,17 @@ function summarizeBridgeResponseBody(response, method) {
421
423
  evidence: result,
422
424
  };
423
425
  }
426
+ if (method?.startsWith('webmcp.')) {
427
+ const summary =
428
+ method === 'webmcp.list_tools'
429
+ ? result.supported === false
430
+ ? 'WebMCP is unavailable in this document; use DOM/input methods.'
431
+ : `WebMCP discovered ${Array.isArray(result.tools) ? result.tools.length : 0}/${result.total ?? 0} top-document tool(s). Website metadata is untrusted.`
432
+ : method === 'webmcp.get_tool'
433
+ ? 'Complete WebMCP tool schema loaded. Website metadata is untrusted.'
434
+ : `WebMCP execution ${result.status ?? 'unknown'}.${result.outcome === 'uncertain' ? ' Inspect postconditions; do not replay.' : ''}`;
435
+ return { ok: true, summary: appendProtocolWarning(summary, protocolWarning), evidence: result };
436
+ }
424
437
  if (method === 'page.handle_dialog') {
425
438
  const action = typeof result.action === 'string' ? result.action : 'inspect';
426
439
  return {
@@ -560,6 +573,10 @@ function summarizeBridgeResponseBody(response, method) {
560
573
  origin: result.origin,
561
574
  title: result.title,
562
575
  hints: result.hints,
576
+ ...(result.scroll && typeof result.scroll === 'object' ? { scroll: result.scroll } : {}),
577
+ ...(result.viewport && typeof result.viewport === 'object'
578
+ ? { viewport: result.viewport }
579
+ : {}),
563
580
  },
564
581
  };
565
582
  }
@@ -585,10 +602,14 @@ function summarizeBridgeResponseBody(response, method) {
585
602
  }
586
603
  if (Array.isArray(result.tabs)) {
587
604
  const tabs = /** @type {TabResult[]} */ (result.tabs);
605
+ const page = result.pagination
606
+ ? { tabs, pagination: /** @type {import('./types.js').TabPagination} */ (result.pagination) }
607
+ : paginateTabList(tabs, { limit: 10 });
588
608
  return {
589
609
  ok: true,
590
- summary: appendProtocolWarning(`Bridge listed ${tabs.length} tab(s).`, protocolWarning),
591
- evidence: tabs.slice(0, 10).map((tab) => ({
610
+ summary: appendProtocolWarning(describeTabList(page.pagination), protocolWarning),
611
+ pagination: page.pagination,
612
+ evidence: page.tabs.map((tab) => ({
592
613
  tabId: tab.tabId,
593
614
  active: tab.active,
594
615
  origin: tab.origin,
@@ -0,0 +1,77 @@
1
+ // @ts-check
2
+
3
+ import { BridgeError, ERROR_CODES } from './errors.js';
4
+
5
+ /** @typedef {import('./types.js').TabPagination} TabPagination */
6
+
7
+ /**
8
+ * Preserve unpaged raw listings, while allowing bounded pages when requested.
9
+ * @param {Record<string, unknown>} params
10
+ * @returns {{ offset: number, limit: number | null }}
11
+ */
12
+ export function normalizeTabListParams(params) {
13
+ const offset = params.offset === undefined ? 0 : params.offset;
14
+ const limit =
15
+ params.limit === undefined ? (params.offset === undefined ? null : 10) : params.limit;
16
+ if (typeof offset !== 'number' || !Number.isSafeInteger(offset) || offset < 0) {
17
+ throw new BridgeError(
18
+ ERROR_CODES.INVALID_REQUEST,
19
+ 'tabs.list offset must be a non-negative integer.'
20
+ );
21
+ }
22
+ if (
23
+ (params.limit !== undefined || limit !== null) &&
24
+ (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 100)
25
+ ) {
26
+ throw new BridgeError(
27
+ ERROR_CODES.INVALID_REQUEST,
28
+ 'tabs.list limit must be an integer from 1 to 100.'
29
+ );
30
+ }
31
+ return { offset, limit };
32
+ }
33
+
34
+ /**
35
+ * @param {number} totalCount
36
+ * @param {number} returnedCount
37
+ * @param {number} offset
38
+ * @returns {TabPagination}
39
+ */
40
+ export function describeTabPage(totalCount, returnedCount, offset) {
41
+ return {
42
+ totalCount,
43
+ returnedCount,
44
+ offset,
45
+ omittedCount: Math.max(0, totalCount - returnedCount),
46
+ truncated: returnedCount < totalCount,
47
+ nextOffset: offset + returnedCount < totalCount ? offset + returnedCount : null,
48
+ };
49
+ }
50
+
51
+ /**
52
+ * @template T
53
+ * @param {T[]} tabs
54
+ * @param {Record<string, unknown>} [params]
55
+ * @returns {{ tabs: T[], pagination: TabPagination }}
56
+ */
57
+ export function paginateTabList(tabs, params = {}) {
58
+ const { offset, limit } = normalizeTabListParams(params);
59
+ const sorted = [...tabs].sort((left, right) => {
60
+ /** @param {unknown} tab @returns {number} */
61
+ const priority = (tab) => {
62
+ const record =
63
+ tab && typeof tab === 'object' ? /** @type {Record<string, unknown>} */ (tab) : {};
64
+ return Number(record.working === true || record.active === true);
65
+ };
66
+ return priority(right) - priority(left);
67
+ });
68
+ const page = sorted.slice(offset, limit === null ? undefined : offset + limit);
69
+ return { tabs: page, pagination: describeTabPage(tabs.length, page.length, offset) };
70
+ }
71
+
72
+ /** @param {TabPagination} page @returns {string} */
73
+ export function describeTabList(page) {
74
+ return page.truncated
75
+ ? `Bridge listed ${page.totalCount} tab(s); showing ${page.returnedCount} at offset ${page.offset}, ${page.omittedCount} omitted. Use tabs.list with limit and offset to retrieve another page.`
76
+ : `Bridge listed ${page.totalCount} tab(s).`;
77
+ }