@browserless.io/mcp 1.7.2 → 1.8.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.
@@ -22,6 +22,7 @@ Just trigger the download in the agent — navigate to the file URL, or click a
22
22
  - A short, size-scaled grace wait lets quick downloads land on the **same** call. A slower one shows up as **in-progress with a byte count** ("downloading 2.0MB / 10MB") — just keep using the browser; it'll appear completed on a later response. As long as you keep touching the browser, the download state stays fresh.
23
23
  - Files **larger than the cap** aren't transferred: you get a `FileTooLarge` note with the **source URL** — fetch it directly (e.g. `curl`) if you have network access.
24
24
  - You decide whether to save each file. (`getDownloads` still exists for an explicit poll, but it's rarely needed.)
25
+ - A **screenshot** can be captured straight to disk with `screenshot { toDisk: true }` instead of returned inline — it then behaves exactly like a download here (same handle/path/URL, same reuse). See the **screenshots** skill.
25
26
 
26
27
  **Local (stdio) mode:** the file is already on the local disk (`BROWSERLESS_DOWNLOAD_DIR`, default a temp dir). The response lists the saved **path** — use/move it, or hand it straight back to `uploadFile { path }`. Nothing more to fetch.
27
28
 
@@ -30,6 +30,24 @@ Capture smallest region that answers the question.
30
30
  - **WebP** — better compression than JPEG
31
31
  - **`omitBackground: true`** — for transparent elements
32
32
 
33
+ ## Save to disk instead of seeing it
34
+
35
+ By default a screenshot comes back as an inline image you see right away — that
36
+ costs vision tokens and lives in context. If you only need the file _later_
37
+ (hand it to the user, or re-upload it elsewhere) and don't need to look at it
38
+ now, add **`toDisk: true`**:
39
+
40
+ ```json
41
+ { "method": "screenshot", "params": { "selector": "#invoice", "toDisk": true } }
42
+ ```
43
+
44
+ You will **not** see the image. The response gives a reusable handle — a local
45
+ path (stdio) or a single-use GET URL (HTTP) — exactly like a download. Reuse it
46
+ with `uploadFile`, or hand the path/URL to the user. See the **file-transfers**
47
+ skill for the handle/path/URL rules and TTL. Note: to actually _look_ at a
48
+ disk-saved shot you'd have to load it back into context — so only use `toDisk`
49
+ when you don't need to view it.
50
+
33
51
  ## Pattern: capture-after-action
34
52
 
35
53
  ```json
@@ -35,4 +35,7 @@ type FormatOpts = {
35
35
  token?: string;
36
36
  };
37
37
  export declare const formatDownloads: (downloads: DownloadEntry[], prefix: string, skills: string, opts: FormatOpts) => Promise<Content[]>;
38
+ export declare const formatScreenshotToDisk: (result: unknown, cmd: {
39
+ params?: Record<string, unknown>;
40
+ }, caption: string, skills: string, opts: FormatOpts) => Promise<Content[] | null>;
38
41
  export declare function registerAgentTools(server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper): void;
@@ -25,19 +25,29 @@ const SCREENSHOT_MIME = {
25
25
  webp: 'image/webp',
26
26
  png: 'image/png',
27
27
  };
28
+ const getScreenshotPayload = (result, cmd) => {
29
+ const base64 = typeof result?.base64 === 'string'
30
+ ? result.base64
31
+ : '';
32
+ if (!base64)
33
+ return null;
34
+ const requestedType = typeof cmd.params?.type === 'string' ? cmd.params.type : 'png';
35
+ return {
36
+ base64,
37
+ mimeType: SCREENSHOT_MIME[requestedType] ?? 'image/png',
38
+ requestedType,
39
+ };
40
+ };
28
41
  /**
29
42
  * Build the MCP response for a screenshot command, or null when there's no
30
43
  * base64 payload (caller falls back to JSON text). Returns the image as a
31
44
  * vision content block (~1.5K tokens) vs. ~67K inlining the base64 as text.
32
45
  */
33
46
  export const formatScreenshotContent = (result, cmd, caption, skills) => {
34
- const base64 = typeof result?.base64 === 'string'
35
- ? result.base64
36
- : '';
37
- if (!base64)
47
+ const payload = getScreenshotPayload(result, cmd);
48
+ if (!payload)
38
49
  return null;
39
- const requestedType = typeof cmd.params?.type === 'string' ? cmd.params.type : 'png';
40
- const mimeType = SCREENSHOT_MIME[requestedType] ?? 'image/png';
50
+ const { base64, mimeType } = payload;
41
51
  const decodedBytes = Math.floor(base64.length * 0.75);
42
52
  const sizeLabel = decodedBytes >= 1_048_576
43
53
  ? `${(decodedBytes / 1_048_576).toFixed(1)} MB`
@@ -177,6 +187,25 @@ export const formatDownloads = async (downloads, prefix, skills, opts) => {
177
187
  content.push({ type: 'text', text: skills });
178
188
  return content;
179
189
  };
190
+ // persist the bytes we already got back to the download store and surface a reusable handle
191
+ export const formatScreenshotToDisk = async (result, cmd, caption, skills, opts) => {
192
+ const payload = getScreenshotPayload(result, cmd);
193
+ if (!payload)
194
+ return null;
195
+ const { base64, mimeType, requestedType } = payload;
196
+ const ext = requestedType === 'jpeg' ? 'jpg' : requestedType;
197
+ const record = await storeDownload(`screenshot.${ext}`, mimeType, Buffer.from(base64, 'base64'), opts.sessionId);
198
+ const text = [
199
+ caption.trimEnd(),
200
+ `Screenshot saved to disk (not shown inline):\n- ${describeReadyDownload(record, opts)}`,
201
+ ]
202
+ .filter(Boolean)
203
+ .join('\n\n');
204
+ const content = [{ type: 'text', text }];
205
+ if (skills)
206
+ content.push({ type: 'text', text: skills });
207
+ return content;
208
+ };
180
209
  const SkillIdSchema = z.enum(skillsRegistry.map((s) => s.id));
181
210
  const SkillToolParamsSchema = z.object({
182
211
  id: SkillIdSchema.describe('The skill to load (see tool description for the full list).'),
@@ -298,9 +327,18 @@ export function registerAgentTools(server, config, analytics) {
298
327
  }
299
328
  log.info(`agent: ${cmd.method} ${JSON.stringify(cmd.params)}`);
300
329
  agentSession.skillState.cmdIndex += 1;
330
+ // `toDisk` is a local directive (route the screenshot to the download
331
+ // store); it's not a CDP screenshot param, so strip it before sending
332
+ // or Chrome rejects the unknown key. The original cmd keeps it so the
333
+ // result formatter can tell it should save instead of inline.
334
+ let outboundParams = cmd.params;
335
+ if (cmd.method === 'screenshot' && 'toDisk' in cmd.params) {
336
+ outboundParams = { ...cmd.params };
337
+ delete outboundParams.toDisk;
338
+ }
301
339
  let resp;
302
340
  try {
303
- resp = await send(agentSession, cmd.method, cmd.params);
341
+ resp = await send(agentSession, cmd.method, outboundParams);
304
342
  }
305
343
  catch (sendErr) {
306
344
  destroySession(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
@@ -372,7 +410,7 @@ export function registerAgentTools(server, config, analytics) {
372
410
  const reportable = closedDuringBatch ? results.slice(0, -1) : results;
373
411
  const last = reportable[reportable.length - 1];
374
412
  const lastResult = last.result;
375
- const lastCmd = commands[commands.length - 1];
413
+ const lastCmd = commands[reportable.length - 1];
376
414
  const closedSuffix = closedDuringBatch
377
415
  ? '\n\nBrowser session closed.'
378
416
  : '';
@@ -437,6 +475,22 @@ export function registerAgentTools(server, config, analytics) {
437
475
  token,
438
476
  });
439
477
  }
478
+ else if (last.method === 'screenshot' &&
479
+ lastCmd.params?.toDisk === true) {
480
+ // Screenshot saved to disk → reusable handle, no inline image.
481
+ const saved = await formatScreenshotToDisk(lastResult, lastCmd, batchPrefix, skillsText, {
482
+ transport: config.transport,
483
+ sessionId: mcpSessionId,
484
+ mcpBaseUrl: config.mcpBaseUrl,
485
+ token,
486
+ });
487
+ baseContent = saved ?? [
488
+ {
489
+ type: 'text',
490
+ text: appendSkills(batchPrefix + JSON.stringify(lastResult, null, 2), triggered),
491
+ },
492
+ ];
493
+ }
440
494
  else {
441
495
  // Screenshot → image content block; otherwise JSON text.
442
496
  const shot = last.method === 'screenshot'
@@ -219,6 +219,7 @@ export declare const AgentCommandSchema: z.ZodUnion<readonly [z.ZodDiscriminated
219
219
  }, z.core.$strip>>;
220
220
  waitForImages: z.ZodOptional<z.ZodBoolean>;
221
221
  timeout: z.ZodOptional<z.ZodNumber>;
222
+ toDisk: z.ZodOptional<z.ZodBoolean>;
222
223
  }, z.core.$strip>>>;
223
224
  }, z.core.$strip>, z.ZodObject<{
224
225
  method: z.ZodLiteral<"uploadFile">;
@@ -481,6 +482,7 @@ export declare const AgentParamsSchema: z.ZodObject<{
481
482
  }, z.core.$strip>>;
482
483
  waitForImages: z.ZodOptional<z.ZodBoolean>;
483
484
  timeout: z.ZodOptional<z.ZodNumber>;
485
+ toDisk: z.ZodOptional<z.ZodBoolean>;
484
486
  }, z.core.$strip>>>;
485
487
  }, z.core.$strip>, z.ZodObject<{
486
488
  method: z.ZodLiteral<"uploadFile">;
@@ -353,6 +353,14 @@ const ScreenshotCommandSchema = z.object({
353
353
  .number()
354
354
  .optional()
355
355
  .describe('Timeout in milliseconds (default 30000)'),
356
+ toDisk: z
357
+ .boolean()
358
+ .optional()
359
+ .describe('Save the screenshot to disk instead of returning it inline. ' +
360
+ 'You will NOT see the image; the response gives a reusable handle ' +
361
+ '(local path in stdio, single-use GET URL over HTTP) exactly like a ' +
362
+ 'download — reuse it with uploadFile or hand it to the user. Use when ' +
363
+ 'you only need the file later, not to look at now (see file-transfers).'),
356
364
  })
357
365
  .optional()
358
366
  .default({})
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@browserless.io/mcp",
3
- "version": "1.7.2",
3
+ "version": "1.8.0",
4
4
  "description": "MCP (Model Context Protocol) server for the Browserless.io browser automation platform",
5
5
  "author": "browserless.io",
6
6
  "license": "SSPL-1.0",
@@ -100,7 +100,8 @@
100
100
  "typescript-eslint": "^8.60.0"
101
101
  },
102
102
  "engines": {
103
- "node": ">=24"
103
+ "node": ">=24",
104
+ "npm": ">=11.10.0"
104
105
  },
105
106
  "overrides": {
106
107
  "mocha": {