@browserless.io/mcp 1.6.2 → 1.7.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/README.md CHANGED
@@ -22,18 +22,17 @@ No local install — see [Configuration](#configuration) for per-client snippets
22
22
 
23
23
  ## Tools
24
24
 
25
- | Tool | Description |
26
- | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
27
- | `browserless_smartscraper` | Scrape any webpage using cascading strategies (HTTP fetch, proxy, headless browser, captcha solving). Returns content in requested formats: `markdown`, `html`, `screenshot`, `pdf`, `links`. |
28
- | `browserless_search` | Search the web using Browserless and optionally scrape each result. Supports web, news, and image search with geo-targeting and time filters. |
29
- | `browserless_map` | Discover and map all URLs on a website. Crawls via sitemaps and link extraction. Returns URLs with optional titles and descriptions. Useful for site audits and content discovery. |
30
- | `browserless_crawl` | Crawl a website and scrape every discovered page. Supports depth control, path filtering, sitemap strategies, and configurable scrape options. Returns scraped content and metadata for each page. |
31
- | `browserless_performance` | Run Lighthouse audits on any URL. Returns scores and metrics for accessibility, best practices, performance, PWA, and SEO. Optionally filter by category or supply performance budgets. |
32
- | `browserless_function` | Execute custom Puppeteer JavaScript on the Browserless cloud. The function receives a `page` object and optional `context`; return `{ data, type }` to control the payload and Content-Type. |
33
- | `browserless_download` | Run custom Puppeteer code and return the file Chrome downloads during execution (e.g. after clicking a download link). The downloaded file is streamed back to the caller. |
34
- | `browserless_export` | Export a webpage via the Browserless `/export` API. Fetches the URL and returns its native content (HTML, PDF, image, etc.) with automatic content-type detection. |
35
- | `browserless_agent` | Drive a persistent browser session via a ReAct loop: snapshot the page, plan, batch interactions (click, type, scroll, evaluate, etc.), and re-snapshot. Uses ref-based selectors derived from snapshots, supports multi-tab workflows, screenshots, captcha solving, and live URLs. |
36
- | `browserless_skill` | Load an on-demand recipe for a non-trivial page mechanic (shadow DOM, cookie consent, modals, captchas, dynamic content, snapshot misses, screenshots, tabs). Companion to `browserless_agent`. |
25
+ | Tool | Description |
26
+ | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
27
+ | `browserless_smartscraper` | Scrape any webpage using cascading strategies (HTTP fetch, proxy, headless browser, captcha solving). Returns content in requested formats: `markdown`, `html`, `screenshot`, `pdf`, `links`. |
28
+ | `browserless_search` | Search the web using Browserless and optionally scrape each result. Supports web, news, and image search with geo-targeting and time filters. |
29
+ | `browserless_map` | Discover and map all URLs on a website. Crawls via sitemaps and link extraction. Returns URLs with optional titles and descriptions. Useful for site audits and content discovery. |
30
+ | `browserless_crawl` | Crawl a website and scrape every discovered page. Supports depth control, path filtering, sitemap strategies, and configurable scrape options. Returns scraped content and metadata for each page. |
31
+ | `browserless_performance` | Run Lighthouse audits on any URL. Returns scores and metrics for accessibility, best practices, performance, PWA, and SEO. Optionally filter by category or supply performance budgets. |
32
+ | `browserless_function` | Execute custom Puppeteer JavaScript on the Browserless cloud. The function receives a `page` object and optional `context`; return `{ data, type }` to control the payload and Content-Type. |
33
+ | `browserless_export` | Export a webpage via the Browserless `/export` API. Fetches the URL and returns its native content (HTML, PDF, image, etc.) with automatic content-type detection. |
34
+ | `browserless_agent` | Drive a persistent browser session via a ReAct loop: snapshot the page, plan, batch interactions (click, type, scroll, evaluate, etc.), and re-snapshot. Uses ref-based selectors derived from snapshots, supports multi-tab workflows, screenshots, captcha solving, live URLs, and file upload/download (captured downloads auto-surface as handles; bytes never enter context). |
35
+ | `browserless_skill` | Load an on-demand recipe for a non-trivial page mechanic (shadow DOM, cookie consent, modals, captchas, dynamic content, snapshot misses, screenshots, tabs). Companion to `browserless_agent`. |
37
36
 
38
37
  ## Skills
39
38
 
@@ -103,6 +102,8 @@ The `proxy` object is read once at session creation. To change it, call `close`
103
102
 
104
103
  The server is hosted at `https://mcp.browserless.io/mcp`. Authenticate via headers (preferred) or a `?token=` query parameter.
105
104
 
105
+ Installing via an AI agent? See [install.md](install.md) for agent-readable setup instructions.
106
+
106
107
  **Using headers** (recommended for clients that support them):
107
108
 
108
109
  ```json
@@ -6,7 +6,6 @@ import type {
6
6
  SmartScraperResponseSchema,
7
7
  } from '../tools/smartscraper.js';
8
8
  import type { FunctionParamsSchema } from '../tools/function.js';
9
- import type { DownloadParamsSchema } from '../tools/download.js';
10
9
  import type { ExportParamsSchema } from '../tools/export.js';
11
10
  import type {
12
11
  SearchSourceSchema,
@@ -27,6 +26,7 @@ import type {
27
26
  CrawlParamsSchema,
28
27
  } from '../tools/crawl.js';
29
28
  import type { AgentParamsSchema } from '../tools/agent.js';
29
+ import type { CreateProfileParams } from '../tools/schemas.js';
30
30
  import type { ProxyOptionsSchema } from '../lib/agent-client.js';
31
31
 
32
32
  /* ------------------------------------------------------------------ */
@@ -36,6 +36,13 @@ import type { ProxyOptionsSchema } from '../lib/agent-client.js';
36
36
  export interface BrowserlessSession extends Record<string, unknown> {
37
37
  token: string;
38
38
  apiUrl: string;
39
+ /**
40
+ * A pre-created browser session id to ATTACH to (via /chromium/agent?sessionId),
41
+ * threaded by the caller through the `x-browserless-session-id` header. Used by
42
+ * the autologin runner, which does POST /profile itself and hands the agent the
43
+ * resulting id instead of letting the model open a `createProfile` session.
44
+ */
45
+ attachSessionId?: string;
39
46
  }
40
47
 
41
48
  export interface SupabaseJwtPayload {
@@ -133,6 +140,7 @@ export interface SnapshotElement {
133
140
  focused?: boolean;
134
141
  required?: boolean;
135
142
  ariaLabel?: string;
143
+ frameId?: string;
136
144
  }
137
145
 
138
146
  export interface TabInfo {
@@ -142,6 +150,13 @@ export interface TabInfo {
142
150
  active: boolean;
143
151
  }
144
152
 
153
+ // for iframe handling
154
+ export interface FrameInfo {
155
+ frameId: string;
156
+ url: string;
157
+ crossOrigin: boolean;
158
+ }
159
+
145
160
  export interface SnapshotResult {
146
161
  url: string;
147
162
  title: string;
@@ -150,6 +165,7 @@ export interface SnapshotResult {
150
165
  tabs?: TabInfo[];
151
166
  activeTargetId?: string | null;
152
167
  detectedChallenges?: string[];
168
+ frames?: FrameInfo[];
153
169
  }
154
170
 
155
171
  export interface ActiveSession {
@@ -161,6 +177,13 @@ export interface ActiveSession {
161
177
  readonly token: string;
162
178
  readonly proxy?: ProxyOptions;
163
179
  readonly profile?: string;
180
+ // When set, this session was opened in profile-creation mode: the WS is bound
181
+ // to a creation session from POST /profile rather than a fresh launch. Feeds
182
+ // the session-cache key (see getSessionKey), so it's readonly.
183
+ readonly createProfile?: CreateProfileParams;
184
+ // The creation session id returned by POST /profile. Reconnects attach to it
185
+ // via /chromium/agent?sessionId rather than launching a new browser.
186
+ creationSessionId?: string;
164
187
  reconnecting?: Promise<WebSocket>;
165
188
  skillState: SkillFireState;
166
189
  lastUsedAt: number;
@@ -208,7 +231,9 @@ export type SkillId =
208
231
  | 'dynamic-content'
209
232
  | 'screenshots'
210
233
  | 'tabs'
211
- | 'autonomous-login';
234
+ | 'autonomous-login'
235
+ | 'auth-profile'
236
+ | 'file-transfers';
212
237
 
213
238
  export interface DetectContext {
214
239
  snapshot?: SnapshotResult;
@@ -269,7 +294,6 @@ export type ScrapeFormat = z.infer<typeof ScrapeFormatSchema>;
269
294
  export type SmartScraperParams = z.infer<typeof SmartScraperParamsSchema>;
270
295
  export type SmartScraperResponse = z.infer<typeof SmartScraperResponseSchema>;
271
296
  export type FunctionParams = z.infer<typeof FunctionParamsSchema>;
272
- export type DownloadParams = z.infer<typeof DownloadParamsSchema>;
273
297
  export type ExportParams = z.infer<typeof ExportParamsSchema>;
274
298
  export type ProxyOptions = z.infer<typeof ProxyOptionsSchema>;
275
299
  export type SearchSource = z.infer<typeof SearchSourceSchema>;
@@ -6,7 +6,6 @@ import { OAuthProxy } from 'fastmcp/auth';
6
6
  import { getConfig } from './config.js';
7
7
  import { registerSmartScraperTool } from './tools/smartscraper.js';
8
8
  import { registerFunctionTool } from './tools/function.js';
9
- import { registerDownloadTool } from './tools/download.js';
10
9
  import { registerExportTool } from './tools/export.js';
11
10
  import { registerAgentTools } from './tools/agent.js';
12
11
  import { registerSearchTool } from './tools/search.js';
@@ -15,10 +14,14 @@ import { registerCrawlTool } from './tools/crawl.js';
15
14
  import { registerPerformanceTool } from './tools/performance.js';
16
15
  import { registerApiDocsResource } from './resources/api-docs.js';
17
16
  import { registerStatusResource } from './resources/status.js';
17
+ import { registerUploadRoute } from './resources/upload-route.js';
18
+ import { registerDownloadRoute } from './resources/download-route.js';
19
+ import { clearSession } from './lib/download-store.js';
18
20
  import { registerScrapeUrlPrompt } from './prompts/scrape-url.js';
19
21
  import { registerExtractContentPrompt } from './prompts/extract-content.js';
20
22
  import { AnalyticsHelper } from './lib/analytics.js';
21
- import { resolveApiKey, installSupabaseTokenTtlPatch, } from './lib/account-resolver.js';
23
+ import { installSupabaseTokenTtlPatch } from './lib/account-resolver.js';
24
+ import { resolveBrowserlessAuth } from './lib/http-auth.js';
22
25
  import { BoundedEventStore } from './lib/bounded-event-store.js';
23
26
  import { RedisOAuthProxy } from './lib/redis-oauth-proxy.js';
24
27
  import { Redis } from 'ioredis';
@@ -78,32 +81,14 @@ const oauthProvider = config.oauthEnabled && config.transport === 'httpStream'
78
81
  const hybridAuthenticate = config.transport === 'httpStream'
79
82
  ? async (request) => {
80
83
  const params = new URLSearchParams(request.url?.split('?')[1] ?? '');
81
- const authHeader = request.headers.authorization;
82
- const headerToken = authHeader?.startsWith('Bearer ')
83
- ? authHeader.slice(7)
84
- : authHeader;
85
- const apiUrl = request.headers['x-browserless-api-url'] ??
86
- params.get('browserlessUrl') ??
87
- config.browserlessApiUrl;
88
- // JWTs have 3 dot-separated base64url segments; plain API keys do not.
89
- const isJwt = headerToken ? headerToken.split('.').length === 3 : false;
90
- // 1. Authorization header with plain API key
91
- if (headerToken && !isJwt) {
92
- return { token: headerToken, apiUrl };
93
- }
94
- // 2. ?token= query param
95
- const directToken = params.get('token') || undefined;
96
- if (directToken) {
97
- return { token: directToken, apiUrl };
98
- }
99
- // 3. Authorization header with JWT → decode Supabase token directly
100
- if (isJwt && headerToken) {
101
- const { apiKey } = await resolveApiKey(config.supabaseUrl, config.supabaseServiceRoleKey, headerToken);
102
- return { token: apiKey, apiUrl };
103
- }
104
- throw new Error('No Browserless API token provided. ' +
105
- 'Pass it as Authorization: Bearer <token> header, ' +
106
- '?token= query parameter, or authenticate via OAuth.');
84
+ return (await resolveBrowserlessAuth({
85
+ authHeader: request.headers.authorization,
86
+ tokenQuery: params.get('token') || undefined,
87
+ apiUrlHeader: request.headers['x-browserless-api-url'],
88
+ browserlessUrlQuery: params.get('browserlessUrl') || undefined,
89
+ sessionIdHeader: request.headers['x-browserless-session-id'],
90
+ sessionIdQuery: params.get('browserlessSessionId') || undefined,
91
+ }, config));
107
92
  }
108
93
  : undefined;
109
94
  const server = new FastMCP({
@@ -114,7 +99,6 @@ const server = new FastMCP({
114
99
  });
115
100
  registerSmartScraperTool(server, config, analytics);
116
101
  registerFunctionTool(server, config, analytics);
117
- registerDownloadTool(server, config, analytics);
118
102
  registerExportTool(server, config, analytics);
119
103
  registerAgentTools(server, config, analytics);
120
104
  registerSearchTool(server, config, analytics);
@@ -131,6 +115,8 @@ server.on('connect', (event) => {
131
115
  });
132
116
  server.on('disconnect', (event) => {
133
117
  const id = event.session.sessionId ?? 'stdio';
118
+ // Drop any files staged/captured for this session (TTL is the backstop).
119
+ clearSession(event.session.sessionId);
134
120
  console.error(`[browserless-mcp] Client disconnected: ${id}`);
135
121
  });
136
122
  if (config.transport === 'httpStream') {
@@ -143,6 +129,12 @@ if (config.transport === 'httpStream') {
143
129
  stateless: false,
144
130
  },
145
131
  });
132
+ // Out-of-band file staging for uploads (the LLM curls a file here and gets a
133
+ // handle, instead of base64-ing it through the conversation). httpStream only.
134
+ registerUploadRoute(server, config);
135
+ // Single-use, out-of-band fetch for captured downloads (the LLM GETs the file
136
+ // instead of pulling bytes through the conversation). httpStream only.
137
+ registerDownloadRoute(server, config);
146
138
  console.error(`[browserless-mcp] HTTP Streamable server listening on port ${config.port}`);
147
139
  }
148
140
  else {
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import type { CreateProfileParams } from '../tools/schemas.js';
2
3
  import type { ActiveSession, AgentResponse, ProxyOptions } from '../@types/types.js';
3
4
  export type { ProxyOptions, ActiveSession, AgentMessage, AgentResponse, AgentError, } from '../@types/types.js';
4
5
  export declare const ProxyOptionsSchema: z.ZodObject<{
@@ -46,13 +47,13 @@ export declare const proxyFingerprint: (proxy?: ProxyOptions) => string;
46
47
  * swap http(s)→ws(s), and append `token` plus proxy params. Boolean proxy
47
48
  * flags follow the API's presence-only contract (set only when truthy).
48
49
  */
49
- export declare const buildAgentWsUrl: (apiUrl: string, token: string, proxy?: ProxyOptions, profile?: string) => string;
50
- export declare const getOrCreateSession: (mcpSessionId: string | undefined, apiUrl: string, token: string, proxy?: ProxyOptions, profile?: string) => Promise<ActiveSession>;
50
+ export declare const buildAgentWsUrl: (apiUrl: string, token: string, proxy?: ProxyOptions, profile?: string, sessionId?: string) => string;
51
+ export declare const getOrCreateSession: (mcpSessionId: string | undefined, apiUrl: string, token: string, proxy?: ProxyOptions, profile?: string, createProfile?: CreateProfileParams, attachSessionId?: string) => Promise<ActiveSession>;
51
52
  export declare const send: (session: ActiveSession, method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<AgentResponse>;
52
- export declare const closeSession: (mcpSessionId: string | undefined, token: string, proxy?: ProxyOptions, profile?: string) => void;
53
+ export declare const closeSession: (mcpSessionId: string | undefined, token: string, proxy?: ProxyOptions, profile?: string, createProfile?: CreateProfileParams, attachSessionId?: string) => void;
53
54
  /**
54
55
  * Force-destroy a session after a browser crash or unrecoverable state, so
55
56
  * the next call reconnects fresh. Unlike `closeSession`, it also drops any
56
57
  * in-flight connect for the key so a concurrent caller can't reuse a dead WS.
57
58
  */
58
- export declare const destroySession: (mcpSessionId: string | undefined, token: string, proxy?: ProxyOptions, profile?: string) => void;
59
+ export declare const destroySession: (mcpSessionId: string | undefined, token: string, proxy?: ProxyOptions, profile?: string, createProfile?: CreateProfileParams, attachSessionId?: string) => void;
@@ -99,11 +99,16 @@ export class ProfileNotFoundError extends UpgradeError {
99
99
  }
100
100
  }
101
101
  // Upgrade statuses where a one-shot retry cannot help: bad request (400),
102
- // bad auth (401), forbidden by plan/policy (403), or missing resource (404).
103
- // Retrying just wastes time and emits a misleading "second attempt failed".
104
- const NON_RETRYABLE_UPGRADE_STATUSES = new Set([400, 401, 403, 404]);
102
+ // bad auth (401), forbidden by plan/policy (403), missing resource (404), or
103
+ // concurrency limit (429). Retrying a 429 just opens another session and
104
+ // stacks more lingering sessions against the same limit, so stop instead.
105
+ const NON_RETRYABLE_UPGRADE_STATUSES = new Set([400, 401, 403, 404, 429]);
105
106
  export const isRetryableUpgradeError = (err) => {
106
107
  if (err instanceof UpgradeError) {
108
+ // A 2xx UpgradeError is a structurally-bad success response — retrying
109
+ // can't fix the shape (and may duplicate side effects), so don't.
110
+ if (err.statusCode >= 200 && err.statusCode < 300)
111
+ return false;
107
112
  return !NON_RETRYABLE_UPGRADE_STATUSES.has(err.statusCode);
108
113
  }
109
114
  return true;
@@ -172,18 +177,26 @@ export const proxyFingerprint = (proxy) => {
172
177
  // Hash the profile rather than serialize it raw: like externalProxyServer,
173
178
  // the eviction-logged session key may otherwise leak a user-identifying
174
179
  // profile name. Hashing keeps per-profile distinctness without that leak.
175
- const getSessionKey = (mcpSessionId, token, proxy, profile) => (mcpSessionId ?? `stdio:${hashToken(token)}`) +
180
+ const getSessionKey = (mcpSessionId, token, proxy, profile, createProfile, attachSessionId) => (mcpSessionId ?? `stdio:${hashToken(token)}`) +
176
181
  proxyFingerprint(proxy) +
177
- (profile ? KEY_SEP + 'profile#' + hashToken(profile) : '');
182
+ (profile ? KEY_SEP + 'profile#' + hashToken(profile) : '') +
183
+ (createProfile ? KEY_SEP + 'create#' + hashToken(createProfile.name) : '') +
184
+ (attachSessionId ? KEY_SEP + 'attach#' + attachSessionId : '');
178
185
  /**
179
186
  * Build the WebSocket URL for `/chromium/agent`: normalize trailing slashes,
180
187
  * swap http(s)→ws(s), and append `token` plus proxy params. Boolean proxy
181
188
  * flags follow the API's presence-only contract (set only when truthy).
182
189
  */
183
- export const buildAgentWsUrl = (apiUrl, token, proxy, profile) => {
190
+ export const buildAgentWsUrl = (apiUrl, token, proxy, profile, sessionId) => {
184
191
  const base = apiUrl.replace(/^http/i, 'ws').replace(/\/+$/, '');
185
192
  const url = new URL(base + '/chromium/agent');
186
193
  url.searchParams.set('token', token);
194
+ // A creation session already owns its proxy/profile (baked in at POST /profile);
195
+ // the WS only needs to attach to it by id, so proxy/profile params are skipped.
196
+ if (sessionId) {
197
+ url.searchParams.set('sessionId', sessionId);
198
+ return url.toString();
199
+ }
187
200
  if (proxy?.proxy)
188
201
  url.searchParams.set('proxy', proxy.proxy);
189
202
  if (proxy?.proxyCountry)
@@ -322,8 +335,50 @@ const readUpgradeError = (res, profile) => new Promise((resolve) => {
322
335
  // `res.destroy()` can fire 'close' without 'end' or 'error'; settle here too.
323
336
  res.on('close', finish);
324
337
  });
325
- const connect = (apiUrl, token, proxy, profile) => new Promise((resolve, reject) => {
326
- const wsUrl = buildAgentWsUrl(apiUrl, token, proxy, profile);
338
+ // POST /profile launches a non-headless browser, which can take several seconds.
339
+ const CREATE_PROFILE_TIMEOUT_MS = 60_000;
340
+ /**
341
+ * Open a profile-creation session via POST /profile. Returns the tracked
342
+ * session id the agent WS then attaches to with `?sessionId`. Non-2xx responses
343
+ * throw UpgradeError so the tool layer's retry/4xx classification applies
344
+ * uniformly with the WS-upgrade path.
345
+ */
346
+ const postCreateProfile = async (apiUrl, token, createProfile) => {
347
+ const base = apiUrl.replace(/\/+$/, '');
348
+ const url = new URL(base + '/profile');
349
+ url.searchParams.set('token', token);
350
+ const controller = new AbortController();
351
+ const timer = setTimeout(() => controller.abort(), CREATE_PROFILE_TIMEOUT_MS);
352
+ let res;
353
+ try {
354
+ res = await fetch(url.toString(), {
355
+ method: 'POST',
356
+ headers: { 'Content-Type': 'application/json' },
357
+ body: JSON.stringify(createProfile),
358
+ signal: controller.signal,
359
+ });
360
+ }
361
+ catch (err) {
362
+ throw new Error(`POST /profile failed: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
363
+ }
364
+ finally {
365
+ clearTimeout(timer);
366
+ }
367
+ if (!res.ok) {
368
+ const body = await res.text().catch(() => '');
369
+ throw new UpgradeError(res.status, res.statusText, body);
370
+ }
371
+ const json = await res.json();
372
+ if (typeof json !== 'object' ||
373
+ json === null ||
374
+ typeof json.id !== 'string' ||
375
+ !json.id) {
376
+ throw new UpgradeError(res.status, res.statusText, `POST /profile returned a malformed response (missing or invalid "id")`);
377
+ }
378
+ return json;
379
+ };
380
+ const connect = (apiUrl, token, proxy, profile, sessionId) => new Promise((resolve, reject) => {
381
+ const wsUrl = buildAgentWsUrl(apiUrl, token, proxy, profile, sessionId);
327
382
  const ws = new WebSocket(wsUrl);
328
383
  let settled = false;
329
384
  const settle = (err, value) => {
@@ -411,9 +466,9 @@ const sendMessage = (ws, msg, timeoutMs = DEFAULT_TIMEOUT) => new Promise((resol
411
466
  ws.on('close', closeHandler);
412
467
  ws.send(JSON.stringify(msg));
413
468
  });
414
- export const getOrCreateSession = async (mcpSessionId, apiUrl, token, proxy, profile) => {
469
+ export const getOrCreateSession = async (mcpSessionId, apiUrl, token, proxy, profile, createProfile, attachSessionId) => {
415
470
  sweepSessions();
416
- const key = getSessionKey(mcpSessionId, token, proxy, profile);
471
+ const key = getSessionKey(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
417
472
  const existing = sessions.get(key);
418
473
  if (existing && existing.ws.readyState === WebSocket.OPEN) {
419
474
  existing.lastUsedAt = Date.now();
@@ -434,7 +489,19 @@ export const getOrCreateSession = async (mcpSessionId, apiUrl, token, proxy, pro
434
489
  sessions.delete(key);
435
490
  }
436
491
  const creation = (async () => {
437
- const ws = await connect(apiUrl, token, proxy, profile);
492
+ // Three modes for the session to attach to:
493
+ // - attachSessionId: a session the caller already created (autologin
494
+ // runner did POST /profile itself) — attach by id, no POST here.
495
+ // - createProfile: open a tracked session via POST /profile, then attach.
496
+ // - neither: launch a fresh agent browser.
497
+ let creationSessionId;
498
+ if (attachSessionId) {
499
+ creationSessionId = attachSessionId;
500
+ }
501
+ else if (createProfile) {
502
+ creationSessionId = (await postCreateProfile(apiUrl, token, createProfile)).id;
503
+ }
504
+ const ws = await connect(apiUrl, token, proxy, profile, creationSessionId);
438
505
  const session = {
439
506
  ws,
440
507
  msgId: 0,
@@ -442,6 +509,8 @@ export const getOrCreateSession = async (mcpSessionId, apiUrl, token, proxy, pro
442
509
  token,
443
510
  proxy,
444
511
  profile,
512
+ createProfile,
513
+ creationSessionId,
445
514
  skillState: createSkillState(),
446
515
  lastUsedAt: Date.now(),
447
516
  };
@@ -473,7 +542,9 @@ export const getOrCreateSession = async (mcpSessionId, apiUrl, token, proxy, pro
473
542
  export const send = async (session, method, params = {}, timeoutMs) => {
474
543
  if (session.ws.readyState !== WebSocket.OPEN) {
475
544
  if (!session.reconnecting) {
476
- session.reconnecting = connect(session.apiUrl, session.token, session.proxy, session.profile).finally(() => {
545
+ // A creation session must re-attach to the same browser by id — a fresh
546
+ // connect() would launch a new one and lose all auth progress.
547
+ session.reconnecting = connect(session.apiUrl, session.token, session.proxy, session.profile, session.creationSessionId).finally(() => {
477
548
  session.reconnecting = undefined;
478
549
  });
479
550
  }
@@ -496,8 +567,8 @@ export const send = async (session, method, params = {}, timeoutMs) => {
496
567
  session.lastUsedAt = Date.now();
497
568
  return sendMessage(session.ws, { id: session.msgId, method, params }, timeoutMs);
498
569
  };
499
- export const closeSession = (mcpSessionId, token, proxy, profile) => {
500
- const key = getSessionKey(mcpSessionId, token, proxy, profile);
570
+ export const closeSession = (mcpSessionId, token, proxy, profile, createProfile, attachSessionId) => {
571
+ const key = getSessionKey(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
501
572
  const session = sessions.get(key);
502
573
  if (session) {
503
574
  try {
@@ -514,8 +585,8 @@ export const closeSession = (mcpSessionId, token, proxy, profile) => {
514
585
  * the next call reconnects fresh. Unlike `closeSession`, it also drops any
515
586
  * in-flight connect for the key so a concurrent caller can't reuse a dead WS.
516
587
  */
517
- export const destroySession = (mcpSessionId, token, proxy, profile) => {
518
- const key = getSessionKey(mcpSessionId, token, proxy, profile);
588
+ export const destroySession = (mcpSessionId, token, proxy, profile, createProfile, attachSessionId) => {
589
+ const key = getSessionKey(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
519
590
  const session = sessions.get(key);
520
591
  if (session) {
521
592
  try {
@@ -1,5 +1,5 @@
1
1
  import type { SnapshotResult } from '../@types/types.js';
2
- export type { SnapshotResult, SnapshotElement, TabInfo, } from '../@types/types.js';
2
+ export type { SnapshotResult, SnapshotElement, TabInfo, FrameInfo, } from '../@types/types.js';
3
3
  /**
4
4
  * Build the cross-origin notice shown above a snapshot when the page changed
5
5
  * origin (protocol + host + port) since the last snapshot. Returns '' when
@@ -84,7 +84,7 @@ export const formatConnectError = (err) => {
84
84
  case 403:
85
85
  return `Forbidden (403) — your plan does not include this feature${detail ? ` (server says: ${detail})` : ''}.`;
86
86
  case 429:
87
- return `Concurrency limit reached (429)${detail ? `: ${detail}` : ''}. Wait for in-flight sessions to finish, or upgrade the plan.`;
87
+ return `Concurrency limit reached (429)${detail ? `: ${detail}` : ''}. Stop retrying — each new attempt opens another session and stacks more against the limit. Close any sessions you still have open (call browserless_agent with method "close"), wait for in-flight sessions to finish, or upgrade the plan, then start over.`;
88
88
  default: {
89
89
  const fallback = detail || err.statusMessage || '';
90
90
  return `Failed to connect to browser agent (HTTP ${err.statusCode})${fallback ? `: ${fallback}` : ''}.`;
@@ -98,10 +98,13 @@ export const formatConnectError = (err) => {
98
98
  };
99
99
  /**
100
100
  * Format a single snapshot element as a compact one-liner:
101
- * [ref] tag role "name" ref=selector value="…" (state)
101
+ * [ref] tag role "name" ref=selector value="…" (state) [frame#N]
102
102
  * e.g. [7] input checkbox "Remember me" ref=input#remember (checked, required)
103
+ * `frameLabels` maps a frameId to its display label (frame#1, …); when an
104
+ * element carries a frameId, the label is appended so the agent sees which
105
+ * iframe it lives in.
103
106
  */
104
- const formatElement = (el) => {
107
+ const formatElement = (el, frameLabels) => {
105
108
  const parts = [`[${el.ref}]`, el.tag, el.role];
106
109
  const name = el.name || el.text || '';
107
110
  if (name)
@@ -125,6 +128,9 @@ const formatElement = (el) => {
125
128
  flags.push('required');
126
129
  if (flags.length)
127
130
  parts.push(`(${flags.join(', ')})`);
131
+ const frameLabel = el.frameId && frameLabels?.get(el.frameId);
132
+ if (frameLabel)
133
+ parts.push(`[${frameLabel}]`);
128
134
  return parts.join(' ');
129
135
  };
130
136
  export const formatSnapshot = (snapshot) => {
@@ -146,9 +152,21 @@ export const formatSnapshot = (snapshot) => {
146
152
  lines.push(`! Detected challenge: ${type}`);
147
153
  }
148
154
  }
155
+ // Label cross-origin iframes (frame#1, …) and list them so the agent knows
156
+ // which elements live in a frame and that their deep-ref selectors pierce it.
157
+ const frameLabels = new Map();
158
+ if (snapshot.frames?.length) {
159
+ snapshot.frames.forEach((frame, i) => frameLabels.set(frame.frameId, `frame#${i + 1}`));
160
+ lines.push(`Frames (${snapshot.frames.length} iframes):`);
161
+ for (const frame of snapshot.frames) {
162
+ const origin = frame.crossOrigin ? 'cross-origin' : 'same-origin';
163
+ lines.push(` ${frameLabels.get(frame.frameId)} ${frame.url} (${origin})`);
164
+ }
165
+ lines.push('Elements tagged [frame#N] live in that iframe; their deep-ref selectors pierce it — pass as-is to click/type/hover.');
166
+ }
149
167
  lines.push('');
150
168
  for (const el of snapshot.elements) {
151
- lines.push(formatElement(el));
169
+ lines.push(formatElement(el, frameLabels));
152
170
  }
153
171
  lines.push('--- END SNAPSHOT ---');
154
172
  return lines.join('\n');
@@ -37,6 +37,11 @@ export interface ToolRunContext<P> {
37
37
  }) => Promise<void>;
38
38
  /** MCP session id (httpStream transport) or undefined for stdio — used by agent tool. */
39
39
  sessionId: string | undefined;
40
+ /**
41
+ * Pre-created browser session id to attach to (from the `x-browserless-session-id`
42
+ * header). When set, the agent tool attaches to it instead of opening its own.
43
+ */
44
+ attachSessionId?: string;
40
45
  }
41
46
  export interface ToolDefinition<P, R> {
42
47
  name: string;
@@ -47,6 +47,7 @@ export function defineTool(server, config, analytics, def) {
47
47
  apiUrl,
48
48
  reportProgress,
49
49
  sessionId,
50
+ attachSessionId: s?.attachSessionId,
50
51
  });
51
52
  }
52
53
  catch (err) {
@@ -0,0 +1,17 @@
1
+ export interface StoredDownload {
2
+ id: string;
3
+ path: string;
4
+ filename: string;
5
+ mimeType: string;
6
+ size: number;
7
+ sessionId?: string;
8
+ }
9
+ export declare const DOWNLOAD_URI_SCHEME = "browserless-download";
10
+ export declare const FILE_TRANSFER_MAX_BYTES: number;
11
+ /** Build the handle URI for a stored download id. */
12
+ export declare const downloadUri: (id: string) => string;
13
+ export declare const storeDownload: (filename: string, mimeType: string, data: Buffer, sessionId?: string) => Promise<StoredDownload>;
14
+ export declare const getDownload: (handle: string) => StoredDownload | undefined;
15
+ export declare const consumeDownload: (handle: string) => StoredDownload | undefined;
16
+ /** Drop every file owned by an MCP session (called when the session ends). */
17
+ export declare const clearSession: (sessionId: string | undefined) => void;
@@ -0,0 +1,84 @@
1
+ import { mkdir, rm, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { basename, join } from 'node:path';
4
+ export const DOWNLOAD_URI_SCHEME = 'browserless-download';
5
+ const TTL_MS = 15 * 60 * 1000;
6
+ // Hard ceiling on a single file transfer (mirrors the enterprise cap).
7
+ export const FILE_TRANSFER_MAX_BYTES = 50 * 1024 * 1024;
8
+ const store = new Map();
9
+ let counter = 0;
10
+ // Where captured files land on the MCP server. Defaults to a temp dir; override
11
+ // with BROWSERLESS_DOWNLOAD_DIR (e.g. a stable folder in local/stdio setups).
12
+ const downloadsDir = () => process.env.BROWSERLESS_DOWNLOAD_DIR ||
13
+ join(tmpdir(), 'browserless-mcp-downloads');
14
+ /** Build the handle URI for a stored download id. */
15
+ export const downloadUri = (id) => `${DOWNLOAD_URI_SCHEME}://${id}`;
16
+ const idFromHandle = (handle) => handle.startsWith(`${DOWNLOAD_URI_SCHEME}://`)
17
+ ? handle.slice(`${DOWNLOAD_URI_SCHEME}://`.length)
18
+ : handle;
19
+ // Strip the internal timer handle before handing an entry to callers.
20
+ const toRecord = (entry) => {
21
+ const { timer: _timer, ...record } = entry;
22
+ return record;
23
+ };
24
+ const dropEntry = (entry) => {
25
+ if (entry.timer)
26
+ clearTimeout(entry.timer);
27
+ store.delete(entry.id);
28
+ void rm(entry.path, { force: true }).catch(() => { });
29
+ };
30
+ // Persist bytes to disk under a fresh handle. `sessionId` ties the file to an
31
+ // MCP session for cleanup on session end (no session → TTL only).
32
+ export const storeDownload = async (filename, mimeType, data, sessionId) => {
33
+ const dir = downloadsDir();
34
+ await mkdir(dir, { recursive: true });
35
+ counter += 1;
36
+ const id = `${Date.now().toString(36)}-${counter}`;
37
+ const safe = basename(filename) || 'download';
38
+ // Prefix with the id so files that share a name don't collide.
39
+ const path = join(dir, `${id}-${safe}`);
40
+ await writeFile(path, data);
41
+ const timer = setTimeout(() => {
42
+ store.delete(id);
43
+ void rm(path, { force: true }).catch(() => { });
44
+ }, TTL_MS);
45
+ timer.unref?.();
46
+ const entry = {
47
+ id,
48
+ path,
49
+ filename: safe,
50
+ mimeType,
51
+ size: data.byteLength,
52
+ sessionId,
53
+ timer,
54
+ };
55
+ store.set(id, entry);
56
+ return toRecord(entry);
57
+ };
58
+ // Resolve a handle (id, URI, or stored path) WITHOUT removing it. Used by
59
+ // uploadFile, which may reference the same file more than once.
60
+ export const getDownload = (handle) => {
61
+ const entry = store.get(idFromHandle(handle)) ??
62
+ [...store.values()].find((r) => r.path === handle);
63
+ return entry && toRecord(entry);
64
+ };
65
+ // Resolve a handle and remove it (single-use): entry, TTL timer, and bytes.
66
+ // Backs `GET /download/:id` so a download can only be fetched once.
67
+ export const consumeDownload = (handle) => {
68
+ const entry = store.get(idFromHandle(handle));
69
+ if (!entry)
70
+ return undefined;
71
+ if (entry.timer)
72
+ clearTimeout(entry.timer);
73
+ store.delete(entry.id);
74
+ return toRecord(entry);
75
+ };
76
+ /** Drop every file owned by an MCP session (called when the session ends). */
77
+ export const clearSession = (sessionId) => {
78
+ if (!sessionId)
79
+ return;
80
+ for (const entry of [...store.values()]) {
81
+ if (entry.sessionId === sessionId)
82
+ dropEntry(entry);
83
+ }
84
+ };