@mario.andreschak/mcp-browser 3.45.0 → 3.45.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/README.md +52 -0
- package/dist/capture.d.ts +34 -10
- package/dist/capture.js +202 -102
- package/dist/capture.js.map +1 -1
- package/dist/gateway.d.ts +1 -1
- package/dist/gateway.js +23 -10
- package/dist/gateway.js.map +1 -1
- package/dist/index.js +15 -1
- package/dist/index.js.map +1 -1
- package/dist/recording.d.ts +10 -3
- package/dist/recording.js +513 -67
- package/dist/recording.js.map +1 -1
- package/dist/resources.js +45 -8
- package/dist/resources.js.map +1 -1
- package/dist/runtime.d.ts +31 -7
- package/dist/runtime.js +262 -88
- package/dist/runtime.js.map +1 -1
- package/dist/tools.d.ts +1 -1
- package/dist/tools.js +283 -157
- package/dist/tools.js.map +1 -1
- package/dist/viewHtml.js +8 -2
- package/dist/viewHtml.js.map +1 -1
- package/package.json +4 -3
- package/scripts/install-browser.mjs +166 -0
package/dist/tools.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import {
|
|
2
|
+
import { promises as fs } from 'node:fs';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { BrowserMcpError, assertNavigationAllowed, browserDiagnostics, browserExtensions, closeSession, createCaptureContext, defaultViewport, failureCategoryForCode, getSession, listSessions, openSession, publicPageState, releaseOwnerScope, resetNavigationCounter, runCancellable, timeoutMs, } from './runtime.js';
|
|
3
5
|
import { BROWSER_APP_URI } from './resources.js';
|
|
4
|
-
import { captureDeterministicPng, captureRegionPng, evaluateElementMetrics, navigateCaptureSource, resolveCaptureSource, sha256Hex, writeCaptureArtifact, } from './capture.js';
|
|
5
|
-
import { recordingStatus, startRecording, stopRecording } from './recording.js';
|
|
6
|
+
import { captureDeterministicPng, captureRegionPng, evaluateElementMetrics, finiteParameter, navigateCaptureSource, normalizeResolution, pngDimensions, resolutionFallbacks, resolveCaptureSource, sha256Hex, writeCaptureArtifact, } from './capture.js';
|
|
7
|
+
import { recordingStatus, releaseRecordingsForOwner, startRecording, stopRecording } from './recording.js';
|
|
6
8
|
import { prepareBrowserAudioStream } from './gateway.js';
|
|
7
9
|
const MAX_TEXT_CHARS = 50_000;
|
|
8
10
|
const MAX_SELECTOR_CHARS = 2_000;
|
|
@@ -39,12 +41,12 @@ export function browserToolDefinitions() {
|
|
|
39
41
|
return [
|
|
40
42
|
{
|
|
41
43
|
name: 'browser_open',
|
|
42
|
-
description: 'Open
|
|
44
|
+
description: 'Open a new browser session, or open/reuse the exact sessionId supplied. url may be remote, localhost, a bare hostname, or a local file path.',
|
|
43
45
|
inputSchema: {
|
|
44
46
|
type: 'object',
|
|
45
47
|
properties: {
|
|
46
|
-
sessionId: { ...SESSION_PROPERTY, description: 'Optional stable id. Omit it to
|
|
47
|
-
url: { type: 'string', description: 'Optional
|
|
48
|
+
sessionId: { ...SESSION_PROPERTY, description: 'Optional stable id. Omit it to create a fresh isolated session; supply it to open or reuse that exact session.' },
|
|
49
|
+
url: { type: 'string', description: 'Optional URL, bare hostname, localhost address, or local file path.' },
|
|
48
50
|
timeoutMs: TIMEOUT_PROPERTY,
|
|
49
51
|
},
|
|
50
52
|
additionalProperties: false,
|
|
@@ -54,7 +56,7 @@ export function browserToolDefinitions() {
|
|
|
54
56
|
},
|
|
55
57
|
{
|
|
56
58
|
name: 'browser_navigate',
|
|
57
|
-
description: 'Navigate
|
|
59
|
+
description: 'Navigate the active browser to a remote URL, bare hostname, localhost address, or local file path. Only executable/non-browser URL schemes are rejected.',
|
|
58
60
|
inputSchema: {
|
|
59
61
|
type: 'object',
|
|
60
62
|
properties: { sessionId: SESSION_PROPERTY, url: { type: 'string' }, timeoutMs: TIMEOUT_PROPERTY },
|
|
@@ -181,12 +183,13 @@ export function browserToolDefinitions() {
|
|
|
181
183
|
},
|
|
182
184
|
{
|
|
183
185
|
name: 'browser_screenshot',
|
|
184
|
-
description: 'Capture
|
|
186
|
+
description: 'Capture an immutable PNG screenshot with artifact ID, SHA-256, geometry, and an optional safe no-overwrite outputPath.',
|
|
185
187
|
inputSchema: {
|
|
186
188
|
type: 'object',
|
|
187
189
|
properties: {
|
|
188
190
|
sessionId: SESSION_PROPERTY,
|
|
189
191
|
fullPage: { type: 'boolean', default: false },
|
|
192
|
+
outputPath: { type: 'string', description: 'Optional immutable .png destination confined to the FLUJO data directory or browser screenshot root.' },
|
|
190
193
|
timeoutMs: TIMEOUT_PROPERTY,
|
|
191
194
|
},
|
|
192
195
|
additionalProperties: false,
|
|
@@ -196,26 +199,20 @@ export function browserToolDefinitions() {
|
|
|
196
199
|
},
|
|
197
200
|
{
|
|
198
201
|
name: 'browser_capture_page',
|
|
199
|
-
description: 'Capture a
|
|
202
|
+
description: 'Capture a page as PNG. source may be a remote URL, localhost URL, local path, file:// URL, or inline HTML; omit source to capture the active session. Resolution presets such as 720p, 1080p, and 4k are accepted and safely adjusted when necessary.',
|
|
200
203
|
inputSchema: {
|
|
201
204
|
type: 'object',
|
|
202
205
|
properties: {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
width: { type: 'integer', minimum: 320, maximum: 1920, default: 1920, description: 'Viewport width in CSS pixels.' },
|
|
208
|
-
height: { type: 'integer', minimum: 240, maximum: 1080, default: 1080, description: 'Viewport height in CSS pixels.' },
|
|
209
|
-
deviceScaleFactor: { type: 'number', minimum: 1, maximum: 3, default: 1 },
|
|
206
|
+
source: { type: 'string', description: 'URL, localhost address, local path, file:// URL, or inline HTML. Omit to capture the active session.' },
|
|
207
|
+
sessionId: SESSION_PROPERTY,
|
|
208
|
+
resolution: { type: 'string', description: 'Preset or WIDTHxHEIGHT, for example 720p, 1080p, 4k, or 1600x900.' },
|
|
209
|
+
selector: { type: 'string', description: 'Optional CSS selector to capture only one element.' },
|
|
210
210
|
fullPage: { type: 'boolean', default: false },
|
|
211
|
-
clipSelector: { type: 'string', minLength: 1, maxLength: MAX_SELECTOR_CHARS, description: 'CSS selector to capture only that element, no browser chrome.' },
|
|
212
|
-
waitFor: { type: 'string', description: 'CSS selector or JS predicate to wait for before capture; the predicate result is never returned.' },
|
|
213
|
-
colorScheme: { type: 'string', enum: ['light', 'dark'], default: 'light' },
|
|
214
|
-
allowLocal: { type: 'boolean', default: false },
|
|
215
211
|
outputPath: { type: 'string', description: 'Optional destination path, confined to the FLUJO data directory.' },
|
|
216
|
-
timeoutMs: TIMEOUT_PROPERTY,
|
|
217
212
|
},
|
|
218
|
-
|
|
213
|
+
// Legacy url/html/filePath/width/height/etc. arguments remain accepted
|
|
214
|
+
// by the handler without cluttering the model-facing contract.
|
|
215
|
+
additionalProperties: true,
|
|
219
216
|
},
|
|
220
217
|
annotations: READ_ANNOTATIONS,
|
|
221
218
|
_meta: APP_META,
|
|
@@ -228,61 +225,54 @@ export function browserToolDefinitions() {
|
|
|
228
225
|
properties: {
|
|
229
226
|
selectors: { type: 'array', items: { type: 'string', minLength: 1, maxLength: MAX_SELECTOR_CHARS }, minItems: 1, maxItems: 50 },
|
|
230
227
|
sessionId: SESSION_PROPERTY,
|
|
231
|
-
|
|
232
|
-
filePath: { type: 'string', description: 'Optional local file to navigate to first (requires allowLocal=true + FLUJO_BROWSER_ALLOW_LOCAL_CAPTURE).' },
|
|
233
|
-
allowLocal: { type: 'boolean', default: false },
|
|
234
|
-
timeoutMs: TIMEOUT_PROPERTY,
|
|
228
|
+
source: { type: 'string', description: 'Optional URL or local path to load before measuring.' },
|
|
235
229
|
},
|
|
236
230
|
required: ['selectors'],
|
|
237
|
-
additionalProperties:
|
|
231
|
+
additionalProperties: true,
|
|
238
232
|
},
|
|
239
233
|
annotations: READ_ANNOTATIONS,
|
|
240
234
|
_meta: APP_META,
|
|
241
235
|
},
|
|
242
236
|
{
|
|
243
237
|
name: 'browser_capture_region',
|
|
244
|
-
description: 'Capture a
|
|
238
|
+
description: 'Capture a rectangular page region as PNG. source accepts remote URLs, localhost, or local paths; omit it to use the active session. Missing or out-of-range coordinates are safely normalized.',
|
|
245
239
|
inputSchema: {
|
|
246
240
|
type: 'object',
|
|
247
241
|
properties: {
|
|
242
|
+
source: { type: 'string', description: 'Optional URL, localhost address, or local path.' },
|
|
248
243
|
sessionId: SESSION_PROPERTY,
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
height: { type: 'integer', minimum: 1, maximum: 2160 },
|
|
255
|
-
allowLocal: { type: 'boolean', default: false },
|
|
244
|
+
region: {
|
|
245
|
+
type: 'object',
|
|
246
|
+
properties: { x: { type: 'number' }, y: { type: 'number' }, width: { type: 'number' }, height: { type: 'number' } },
|
|
247
|
+
description: 'Region in CSS pixels. Defaults to the visible viewport.',
|
|
248
|
+
},
|
|
256
249
|
outputPath: { type: 'string', description: 'Optional destination path, confined to the FLUJO data directory.' },
|
|
257
|
-
timeoutMs: TIMEOUT_PROPERTY,
|
|
258
250
|
},
|
|
259
|
-
|
|
260
|
-
required: ['width', 'height'],
|
|
261
|
-
additionalProperties: false,
|
|
251
|
+
additionalProperties: true,
|
|
262
252
|
},
|
|
263
253
|
annotations: READ_ANNOTATIONS,
|
|
264
254
|
_meta: APP_META,
|
|
265
255
|
},
|
|
266
256
|
{
|
|
267
257
|
name: 'browser_record_start',
|
|
268
|
-
description: 'Start
|
|
258
|
+
description: 'Start a sturdy browser recording and immediately return a sessionId. Optionally load source first and auto-stop after durationMs. Unsupported resolutions fall back automatically and are reported in warnings/effectiveResolution.',
|
|
269
259
|
inputSchema: {
|
|
270
260
|
type: 'object',
|
|
271
261
|
properties: {
|
|
272
|
-
|
|
273
|
-
|
|
262
|
+
source: { type: 'string', description: 'Optional URL, localhost address, local path, or inline HTML to load before returning.' },
|
|
263
|
+
resolution: { type: 'string', description: 'Preset or WIDTHxHEIGHT, for example 720p, 1080p, 4k, or 1600x900.' },
|
|
274
264
|
audio: { type: 'boolean', default: true, description: 'Capture page audio into a WAV sidecar (and mux it in if ffmpeg is available).' },
|
|
275
|
-
durationMs: { type: 'number',
|
|
265
|
+
durationMs: { type: 'number', description: 'Optional auto-stop delay. The start call still returns immediately; retrieve the artifact with stop or status.' },
|
|
276
266
|
outputPath: { type: 'string', description: 'Optional destination path for the finished artifact, confined to the FLUJO data directory.' },
|
|
277
267
|
},
|
|
278
|
-
additionalProperties:
|
|
268
|
+
additionalProperties: true,
|
|
279
269
|
},
|
|
280
270
|
annotations: INTERACTION_ANNOTATIONS,
|
|
281
271
|
_meta: APP_META,
|
|
282
272
|
},
|
|
283
273
|
{
|
|
284
274
|
name: 'browser_record_stop',
|
|
285
|
-
description: 'Stop a
|
|
275
|
+
description: 'Stop and finalize a recording, or retrieve one that just auto-stopped. Returns usable artifact paths, recovery warnings, and the video itself as MCP media when small enough.',
|
|
286
276
|
inputSchema: {
|
|
287
277
|
type: 'object',
|
|
288
278
|
properties: {
|
|
@@ -292,12 +282,12 @@ export function browserToolDefinitions() {
|
|
|
292
282
|
},
|
|
293
283
|
additionalProperties: false,
|
|
294
284
|
},
|
|
295
|
-
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint:
|
|
285
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
296
286
|
_meta: APP_META,
|
|
297
287
|
},
|
|
298
288
|
{
|
|
299
289
|
name: 'browser_record_status',
|
|
300
|
-
description: 'Report
|
|
290
|
+
description: 'Report recording, finalizing, or recently completed state. Completed status includes the artifact and embeds the video as MCP media when small enough.',
|
|
301
291
|
inputSchema: {
|
|
302
292
|
type: 'object',
|
|
303
293
|
properties: {
|
|
@@ -309,6 +299,20 @@ export function browserToolDefinitions() {
|
|
|
309
299
|
annotations: READ_ANNOTATIONS,
|
|
310
300
|
_meta: APP_META,
|
|
311
301
|
},
|
|
302
|
+
{
|
|
303
|
+
name: 'browser_list_sessions',
|
|
304
|
+
description: 'List this caller owner scope\'s active and reserved browser sessions with lifecycle and capacity diagnostics.',
|
|
305
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
306
|
+
annotations: READ_ANNOTATIONS,
|
|
307
|
+
_meta: APP_META,
|
|
308
|
+
},
|
|
309
|
+
{
|
|
310
|
+
name: 'browser_release_owner',
|
|
311
|
+
description: 'Close every browser session owned by this authoritative caller scope. Idempotent.',
|
|
312
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
313
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
314
|
+
_meta: APP_META,
|
|
315
|
+
},
|
|
312
316
|
{
|
|
313
317
|
name: 'browser_diagnostics',
|
|
314
318
|
description: 'Report configured/actual browser mode, channel, headless state, persistence, locale, service-worker policy, and the active page fingerprint without opening a destination site.',
|
|
@@ -344,10 +348,11 @@ export function browserToolDefinitions() {
|
|
|
344
348
|
},
|
|
345
349
|
];
|
|
346
350
|
}
|
|
347
|
-
function success(data, extraContent = []) {
|
|
351
|
+
function success(data, extraContent = [], gatewaySessionToken) {
|
|
348
352
|
return {
|
|
349
353
|
content: [{ type: 'text', text: JSON.stringify(data) }, ...extraContent],
|
|
350
354
|
structuredContent: data,
|
|
355
|
+
...(gatewaySessionToken ? { _meta: { flujo: { gatewaySessionToken } } } : {}),
|
|
351
356
|
};
|
|
352
357
|
}
|
|
353
358
|
function failure(code, message, category) {
|
|
@@ -358,6 +363,40 @@ function failure(code, message, category) {
|
|
|
358
363
|
structuredContent: data,
|
|
359
364
|
};
|
|
360
365
|
}
|
|
366
|
+
async function recordingResult(data) {
|
|
367
|
+
if (data.success === false) {
|
|
368
|
+
return {
|
|
369
|
+
isError: true,
|
|
370
|
+
content: [{ type: 'text', text: JSON.stringify(data) }],
|
|
371
|
+
structuredContent: data,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
const outputPath = typeof data.outputPath === 'string' ? data.outputPath : undefined;
|
|
375
|
+
if (!outputPath || data.status !== 'stopped')
|
|
376
|
+
return success(data);
|
|
377
|
+
const stat = await fs.stat(outputPath).catch(() => undefined);
|
|
378
|
+
const maxBytesRaw = Number(process.env.FLUJO_BROWSER_INLINE_RECORDING_MAX_BYTES);
|
|
379
|
+
const maxBytes = Number.isFinite(maxBytesRaw) && maxBytesRaw > 0 ? Math.trunc(maxBytesRaw) : 16 * 1024 * 1024;
|
|
380
|
+
if (!stat?.isFile() || stat.size <= 0)
|
|
381
|
+
return success(data);
|
|
382
|
+
if (stat.size > maxBytes) {
|
|
383
|
+
const warnings = Array.isArray(data.warnings) ? [...data.warnings] : [];
|
|
384
|
+
warnings.push(`The ${stat.size}-byte video is available at outputPath but was not inlined into MCP because it exceeds the ${maxBytes}-byte transport limit.`);
|
|
385
|
+
return success({ ...data, warnings });
|
|
386
|
+
}
|
|
387
|
+
const mimeType = pathToFileURL(outputPath).pathname.toLowerCase().endsWith('.mp4')
|
|
388
|
+
? 'video/mp4'
|
|
389
|
+
: (pathToFileURL(outputPath).pathname.toLowerCase().endsWith('.mov') ? 'video/quicktime' : 'video/webm');
|
|
390
|
+
const blob = (await fs.readFile(outputPath)).toString('base64');
|
|
391
|
+
return success(data, [{
|
|
392
|
+
type: 'resource',
|
|
393
|
+
resource: {
|
|
394
|
+
uri: pathToFileURL(outputPath).href,
|
|
395
|
+
mimeType,
|
|
396
|
+
blob,
|
|
397
|
+
},
|
|
398
|
+
}]);
|
|
399
|
+
}
|
|
361
400
|
function normalizedError(error) {
|
|
362
401
|
if (error instanceof BrowserMcpError)
|
|
363
402
|
return error;
|
|
@@ -372,7 +411,8 @@ function normalizedError(error) {
|
|
|
372
411
|
if (/Target page, context or browser has been closed|browser has disconnected/i.test(message)) {
|
|
373
412
|
return new BrowserMcpError('BROWSER_UNAVAILABLE', 'The browser process became unavailable; open a new session.');
|
|
374
413
|
}
|
|
375
|
-
|
|
414
|
+
const useful = message.trim().replace(/\s+/g, ' ').slice(0, 800);
|
|
415
|
+
return new BrowserMcpError('UNEXPECTED', useful ? `The browser operation failed: ${useful}` : 'The browser operation failed.');
|
|
376
416
|
}
|
|
377
417
|
function objectArgs(value) {
|
|
378
418
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
@@ -391,92 +431,141 @@ function finiteNumberArg(args, key, fallback) {
|
|
|
391
431
|
const value = args[key];
|
|
392
432
|
if (value === undefined && fallback !== undefined)
|
|
393
433
|
return fallback;
|
|
394
|
-
|
|
434
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
435
|
+
if (!Number.isFinite(parsed)) {
|
|
395
436
|
throw new BrowserMcpError('INVALID_ARGUMENT', `${key} must be a finite number.`);
|
|
396
437
|
}
|
|
397
|
-
return
|
|
438
|
+
return parsed;
|
|
398
439
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (typeof args.sessionId === 'string' && args.sessionId.length > 0) {
|
|
402
|
-
const session = getSession(args.sessionId);
|
|
403
|
-
return { page: session.page, close: async () => undefined };
|
|
404
|
-
}
|
|
405
|
-
const { context, page } = await createCaptureContext(signal, viewport);
|
|
406
|
-
return { page, close: () => context.close().catch(() => undefined) };
|
|
440
|
+
function hasCaptureSource(args) {
|
|
441
|
+
return ['source', 'url', 'html', 'filePath'].some((key) => typeof args[key] === 'string' && String(args[key]).trim().length > 0);
|
|
407
442
|
}
|
|
408
|
-
async function
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
const deviceScaleFactor = finiteNumberArg(args, 'deviceScaleFactor', 1);
|
|
415
|
-
if (deviceScaleFactor < 1 || deviceScaleFactor > 3) {
|
|
416
|
-
throw new BrowserMcpError('INVALID_ARGUMENT', 'deviceScaleFactor must be between 1 and 3.');
|
|
417
|
-
}
|
|
418
|
-
const colorScheme = args.colorScheme === 'dark' ? 'dark' : 'light';
|
|
419
|
-
const fullPage = args.fullPage === true;
|
|
420
|
-
let clipSelector;
|
|
421
|
-
if (typeof args.clipSelector === 'string' && args.clipSelector.length > 0) {
|
|
422
|
-
if (args.clipSelector.length > MAX_SELECTOR_CHARS) {
|
|
423
|
-
throw new BrowserMcpError('INVALID_ARGUMENT', `clipSelector must be no longer than ${MAX_SELECTOR_CHARS} characters.`);
|
|
424
|
-
}
|
|
425
|
-
clipSelector = args.clipSelector;
|
|
426
|
-
}
|
|
427
|
-
const waitFor = typeof args.waitFor === 'string' && args.waitFor.length > 0 ? args.waitFor : undefined;
|
|
428
|
-
const source = await resolveCaptureSource({
|
|
443
|
+
async function optionalCaptureSource(args) {
|
|
444
|
+
if (!hasCaptureSource(args))
|
|
445
|
+
return undefined;
|
|
446
|
+
return resolveCaptureSource({
|
|
447
|
+
source: args.source,
|
|
429
448
|
url: typeof args.url === 'string' ? args.url : undefined,
|
|
430
449
|
html: typeof args.html === 'string' ? args.html : undefined,
|
|
431
450
|
filePath: typeof args.filePath === 'string' ? args.filePath : undefined,
|
|
432
451
|
allowLocal: args.allowLocal === true,
|
|
433
452
|
});
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
success: true,
|
|
441
|
-
path: filePath,
|
|
442
|
-
width,
|
|
443
|
-
height,
|
|
444
|
-
deviceScaleFactor,
|
|
445
|
-
colorType,
|
|
446
|
-
fullPage,
|
|
447
|
-
clipSelector: clipSelector ?? null,
|
|
448
|
-
bytes: png.length,
|
|
449
|
-
sha256: sha256Hex(png),
|
|
450
|
-
mimeType: 'image/png',
|
|
451
|
-
},
|
|
452
|
-
image: { data: png.toString('base64'), mimeType: 'image/png' },
|
|
453
|
-
};
|
|
453
|
+
}
|
|
454
|
+
/** Resolve the page a capture tool should operate on: an existing session's page, the active page, or an ephemeral context. */
|
|
455
|
+
async function acquireCapturePage(args, signal, viewport, preferActive, ownerScope) {
|
|
456
|
+
if (typeof args.sessionId === 'string' && args.sessionId.length > 0) {
|
|
457
|
+
const session = getSession(args.sessionId, ownerScope);
|
|
458
|
+
return { page: session.page, close: async () => undefined, session };
|
|
454
459
|
}
|
|
455
|
-
|
|
456
|
-
|
|
460
|
+
if (preferActive) {
|
|
461
|
+
try {
|
|
462
|
+
const session = getSession(undefined, ownerScope);
|
|
463
|
+
return { page: session.page, close: async () => undefined, session };
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
if (!(error instanceof BrowserMcpError) || error.code !== 'NOT_FOUND')
|
|
467
|
+
throw error;
|
|
468
|
+
}
|
|
457
469
|
}
|
|
470
|
+
const { context, page } = await createCaptureContext(signal, viewport);
|
|
471
|
+
return { page, close: () => context.close().catch(() => undefined) };
|
|
458
472
|
}
|
|
459
|
-
async function
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
473
|
+
async function captureRegionOrPage(args, signal, timeout, ownerScope) {
|
|
474
|
+
const resolution = normalizeResolution(args.resolution, args.width, args.height, {
|
|
475
|
+
defaultValue: { width: 1920, height: 1080 },
|
|
476
|
+
minWidth: 320,
|
|
477
|
+
minHeight: 240,
|
|
478
|
+
maxWidth: 3840,
|
|
479
|
+
maxHeight: 2160,
|
|
480
|
+
});
|
|
481
|
+
const rawScale = finiteParameter(args.deviceScaleFactor, 1);
|
|
482
|
+
const deviceScaleFactor = Math.min(3, Math.max(1, rawScale));
|
|
483
|
+
if (deviceScaleFactor !== rawScale)
|
|
484
|
+
resolution.warnings.push(`deviceScaleFactor was adjusted to ${deviceScaleFactor}.`);
|
|
485
|
+
const colorScheme = args.colorScheme === 'dark' ? 'dark' : 'light';
|
|
486
|
+
const fullPage = args.fullPage === true;
|
|
487
|
+
let clipSelector = typeof args.selector === 'string' && args.selector.trim()
|
|
488
|
+
? args.selector.trim()
|
|
489
|
+
: (typeof args.clipSelector === 'string' && args.clipSelector.trim() ? args.clipSelector.trim() : undefined);
|
|
490
|
+
if (clipSelector && clipSelector.length > MAX_SELECTOR_CHARS) {
|
|
491
|
+
resolution.warnings.push(`selector was shortened to ${MAX_SELECTOR_CHARS} characters.`);
|
|
492
|
+
clipSelector = clipSelector.slice(0, MAX_SELECTOR_CHARS);
|
|
466
493
|
}
|
|
467
|
-
|
|
468
|
-
|
|
494
|
+
const waitFor = typeof args.waitFor === 'string' && args.waitFor.length > 0 ? args.waitFor : undefined;
|
|
495
|
+
const source = await optionalCaptureSource(args);
|
|
496
|
+
const warnings = [...resolution.warnings, ...(source?.warnings ?? [])];
|
|
497
|
+
const attempts = [];
|
|
498
|
+
let lastError;
|
|
499
|
+
for (const candidate of resolutionFallbacks(resolution.effective).filter(({ width, height }) => width <= 3840 && height <= 2160)) {
|
|
500
|
+
const handle = await acquireCapturePage(args, signal, { ...candidate, deviceScaleFactor, colorScheme }, !source, ownerScope);
|
|
501
|
+
try {
|
|
502
|
+
if (resolution.explicit && typeof handle.page.setViewportSize === 'function') {
|
|
503
|
+
const current = handle.page.viewportSize();
|
|
504
|
+
if (handle.session?.viewportPolicy === 'fixed' && current
|
|
505
|
+
&& (current.width !== candidate.width || current.height !== candidate.height)) {
|
|
506
|
+
throw new BrowserMcpError('INVALID_ARGUMENT', 'A fixed recording viewport cannot be changed by a capture request.');
|
|
507
|
+
}
|
|
508
|
+
if (handle.session?.viewportPolicy !== 'fixed')
|
|
509
|
+
await handle.page.setViewportSize(candidate);
|
|
510
|
+
}
|
|
511
|
+
const { png, colorType } = await captureDeterministicPng(handle.page, source, { fullPage, clipSelector, waitFor, timeoutMs: timeout });
|
|
512
|
+
const filePath = await writeCaptureArtifact(typeof args.outputPath === 'string' ? args.outputPath : undefined, ['captures', `${randomUUID()}.png`], png);
|
|
513
|
+
if (attempts.length > 0)
|
|
514
|
+
warnings.push(`Capture recovered at ${candidate.width}x${candidate.height} after ${attempts.length} failed attempt(s).`);
|
|
515
|
+
return {
|
|
516
|
+
data: {
|
|
517
|
+
success: true,
|
|
518
|
+
path: filePath,
|
|
519
|
+
requestedResolution: resolution.requested,
|
|
520
|
+
effectiveResolution: candidate,
|
|
521
|
+
width: candidate.width,
|
|
522
|
+
height: candidate.height,
|
|
523
|
+
deviceScaleFactor,
|
|
524
|
+
colorType,
|
|
525
|
+
fullPage,
|
|
526
|
+
selector: clipSelector ?? null,
|
|
527
|
+
bytes: png.length,
|
|
528
|
+
sha256: sha256Hex(png),
|
|
529
|
+
mimeType: 'image/png',
|
|
530
|
+
warnings,
|
|
531
|
+
...(attempts.length ? { attempts } : {}),
|
|
532
|
+
},
|
|
533
|
+
image: { data: png.toString('base64'), mimeType: 'image/png' },
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
lastError = error;
|
|
538
|
+
attempts.push(`${candidate.width}x${candidate.height}: ${error instanceof Error ? error.message : 'capture failed'}`);
|
|
539
|
+
}
|
|
540
|
+
finally {
|
|
541
|
+
await handle.close();
|
|
542
|
+
}
|
|
469
543
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
allowLocal: args.allowLocal === true,
|
|
474
|
-
});
|
|
544
|
+
throw new BrowserMcpError('UNEXPECTED', `Capture failed after safe resolution fallbacks. ${attempts.join(' | ') || (lastError instanceof Error ? lastError.message : '')}`);
|
|
545
|
+
}
|
|
546
|
+
async function captureRegionTool(args, signal, timeout, ownerScope) {
|
|
475
547
|
const viewport = defaultViewport();
|
|
548
|
+
const region = args.region && typeof args.region === 'object' && !Array.isArray(args.region)
|
|
549
|
+
? args.region
|
|
550
|
+
: args;
|
|
551
|
+
const rawX = finiteParameter(region.x, 0);
|
|
552
|
+
const rawY = finiteParameter(region.y, 0);
|
|
553
|
+
const x = Math.min(3839, Math.max(0, Math.round(rawX)));
|
|
554
|
+
const y = Math.min(2159, Math.max(0, Math.round(rawY)));
|
|
555
|
+
const rawWidth = finiteParameter(region.width, Math.max(1, viewport.width - x));
|
|
556
|
+
const rawHeight = finiteParameter(region.height, Math.max(1, viewport.height - y));
|
|
557
|
+
const width = Math.min(3840 - x, Math.max(1, Math.round(rawWidth)));
|
|
558
|
+
const height = Math.min(2160 - y, Math.max(1, Math.round(rawHeight)));
|
|
559
|
+
const warnings = [];
|
|
560
|
+
if (x !== rawX || y !== rawY || width !== rawWidth || height !== rawHeight) {
|
|
561
|
+
warnings.push(`Region was normalized to x=${x}, y=${y}, width=${width}, height=${height}.`);
|
|
562
|
+
}
|
|
563
|
+
const source = await optionalCaptureSource(args);
|
|
564
|
+
warnings.push(...(source?.warnings ?? []));
|
|
476
565
|
const { page, close } = await acquireCapturePage(args, signal, {
|
|
477
566
|
width: Math.min(3840, Math.max(viewport.width, x + width)),
|
|
478
567
|
height: Math.min(2160, Math.max(viewport.height, y + height)),
|
|
479
|
-
});
|
|
568
|
+
}, !source, ownerScope);
|
|
480
569
|
try {
|
|
481
570
|
const { png, colorType } = await captureRegionPng(page, source, { x, y, width, height }, timeout);
|
|
482
571
|
const filePath = await writeCaptureArtifact(typeof args.outputPath === 'string' ? args.outputPath : undefined, ['regions', `${randomUUID()}.png`], png);
|
|
@@ -492,6 +581,7 @@ async function captureRegionTool(args, signal, timeout) {
|
|
|
492
581
|
bytes: png.length,
|
|
493
582
|
sha256: sha256Hex(png),
|
|
494
583
|
mimeType: 'image/png',
|
|
584
|
+
warnings,
|
|
495
585
|
},
|
|
496
586
|
image: { data: png.toString('base64'), mimeType: 'image/png' },
|
|
497
587
|
};
|
|
@@ -500,7 +590,7 @@ async function captureRegionTool(args, signal, timeout) {
|
|
|
500
590
|
await close();
|
|
501
591
|
}
|
|
502
592
|
}
|
|
503
|
-
async function captureElementMetricsTool(args, signal, timeout) {
|
|
593
|
+
async function captureElementMetricsTool(args, signal, timeout, ownerScope) {
|
|
504
594
|
const rawSelectors = args.selectors;
|
|
505
595
|
if (!Array.isArray(rawSelectors) || rawSelectors.length === 0) {
|
|
506
596
|
throw new BrowserMcpError('INVALID_ARGUMENT', 'Provide a non-empty "selectors" array.');
|
|
@@ -511,36 +601,31 @@ async function captureElementMetricsTool(args, signal, timeout) {
|
|
|
511
601
|
if (selectors.length === 0) {
|
|
512
602
|
throw new BrowserMcpError('INVALID_ARGUMENT', `Each selector must be 1-${MAX_SELECTOR_CHARS} characters.`);
|
|
513
603
|
}
|
|
514
|
-
const hasSource =
|
|
515
|
-
const allowLocal = args.allowLocal === true;
|
|
604
|
+
const hasSource = hasCaptureSource(args);
|
|
516
605
|
if (typeof args.sessionId === 'string' && args.sessionId.length > 0) {
|
|
517
|
-
const session = getSession(args.sessionId);
|
|
606
|
+
const session = getSession(args.sessionId, ownerScope);
|
|
518
607
|
if (hasSource) {
|
|
519
|
-
const source = await
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
allowLocal,
|
|
523
|
-
});
|
|
608
|
+
const source = await optionalCaptureSource(args);
|
|
609
|
+
if (!source)
|
|
610
|
+
throw new BrowserMcpError('INVALID_ARGUMENT', 'Could not resolve the supplied source.');
|
|
524
611
|
await navigateCaptureSource(session.page, source, timeout);
|
|
525
612
|
}
|
|
526
613
|
return { success: true, metrics: await evaluateElementMetrics(session.page, selectors) };
|
|
527
614
|
}
|
|
528
615
|
if (hasSource) {
|
|
529
|
-
const source = await
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
allowLocal,
|
|
533
|
-
});
|
|
616
|
+
const source = await optionalCaptureSource(args);
|
|
617
|
+
if (!source)
|
|
618
|
+
throw new BrowserMcpError('INVALID_ARGUMENT', 'Could not resolve the supplied source.');
|
|
534
619
|
const { context, page } = await createCaptureContext(signal, defaultViewport());
|
|
535
620
|
try {
|
|
536
621
|
await navigateCaptureSource(page, source, timeout);
|
|
537
|
-
return { success: true, metrics: await evaluateElementMetrics(page, selectors) };
|
|
622
|
+
return { success: true, metrics: await evaluateElementMetrics(page, selectors), warnings: source.warnings };
|
|
538
623
|
}
|
|
539
624
|
finally {
|
|
540
625
|
await context.close().catch(() => undefined);
|
|
541
626
|
}
|
|
542
627
|
}
|
|
543
|
-
const session = getSession(undefined);
|
|
628
|
+
const session = getSession(undefined, ownerScope);
|
|
544
629
|
return { success: true, metrics: await evaluateElementMetrics(session.page, selectors) };
|
|
545
630
|
}
|
|
546
631
|
function siteBlockClassification(status, title, text, url) {
|
|
@@ -590,7 +675,7 @@ async function navigate(session, rawUrl, timeout, signal) {
|
|
|
590
675
|
// Install the main-world audio hook before page.goto: once a page has created
|
|
591
676
|
// its AudioContext or fired a media play event, it cannot be intercepted
|
|
592
677
|
// retroactively.
|
|
593
|
-
await prepareBrowserAudioStream(session.id);
|
|
678
|
+
await prepareBrowserAudioStream(session.id, session.gatewayToken ?? '');
|
|
594
679
|
resetNavigationCounter(session);
|
|
595
680
|
return runCancellable(session, signal, async () => {
|
|
596
681
|
try {
|
|
@@ -605,22 +690,24 @@ async function navigate(session, rawUrl, timeout, signal) {
|
|
|
605
690
|
}
|
|
606
691
|
});
|
|
607
692
|
}
|
|
608
|
-
export async function browserCallTool(name, rawArgs, signal) {
|
|
693
|
+
export async function browserCallTool(name, rawArgs, signal, ownerScope) {
|
|
609
694
|
try {
|
|
610
695
|
const args = objectArgs(rawArgs);
|
|
611
696
|
if (name === 'browser_open') {
|
|
612
|
-
const session = await openSession(args.sessionId, signal);
|
|
697
|
+
const session = await openSession(args.sessionId, signal, ownerScope);
|
|
613
698
|
const timeout = timeoutMs(args.timeoutMs);
|
|
614
699
|
const data = typeof args.url === 'string' && args.url.length > 0
|
|
615
700
|
? await navigate(session, args.url, timeout, signal)
|
|
616
701
|
: { success: true, ...publicPageState(session) };
|
|
617
|
-
|
|
702
|
+
// Keep the session identity in the structured result at the process
|
|
703
|
+
// boundary; callers must not scrape the human-readable text payload.
|
|
704
|
+
return success({ ...data, sessionId: session.id }, [], session.gatewayToken);
|
|
618
705
|
}
|
|
619
706
|
if (name === 'browser_close') {
|
|
620
707
|
let sessionId;
|
|
621
708
|
if (args.sessionId === undefined || args.sessionId === '') {
|
|
622
709
|
try {
|
|
623
|
-
sessionId = getSession(undefined).id;
|
|
710
|
+
sessionId = getSession(undefined, ownerScope).id;
|
|
624
711
|
}
|
|
625
712
|
catch (error) {
|
|
626
713
|
if (error instanceof BrowserMcpError && error.code === 'NOT_FOUND') {
|
|
@@ -632,17 +719,23 @@ export async function browserCallTool(name, rawArgs, signal) {
|
|
|
632
719
|
else {
|
|
633
720
|
sessionId = stringArg(args, 'sessionId', 64);
|
|
634
721
|
}
|
|
635
|
-
const closed = await closeSession(sessionId);
|
|
722
|
+
const closed = await closeSession(sessionId, ownerScope);
|
|
636
723
|
return success({ success: true, sessionId, closed });
|
|
637
724
|
}
|
|
725
|
+
if (name === 'browser_list_sessions')
|
|
726
|
+
return success(listSessions(ownerScope));
|
|
727
|
+
if (name === 'browser_release_owner') {
|
|
728
|
+
const finalizedRecordings = await releaseRecordingsForOwner(ownerScope);
|
|
729
|
+
return success({ ...await releaseOwnerScope(ownerScope), finalizedRecordings });
|
|
730
|
+
}
|
|
638
731
|
if (name === 'browser_diagnostics') {
|
|
639
732
|
let session;
|
|
640
733
|
if (typeof args.sessionId === 'string' && args.sessionId.length > 0) {
|
|
641
|
-
session = getSession(args.sessionId);
|
|
734
|
+
session = getSession(args.sessionId, ownerScope);
|
|
642
735
|
}
|
|
643
736
|
else {
|
|
644
737
|
try {
|
|
645
|
-
session = getSession(undefined);
|
|
738
|
+
session = getSession(undefined, ownerScope);
|
|
646
739
|
}
|
|
647
740
|
catch (error) {
|
|
648
741
|
if (!(error instanceof BrowserMcpError) || error.code !== 'NOT_FOUND')
|
|
@@ -656,36 +749,42 @@ export async function browserCallTool(name, rawArgs, signal) {
|
|
|
656
749
|
}
|
|
657
750
|
const timeout = timeoutMs(args.timeoutMs);
|
|
658
751
|
if (name === 'browser_capture_page') {
|
|
659
|
-
const result = await captureRegionOrPage(args, signal, timeout);
|
|
752
|
+
const result = await captureRegionOrPage(args, signal, timeout, ownerScope);
|
|
660
753
|
return success(result.data, [{ type: 'image', data: result.image.data, mimeType: result.image.mimeType }]);
|
|
661
754
|
}
|
|
662
755
|
if (name === 'browser_capture_region') {
|
|
663
|
-
const result = await captureRegionTool(args, signal, timeout);
|
|
756
|
+
const result = await captureRegionTool(args, signal, timeout, ownerScope);
|
|
664
757
|
return success(result.data, [{ type: 'image', data: result.image.data, mimeType: result.image.mimeType }]);
|
|
665
758
|
}
|
|
666
759
|
if (name === 'browser_capture_element_metrics') {
|
|
667
|
-
return success(await captureElementMetricsTool(args, signal, timeout));
|
|
760
|
+
return success(await captureElementMetricsTool(args, signal, timeout, ownerScope));
|
|
668
761
|
}
|
|
669
762
|
if (name === 'browser_record_start') {
|
|
670
|
-
return
|
|
763
|
+
return recordingResult(await startRecording({
|
|
764
|
+
source: args.source,
|
|
765
|
+
resolution: args.resolution,
|
|
671
766
|
width: args.width,
|
|
672
767
|
height: args.height,
|
|
673
768
|
audio: args.audio,
|
|
674
769
|
durationMs: args.durationMs,
|
|
675
770
|
outputPath: args.outputPath,
|
|
676
|
-
|
|
771
|
+
url: args.url,
|
|
772
|
+
html: args.html,
|
|
773
|
+
filePath: args.filePath,
|
|
774
|
+
timeoutMs: args.timeoutMs,
|
|
775
|
+
}, signal, ownerScope));
|
|
677
776
|
}
|
|
678
777
|
if (name === 'browser_record_stop') {
|
|
679
|
-
return
|
|
778
|
+
return recordingResult(await stopRecording({
|
|
680
779
|
recordingId: args.recordingId,
|
|
681
780
|
sessionId: args.sessionId,
|
|
682
781
|
outputPath: args.outputPath,
|
|
683
|
-
}));
|
|
782
|
+
}, ownerScope));
|
|
684
783
|
}
|
|
685
784
|
if (name === 'browser_record_status') {
|
|
686
|
-
return
|
|
785
|
+
return recordingResult(recordingStatus({ recordingId: args.recordingId, sessionId: args.sessionId }, ownerScope));
|
|
687
786
|
}
|
|
688
|
-
const session = getSession(args.sessionId);
|
|
787
|
+
const session = getSession(args.sessionId, ownerScope);
|
|
689
788
|
if (name === 'browser_navigate') {
|
|
690
789
|
return success(await navigate(session, stringArg(args, 'url', 8_192), timeout, signal));
|
|
691
790
|
}
|
|
@@ -800,22 +899,49 @@ export async function browserCallTool(name, rawArgs, signal) {
|
|
|
800
899
|
}
|
|
801
900
|
if (name === 'browser_screenshot') {
|
|
802
901
|
const fullPage = args.fullPage === true;
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
902
|
+
const capturedAt = new Date().toISOString();
|
|
903
|
+
const artifactId = randomUUID();
|
|
904
|
+
const [png, deviceScaleFactor] = await Promise.all([
|
|
905
|
+
runCancellable(session, signal, () => session.page.screenshot({
|
|
906
|
+
type: 'png',
|
|
907
|
+
fullPage,
|
|
908
|
+
timeout,
|
|
909
|
+
})),
|
|
910
|
+
session.page.evaluate('window.devicePixelRatio').catch(() => session.deviceScaleFactor ?? 1),
|
|
911
|
+
]);
|
|
808
912
|
if (png.length > MAX_SCREENSHOT_BYTES) {
|
|
809
913
|
throw new BrowserMcpError('INVALID_ARGUMENT', 'The screenshot exceeded the 5 MB artifact limit.');
|
|
810
914
|
}
|
|
811
|
-
const filePath = await
|
|
915
|
+
const filePath = await writeCaptureArtifact(typeof args.outputPath === 'string' && args.outputPath.length > 0 ? args.outputPath : undefined, [session.id, `${artifactId}-${fullPage ? 'full-page' : 'viewport'}.png`], png);
|
|
916
|
+
const viewport = session.page.viewportSize();
|
|
917
|
+
const encodedPng = pngDimensions(png);
|
|
812
918
|
const data = {
|
|
813
919
|
success: true,
|
|
814
920
|
...publicPageState(session),
|
|
921
|
+
artifactId,
|
|
815
922
|
path: filePath,
|
|
816
923
|
mimeType: 'image/png',
|
|
817
924
|
bytes: png.length,
|
|
818
|
-
|
|
925
|
+
sha256: sha256Hex(png),
|
|
926
|
+
capturedAt,
|
|
927
|
+
viewport,
|
|
928
|
+
actualViewport: viewport,
|
|
929
|
+
deviceScaleFactor,
|
|
930
|
+
encodedPng,
|
|
931
|
+
fullPage,
|
|
932
|
+
...(session.recordingId ? {
|
|
933
|
+
recordingId: session.recordingId,
|
|
934
|
+
recordingGeometry: {
|
|
935
|
+
configuredViewport: session.configuredViewport,
|
|
936
|
+
configuredVideoResolution: session.configuredVideoResolution,
|
|
937
|
+
deviceScaleFactor: session.deviceScaleFactor ?? deviceScaleFactor,
|
|
938
|
+
viewportPolicy: session.viewportPolicy,
|
|
939
|
+
},
|
|
940
|
+
} : {}),
|
|
941
|
+
captureConditions: {
|
|
942
|
+
exactPixelArtifact: !fullPage,
|
|
943
|
+
note: 'PNG is lossless; callers remain responsible for freezing application animation, timestamps, cursors, and overlays.',
|
|
944
|
+
},
|
|
819
945
|
};
|
|
820
946
|
return success(data, [{ type: 'image', data: png.toString('base64'), mimeType: 'image/png' }]);
|
|
821
947
|
}
|