@browserless.io/mcp 1.6.2 → 1.7.1

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.
Files changed (33) hide show
  1. package/README.md +13 -12
  2. package/build/src/@types/types.d.ts +27 -3
  3. package/build/src/index.js +21 -29
  4. package/build/src/lib/agent-client.d.ts +5 -4
  5. package/build/src/lib/agent-client.js +87 -16
  6. package/build/src/lib/agent-format.d.ts +1 -1
  7. package/build/src/lib/agent-format.js +22 -4
  8. package/build/src/lib/define-tool.d.ts +5 -0
  9. package/build/src/lib/define-tool.js +1 -0
  10. package/build/src/lib/download-store.d.ts +17 -0
  11. package/build/src/lib/download-store.js +84 -0
  12. package/build/src/lib/http-auth.d.ts +22 -0
  13. package/build/src/lib/http-auth.js +33 -0
  14. package/build/src/resources/download-route.d.ts +16 -0
  15. package/build/src/resources/download-route.js +53 -0
  16. package/build/src/resources/upload-route.d.ts +3 -0
  17. package/build/src/resources/upload-route.js +53 -0
  18. package/build/src/skills/auth-profile.md +66 -0
  19. package/build/src/skills/autonomous-login.md +44 -43
  20. package/build/src/skills/file-transfers.md +88 -0
  21. package/build/src/skills/index.js +19 -0
  22. package/build/src/skills/shadow-dom.md +10 -1
  23. package/build/src/skills/system-prompt.d.ts +3 -2
  24. package/build/src/skills/system-prompt.js +32 -2
  25. package/build/src/tools/agent.d.ts +23 -0
  26. package/build/src/tools/agent.js +212 -30
  27. package/build/src/tools/map.js +1 -1
  28. package/build/src/tools/schemas.d.ts +79 -0
  29. package/build/src/tools/schemas.js +126 -3
  30. package/build/src/tools/smartscraper.js +4 -3
  31. package/package.json +5 -3
  32. package/build/src/tools/download.d.ts +0 -11
  33. package/build/src/tools/download.js +0 -92
@@ -50,7 +50,10 @@ Load manually via **browserless_skill** if suspected but not injected:
50
50
  ## Selectors
51
51
  - Use **ref=** (CSS) or **deep-ref=** (starts \`< \`) exactly as shown in snapshot
52
52
  - Example: \`[3] button "Sign In" ref=button#submit\` → \`"button#submit"\`
53
- - deep-ref for shadow DOM — see \`shadow-dom\` skill
53
+ - deep-ref for shadow DOM / iframes — see \`shadow-dom\` skill
54
+
55
+ ## Iframes
56
+ Snapshots include a \`Frames\` list (cross-origin iframes) when present. Elements inside a frame are tagged \`[frame#N]\` and carry a \`deep-ref=< *url* css\` selector that already pierces the frame — pass it as-is to \`click\`/\`type\`/\`hover\`/\`checkbox\`. No frame switching needed. captcha/payment widgets (reCAPTCHA, hCaptcha, Stripe, Turnstile) show up here. \`shadow-dom\` skill auto-loads when frames present.
54
57
 
55
58
  ## Tabs
56
59
  Snapshots include \`tabs\` + \`activeTargetId\` — no getTabs needed. Multi-tab / \`snapshot { targetId }\` in \`tabs\` skill (auto-loads when >1 tab).
@@ -66,6 +69,17 @@ Only click when href is \`javascript:\` / \`#\` / missing.
66
69
  3. **evaluate** { content } — JS (IIFE): \`(() => { return ... })()\`
67
70
  4. **html** { selector } — raw HTML
68
71
 
72
+ ## Files (upload / download)
73
+ **To download a file, DRIVE THE BROWSER — do not \`curl\`/\`wget\`/\`fetch\` the file yourself as a first move.** Many real downloads (login/cookie-gated, generated server-side on demand, or triggered by a click whose response headers force the download) have NO fetchable URL — a direct fetch silently gets the wrong bytes, an HTML error page, or 403. Click/goto in the agent and collect from the auto-surfaced ledger. The ONLY time a direct fetch is correct: the ledger hands you a URL to use — the single-use \`/download/<id>\` URL, or an over-cap \`sourceUrl\`. Reaching for \`curl\` first is a bug, not a shortcut.
74
+ **NEVER read a file's bytes or base64 into this conversation, and NEVER split/reassemble/inline base64 by hand.** That is the wrong tool and will stall.
75
+ - **Upload a local file (stdio)**: \`uploadFile { selector, files: [{ path }] }\` — the server reads + encodes it.
76
+ - **Upload a local file (HTTP)**: the server can't read your disk. Stage it once over HTTP, then use the handle:
77
+ \`curl -s -F file=@"/path/to/file" "<MCP_BASE_URL>/upload?token=<TOKEN>"\` → returns \`{ "handle": "browserless-download://…" }\` → \`uploadFile { files: [{ handle }] }\`. (The path-rejection error gives you the exact command with your token + URL filled in.)
78
+ - **Re-upload something from \`getDownloads\`**: pass its \`handle\` (works in both modes).
79
+ - **Download**: just trigger it in the agent (click a download link, or goto the file URL). The captured file **auto-surfaces** as a notification on the agent response (filename/size/handle), never the bytes — the server waits for it to finish (bounded by size), so it usually lands on that same call. stdio: file already saved, you get its path. HTTP: a **single-use** \`curl … /download/<id>?token=\` URL — fetch only if you need it. Files over the cap aren't transferred — you get the source URL to fetch directly. Path/handle reuses in \`uploadFile\`. (No separate download tool — use the agent.)
80
+ - base64 \`content\` is a LAST RESORT — tiny inline data only.
81
+ - Full recipe: \`file-transfers\` skill.
82
+
69
83
  ## Batching — Maximize Per Call
70
84
  Plan ALL actions from snapshot before next snapshot.
71
85
 
@@ -112,6 +126,21 @@ Never retry same failed action without re-snapshot.
112
126
  - See schema for: screenshot, solve, back, forward, reload, click, type, select, checkbox, hover, scroll, text, html, waitForNavigation, waitForTimeout, waitForRequest, liveURL, getTabs, switchTab, closeTab
113
127
 
114
128
  `;
129
+ // Transport-specific file-transfer guidance, appended to the agent tool
130
+ // description so the model knows its mode UP FRONT — instead of guessing (and
131
+ // base64-ing files it should pass by path). The server knows the transport; the
132
+ // model can't introspect it.
133
+ export const fileTransferModeNote = (transport, mcpBaseUrl) => transport === 'stdio'
134
+ ? `\n\n## Runtime: LOCAL (stdio)\n` +
135
+ `Before any file transfer, know your mode: this server runs over **stdio**, on the same machine as your files. ` +
136
+ `To UPLOAD a local file, pass its **\`path\`** straight to \`uploadFile\` (\`files: [{ path: "/abs/file" }]\`) — the server reads it. ` +
137
+ `**Do NOT base64 the file or read its bytes into the conversation.** ` +
138
+ `DOWNLOADS are saved to local disk; the agent response gives you the path.`
139
+ : `\n\n## Runtime: REMOTE (HTTP)\n` +
140
+ `Before any file transfer, know your mode: this server runs over **HTTP** and **cannot read your filesystem**. ` +
141
+ `To UPLOAD a local file, stage it once over HTTP, then use the handle:\n` +
142
+ ` \`curl -s -F file=@"/abs/file" "${mcpBaseUrl}/upload?token=<YOUR_TOKEN>"\` -> { "handle": "browserless-download://..." } -> \`uploadFile { files: [{ handle }] }\`.\n` +
143
+ `**Never base64 a file through the conversation.** DOWNLOADS come back with a single-use \`${mcpBaseUrl}/download/<id>\` URL.`;
115
144
  export const SKILL_TOOL_DESCRIPTION = `Load a Browserless agent skill on demand.
116
145
 
117
146
  Use this when you suspect the page exhibits a non-trivial mechanic but no SKILL block was auto-injected into a previous response. The auto-injection heuristics are conservative; calling this tool is the explicit fallback.
@@ -125,4 +154,5 @@ Available skills:
125
154
  - **screenshots** — when to screenshot vs. snapshot, scope and format choices
126
155
  - **tabs** — multi-tab workflows, peek-without-switching
127
156
  - **autonomous-login** — load before authenticating: when the user asked you to log in, when a wall blocks the task, or as soon as a password input appears. Covers the don't-login-by-default posture, contextual credential matching, MFA/captcha branches, and the required final JSON response shape.
128
- - **captchas** — the \`solve\` command, response semantics, escalation path (Cloud-only)`;
157
+ - **captchas** — the \`solve\` command, response semantics, escalation path (Cloud-only)
158
+ - **file-transfers** — \`uploadFile\` / \`getDownloads\`, stdio-path vs. base64 content, size caps`;
@@ -12,4 +12,27 @@ export { buildCrossOriginNotice, formatConnectError, formatErrorMessage, formatS
12
12
  export declare const formatScreenshotContent: (result: unknown, cmd: {
13
13
  params?: Record<string, unknown>;
14
14
  }, caption: string, skills: string) => Content[] | null;
15
+ type DownloadEntry = {
16
+ filename?: string;
17
+ mimeType?: string;
18
+ size?: number;
19
+ data?: string;
20
+ error?: string;
21
+ maxBytes?: number;
22
+ sourceUrl?: string;
23
+ inProgress?: boolean;
24
+ receivedBytes?: number;
25
+ totalBytes?: number;
26
+ };
27
+ export declare const normalizeUploadCommand: (cmd: {
28
+ method: string;
29
+ params: Record<string, unknown>;
30
+ }, transport: McpConfig["transport"], mcpBaseUrl?: string) => Promise<void>;
31
+ type FormatOpts = {
32
+ transport: McpConfig['transport'];
33
+ sessionId?: string;
34
+ mcpBaseUrl?: string;
35
+ token?: string;
36
+ };
37
+ export declare const formatDownloads: (downloads: DownloadEntry[], prefix: string, skills: string, opts: FormatOpts) => Promise<Content[]>;
15
38
  export declare function registerAgentTools(server: FastMCP, config: McpConfig, analytics?: AnalyticsHelper): void;
@@ -1,11 +1,14 @@
1
1
  import { UserError } from 'fastmcp';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { basename } from 'node:path';
2
4
  import { z } from 'zod';
5
+ import { downloadUri, getDownload, storeDownload, FILE_TRANSFER_MAX_BYTES, } from '../lib/download-store.js';
3
6
  import { getOrCreateSession, send, closeSession, destroySession, isRetryableUpgradeError, } from '../lib/agent-client.js';
4
7
  import { classifyAgentError } from '../lib/error-classifier.js';
5
8
  import { defineTool } from '../lib/define-tool.js';
6
9
  import { detectSkills, markFired, renderSkill, renderSkills, skillsRegistry, } from '../skills/index.js';
7
10
  import { AgentParamsSchema } from './schemas.js';
8
- import { AGENT_SYSTEM_PROMPT, SKILL_TOOL_DESCRIPTION, } from '../skills/system-prompt.js';
11
+ import { AGENT_SYSTEM_PROMPT, SKILL_TOOL_DESCRIPTION, fileTransferModeNote, } from '../skills/system-prompt.js';
9
12
  import { buildCrossOriginNotice, formatConnectError, formatErrorMessage, formatSnapshot, } from '../lib/agent-format.js';
10
13
  // export schemas, system prompt, and formatters
11
14
  export { AgentParamsSchema } from './schemas.js';
@@ -53,10 +56,127 @@ export const formatScreenshotContent = (result, cmd, caption, skills) => {
53
56
  content.push({ type: 'text', text: skills });
54
57
  return content;
55
58
  };
56
- // Zod parses params at the tool boundary, so this only needs to supply the {}
57
- // default when the field was omitted — the schema never delivers a string,
58
- // array, or null here.
59
- const coerceParams = (params) => params ?? {};
59
+ const fmtBytes = (n) => typeof n !== 'number'
60
+ ? '?'
61
+ : n >= 1_048_576
62
+ ? `${(n / 1_048_576).toFixed(1)}MB`
63
+ : `${Math.round(n / 1024)}KB`;
64
+ // Still-downloading entry: report progress so the caller knows to touch the
65
+ // browser again to collect it (no bytes, nothing to save yet).
66
+ const describeInProgressDownload = (d) => {
67
+ const got = fmtBytes(d.receivedBytes);
68
+ const total = d.totalBytes && d.totalBytes > 0 ? ` / ${fmtBytes(d.totalBytes)}` : '';
69
+ return `${d.filename ?? 'file'} — downloading (${got}${total}); touch the browser again to collect it`;
70
+ };
71
+ // Resolve each uploadFile entry to base64 `content` (from `content`, a prior
72
+ // `handle`, or a local `path` in stdio) so the model never emits base64 itself.
73
+ export const normalizeUploadCommand = async (cmd, transport, mcpBaseUrl) => {
74
+ if (cmd.method !== 'uploadFile')
75
+ return;
76
+ const files = cmd.params.files;
77
+ if (!Array.isArray(files))
78
+ return;
79
+ for (const file of files) {
80
+ if (!file || typeof file !== 'object')
81
+ continue;
82
+ const f = file;
83
+ if (typeof f.content === 'string' && f.content)
84
+ continue;
85
+ let buf;
86
+ let defaultName;
87
+ if (typeof f.handle === 'string' && f.handle) {
88
+ const record = getDownload(f.handle);
89
+ if (!record) {
90
+ throw new UserError(`Unknown upload handle "${f.handle}". Pass a handle returned by ` +
91
+ `getDownloads, or supply base64 "content".`);
92
+ }
93
+ buf = await readFile(record.path);
94
+ defaultName = record.filename;
95
+ delete f.handle;
96
+ }
97
+ else if (typeof f.path === 'string' && f.path) {
98
+ if (transport !== 'stdio') {
99
+ const base = mcpBaseUrl ?? '<MCP_BASE_URL>';
100
+ const tokenQ = '?token=<YOUR_BROWSERLESS_TOKEN>';
101
+ throw new UserError('uploadFile "path" is not available in HTTP mode (the server can\'t ' +
102
+ 'read your filesystem). Stage the file once over HTTP, then pass the ' +
103
+ 'returned handle — do NOT base64 it through the conversation:\n' +
104
+ ` curl -s -F file=@"${f.path}" "${base}/upload${tokenQ}"\n` +
105
+ 'then: uploadFile { files: [{ handle: "<handle from the response>" }] }');
106
+ }
107
+ const path = f.path;
108
+ buf = await readFile(path).catch((e) => {
109
+ throw new UserError(`Failed to read upload file "${path}": ` +
110
+ (e instanceof Error ? e.message : String(e)));
111
+ });
112
+ defaultName = basename(path);
113
+ delete f.path;
114
+ }
115
+ else {
116
+ continue;
117
+ }
118
+ if (buf.byteLength > FILE_TRANSFER_MAX_BYTES) {
119
+ throw new UserError(`Upload file "${defaultName}" is ${buf.byteLength} bytes, over the ` +
120
+ `50MB limit.`);
121
+ }
122
+ f.content = buf.toString('base64');
123
+ if (!f.name)
124
+ f.name = defaultName;
125
+ }
126
+ };
127
+ const describeFailedDownload = (d) => {
128
+ let s = `${d.filename ?? 'unknown'}: ${d.error ?? 'no data'}` +
129
+ (d.maxBytes ? ` (max ${d.maxBytes} bytes)` : '');
130
+ // Over-cap files can't go through the transfer flow — point at the source so
131
+ // the caller can fetch it directly (e.g. curl) if it has network access.
132
+ if (d.error === 'FileTooLarge' && d.sourceUrl) {
133
+ s += ` — too large to transfer; fetch directly: ${d.sourceUrl}`;
134
+ }
135
+ return s;
136
+ };
137
+ // Persist a download to the server's filesystem (out of the model's context),
138
+ // tagged to the MCP session for cleanup. Returns null for failed/empty entries.
139
+ const persistDownload = async (d, sessionId) => {
140
+ if (d.error || !d.data || !d.filename)
141
+ return null;
142
+ return storeDownload(d.filename, d.mimeType ?? 'application/octet-stream', Buffer.from(d.data, 'base64'), sessionId);
143
+ };
144
+ // stdio: file is already on the local disk → return its path (reuse as
145
+ // uploadFile { path }). http: return a single-use GET URL + handle; base64
146
+ // never enters context, and fetching consumes the file.
147
+ const describeReadyDownload = (record, opts) => {
148
+ if (opts.transport === 'stdio') {
149
+ return (`${record.path} (${record.mimeType}, ${record.size} bytes) — ` +
150
+ `reuse as uploadFile { path: "${record.path}" }`);
151
+ }
152
+ const base = opts.mcpBaseUrl ?? '<MCP_BASE_URL>';
153
+ const tokenQ = `?token=${opts.token ?? '<YOUR_BROWSERLESS_TOKEN>'}`;
154
+ return (`${record.filename} (${record.mimeType}, ${record.size} bytes)\n` +
155
+ ` save it: curl -s "${base}/download/${record.id}${tokenQ}" -o "${record.filename}" (single use)\n` +
156
+ ` or reuse: uploadFile { files: [{ handle: "${downloadUri(record.id)}" }] }`);
157
+ };
158
+ // Surface captured downloads as metadata + how to retrieve them (never bytes).
159
+ export const formatDownloads = async (downloads, prefix, skills, opts) => {
160
+ const lines = [];
161
+ for (const d of downloads) {
162
+ if (d.inProgress) {
163
+ lines.push(`- ${describeInProgressDownload(d)}`);
164
+ continue;
165
+ }
166
+ const record = await persistDownload(d, opts.sessionId);
167
+ lines.push(`- ${record ? describeReadyDownload(record, opts) : describeFailedDownload(d)}`);
168
+ }
169
+ const header = opts.transport === 'stdio'
170
+ ? 'Downloads:'
171
+ : 'Downloads (save the ones you need — each GET works once):';
172
+ const text = downloads.length
173
+ ? `${prefix}${header}\n${lines.join('\n')}`
174
+ : `${prefix}No new downloads.`;
175
+ const content = [{ type: 'text', text }];
176
+ if (skills)
177
+ content.push({ type: 'text', text: skills });
178
+ return content;
179
+ };
60
180
  const SkillIdSchema = z.enum(skillsRegistry.map((s) => s.id));
61
181
  const SkillToolParamsSchema = z.object({
62
182
  id: SkillIdSchema.describe('The skill to load (see tool description for the full list).'),
@@ -85,7 +205,8 @@ export function registerAgentTools(server, config, analytics) {
85
205
  });
86
206
  defineTool(server, config, analytics, {
87
207
  name: 'browserless_agent',
88
- description: AGENT_SYSTEM_PROMPT,
208
+ description: AGENT_SYSTEM_PROMPT +
209
+ fileTransferModeNote(config.transport, config.mcpBaseUrl),
89
210
  parameters: AgentParamsSchema,
90
211
  annotations: {
91
212
  title: 'Browserless Agent',
@@ -93,15 +214,16 @@ export function registerAgentTools(server, config, analytics) {
93
214
  destructiveHint: true,
94
215
  openWorldHint: true,
95
216
  },
96
- run: async ({ params, log, analytics, token, apiUrl, sessionId: mcpSessionId, }) => {
217
+ run: async ({ params, log, analytics, token, apiUrl, sessionId: mcpSessionId, attachSessionId, }) => {
97
218
  const commands = params.commands && params.commands.length > 0
98
219
  ? params.commands.map((c) => ({
99
220
  method: c.method,
100
- params: coerceParams(c.params),
221
+ params: c.params ?? {},
101
222
  }))
102
- : [{ method: params.method, params: coerceParams(params.params) }];
223
+ : [{ method: params.method, params: params.params ?? {} }];
103
224
  const proxy = params.proxy;
104
225
  const profile = params.profile;
226
+ const createProfile = params.createProfile;
105
227
  const sendAnalytics = (success) => {
106
228
  analytics?.fireToolRequest(token, 'browserless_agent', {
107
229
  methods: commands.map((c) => c.method).join(','),
@@ -113,6 +235,7 @@ export function registerAgentTools(server, config, analytics) {
113
235
  proxy_sticky: !!proxy?.proxySticky,
114
236
  proxy_external: !!proxy?.externalProxyServer,
115
237
  profile_used: !!profile,
238
+ create_profile: !!createProfile,
116
239
  });
117
240
  };
118
241
  const proxyCmd = commands.find((c) => c.method === 'proxy');
@@ -122,14 +245,32 @@ export function registerAgentTools(server, config, analytics) {
122
245
  'Recovery: call `close` to end the current session, then call browserless_agent again with the proxy options set at the top level (alongside `method`/`commands`), e.g. { "proxy": "residential", "proxyCountry": "us", "commands": [ ... ] }.');
123
246
  }
124
247
  if (commands.length === 1 && commands[0].method === 'close') {
125
- closeSession(mcpSessionId, token, proxy, profile);
248
+ closeSession(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
126
249
  sendAnalytics(true);
127
250
  return [{ type: 'text', text: 'Browser session closed.' }];
128
251
  }
252
+ // Open-only call: no real command (e.g. `createProfile`/`profile`/`proxy`
253
+ // set with no method/commands). Dispatching the empty-method default would
254
+ // make the agent route reject it as `Missing required id/method`, so just
255
+ // open (or reuse) the session and report it's ready for follow-up commands.
256
+ if (commands.length === 1 && !commands[0].method) {
257
+ try {
258
+ await getOrCreateSession(mcpSessionId, apiUrl, token, proxy, profile, createProfile, attachSessionId);
259
+ }
260
+ catch (connErr) {
261
+ sendAnalytics(false);
262
+ throw new UserError(formatConnectError(connErr));
263
+ }
264
+ sendAnalytics(true);
265
+ const text = createProfile
266
+ ? `Profile-creation session "${createProfile.name}" is open (non-headless). Send commands to drive the login, then call saveProfile.`
267
+ : 'Browser session is open. Send commands to drive it.';
268
+ return [{ type: 'text', text }];
269
+ }
129
270
  const runCommands = async (isRetry) => {
130
271
  let agentSession;
131
272
  try {
132
- agentSession = await getOrCreateSession(mcpSessionId, apiUrl, token, proxy, profile);
273
+ agentSession = await getOrCreateSession(mcpSessionId, apiUrl, token, proxy, profile, createProfile, attachSessionId);
133
274
  }
134
275
  catch (connErr) {
135
276
  // No retry when the server gave a definitive 4xx — re-attempting
@@ -138,7 +279,7 @@ export function registerAgentTools(server, config, analytics) {
138
279
  if (isRetry || !isRetryableUpgradeError(connErr)) {
139
280
  throw new UserError(formatConnectError(connErr));
140
281
  }
141
- destroySession(mcpSessionId, token, proxy, profile);
282
+ destroySession(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
142
283
  return runCommands(true);
143
284
  }
144
285
  // Execute all commands sequentially
@@ -150,7 +291,7 @@ export function registerAgentTools(server, config, analytics) {
150
291
  let crossOriginBaseline = agentSession.lastUrl;
151
292
  for (const cmd of commands) {
152
293
  if (cmd.method === 'close') {
153
- closeSession(mcpSessionId, token, proxy, profile);
294
+ closeSession(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
154
295
  results.push({ method: 'close', result: { closed: true } });
155
296
  closedDuringBatch = true;
156
297
  break;
@@ -162,7 +303,7 @@ export function registerAgentTools(server, config, analytics) {
162
303
  resp = await send(agentSession, cmd.method, cmd.params);
163
304
  }
164
305
  catch (sendErr) {
165
- destroySession(mcpSessionId, token, proxy, profile);
306
+ destroySession(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
166
307
  const errMessage = sendErr instanceof Error ? sendErr.message : String(sendErr);
167
308
  if (!isRetry) {
168
309
  log.warn(`agent: ${cmd.method} failed (first attempt, retrying once): ${errMessage}`);
@@ -182,7 +323,7 @@ export function registerAgentTools(server, config, analytics) {
182
323
  if (resp.error) {
183
324
  const err = resp.error;
184
325
  if (err.code && FATAL_CODES.has(err.code)) {
185
- destroySession(mcpSessionId, token, proxy, profile);
326
+ destroySession(mcpSessionId, token, proxy, profile, createProfile, attachSessionId);
186
327
  if (!isRetry) {
187
328
  return runCommands(true);
188
329
  }
@@ -253,13 +394,29 @@ export function registerAgentTools(server, config, analytics) {
253
394
  if (!last) {
254
395
  return [{ type: 'text', text: 'Browser session closed.' }];
255
396
  }
256
- // Snapshot: format as compact ref-based text
397
+ // Auto-surface files Chrome captured this batch so the model needn't call
398
+ // getDownloads. Skipped on explicit drain/close; a failed poll is ignored.
399
+ let autoDownloads = [];
400
+ if (!closedDuringBatch && last.method !== 'getDownloads') {
401
+ try {
402
+ const dl = await send(agentSession, 'getDownloads', {});
403
+ autoDownloads =
404
+ dl.result
405
+ ?.downloads ?? [];
406
+ }
407
+ catch {
408
+ // ignore — downloads will surface on a later call
409
+ }
410
+ }
411
+ const skillsText = triggered.length > 0 ? renderSkills(triggered) : '';
412
+ let baseContent;
257
413
  if (lastSnapshot) {
414
+ // Snapshot: compact ref-based text.
258
415
  const notice = buildCrossOriginNotice(crossOriginBaseline, lastSnapshot.url);
259
416
  const noticeBlock = notice ? `${notice}\n\n` : '';
260
417
  if (lastSnapshot.url)
261
418
  agentSession.lastUrl = lastSnapshot.url;
262
- return [
419
+ baseContent = [
263
420
  {
264
421
  type: 'text',
265
422
  text: appendSkills(batchPrefix +
@@ -269,22 +426,47 @@ export function registerAgentTools(server, config, analytics) {
269
426
  },
270
427
  ];
271
428
  }
272
- // Screenshot: return as image content block (vision input 1.5K tokens
273
- // vs. ~67K tokens if we dumped the base64 inline as text).
274
- if (last.method === 'screenshot') {
275
- const content = formatScreenshotContent(lastResult, lastCmd, batchPrefix, triggered.length > 0 ? renderSkills(triggered) : '');
276
- if (content)
277
- return content;
429
+ else if (last.method === 'getDownloads') {
430
+ // Explicit drain.
431
+ const downloads = lastResult?.downloads ?? [];
432
+ const prefix = batchPrefix + (closedSuffix ? `${closedSuffix}\n\n` : '');
433
+ return await formatDownloads(downloads, prefix, skillsText, {
434
+ transport: config.transport,
435
+ sessionId: mcpSessionId,
436
+ mcpBaseUrl: config.mcpBaseUrl,
437
+ token,
438
+ });
278
439
  }
279
- // Everything else: return as JSON text
280
- return [
281
- {
282
- type: 'text',
283
- text: appendSkills(batchPrefix + JSON.stringify(lastResult, null, 2), triggered),
284
- },
285
- ];
440
+ else {
441
+ // Screenshot → image content block; otherwise JSON text.
442
+ const shot = last.method === 'screenshot'
443
+ ? formatScreenshotContent(lastResult, lastCmd, batchPrefix, skillsText)
444
+ : null;
445
+ baseContent = shot ?? [
446
+ {
447
+ type: 'text',
448
+ text: appendSkills(batchPrefix + JSON.stringify(lastResult, null, 2), triggered),
449
+ },
450
+ ];
451
+ }
452
+ // Append the captured-download notification (metadata only, no bytes).
453
+ if (autoDownloads.length > 0) {
454
+ const notice = await formatDownloads(autoDownloads, '', '', {
455
+ transport: config.transport,
456
+ sessionId: mcpSessionId,
457
+ mcpBaseUrl: config.mcpBaseUrl,
458
+ token,
459
+ });
460
+ baseContent = [...baseContent, ...notice];
461
+ }
462
+ return baseContent;
286
463
  };
287
464
  try {
465
+ // Resolve any local upload paths to base64 once, before the (possibly
466
+ // retried) send loop runs.
467
+ for (const cmd of commands) {
468
+ await normalizeUploadCommand(cmd, config.transport, config.mcpBaseUrl);
469
+ }
288
470
  const result = await runCommands(false);
289
471
  sendAnalytics(true);
290
472
  return result;
@@ -42,7 +42,7 @@ export function registerMapTool(server, config, analytics) {
42
42
  defineTool(server, config, analytics, {
43
43
  name: 'browserless_map',
44
44
  description: 'Discover and map all URLs on a website using Browserless. ' +
45
- 'Crawls a site via sitemaps and link extraction to find all pages. ' +
45
+ 'Scans a site via sitemaps and link extraction to find all pages. ' +
46
46
  'Returns a list of URLs with optional titles and descriptions. ' +
47
47
  'Use the search parameter to order results by relevance to a query. ' +
48
48
  'Useful for site audits, content discovery, and building site maps.',
@@ -84,6 +84,12 @@ export declare const AgentCommandSchema: z.ZodUnion<readonly [z.ZodDiscriminated
84
84
  selector: z.ZodString;
85
85
  text: z.ZodString;
86
86
  }, z.core.$strip>;
87
+ }, z.core.$strip>, z.ZodObject<{
88
+ method: z.ZodLiteral<"loadSecret">;
89
+ params: z.ZodObject<{
90
+ ref: z.ZodString;
91
+ selector: z.ZodOptional<z.ZodString>;
92
+ }, z.core.$strip>;
87
93
  }, z.core.$strip>, z.ZodObject<{
88
94
  method: z.ZodLiteral<"select">;
89
95
  params: z.ZodObject<{
@@ -214,6 +220,21 @@ export declare const AgentCommandSchema: z.ZodUnion<readonly [z.ZodDiscriminated
214
220
  waitForImages: z.ZodOptional<z.ZodBoolean>;
215
221
  timeout: z.ZodOptional<z.ZodNumber>;
216
222
  }, z.core.$strip>>>;
223
+ }, z.core.$strip>, z.ZodObject<{
224
+ method: z.ZodLiteral<"uploadFile">;
225
+ params: z.ZodObject<{
226
+ selector: z.ZodString;
227
+ files: z.ZodArray<z.ZodObject<{
228
+ content: z.ZodOptional<z.ZodString>;
229
+ handle: z.ZodOptional<z.ZodString>;
230
+ path: z.ZodOptional<z.ZodString>;
231
+ name: z.ZodOptional<z.ZodString>;
232
+ mimeType: z.ZodOptional<z.ZodString>;
233
+ }, z.core.$strip>>;
234
+ }, z.core.$strip>;
235
+ }, z.core.$strip>, z.ZodObject<{
236
+ method: z.ZodLiteral<"getDownloads">;
237
+ params: z.ZodDefault<z.ZodOptional<z.ZodObject<{}, z.core.$strip>>>;
217
238
  }, z.core.$strip>, z.ZodObject<{
218
239
  method: z.ZodLiteral<"close">;
219
240
  params: z.ZodDefault<z.ZodOptional<z.ZodObject<{}, z.core.$strip>>>;
@@ -221,6 +242,23 @@ export declare const AgentCommandSchema: z.ZodUnion<readonly [z.ZodDiscriminated
221
242
  method: z.ZodString;
222
243
  params: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
223
244
  }, z.core.$strip>]>;
245
+ declare const CreateProfileSchema: z.ZodObject<{
246
+ name: z.ZodString;
247
+ proxy: z.ZodOptional<z.ZodObject<{
248
+ type: z.ZodOptional<z.ZodLiteral<"residential">>;
249
+ sticky: z.ZodOptional<z.ZodBoolean>;
250
+ country: z.ZodOptional<z.ZodString>;
251
+ city: z.ZodOptional<z.ZodString>;
252
+ state: z.ZodOptional<z.ZodString>;
253
+ preset: z.ZodOptional<z.ZodString>;
254
+ }, z.core.$strip>>;
255
+ browser: z.ZodOptional<z.ZodEnum<{
256
+ chrome: "chrome";
257
+ chromium: "chromium";
258
+ stealth: "stealth";
259
+ }>>;
260
+ stealth: z.ZodOptional<z.ZodBoolean>;
261
+ }, z.core.$strip>;
224
262
  export declare const AgentParamsSchema: z.ZodObject<{
225
263
  method: z.ZodDefault<z.ZodOptional<z.ZodString>>;
226
264
  params: z.ZodDefault<z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>>;
@@ -308,6 +346,12 @@ export declare const AgentParamsSchema: z.ZodObject<{
308
346
  selector: z.ZodString;
309
347
  text: z.ZodString;
310
348
  }, z.core.$strip>;
349
+ }, z.core.$strip>, z.ZodObject<{
350
+ method: z.ZodLiteral<"loadSecret">;
351
+ params: z.ZodObject<{
352
+ ref: z.ZodString;
353
+ selector: z.ZodOptional<z.ZodString>;
354
+ }, z.core.$strip>;
311
355
  }, z.core.$strip>, z.ZodObject<{
312
356
  method: z.ZodLiteral<"select">;
313
357
  params: z.ZodObject<{
@@ -438,6 +482,21 @@ export declare const AgentParamsSchema: z.ZodObject<{
438
482
  waitForImages: z.ZodOptional<z.ZodBoolean>;
439
483
  timeout: z.ZodOptional<z.ZodNumber>;
440
484
  }, z.core.$strip>>>;
485
+ }, z.core.$strip>, z.ZodObject<{
486
+ method: z.ZodLiteral<"uploadFile">;
487
+ params: z.ZodObject<{
488
+ selector: z.ZodString;
489
+ files: z.ZodArray<z.ZodObject<{
490
+ content: z.ZodOptional<z.ZodString>;
491
+ handle: z.ZodOptional<z.ZodString>;
492
+ path: z.ZodOptional<z.ZodString>;
493
+ name: z.ZodOptional<z.ZodString>;
494
+ mimeType: z.ZodOptional<z.ZodString>;
495
+ }, z.core.$strip>>;
496
+ }, z.core.$strip>;
497
+ }, z.core.$strip>, z.ZodObject<{
498
+ method: z.ZodLiteral<"getDownloads">;
499
+ params: z.ZodDefault<z.ZodOptional<z.ZodObject<{}, z.core.$strip>>>;
441
500
  }, z.core.$strip>, z.ZodObject<{
442
501
  method: z.ZodLiteral<"close">;
443
502
  params: z.ZodDefault<z.ZodOptional<z.ZodObject<{}, z.core.$strip>>>;
@@ -458,9 +517,29 @@ export declare const AgentParamsSchema: z.ZodObject<{
458
517
  externalProxyServer: z.ZodOptional<z.ZodString>;
459
518
  }, z.core.$strip>>;
460
519
  profile: z.ZodOptional<z.ZodString>;
520
+ createProfile: z.ZodOptional<z.ZodObject<{
521
+ name: z.ZodString;
522
+ proxy: z.ZodOptional<z.ZodObject<{
523
+ type: z.ZodOptional<z.ZodLiteral<"residential">>;
524
+ sticky: z.ZodOptional<z.ZodBoolean>;
525
+ country: z.ZodOptional<z.ZodString>;
526
+ city: z.ZodOptional<z.ZodString>;
527
+ state: z.ZodOptional<z.ZodString>;
528
+ preset: z.ZodOptional<z.ZodString>;
529
+ }, z.core.$strip>>;
530
+ browser: z.ZodOptional<z.ZodEnum<{
531
+ chrome: "chrome";
532
+ chromium: "chromium";
533
+ stealth: "stealth";
534
+ }>>;
535
+ stealth: z.ZodOptional<z.ZodBoolean>;
536
+ }, z.core.$strip>>;
461
537
  rationale: z.ZodOptional<z.ZodString>;
462
538
  }, z.core.$strip>;
463
539
  /** A single validated agent command. */
464
540
  export type AgentCommand = z.infer<typeof AgentCommandSchema>;
465
541
  /** The full `browserless_agent` tool params (single command, batch, proxy, profile). */
466
542
  export type AgentParams = z.infer<typeof AgentParamsSchema>;
543
+ /** Params for opening a profile-creation session (POST /profile passthrough). */
544
+ export type CreateProfileParams = z.infer<typeof CreateProfileSchema>;
545
+ export {};