@mario.andreschak/mcp-browser 3.42.1 → 3.44.0

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,5 +1,9 @@
1
- import { BrowserMcpError, assertNavigationAllowed, closeSession, getSession, openSession, publicPageState, resetNavigationCounter, runCancellable, timeoutMs, } from './runtime.js';
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
3
  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 { prepareBrowserAudioStream } from './gateway.js';
3
7
  const MAX_TEXT_CHARS = 50_000;
4
8
  const MAX_SELECTOR_CHARS = 2_000;
5
9
  const MAX_SCREENSHOT_BYTES = 5_000_000;
@@ -23,7 +27,7 @@ const APP_META = {
23
27
  const SESSION_PROPERTY = {
24
28
  type: 'string',
25
29
  pattern: '^[A-Za-z0-9_-]{1,64}$',
26
- description: 'Opaque browser session identifier returned by browser_open.',
30
+ description: 'Optional browser session identifier. When omitted, the most recently used live session is reused.',
27
31
  };
28
32
  const TIMEOUT_PROPERTY = {
29
33
  type: 'number',
@@ -35,11 +39,11 @@ export function browserToolDefinitions() {
35
39
  return [
36
40
  {
37
41
  name: 'browser_open',
38
- description: 'Open an isolated incognito browser session, optionally navigating to an allowed HTTP(S) URL.',
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.',
39
43
  inputSchema: {
40
44
  type: 'object',
41
45
  properties: {
42
- sessionId: { ...SESSION_PROPERTY, description: 'Optional stable id for bounded session reuse.' },
46
+ sessionId: { ...SESSION_PROPERTY, description: 'Optional stable id. Omit it to reuse the most recently used live session, or create one when none exists.' },
43
47
  url: { type: 'string', description: 'Optional initial HTTP(S) URL.' },
44
48
  timeoutMs: TIMEOUT_PROPERTY,
45
49
  },
@@ -50,11 +54,44 @@ export function browserToolDefinitions() {
50
54
  },
51
55
  {
52
56
  name: 'browser_navigate',
53
- description: 'Navigate an existing isolated browser session to an allowed HTTP(S) URL.',
57
+ description: 'Navigate an existing browser session to an allowed HTTP(S) URL. The result distinguishes FLUJO policy blocks from destination-site/WAF blocks.',
54
58
  inputSchema: {
55
59
  type: 'object',
56
60
  properties: { sessionId: SESSION_PROPERTY, url: { type: 'string' }, timeoutMs: TIMEOUT_PROPERTY },
57
- required: ['sessionId', 'url'],
61
+ required: ['url'],
62
+ additionalProperties: false,
63
+ },
64
+ annotations: INTERACTION_ANNOTATIONS,
65
+ _meta: APP_META,
66
+ },
67
+ {
68
+ name: 'browser_back',
69
+ description: 'Navigate an existing browser session backward in its page history.',
70
+ inputSchema: {
71
+ type: 'object',
72
+ properties: { sessionId: SESSION_PROPERTY, timeoutMs: TIMEOUT_PROPERTY },
73
+ additionalProperties: false,
74
+ },
75
+ annotations: INTERACTION_ANNOTATIONS,
76
+ _meta: APP_META,
77
+ },
78
+ {
79
+ name: 'browser_forward',
80
+ description: 'Navigate an existing browser session forward in its page history.',
81
+ inputSchema: {
82
+ type: 'object',
83
+ properties: { sessionId: SESSION_PROPERTY, timeoutMs: TIMEOUT_PROPERTY },
84
+ additionalProperties: false,
85
+ },
86
+ annotations: INTERACTION_ANNOTATIONS,
87
+ _meta: APP_META,
88
+ },
89
+ {
90
+ name: 'browser_reload',
91
+ description: 'Reload the current page in an existing browser session.',
92
+ inputSchema: {
93
+ type: 'object',
94
+ properties: { sessionId: SESSION_PROPERTY, timeoutMs: TIMEOUT_PROPERTY },
58
95
  additionalProperties: false,
59
96
  },
60
97
  annotations: INTERACTION_ANNOTATIONS,
@@ -66,7 +103,6 @@ export function browserToolDefinitions() {
66
103
  inputSchema: {
67
104
  type: 'object',
68
105
  properties: { sessionId: SESSION_PROPERTY, timeoutMs: TIMEOUT_PROPERTY },
69
- required: ['sessionId'],
70
106
  additionalProperties: false,
71
107
  },
72
108
  annotations: READ_ANNOTATIONS,
@@ -74,15 +110,19 @@ export function browserToolDefinitions() {
74
110
  },
75
111
  {
76
112
  name: 'browser_click',
77
- description: 'Click the first element matching an explicit selector in an existing browser session.',
113
+ description: 'Click either the first element matching a selector or viewport coordinates in an existing browser session.',
78
114
  inputSchema: {
79
115
  type: 'object',
80
116
  properties: {
81
117
  sessionId: SESSION_PROPERTY,
82
118
  selector: { type: 'string', minLength: 1, maxLength: MAX_SELECTOR_CHARS },
119
+ x: { type: 'number', minimum: 0, description: 'Viewport x coordinate in CSS pixels.' },
120
+ y: { type: 'number', minimum: 0, description: 'Viewport y coordinate in CSS pixels.' },
121
+ button: { type: 'string', enum: ['left', 'right', 'middle'], default: 'left' },
122
+ clickCount: { type: 'integer', minimum: 1, maximum: 3, default: 1 },
83
123
  timeoutMs: TIMEOUT_PROPERTY,
84
124
  },
85
- required: ['sessionId', 'selector'],
125
+ anyOf: [{ required: ['selector'] }, { required: ['x', 'y'] }],
86
126
  additionalProperties: false,
87
127
  },
88
128
  annotations: INTERACTION_ANNOTATIONS,
@@ -90,7 +130,7 @@ export function browserToolDefinitions() {
90
130
  },
91
131
  {
92
132
  name: 'browser_type',
93
- description: 'Fill the first element matching an explicit selector; optionally press Enter afterward.',
133
+ description: 'Fill an element matching a selector, or type into the currently focused page element; optionally press Enter afterward.',
94
134
  inputSchema: {
95
135
  type: 'object',
96
136
  properties: {
@@ -100,7 +140,40 @@ export function browserToolDefinitions() {
100
140
  submit: { type: 'boolean', default: false },
101
141
  timeoutMs: TIMEOUT_PROPERTY,
102
142
  },
103
- required: ['sessionId', 'selector', 'text'],
143
+ required: ['text'],
144
+ additionalProperties: false,
145
+ },
146
+ annotations: INTERACTION_ANNOTATIONS,
147
+ _meta: APP_META,
148
+ },
149
+ {
150
+ name: 'browser_press',
151
+ description: 'Press a keyboard key or shortcut in the currently focused page element.',
152
+ inputSchema: {
153
+ type: 'object',
154
+ properties: {
155
+ sessionId: SESSION_PROPERTY,
156
+ key: { type: 'string', minLength: 1, maxLength: 100, description: 'Patchright key name or shortcut, such as Enter, Tab, ArrowDown, or Control+A.' },
157
+ timeoutMs: TIMEOUT_PROPERTY,
158
+ },
159
+ required: ['key'],
160
+ additionalProperties: false,
161
+ },
162
+ annotations: INTERACTION_ANNOTATIONS,
163
+ _meta: APP_META,
164
+ },
165
+ {
166
+ name: 'browser_scroll',
167
+ description: 'Scroll the current page by viewport-relative pixel deltas.',
168
+ inputSchema: {
169
+ type: 'object',
170
+ properties: {
171
+ sessionId: SESSION_PROPERTY,
172
+ deltaX: { type: 'number', minimum: -100000, maximum: 100000, default: 0 },
173
+ deltaY: { type: 'number', minimum: -100000, maximum: 100000, default: 0 },
174
+ timeoutMs: TIMEOUT_PROPERTY,
175
+ },
176
+ anyOf: [{ required: ['deltaX'] }, { required: ['deltaY'] }],
104
177
  additionalProperties: false,
105
178
  },
106
179
  annotations: INTERACTION_ANNOTATIONS,
@@ -108,7 +181,7 @@ export function browserToolDefinitions() {
108
181
  },
109
182
  {
110
183
  name: 'browser_screenshot',
111
- description: 'Capture a PNG screenshot of the current page in memory; no host filesystem path is exposed.',
184
+ description: 'Capture a PNG screenshot, persist it under the FLUJO data directory, and report its full absolute file path.',
112
185
  inputSchema: {
113
186
  type: 'object',
114
187
  properties: {
@@ -116,7 +189,143 @@ export function browserToolDefinitions() {
116
189
  fullPage: { type: 'boolean', default: false },
117
190
  timeoutMs: TIMEOUT_PROPERTY,
118
191
  },
119
- required: ['sessionId'],
192
+ additionalProperties: false,
193
+ },
194
+ annotations: READ_ANNOTATIONS,
195
+ _meta: APP_META,
196
+ },
197
+ {
198
+ 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.',
200
+ inputSchema: {
201
+ type: 'object',
202
+ 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 },
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
+ outputPath: { type: 'string', description: 'Optional destination path, confined to the FLUJO data directory.' },
216
+ timeoutMs: TIMEOUT_PROPERTY,
217
+ },
218
+ additionalProperties: false,
219
+ },
220
+ annotations: READ_ANNOTATIONS,
221
+ _meta: APP_META,
222
+ },
223
+ {
224
+ name: 'browser_capture_element_metrics',
225
+ description: 'Query per-selector layout metrics (bounding box, computed style, overflow/clipping flags, viewport visibility) without taking a screenshot.',
226
+ inputSchema: {
227
+ type: 'object',
228
+ properties: {
229
+ selectors: { type: 'array', items: { type: 'string', minLength: 1, maxLength: MAX_SELECTOR_CHARS }, minItems: 1, maxItems: 50 },
230
+ 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,
235
+ },
236
+ required: ['selectors'],
237
+ additionalProperties: false,
238
+ },
239
+ annotations: READ_ANNOTATIONS,
240
+ _meta: APP_META,
241
+ },
242
+ {
243
+ 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.',
245
+ inputSchema: {
246
+ type: 'object',
247
+ properties: {
248
+ 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 },
256
+ outputPath: { type: 'string', description: 'Optional destination path, confined to the FLUJO data directory.' },
257
+ timeoutMs: TIMEOUT_PROPERTY,
258
+ },
259
+ anyOf: [{ required: ['url'] }, { required: ['filePath'] }],
260
+ required: ['width', 'height'],
261
+ additionalProperties: false,
262
+ },
263
+ annotations: READ_ANNOTATIONS,
264
+ _meta: APP_META,
265
+ },
266
+ {
267
+ 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.',
269
+ inputSchema: {
270
+ type: 'object',
271
+ 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).' },
274
+ 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).' },
276
+ outputPath: { type: 'string', description: 'Optional destination path for the finished artifact, confined to the FLUJO data directory.' },
277
+ },
278
+ additionalProperties: false,
279
+ },
280
+ annotations: INTERACTION_ANNOTATIONS,
281
+ _meta: APP_META,
282
+ },
283
+ {
284
+ 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).',
286
+ inputSchema: {
287
+ type: 'object',
288
+ properties: {
289
+ recordingId: { ...SESSION_PROPERTY, description: 'Recording id returned by browser_record_start (same as its sessionId). Omit when exactly one recording is running.' },
290
+ sessionId: SESSION_PROPERTY,
291
+ outputPath: { type: 'string', description: 'Optional destination path for the finished artifact, confined to the FLUJO data directory.' },
292
+ },
293
+ additionalProperties: false,
294
+ },
295
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false },
296
+ _meta: APP_META,
297
+ },
298
+ {
299
+ name: 'browser_record_status',
300
+ description: 'Report whether a recording is running and its elapsed time/captured audio bytes, without taking a screenshot.',
301
+ inputSchema: {
302
+ type: 'object',
303
+ properties: {
304
+ recordingId: SESSION_PROPERTY,
305
+ sessionId: SESSION_PROPERTY,
306
+ },
307
+ additionalProperties: false,
308
+ },
309
+ annotations: READ_ANNOTATIONS,
310
+ _meta: APP_META,
311
+ },
312
+ {
313
+ name: 'browser_diagnostics',
314
+ description: 'Report configured/actual browser mode, channel, headless state, persistence, locale, service-worker policy, and the active page fingerprint without opening a destination site.',
315
+ inputSchema: {
316
+ type: 'object',
317
+ properties: { sessionId: SESSION_PROPERTY },
318
+ additionalProperties: false,
319
+ },
320
+ annotations: READ_ANNOTATIONS,
321
+ _meta: APP_META,
322
+ },
323
+ {
324
+ name: 'browser_extensions',
325
+ description: 'List extensions installed in FLUJO\'s dedicated trusted Chrome profile, explicitly configured unpacked-extension directories, and currently active extension targets. Never reads the personal Chrome profile.',
326
+ inputSchema: {
327
+ type: 'object',
328
+ properties: {},
120
329
  additionalProperties: false,
121
330
  },
122
331
  annotations: READ_ANNOTATIONS,
@@ -124,11 +333,10 @@ export function browserToolDefinitions() {
124
333
  },
125
334
  {
126
335
  name: 'browser_close',
127
- description: 'Close an isolated browser session and discard its cookies, storage, and temporary state.',
336
+ description: 'Close the session tab. Sandbox state is discarded; trusted-mode cookies and profile state remain in the dedicated persistent profile.',
128
337
  inputSchema: {
129
338
  type: 'object',
130
339
  properties: { sessionId: SESSION_PROPERTY },
131
- required: ['sessionId'],
132
340
  additionalProperties: false,
133
341
  },
134
342
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
@@ -142,8 +350,8 @@ function success(data, extraContent = []) {
142
350
  structuredContent: data,
143
351
  };
144
352
  }
145
- function failure(code, message) {
146
- const data = { success: false, error: { code, message } };
353
+ function failure(code, message, category) {
354
+ const data = { success: false, error: { code, category: category ?? failureCategoryForCode(code), message } };
147
355
  return {
148
356
  isError: true,
149
357
  content: [{ type: 'text', text: JSON.stringify(data) }],
@@ -179,7 +387,188 @@ function stringArg(args, key, maxLength = 100_000) {
179
387
  }
180
388
  return value;
181
389
  }
182
- async function pageState(session, timeout) {
390
+ function finiteNumberArg(args, key, fallback) {
391
+ const value = args[key];
392
+ if (value === undefined && fallback !== undefined)
393
+ return fallback;
394
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
395
+ throw new BrowserMcpError('INVALID_ARGUMENT', `${key} must be a finite number.`);
396
+ }
397
+ return value;
398
+ }
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) };
407
+ }
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({
429
+ url: typeof args.url === 'string' ? args.url : undefined,
430
+ html: typeof args.html === 'string' ? args.html : undefined,
431
+ filePath: typeof args.filePath === 'string' ? args.filePath : undefined,
432
+ allowLocal: args.allowLocal === true,
433
+ });
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
+ };
454
+ }
455
+ finally {
456
+ await close();
457
+ }
458
+ }
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.');
466
+ }
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.');
469
+ }
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
+ });
475
+ const viewport = defaultViewport();
476
+ const { page, close } = await acquireCapturePage(args, signal, {
477
+ width: Math.min(3840, Math.max(viewport.width, x + width)),
478
+ height: Math.min(2160, Math.max(viewport.height, y + height)),
479
+ });
480
+ try {
481
+ const { png, colorType } = await captureRegionPng(page, source, { x, y, width, height }, timeout);
482
+ const filePath = await writeCaptureArtifact(typeof args.outputPath === 'string' ? args.outputPath : undefined, ['regions', `${randomUUID()}.png`], png);
483
+ return {
484
+ data: {
485
+ success: true,
486
+ path: filePath,
487
+ x,
488
+ y,
489
+ width,
490
+ height,
491
+ colorType,
492
+ bytes: png.length,
493
+ sha256: sha256Hex(png),
494
+ mimeType: 'image/png',
495
+ },
496
+ image: { data: png.toString('base64'), mimeType: 'image/png' },
497
+ };
498
+ }
499
+ finally {
500
+ await close();
501
+ }
502
+ }
503
+ async function captureElementMetricsTool(args, signal, timeout) {
504
+ const rawSelectors = args.selectors;
505
+ if (!Array.isArray(rawSelectors) || rawSelectors.length === 0) {
506
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'Provide a non-empty "selectors" array.');
507
+ }
508
+ const selectors = rawSelectors
509
+ .map((value) => String(value))
510
+ .filter((value) => value.length > 0 && value.length <= MAX_SELECTOR_CHARS);
511
+ if (selectors.length === 0) {
512
+ throw new BrowserMcpError('INVALID_ARGUMENT', `Each selector must be 1-${MAX_SELECTOR_CHARS} characters.`);
513
+ }
514
+ const hasSource = typeof args.url === 'string' || typeof args.filePath === 'string';
515
+ const allowLocal = args.allowLocal === true;
516
+ if (typeof args.sessionId === 'string' && args.sessionId.length > 0) {
517
+ const session = getSession(args.sessionId);
518
+ 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
+ });
524
+ await navigateCaptureSource(session.page, source, timeout);
525
+ }
526
+ return { success: true, metrics: await evaluateElementMetrics(session.page, selectors) };
527
+ }
528
+ 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
+ });
534
+ const { context, page } = await createCaptureContext(signal, defaultViewport());
535
+ try {
536
+ await navigateCaptureSource(page, source, timeout);
537
+ return { success: true, metrics: await evaluateElementMetrics(page, selectors) };
538
+ }
539
+ finally {
540
+ await context.close().catch(() => undefined);
541
+ }
542
+ }
543
+ const session = getSession(undefined);
544
+ return { success: true, metrics: await evaluateElementMetrics(session.page, selectors) };
545
+ }
546
+ function siteBlockClassification(status, title, text, url) {
547
+ const challengeText = `${title}\n${text.slice(0, 4_000)}`;
548
+ const challengePattern = /just a moment|verify (?:that )?you are human|unusual traffic|ungewöhnlichen datenverkehr|tr[aá]fico inusual|trafic inhabituel|attention required|access denied|captcha|security check|request unsuccessful/i;
549
+ const challengeUrlPattern = /\/(?:sorry|captcha)(?:\/|$)|\/challenge(?:\/|$)|\/cdn-cgi\/challenge-platform(?:\/|$)/i;
550
+ if (status !== undefined && [401, 403, 407, 429, 451].includes(status)) {
551
+ return {
552
+ classification: 'site',
553
+ blocked: true,
554
+ status,
555
+ reason: `The destination returned HTTP ${status}; this was not blocked by FLUJO policy.`,
556
+ };
557
+ }
558
+ if (challengeUrlPattern.test(url) || challengePattern.test(challengeText)) {
559
+ return {
560
+ classification: 'site',
561
+ blocked: true,
562
+ ...(status !== undefined ? { status } : {}),
563
+ reason: 'The destination rendered an anti-bot, CAPTCHA, or access-denied challenge; this was not blocked by FLUJO policy.',
564
+ };
565
+ }
566
+ if (status !== undefined) {
567
+ return { classification: 'none', blocked: false, status };
568
+ }
569
+ return undefined;
570
+ }
571
+ async function pageState(session, timeout, response) {
183
572
  const [title, bodyText] = await Promise.all([
184
573
  session.page.title(),
185
574
  session.page.locator('body').innerText({ timeout }).catch(() => ''),
@@ -187,14 +576,26 @@ async function pageState(session, timeout) {
187
576
  const text = bodyText.length > MAX_TEXT_CHARS
188
577
  ? `${bodyText.slice(0, MAX_TEXT_CHARS)}\n…[truncated]`
189
578
  : bodyText;
190
- return { success: true, ...publicPageState(session), title, text };
579
+ const navigation = siteBlockClassification(response?.status(), title, text, session.page.url());
580
+ return {
581
+ success: true,
582
+ ...publicPageState(session),
583
+ title,
584
+ text,
585
+ ...(navigation ? { navigation } : {}),
586
+ };
191
587
  }
192
588
  async function navigate(session, rawUrl, timeout, signal) {
193
589
  const url = await assertNavigationAllowed(rawUrl);
590
+ // Install the main-world audio hook before page.goto: once a page has created
591
+ // its AudioContext or fired a media play event, it cannot be intercepted
592
+ // retroactively.
593
+ await prepareBrowserAudioStream(session.id);
194
594
  resetNavigationCounter(session);
195
595
  return runCancellable(session, signal, async () => {
196
596
  try {
197
- await session.page.goto(url.href, { waitUntil: 'domcontentloaded', timeout });
597
+ const response = await session.page.goto(url.href, { waitUntil: 'domcontentloaded', timeout });
598
+ return pageState(session, timeout, response);
198
599
  }
199
600
  catch (error) {
200
601
  if (session.navigationBlocked) {
@@ -202,7 +603,6 @@ async function navigate(session, rawUrl, timeout, signal) {
202
603
  }
203
604
  throw error;
204
605
  }
205
- return pageState(session, timeout);
206
606
  });
207
607
  }
208
608
  export async function browserCallTool(name, rawArgs, signal) {
@@ -217,23 +617,126 @@ export async function browserCallTool(name, rawArgs, signal) {
217
617
  return success(data);
218
618
  }
219
619
  if (name === 'browser_close') {
220
- const sessionId = stringArg(args, 'sessionId', 64);
620
+ let sessionId;
621
+ if (args.sessionId === undefined || args.sessionId === '') {
622
+ try {
623
+ sessionId = getSession(undefined).id;
624
+ }
625
+ catch (error) {
626
+ if (error instanceof BrowserMcpError && error.code === 'NOT_FOUND') {
627
+ return success({ success: true, sessionId: null, closed: false });
628
+ }
629
+ throw error;
630
+ }
631
+ }
632
+ else {
633
+ sessionId = stringArg(args, 'sessionId', 64);
634
+ }
221
635
  const closed = await closeSession(sessionId);
222
636
  return success({ success: true, sessionId, closed });
223
637
  }
224
- const session = getSession(args.sessionId);
638
+ if (name === 'browser_diagnostics') {
639
+ let session;
640
+ if (typeof args.sessionId === 'string' && args.sessionId.length > 0) {
641
+ session = getSession(args.sessionId);
642
+ }
643
+ else {
644
+ try {
645
+ session = getSession(undefined);
646
+ }
647
+ catch (error) {
648
+ if (!(error instanceof BrowserMcpError) || error.code !== 'NOT_FOUND')
649
+ throw error;
650
+ }
651
+ }
652
+ return success(await browserDiagnostics(session));
653
+ }
654
+ if (name === 'browser_extensions') {
655
+ return success(await browserExtensions());
656
+ }
225
657
  const timeout = timeoutMs(args.timeoutMs);
658
+ if (name === 'browser_capture_page') {
659
+ const result = await captureRegionOrPage(args, signal, timeout);
660
+ return success(result.data, [{ type: 'image', data: result.image.data, mimeType: result.image.mimeType }]);
661
+ }
662
+ if (name === 'browser_capture_region') {
663
+ const result = await captureRegionTool(args, signal, timeout);
664
+ return success(result.data, [{ type: 'image', data: result.image.data, mimeType: result.image.mimeType }]);
665
+ }
666
+ if (name === 'browser_capture_element_metrics') {
667
+ return success(await captureElementMetricsTool(args, signal, timeout));
668
+ }
669
+ if (name === 'browser_record_start') {
670
+ return success(await startRecording({
671
+ width: args.width,
672
+ height: args.height,
673
+ audio: args.audio,
674
+ durationMs: args.durationMs,
675
+ outputPath: args.outputPath,
676
+ }, signal));
677
+ }
678
+ if (name === 'browser_record_stop') {
679
+ return success(await stopRecording({
680
+ recordingId: args.recordingId,
681
+ sessionId: args.sessionId,
682
+ outputPath: args.outputPath,
683
+ }));
684
+ }
685
+ if (name === 'browser_record_status') {
686
+ return success(recordingStatus({ recordingId: args.recordingId, sessionId: args.sessionId }));
687
+ }
688
+ const session = getSession(args.sessionId);
226
689
  if (name === 'browser_navigate') {
227
690
  return success(await navigate(session, stringArg(args, 'url', 8_192), timeout, signal));
228
691
  }
692
+ if (name === 'browser_back' || name === 'browser_forward' || name === 'browser_reload') {
693
+ resetNavigationCounter(session);
694
+ const data = await runCancellable(session, signal, async () => {
695
+ if (name === 'browser_back') {
696
+ await session.page.goBack({ waitUntil: 'domcontentloaded', timeout });
697
+ }
698
+ else if (name === 'browser_forward') {
699
+ await session.page.goForward({ waitUntil: 'domcontentloaded', timeout });
700
+ }
701
+ else {
702
+ await session.page.reload({ waitUntil: 'domcontentloaded', timeout });
703
+ }
704
+ if (session.navigationBlocked) {
705
+ throw new BrowserMcpError('NAVIGATION_BLOCKED', 'The history navigation was blocked by browser policy.');
706
+ }
707
+ return pageState(session, timeout);
708
+ });
709
+ return success(data);
710
+ }
229
711
  if (name === 'browser_snapshot') {
230
712
  return success(await runCancellable(session, signal, () => pageState(session, timeout)));
231
713
  }
232
714
  if (name === 'browser_click') {
233
- const selector = stringArg(args, 'selector', MAX_SELECTOR_CHARS);
234
715
  resetNavigationCounter(session);
235
716
  const data = await runCancellable(session, signal, async () => {
236
- await session.page.locator(selector).first().click({ timeout });
717
+ if (args.button !== undefined && !['left', 'right', 'middle'].includes(String(args.button))) {
718
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'button must be left, right, or middle.');
719
+ }
720
+ const button = args.button === 'right' || args.button === 'middle' ? args.button : 'left';
721
+ const clickCount = finiteNumberArg(args, 'clickCount', 1);
722
+ if (!Number.isInteger(clickCount) || clickCount < 1 || clickCount > 3) {
723
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'clickCount must be an integer from 1 to 3.');
724
+ }
725
+ if (typeof args.selector === 'string' && args.selector.length > 0) {
726
+ if (args.selector.length > MAX_SELECTOR_CHARS) {
727
+ throw new BrowserMcpError('INVALID_ARGUMENT', `selector must be no longer than ${MAX_SELECTOR_CHARS} characters.`);
728
+ }
729
+ await session.page.locator(args.selector).first().click({ timeout, button, clickCount });
730
+ }
731
+ else {
732
+ const x = finiteNumberArg(args, 'x');
733
+ const y = finiteNumberArg(args, 'y');
734
+ const viewport = session.page.viewportSize();
735
+ if (x < 0 || y < 0 || (viewport && (x >= viewport.width || y >= viewport.height))) {
736
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'Click coordinates must be inside the current viewport.');
737
+ }
738
+ await session.page.mouse.click(x, y, { button, clickCount });
739
+ }
237
740
  await session.page.waitForLoadState('domcontentloaded', { timeout }).catch(() => undefined);
238
741
  if (session.navigationBlocked) {
239
742
  throw new BrowserMcpError('NAVIGATION_BLOCKED', 'The interaction attempted a navigation blocked by browser policy.');
@@ -243,17 +746,26 @@ export async function browserCallTool(name, rawArgs, signal) {
243
746
  return success(data);
244
747
  }
245
748
  if (name === 'browser_type') {
246
- const selector = stringArg(args, 'selector', MAX_SELECTOR_CHARS);
247
749
  const text = args.text;
248
750
  if (typeof text !== 'string' || text.length > 100_000) {
249
751
  throw new BrowserMcpError('INVALID_ARGUMENT', 'text must be a string no longer than 100000 characters.');
250
752
  }
251
753
  resetNavigationCounter(session);
252
754
  const data = await runCancellable(session, signal, async () => {
253
- const locator = session.page.locator(selector).first();
254
- await locator.fill(text, { timeout });
255
- if (args.submit === true)
256
- await locator.press('Enter', { timeout });
755
+ if (typeof args.selector === 'string' && args.selector.length > 0) {
756
+ if (args.selector.length > MAX_SELECTOR_CHARS) {
757
+ throw new BrowserMcpError('INVALID_ARGUMENT', `selector must be no longer than ${MAX_SELECTOR_CHARS} characters.`);
758
+ }
759
+ const locator = session.page.locator(args.selector).first();
760
+ await locator.fill(text, { timeout });
761
+ if (args.submit === true)
762
+ await locator.press('Enter', { timeout });
763
+ }
764
+ else {
765
+ await session.page.keyboard.insertText(text);
766
+ if (args.submit === true)
767
+ await session.page.keyboard.press('Enter');
768
+ }
257
769
  if (session.navigationBlocked) {
258
770
  throw new BrowserMcpError('NAVIGATION_BLOCKED', 'The interaction attempted a navigation blocked by browser policy.');
259
771
  }
@@ -261,23 +773,57 @@ export async function browserCallTool(name, rawArgs, signal) {
261
773
  });
262
774
  return success(data);
263
775
  }
776
+ if (name === 'browser_press') {
777
+ const key = stringArg(args, 'key', 100);
778
+ resetNavigationCounter(session);
779
+ const data = await runCancellable(session, signal, async () => {
780
+ await session.page.keyboard.press(key);
781
+ await session.page.waitForLoadState('domcontentloaded', { timeout }).catch(() => undefined);
782
+ if (session.navigationBlocked) {
783
+ throw new BrowserMcpError('NAVIGATION_BLOCKED', 'The keyboard interaction attempted a navigation blocked by browser policy.');
784
+ }
785
+ return pageState(session, timeout);
786
+ });
787
+ return success(data);
788
+ }
789
+ if (name === 'browser_scroll') {
790
+ const deltaX = finiteNumberArg(args, 'deltaX', 0);
791
+ const deltaY = finiteNumberArg(args, 'deltaY', 0);
792
+ if (Math.abs(deltaX) > 100_000 || Math.abs(deltaY) > 100_000) {
793
+ throw new BrowserMcpError('INVALID_ARGUMENT', 'Scroll deltas must be between -100000 and 100000.');
794
+ }
795
+ const data = await runCancellable(session, signal, async () => {
796
+ await session.page.mouse.wheel(deltaX, deltaY);
797
+ return pageState(session, timeout);
798
+ });
799
+ return success(data);
800
+ }
264
801
  if (name === 'browser_screenshot') {
802
+ const fullPage = args.fullPage === true;
265
803
  const png = await runCancellable(session, signal, () => session.page.screenshot({
266
804
  type: 'png',
267
- fullPage: args.fullPage === true,
805
+ fullPage,
268
806
  timeout,
269
807
  }));
270
808
  if (png.length > MAX_SCREENSHOT_BYTES) {
271
809
  throw new BrowserMcpError('INVALID_ARGUMENT', 'The screenshot exceeded the 5 MB artifact limit.');
272
810
  }
273
- const data = { success: true, ...publicPageState(session), mimeType: 'image/png', bytes: png.length };
811
+ const filePath = await writeScreenshotArtifact(session.id, fullPage, png);
812
+ const data = {
813
+ success: true,
814
+ ...publicPageState(session),
815
+ path: filePath,
816
+ mimeType: 'image/png',
817
+ bytes: png.length,
818
+ viewport: session.page.viewportSize(),
819
+ };
274
820
  return success(data, [{ type: 'image', data: png.toString('base64'), mimeType: 'image/png' }]);
275
821
  }
276
822
  return failure('NOT_FOUND', `Unknown browser tool: ${name}`);
277
823
  }
278
824
  catch (error) {
279
825
  const normalized = normalizedError(error);
280
- return failure(normalized.code, normalized.message);
826
+ return failure(normalized.code, normalized.message, normalized.category);
281
827
  }
282
828
  }
283
829
  //# sourceMappingURL=tools.js.map