@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.
- package/README.md +2 -0
- package/package.json +1 -1
- package/packages/agent-client/src/cli-args.js +22 -3
- package/packages/agent-client/src/cli-batch.js +14 -3
- package/packages/agent-client/src/cli.js +4 -2
- package/packages/agent-client/src/command-registry.js +1 -1
- package/packages/agent-client/src/runtime.js +12 -7
- package/packages/mcp-server/src/guidance.js +3 -1
- package/packages/mcp-server/src/handlers-navigation.js +31 -11
- package/packages/mcp-server/src/handlers-page.js +17 -2
- package/packages/mcp-server/src/handlers-utils.js +227 -1
- package/packages/mcp-server/src/handlers.js +5 -1
- package/packages/mcp-server/src/server.js +64 -4
- package/packages/native-host/src/daemon.js +42 -6
- package/packages/protocol/src/budget-presets.js +3 -1
- package/packages/protocol/src/errors.js +26 -0
- package/packages/protocol/src/index.js +2 -0
- package/packages/protocol/src/protocol.js +90 -5
- package/packages/protocol/src/registry.js +33 -1
- package/packages/protocol/src/summary.js +24 -3
- package/packages/protocol/src/tab-list.js +77 -0
- package/packages/protocol/src/types.ts +97 -0
- package/packages/protocol/src/webmcp.js +90 -0
- package/skills/browser-bridge/SKILL.md +27 -1
- package/skills/browser-bridge/references/capabilities.md +8 -6
- package/skills/browser-bridge/references/interaction.md +8 -2
- package/skills/browser-bridge/references/protocol.md +8 -1
- package/skills/browser-bridge/references/webmcp.md +16 -0
|
@@ -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
|
-
'
|
|
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(
|
|
207
|
+
runWithMcpRequestEra(
|
|
208
|
+
era,
|
|
209
|
+
() => runWithMcpRequestTarget(args, () => handler(args, context)),
|
|
210
|
+
context.mcpReq.signal
|
|
211
|
+
)
|
|
177
212
|
);
|
|
178
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.',
|
|
@@ -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
|
-
|
|
302
|
+
meta.method === 'webmcp.execute_tool'
|
|
302
303
|
? {
|
|
303
304
|
retry: false,
|
|
304
|
-
|
|
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(
|
|
591
|
-
|
|
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
|
+
}
|