@browserless.io/mcp 1.7.1 → 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.
@@ -1,3 +1,4 @@
1
+ import type { Context } from 'hono';
1
2
  import type { McpConfig } from '../@types/types.js';
2
3
  export interface ResolvedBrowserlessAuth {
3
4
  token: string;
@@ -20,3 +21,4 @@ export interface AuthInput {
20
21
  * callback and the custom `/upload` route so both gate on the same rules.
21
22
  */
22
23
  export declare const resolveBrowserlessAuth: (input: AuthInput, config: Pick<McpConfig, "browserlessApiUrl" | "supabaseUrl" | "supabaseServiceRoleKey">) => Promise<ResolvedBrowserlessAuth>;
24
+ export declare const guardRouteAuth: (c: Context, config: Parameters<typeof resolveBrowserlessAuth>[1]) => Promise<Response | null>;
@@ -7,22 +7,22 @@ import { resolveApiKey } from './account-resolver.js';
7
7
  * callback and the custom `/upload` route so both gate on the same rules.
8
8
  */
9
9
  export const resolveBrowserlessAuth = async (input, config) => {
10
- const headerToken = input.authHeader?.startsWith('Bearer ')
11
- ? input.authHeader.slice(7)
12
- : input.authHeader;
13
10
  const apiUrl = input.apiUrlHeader ?? input.browserlessUrlQuery ?? config.browserlessApiUrl;
14
11
  // A pre-created session id to attach to, threaded by the autologin runner.
15
12
  // The agent tool opens /chromium/agent?sessionId=<this> instead of doing its
16
13
  // own POST /profile.
17
- const attachSessionId = input.sessionIdHeader ?? input.sessionIdQuery ?? undefined;
14
+ const attachSessionId = input.sessionIdHeader ?? input.sessionIdQuery;
15
+ const headerToken = input.authHeader?.startsWith('Bearer ')
16
+ ? input.authHeader.slice(7)
17
+ : input.authHeader;
18
18
  // JWTs have 3 dot-separated base64url segments; plain API keys do not.
19
19
  const isJwt = headerToken ? headerToken.split('.').length === 3 : false;
20
- if (headerToken && !isJwt) {
21
- return { token: headerToken, apiUrl, attachSessionId };
22
- }
23
- if (input.tokenQuery) {
24
- return { token: input.tokenQuery, apiUrl, attachSessionId };
20
+ // A plain key (header or ?token=) is used directly and wins over JWT exchange.
21
+ const plainKey = (isJwt ? undefined : headerToken) ?? input.tokenQuery;
22
+ if (plainKey) {
23
+ return { token: plainKey, apiUrl, attachSessionId };
25
24
  }
25
+ // A JWT is exchanged for the account's Browserless API key via PostgREST.
26
26
  if (isJwt && headerToken) {
27
27
  const { apiKey } = await resolveApiKey(config.supabaseUrl, config.supabaseServiceRoleKey, headerToken);
28
28
  return { token: apiKey, apiUrl, attachSessionId };
@@ -31,3 +31,17 @@ export const resolveBrowserlessAuth = async (input, config) => {
31
31
  'Pass it as Authorization: Bearer <token> header, ' +
32
32
  '?token= query parameter, or authenticate via OAuth.');
33
33
  };
34
+ export const guardRouteAuth = async (c, config) => {
35
+ try {
36
+ await resolveBrowserlessAuth({
37
+ authHeader: c.req.header('authorization'),
38
+ tokenQuery: c.req.query('token'),
39
+ apiUrlHeader: c.req.header('x-browserless-api-url'),
40
+ browserlessUrlQuery: c.req.query('browserlessUrl'),
41
+ }, config);
42
+ return null;
43
+ }
44
+ catch {
45
+ return c.json({ ok: false, error: 'Unauthorized' }, 401);
46
+ }
47
+ };
@@ -5,9 +5,8 @@ export declare class RedisOAuthProxy extends OAuthProxy {
5
5
  constructor(config: OAuthProxyConfig, redis: Redis);
6
6
  private get _internal();
7
7
  registerClient(request: DCRRequest): Promise<DCRResponse>;
8
- private isClientRegistered;
8
+ private getClientRedirectUris;
9
9
  authorize(params: AuthorizationParams): Promise<Response>;
10
10
  handleCallback(request: Request): Promise<Response>;
11
11
  exchangeAuthorizationCode(request: TokenRequest): Promise<TokenResponse>;
12
- destroy(): void;
13
12
  }
@@ -1,4 +1,4 @@
1
- import { OAuthProxy, OAuthProxyError, } from 'fastmcp/auth';
1
+ import { OAuthProxy, OAuthProxyError, PKCEUtils, } from 'fastmcp/auth';
2
2
  /**
3
3
  * Redis-backed OAuthProxy using Redis as the single source of truth for OAuth
4
4
  * flow state (transactions, authorization codes, DCRs), so the steps of one
@@ -17,10 +17,11 @@ import { OAuthProxy, OAuthProxyError, } from 'fastmcp/auth';
17
17
  const KEY_PREFIX = 'mcp:oauth:';
18
18
  const TX_PREFIX = `${KEY_PREFIX}tx:`;
19
19
  const CODE_PREFIX = `${KEY_PREFIX}code:`;
20
- const CLIENT_PREFIX = `${KEY_PREFIX}client:`;
20
+ const CLIENT_ID_PREFIX = `${KEY_PREFIX}client-id:`;
21
21
  const DEFAULT_TRANSACTION_TTL = 600;
22
22
  const DEFAULT_CODE_TTL = 300;
23
- const DEFAULT_CLIENT_TTL = 3600;
23
+ // DCR clients are reused for weeks; a short TTL would expire one mid-life.
24
+ const DEFAULT_CLIENT_TTL = 90 * 24 * 60 * 60;
24
25
  const DATE_FIELDS = new Set(['createdAt', 'expiresAt', 'issuedAt']);
25
26
  function serialize(obj) {
26
27
  return JSON.stringify(obj, (_key, value) => {
@@ -51,44 +52,25 @@ export class RedisOAuthProxy extends OAuthProxy {
51
52
  if (this.config.consentRequired) {
52
53
  throw new Error('RedisOAuthProxy requires consentRequired: false — consent flow is not supported in multi-instance mode');
53
54
  }
55
+ // We return upstream tokens directly (no token swap); fail fast otherwise.
56
+ if (this.config.enableTokenSwap) {
57
+ throw new Error('RedisOAuthProxy requires enableTokenSwap: false — token-swap mode is not supported');
58
+ }
54
59
  }
55
60
  get _internal() {
56
61
  return this;
57
62
  }
58
63
  async registerClient(request) {
59
- // Delegate validation/response to the parent, then mirror the accepted
60
- // URIs into Redis so every instance can honor the v4 redirect_uri check.
61
- // (The parent's in-memory Map is also populated but we never read it.)
64
+ // Store the client's redirect_uris under the issued client_id so any
65
+ // instance can validate per-client (the parent's Map is process-local).
62
66
  const response = await super.registerClient(request);
63
67
  const ttl = this._internal.config.clientRegistrationTtl ?? DEFAULT_CLIENT_TTL;
64
- // Snapshot pre-existence so rollback doesn't DEL a valid prior
65
- // registration of the same URI (two DCR calls sharing a redirect_uri).
66
- // allSettled → a probe failure is fail-fast with no writes attempted.
67
- const probes = await Promise.allSettled(response.redirect_uris.map(async (uri) => ({
68
- uri,
69
- existed: (await this.redis.exists(`${CLIENT_PREFIX}${uri}`)) > 0,
70
- })));
71
- const probeFailed = probes.find((p) => p.status === 'rejected');
72
- if (probeFailed) {
73
- throw probeFailed.reason;
74
- }
75
- const redisPreExisting = new Set(probes
76
- .filter((p) => p.status === 'fulfilled' && p.value.existed)
77
- .map((p) => p.value.uri));
78
- const writes = await Promise.allSettled(response.redirect_uris.map((uri) => this.redis.set(`${CLIENT_PREFIX}${uri}`, '1', 'EX', ttl)));
79
- const writeFailed = writes.find((w) => w.status === 'rejected');
80
- if (writeFailed) {
81
- // Best-effort cleanup of Redis keys this call introduced; if these
82
- // deletes also fail the originating error still wins.
83
- await Promise.allSettled(response.redirect_uris
84
- .filter((uri) => !redisPreExisting.has(uri))
85
- .map((uri) => this.redis.del(`${CLIENT_PREFIX}${uri}`)));
86
- throw writeFailed.reason;
87
- }
68
+ await this.redis.set(`${CLIENT_ID_PREFIX}${response.client_id}`, JSON.stringify(response.redirect_uris), 'EX', ttl);
88
69
  return response;
89
70
  }
90
- async isClientRegistered(uri) {
91
- return (await this.redis.exists(`${CLIENT_PREFIX}${uri}`)) === 1;
71
+ async getClientRedirectUris(clientId) {
72
+ const json = await this.redis.get(`${CLIENT_ID_PREFIX}${clientId}`);
73
+ return json ? JSON.parse(json) : null;
92
74
  }
93
75
  async authorize(params) {
94
76
  if (!params.client_id || !params.redirect_uri || !params.response_type) {
@@ -97,15 +79,13 @@ export class RedisOAuthProxy extends OAuthProxy {
97
79
  if (params.response_type !== 'code') {
98
80
  throw new OAuthProxyError('unsupported_response_type', "Only 'code' response type is supported");
99
81
  }
100
- // RFC 6749 §5.2 reject any client_id other than the single upstream
101
- // identity this proxy fronts. Ported from fastmcp v4 OAuthProxy.authorize.
102
- if (params.client_id !== this._internal.config.upstreamClientId) {
82
+ // redirect_uri must be one registered for THIS client_id a global check
83
+ // would let any client pair with any other's URI (CWE-601).
84
+ const registeredUris = await this.getClientRedirectUris(params.client_id);
85
+ if (!registeredUris) {
103
86
  throw new OAuthProxyError('invalid_client', 'Unknown client_id');
104
87
  }
105
- // RFC 6749 §3.1.2.3 / RFC 6819 §4.1.5 — redirect_uri must be one
106
- // previously registered via DCR; skipping this is CWE-601 (auth-code
107
- // theft). We read the shared Redis registry so cross-instance DCR counts.
108
- if (!(await this.isClientRegistered(params.redirect_uri))) {
88
+ if (!registeredUris.includes(params.redirect_uri)) {
109
89
  throw new OAuthProxyError('invalid_request', 'redirect_uri is not registered for this client');
110
90
  }
111
91
  if (params.code_challenge && !params.code_challenge_method) {
@@ -132,10 +112,10 @@ export class RedisOAuthProxy extends OAuthProxy {
132
112
  throw new OAuthProxyError('invalid_request', 'Invalid or expired state');
133
113
  }
134
114
  const transaction = deserialize(txJson);
135
- // Defense-in-depth: reject if the transaction's stored callback URL is no
136
- // longer registered. Guards against DCR revocation mid-flow and any path
137
- // that could have persisted an unvalidated URI.
138
- if (!(await this.isClientRegistered(transaction.clientCallbackUrl))) {
115
+ // Defense-in-depth: the callback URL must still be bound to this client
116
+ // (guards against mid-flow DCR revocation/expiry).
117
+ const txUris = await this.getClientRedirectUris(transaction.clientId);
118
+ if (!txUris || !txUris.includes(transaction.clientCallbackUrl)) {
139
119
  await this.redis.del(`${TX_PREFIX}${state}`);
140
120
  throw new OAuthProxyError('invalid_request', 'Transaction callback URL is not registered');
141
121
  }
@@ -144,11 +124,14 @@ export class RedisOAuthProxy extends OAuthProxy {
144
124
  // We read from it, persist to Redis, then clean up the Map entry.
145
125
  const clientCode = this._internal.generateAuthorizationCode(transaction, upstreamTokens);
146
126
  const codeData = this._internal.clientCodes.get(clientCode);
147
- if (codeData) {
148
- const codeTtl = this._internal.config.authorizationCodeTtl || DEFAULT_CODE_TTL;
149
- await this.redis.set(`${CODE_PREFIX}${clientCode}`, serialize(codeData), 'EX', codeTtl);
150
- this._internal.clientCodes.delete(clientCode);
151
- }
127
+ // generateAuthorizationCode just populated the Map; if a cleanup race
128
+ // emptied it, fail loud rather than redirect with an unpersisted code.
129
+ if (!codeData) {
130
+ throw new OAuthProxyError('server_error', 'Failed to persist authorization code');
131
+ }
132
+ const codeTtl = this._internal.config.authorizationCodeTtl || DEFAULT_CODE_TTL;
133
+ await this.redis.set(`${CODE_PREFIX}${clientCode}`, serialize(codeData), 'EX', codeTtl);
134
+ this._internal.clientCodes.delete(clientCode);
152
135
  // Remove consumed transaction
153
136
  await this.redis.del(`${TX_PREFIX}${state}`);
154
137
  const redirectUrl = new URL(transaction.clientCallbackUrl);
@@ -163,10 +146,9 @@ export class RedisOAuthProxy extends OAuthProxy {
163
146
  if (request.grant_type !== 'authorization_code') {
164
147
  throw new OAuthProxyError('unsupported_grant_type', 'Only authorization_code grant type is supported');
165
148
  }
166
- // RFC 6749 §5.2 — reject unknown clients at token exchange too, so a
167
- // stolen authorization code cannot be redeemed by an arbitrary caller.
168
- // Ported from fastmcp v4 OAuthProxy.exchangeAuthorizationCode.
169
- if (request.client_id !== this._internal.config.upstreamClientId) {
149
+ // Reject unknown clients here too; the code↔client binding below enforces
150
+ // that only the owning client can redeem the code.
151
+ if (!(await this.getClientRedirectUris(request.client_id))) {
170
152
  throw new OAuthProxyError('invalid_client', 'Unknown client_id');
171
153
  }
172
154
  // Atomically read-and-delete the code. The parent's in-memory `used` flag
@@ -180,19 +162,15 @@ export class RedisOAuthProxy extends OAuthProxy {
180
162
  if (clientCode.clientId !== request.client_id) {
181
163
  throw new OAuthProxyError('invalid_client', 'Client ID mismatch');
182
164
  }
183
- if (clientCode.codeChallenge && !request.code_verifier) {
184
- throw new OAuthProxyError('invalid_request', 'code_verifier required for PKCE');
185
- }
186
- if (clientCode.codeChallenge && request.code_verifier) {
187
- // Delegate PKCE validation to parent by placing code in Map temporarily.
188
- // Redis key is already consumed by GETDEL above, so no additional del
189
- // is needed in finally — only the local Map cleanup.
190
- this._internal.clientCodes.set(request.code, clientCode);
191
- try {
192
- return await super.exchangeAuthorizationCode(request);
165
+ // PKCE inline, not via super (the parent re-checks client_id against a
166
+ // process-local Map). One-time-use is enforced by the GETDEL above, not the
167
+ // parent's `used` flag.
168
+ if (clientCode.codeChallenge) {
169
+ if (!request.code_verifier) {
170
+ throw new OAuthProxyError('invalid_request', 'code_verifier required for PKCE');
193
171
  }
194
- finally {
195
- this._internal.clientCodes.delete(request.code);
172
+ if (!PKCEUtils.validateChallenge(request.code_verifier, clientCode.codeChallenge, clientCode.codeChallengeMethod)) {
173
+ throw new OAuthProxyError('invalid_grant', 'Invalid PKCE verifier');
196
174
  }
197
175
  }
198
176
  const response = {
@@ -203,12 +181,12 @@ export class RedisOAuthProxy extends OAuthProxy {
203
181
  if (clientCode.upstreamTokens.refreshToken) {
204
182
  response.refresh_token = clientCode.upstreamTokens.refreshToken;
205
183
  }
184
+ if (clientCode.upstreamTokens.idToken) {
185
+ response.id_token = clientCode.upstreamTokens.idToken;
186
+ }
206
187
  if (clientCode.upstreamTokens.scope?.length > 0) {
207
188
  response.scope = clientCode.upstreamTokens.scope.join(' ');
208
189
  }
209
190
  return response;
210
191
  }
211
- destroy() {
212
- super.destroy();
213
- }
214
192
  }
@@ -1,6 +1,6 @@
1
1
  import { readFile, rm } from 'node:fs/promises';
2
2
  import { consumeDownload } from '../lib/download-store.js';
3
- import { resolveBrowserlessAuth } from '../lib/http-auth.js';
3
+ import { guardRouteAuth } from '../lib/http-auth.js';
4
4
  /**
5
5
  * Registers `GET /download/:id` on the HTTP-stream server. getDownloads surfaces
6
6
  * a download as a notification (metadata only) plus this URL; the client fetches
@@ -17,17 +17,9 @@ import { resolveBrowserlessAuth } from '../lib/http-auth.js';
17
17
  export function registerDownloadRoute(server, config) {
18
18
  const app = server.getApp();
19
19
  app.get('/download/:id', async (c) => {
20
- try {
21
- await resolveBrowserlessAuth({
22
- authHeader: c.req.header('authorization'),
23
- tokenQuery: c.req.query('token'),
24
- apiUrlHeader: c.req.header('x-browserless-api-url'),
25
- browserlessUrlQuery: c.req.query('browserlessUrl'),
26
- }, config);
27
- }
28
- catch {
29
- return c.json({ ok: false, error: 'Unauthorized' }, 401);
30
- }
20
+ const denied = await guardRouteAuth(c, config);
21
+ if (denied)
22
+ return denied;
31
23
  // Single-use: consume removes it from the registry so a second GET 404s.
32
24
  const record = consumeDownload(c.req.param('id'));
33
25
  if (!record) {
@@ -1,5 +1,5 @@
1
1
  import { downloadUri, storeDownload, FILE_TRANSFER_MAX_BYTES, } from '../lib/download-store.js';
2
- import { resolveBrowserlessAuth } from '../lib/http-auth.js';
2
+ import { guardRouteAuth } from '../lib/http-auth.js';
3
3
  // Registers `POST /upload` (httpStream only): clients push a file's bytes over
4
4
  // plain HTTP and get back a handle to pass to the agent's `uploadFile`.
5
5
  // curl -s -F file=@/path/to/file "<mcpBaseUrl>/upload?token=<token>"
@@ -7,19 +7,11 @@ import { resolveBrowserlessAuth } from '../lib/http-auth.js';
7
7
  export function registerUploadRoute(server, config) {
8
8
  const app = server.getApp();
9
9
  app.post('/upload', async (c) => {
10
- // Raw Hono routes bypass FastMCP's authenticate, so gate the route on the
11
- // same Browserless token rules as the MCP surface — no anonymous drops.
12
- try {
13
- await resolveBrowserlessAuth({
14
- authHeader: c.req.header('authorization'),
15
- tokenQuery: c.req.query('token'),
16
- apiUrlHeader: c.req.header('x-browserless-api-url'),
17
- browserlessUrlQuery: c.req.query('browserlessUrl'),
18
- }, config);
19
- }
20
- catch {
21
- return c.json({ ok: false, error: 'Unauthorized' }, 401);
22
- }
10
+ // Raw Hono routes bypass FastMCP's authenticate, so gate on the same token
11
+ // rules as the MCP surface — no anonymous drops.
12
+ const denied = await guardRouteAuth(c, config);
13
+ if (denied)
14
+ return denied;
23
15
  let file;
24
16
  try {
25
17
  const body = await c.req.parseBody();
@@ -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.1",
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": {