@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/dist/tools.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { BrowserMcpError, assertNavigationAllowed, browserDiagnostics, browserExtensions, closeSession, createCaptureContext, defaultViewport, failureCategoryForCode, getSession, openSession, publicPageState, resetNavigationCounter, runCancellable, timeoutMs, writeScreenshotArtifact, } from './runtime.js';
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 or reuse a browser session in the configured mode: isolated incognito sandbox, or trusted persistent Chrome. Optionally navigates to an allowed HTTP(S) URL.',
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 reuse the most recently used live session, or create one when none exists.' },
47
- url: { type: 'string', description: 'Optional initial HTTP(S) URL.' },
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 an existing browser session to an allowed HTTP(S) URL. The result distinguishes FLUJO policy blocks from destination-site/WAF blocks.',
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 a PNG screenshot, persist it under the FLUJO data directory, and report its full absolute file path.',
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 deterministic PNG screenshot of a page, inline HTML, or a local file with viewport control, disabled animations, and a fonts-ready wait. Returns the PNG as a run-resource image artifact.',
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
- sessionId: { ...SESSION_PROPERTY, description: 'Optional: capture in an existing session\'s page instead of an ephemeral one.' },
204
- url: { type: 'string', description: 'HTTP/HTTPS/file:// URL or localhost (file:// and localhost require allowLocal=true + FLUJO_BROWSER_ALLOW_LOCAL_CAPTURE).' },
205
- html: { type: 'string', maxLength: 500000, description: 'Inline HTML to render instead of loading a URL.' },
206
- filePath: { type: 'string', description: 'Local file path resolved to file:// (requires allowLocal=true + FLUJO_BROWSER_ALLOW_LOCAL_CAPTURE).' },
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
- additionalProperties: false,
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
- url: { type: 'string', description: 'Optional URL to navigate to first (in the given session, or an ephemeral one).' },
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: false,
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 specific rectangular pixel region of a page. Cheaper than full-page capture when the exact region is already known.',
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
- url: { type: 'string', description: 'HTTP/HTTPS/file:// URL or localhost (file:// and localhost require allowLocal=true + FLUJO_BROWSER_ALLOW_LOCAL_CAPTURE).' },
250
- filePath: { type: 'string', description: 'Local file path resolved to file:// (requires allowLocal=true + FLUJO_BROWSER_ALLOW_LOCAL_CAPTURE).' },
251
- x: { type: 'integer', minimum: 0, default: 0 },
252
- y: { type: 'integer', minimum: 0, default: 0 },
253
- width: { type: 'integer', minimum: 1, maximum: 3840 },
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
- anyOf: [{ required: ['url'] }, { required: ['filePath'] }],
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 recording a fresh, dedicated browser session as a WebM video with optional audio (Web Audio + <audio>/<video> tapped via CDP). Drive the returned sessionId with the ordinary browser_* tools, then call browser_record_stop. If durationMs is given, the recording auto-stops and this call returns the finished artifact.',
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
- width: { type: 'integer', minimum: 320, maximum: 3840, description: 'Recording viewport width (default matches FLUJO_BROWSER_VIEWPORT_WIDTH).' },
273
- height: { type: 'integer', minimum: 240, maximum: 2160, description: 'Recording viewport height (default matches FLUJO_BROWSER_VIEWPORT_HEIGHT).' },
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', minimum: 250, description: 'Auto-stop after this many milliseconds and return the finished artifact (clamped to FLUJO_BROWSER_RECORD_MAX_MS).' },
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: false,
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 running recording and return its artifact metadata (video path, optional audio path, muxed output if ffmpeg was available).',
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: false, openWorldHint: false },
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 whether a recording is running and its elapsed time/captured audio bytes, without taking a screenshot.',
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
- return new BrowserMcpError('UNEXPECTED', 'The browser operation failed.');
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
- if (typeof value !== 'number' || !Number.isFinite(value)) {
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 value;
438
+ return parsed;
398
439
  }
399
- /** Resolve the page a capture tool should operate on: an existing session's page, or a fresh ephemeral context. */
400
- async function acquireCapturePage(args, signal, viewport) {
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 captureRegionOrPage(args, signal, timeout) {
409
- const width = finiteNumberArg(args, 'width', 1920);
410
- const height = finiteNumberArg(args, 'height', 1080);
411
- if (!Number.isInteger(width) || width < 320 || width > 1920 || !Number.isInteger(height) || height < 240 || height > 1080) {
412
- throw new BrowserMcpError('INVALID_ARGUMENT', 'width must be an integer 320-1920 and height an integer 240-1080.');
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
- const { page, close } = await acquireCapturePage(args, signal, { width, height, deviceScaleFactor, colorScheme });
435
- try {
436
- const { png, colorType } = await captureDeterministicPng(page, source, { fullPage, clipSelector, waitFor, timeoutMs: timeout });
437
- const filePath = await writeCaptureArtifact(typeof args.outputPath === 'string' ? args.outputPath : undefined, ['captures', `${randomUUID()}.png`], png);
438
- return {
439
- data: {
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
- finally {
456
- await close();
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 captureRegionTool(args, signal, timeout) {
460
- const x = finiteNumberArg(args, 'x', 0);
461
- const y = finiteNumberArg(args, 'y', 0);
462
- const width = finiteNumberArg(args, 'width');
463
- const height = finiteNumberArg(args, 'height');
464
- if (!Number.isInteger(x) || x < 0 || !Number.isInteger(y) || y < 0) {
465
- throw new BrowserMcpError('INVALID_ARGUMENT', 'x and y must be non-negative integers.');
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
- if (!Number.isInteger(width) || width < 1 || width > 3840 || !Number.isInteger(height) || height < 1 || height > 2160) {
468
- throw new BrowserMcpError('INVALID_ARGUMENT', 'width and height must be positive integers up to 3840x2160.');
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
- const source = await resolveCaptureSource({
471
- url: typeof args.url === 'string' ? args.url : undefined,
472
- filePath: typeof args.filePath === 'string' ? args.filePath : undefined,
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 = typeof args.url === 'string' || typeof args.filePath === 'string';
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 resolveCaptureSource({
520
- url: typeof args.url === 'string' ? args.url : undefined,
521
- filePath: typeof args.filePath === 'string' ? args.filePath : undefined,
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 resolveCaptureSource({
530
- url: typeof args.url === 'string' ? args.url : undefined,
531
- filePath: typeof args.filePath === 'string' ? args.filePath : undefined,
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
- return success(data);
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 success(await startRecording({
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
- }, signal));
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 success(await stopRecording({
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 success(recordingStatus({ recordingId: args.recordingId, sessionId: args.sessionId }));
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 png = await runCancellable(session, signal, () => session.page.screenshot({
804
- type: 'png',
805
- fullPage,
806
- timeout,
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 writeScreenshotArtifact(session.id, fullPage, png);
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
- viewport: session.page.viewportSize(),
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
  }