diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3a76921..1a73e3f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,14 +2,18 @@
## [1.6.2](https://github.com/browserless/browserless-mcp/compare/v1.6.1...v1.6.2) (2026-06-08)
-
### Bug Fixes
-* drop stale COPY patches/ from Dockerfile ([#109](https://github.com/browserless/browserless-mcp/issues/109)) ([976e38d](https://github.com/browserless/browserless-mcp/commit/976e38d4b79643d60485a01cdee0c16486b17afd))
+- drop stale COPY patches/ from Dockerfile ([#109](https://github.com/browserless/browserless-mcp/issues/109)) ([976e38d](https://github.com/browserless/browserless-mcp/commit/976e38d4b79643d60485a01cdee0c16486b17afd))
## Latest
+- Add file upload/download support to `browserless_agent` via the `uploadFile` and `getDownloads` commands, plus a `file-transfers` skill. Downloads **auto-surface** on every agent response as a ledger — never the bytes, without the model calling `getDownloads`: completed files (handle/path), still-running ones (with progress, so the model re-checks on its next browser touch), and over-cap ones (source URL for a direct fetch). In stdio mode the file is saved locally and you get its path; `uploadFile` accepts a `handle`, a local `path`, or base64 `content`. Honors the server-side 10MB/50MB transfer cap.
+- Add out-of-band HTTP file endpoints (httpStream transport), token-gated like the MCP surface: `POST /upload` stages a local file (`curl -F file=@path "/upload?token="`) and returns a handle for `uploadFile`; `GET /download/?token=` fetches a captured download. Files share a temp store dropped after one download fetch, a 15-minute TTL, or session end — whichever comes first.
+- **Removed the standalone `browserless_download` tool.** File downloads now go through `browserless_agent` (trigger the download, then it auto-surfaces) — a single path that never inlines bytes into context. Replaces the old tool that returned the file as base64.
+
## v1.6.1
+
Drop vestigial mcp-proxy postinstall patch that broke `npm install` in consumers
- Dependency updates
diff --git a/README.md b/README.md
index 1b20c59..0c636bf 100644
--- a/README.md
+++ b/README.md
@@ -22,18 +22,17 @@ No local install — see [Configuration](#configuration) for per-client snippets
## Tools
-| Tool | Description |
-| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `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`. |
-| `browserless_search` | Search the web using Browserless and optionally scrape each result. Supports web, news, and image search with geo-targeting and time filters. |
-| `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. |
-| `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. |
-| `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. |
-| `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. |
-| `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. |
-| `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. |
-| `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. |
-| `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`. |
+| Tool | Description |
+| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `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`. |
+| `browserless_search` | Search the web using Browserless and optionally scrape each result. Supports web, news, and image search with geo-targeting and time filters. |
+| `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. |
+| `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. |
+| `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. |
+| `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. |
+| `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. |
+| `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). |
+| `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`. |
## Skills
diff --git a/src/@types/types.d.ts b/src/@types/types.d.ts
index 9cdcbf4..663d74d 100644
--- a/src/@types/types.d.ts
+++ b/src/@types/types.d.ts
@@ -6,7 +6,6 @@ import type {
SmartScraperResponseSchema,
} from '../tools/smartscraper.js';
import type { FunctionParamsSchema } from '../tools/function.js';
-import type { DownloadParamsSchema } from '../tools/download.js';
import type { ExportParamsSchema } from '../tools/export.js';
import type {
SearchSourceSchema,
@@ -233,7 +232,8 @@ export type SkillId =
| 'screenshots'
| 'tabs'
| 'autonomous-login'
- | 'auth-profile';
+ | 'auth-profile'
+ | 'file-transfers';
export interface DetectContext {
snapshot?: SnapshotResult;
@@ -294,7 +294,6 @@ export type ScrapeFormat = z.infer;
export type SmartScraperParams = z.infer;
export type SmartScraperResponse = z.infer;
export type FunctionParams = z.infer;
-export type DownloadParams = z.infer;
export type ExportParams = z.infer;
export type ProxyOptions = z.infer;
export type SearchSource = z.infer;
diff --git a/src/index.ts b/src/index.ts
index 7798a7d..bd54b4b 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -8,7 +8,6 @@ import { getConfig } from './config.js';
import type { BrowserlessSession } from './@types/types.js';
import { registerSmartScraperTool } from './tools/smartscraper.js';
import { registerFunctionTool } from './tools/function.js';
-import { registerDownloadTool } from './tools/download.js';
import { registerExportTool } from './tools/export.js';
import { registerAgentTools } from './tools/agent.js';
import { registerSearchTool } from './tools/search.js';
@@ -17,13 +16,14 @@ import { registerCrawlTool } from './tools/crawl.js';
import { registerPerformanceTool } from './tools/performance.js';
import { registerApiDocsResource } from './resources/api-docs.js';
import { registerStatusResource } from './resources/status.js';
+import { registerUploadRoute } from './resources/upload-route.js';
+import { registerDownloadRoute } from './resources/download-route.js';
+import { clearSession } from './lib/download-store.js';
import { registerScrapeUrlPrompt } from './prompts/scrape-url.js';
import { registerExtractContentPrompt } from './prompts/extract-content.js';
import { AnalyticsHelper } from './lib/analytics.js';
-import {
- resolveApiKey,
- installSupabaseTokenTtlPatch,
-} from './lib/account-resolver.js';
+import { installSupabaseTokenTtlPatch } from './lib/account-resolver.js';
+import { resolveBrowserlessAuth } from './lib/http-auth.js';
import { BoundedEventStore } from './lib/bounded-event-store.js';
import { RedisOAuthProxy } from './lib/redis-oauth-proxy.js';
import { Redis } from 'ioredis';
@@ -107,58 +107,21 @@ const hybridAuthenticate =
config.transport === 'httpStream'
? async (request: IncomingMessage) => {
const params = new URLSearchParams(request.url?.split('?')[1] ?? '');
- const authHeader = request.headers.authorization as string | undefined;
- const headerToken = authHeader?.startsWith('Bearer ')
- ? authHeader.slice(7)
- : authHeader;
-
- const apiUrl =
- (request.headers['x-browserless-api-url'] as string) ??
- params.get('browserlessUrl') ??
- config.browserlessApiUrl;
-
- // A pre-created session id to attach to, threaded by the autologin
- // runner. The agent tool opens /chromium/agent?sessionId= instead
- // of doing its own POST /profile.
- const attachSessionId =
- (request.headers['x-browserless-session-id'] as string) ??
- params.get('browserlessSessionId') ??
- undefined;
-
- // JWTs have 3 dot-separated base64url segments; plain API keys do not.
- const isJwt = headerToken ? headerToken.split('.').length === 3 : false;
-
- // apiUrl/attachSessionId are the same across every auth path; only the
- // resolved token differs.
- const session = (token: string): BrowserlessSession =>
- ({ token, apiUrl, attachSessionId }) as BrowserlessSession;
-
- // 1. Authorization header with plain API key
- if (headerToken && !isJwt) {
- return session(headerToken);
- }
-
- // 2. ?token= query param
- const directToken = params.get('token') || undefined;
- if (directToken) {
- return session(directToken);
- }
-
- // 3. Authorization header with JWT → decode Supabase token directly
- if (isJwt && headerToken) {
- const { apiKey } = await resolveApiKey(
- config.supabaseUrl,
- config.supabaseServiceRoleKey,
- headerToken,
- );
- return session(apiKey);
- }
-
- throw new Error(
- 'No Browserless API token provided. ' +
- 'Pass it as Authorization: Bearer header, ' +
- '?token= query parameter, or authenticate via OAuth.',
- );
+ return (await resolveBrowserlessAuth(
+ {
+ authHeader: request.headers.authorization as string | undefined,
+ tokenQuery: params.get('token') || undefined,
+ apiUrlHeader: request.headers['x-browserless-api-url'] as
+ | string
+ | undefined,
+ browserlessUrlQuery: params.get('browserlessUrl') || undefined,
+ sessionIdHeader: request.headers['x-browserless-session-id'] as
+ | string
+ | undefined,
+ sessionIdQuery: params.get('browserlessSessionId') || undefined,
+ },
+ config,
+ )) as BrowserlessSession;
}
: undefined;
@@ -171,7 +134,6 @@ const server = new FastMCP({
registerSmartScraperTool(server, config, analytics);
registerFunctionTool(server, config, analytics);
-registerDownloadTool(server, config, analytics);
registerExportTool(server, config, analytics);
registerAgentTools(server, config, analytics);
registerSearchTool(server, config, analytics);
@@ -190,6 +152,8 @@ server.on('connect', (event) => {
server.on('disconnect', (event) => {
const id = event.session.sessionId ?? 'stdio';
+ // Drop any files staged/captured for this session (TTL is the backstop).
+ clearSession(event.session.sessionId);
console.error(`[browserless-mcp] Client disconnected: ${id}`);
});
@@ -203,6 +167,12 @@ if (config.transport === 'httpStream') {
stateless: false,
},
});
+ // Out-of-band file staging for uploads (the LLM curls a file here and gets a
+ // handle, instead of base64-ing it through the conversation). httpStream only.
+ registerUploadRoute(server, config);
+ // Single-use, out-of-band fetch for captured downloads (the LLM GETs the file
+ // instead of pulling bytes through the conversation). httpStream only.
+ registerDownloadRoute(server, config);
console.error(
`[browserless-mcp] HTTP Streamable server listening on port ${config.port}`,
);
diff --git a/src/lib/download-store.ts b/src/lib/download-store.ts
new file mode 100644
index 0000000..a1b5d55
--- /dev/null
+++ b/src/lib/download-store.ts
@@ -0,0 +1,118 @@
+import { mkdir, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { basename, join } from 'node:path';
+
+// A captured download (or staged upload) persisted to the server's filesystem;
+// bytes stay on disk (never in context). Dropped on TTL, session end, or fetch.
+export interface StoredDownload {
+ id: string;
+ path: string;
+ filename: string;
+ mimeType: string;
+ size: number;
+ sessionId?: string;
+}
+
+interface StoreEntry extends StoredDownload {
+ timer?: ReturnType;
+}
+
+export const DOWNLOAD_URI_SCHEME = 'browserless-download';
+
+const TTL_MS = 15 * 60 * 1000;
+
+// Hard ceiling on a single file transfer (mirrors the enterprise cap).
+export const FILE_TRANSFER_MAX_BYTES = 50 * 1024 * 1024;
+
+const store = new Map();
+let counter = 0;
+
+// Where captured files land on the MCP server. Defaults to a temp dir; override
+// with BROWSERLESS_DOWNLOAD_DIR (e.g. a stable folder in local/stdio setups).
+const downloadsDir = (): string =>
+ process.env.BROWSERLESS_DOWNLOAD_DIR ||
+ join(tmpdir(), 'browserless-mcp-downloads');
+
+/** Build the handle URI for a stored download id. */
+export const downloadUri = (id: string): string =>
+ `${DOWNLOAD_URI_SCHEME}://${id}`;
+
+const idFromHandle = (handle: string): string =>
+ handle.startsWith(`${DOWNLOAD_URI_SCHEME}://`)
+ ? handle.slice(`${DOWNLOAD_URI_SCHEME}://`.length)
+ : handle;
+
+// Strip the internal timer handle before handing an entry to callers.
+const toRecord = (entry: StoreEntry): StoredDownload => {
+ const { timer: _timer, ...record } = entry;
+ return record;
+};
+
+const dropEntry = (entry: StoreEntry): void => {
+ if (entry.timer) clearTimeout(entry.timer);
+ store.delete(entry.id);
+ void rm(entry.path, { force: true }).catch(() => {});
+};
+
+// Persist bytes to disk under a fresh handle. `sessionId` ties the file to an
+// MCP session for cleanup on session end (no session → TTL only).
+export const storeDownload = async (
+ filename: string,
+ mimeType: string,
+ data: Buffer,
+ sessionId?: string,
+): Promise => {
+ const dir = downloadsDir();
+ await mkdir(dir, { recursive: true });
+ counter += 1;
+ const id = `${Date.now().toString(36)}-${counter}`;
+ const safe = basename(filename) || 'download';
+ // Prefix with the id so files that share a name don't collide.
+ const path = join(dir, `${id}-${safe}`);
+ await writeFile(path, data);
+
+ const timer = setTimeout(() => {
+ store.delete(id);
+ void rm(path, { force: true }).catch(() => {});
+ }, TTL_MS);
+ timer.unref?.();
+
+ const entry: StoreEntry = {
+ id,
+ path,
+ filename: safe,
+ mimeType,
+ size: data.byteLength,
+ sessionId,
+ timer,
+ };
+ store.set(id, entry);
+ return toRecord(entry);
+};
+
+// Resolve a handle (id, URI, or stored path) WITHOUT removing it. Used by
+// uploadFile, which may reference the same file more than once.
+export const getDownload = (handle: string): StoredDownload | undefined => {
+ const entry =
+ store.get(idFromHandle(handle)) ??
+ [...store.values()].find((r) => r.path === handle);
+ return entry && toRecord(entry);
+};
+
+// Resolve a handle and remove it (single-use): entry, TTL timer, and bytes.
+// Backs `GET /download/:id` so a download can only be fetched once.
+export const consumeDownload = (handle: string): StoredDownload | undefined => {
+ const entry = store.get(idFromHandle(handle));
+ if (!entry) return undefined;
+ if (entry.timer) clearTimeout(entry.timer);
+ store.delete(entry.id);
+ return toRecord(entry);
+};
+
+/** Drop every file owned by an MCP session (called when the session ends). */
+export const clearSession = (sessionId: string | undefined): void => {
+ if (!sessionId) return;
+ for (const entry of [...store.values()]) {
+ if (entry.sessionId === sessionId) dropEntry(entry);
+ }
+};
diff --git a/src/lib/http-auth.ts b/src/lib/http-auth.ts
new file mode 100644
index 0000000..b5afe76
--- /dev/null
+++ b/src/lib/http-auth.ts
@@ -0,0 +1,69 @@
+import { resolveApiKey } from './account-resolver.js';
+import type { McpConfig } from '../@types/types.js';
+
+export interface ResolvedBrowserlessAuth {
+ token: string;
+ apiUrl: string;
+ attachSessionId?: string;
+}
+
+export interface AuthInput {
+ authHeader?: string;
+ tokenQuery?: string;
+ apiUrlHeader?: string;
+ browserlessUrlQuery?: string;
+ sessionIdHeader?: string;
+ sessionIdQuery?: string;
+}
+
+/**
+ * Resolve a Browserless API token from an inbound HTTP request, in order:
+ * (1) Authorization header with a plain API key, (2) `?token=` query param,
+ * (3) Authorization header with a Supabase JWT → resolved via PostgREST.
+ * Throws when none is present/valid. Shared by the FastMCP `authenticate`
+ * callback and the custom `/upload` route so both gate on the same rules.
+ */
+export const resolveBrowserlessAuth = async (
+ input: AuthInput,
+ config: Pick<
+ McpConfig,
+ 'browserlessApiUrl' | 'supabaseUrl' | 'supabaseServiceRoleKey'
+ >,
+): Promise => {
+ const headerToken = input.authHeader?.startsWith('Bearer ')
+ ? input.authHeader.slice(7)
+ : input.authHeader;
+
+ const apiUrl =
+ input.apiUrlHeader ?? input.browserlessUrlQuery ?? config.browserlessApiUrl;
+
+ // A pre-created session id to attach to, threaded by the autologin runner.
+ // The agent tool opens /chromium/agent?sessionId= instead of doing its
+ // own POST /profile.
+ const attachSessionId =
+ input.sessionIdHeader ?? input.sessionIdQuery ?? undefined;
+
+ // JWTs have 3 dot-separated base64url segments; plain API keys do not.
+ const isJwt = headerToken ? headerToken.split('.').length === 3 : false;
+
+ if (headerToken && !isJwt) {
+ return { token: headerToken, apiUrl, attachSessionId };
+ }
+ if (input.tokenQuery) {
+ return { token: input.tokenQuery, apiUrl, attachSessionId };
+ }
+ if (isJwt && headerToken) {
+ const { apiKey } = await resolveApiKey(
+ config.supabaseUrl,
+ config.supabaseServiceRoleKey,
+ headerToken,
+ );
+ return { token: apiKey, apiUrl, attachSessionId };
+ }
+
+ throw new Error(
+ 'No Browserless API token provided. ' +
+ 'Pass it as Authorization: Bearer header, ' +
+ '?token= query parameter, or authenticate via OAuth.',
+ );
+};
diff --git a/src/resources/download-route.ts b/src/resources/download-route.ts
new file mode 100644
index 0000000..255b843
--- /dev/null
+++ b/src/resources/download-route.ts
@@ -0,0 +1,68 @@
+import type { FastMCP } from 'fastmcp';
+import { readFile, rm } from 'node:fs/promises';
+import { consumeDownload } from '../lib/download-store.js';
+import { resolveBrowserlessAuth } from '../lib/http-auth.js';
+import type { McpConfig } from '../@types/types.js';
+
+/**
+ * Registers `GET /download/:id` on the HTTP-stream server. getDownloads surfaces
+ * a download as a notification (metadata only) plus this URL; the client fetches
+ * the bytes out-of-band when it decides to save them — over plain HTTP, NOT
+ * through the conversation:
+ *
+ * curl -s "/download/?token=" -o ./file
+ *
+ * Single use: the file is removed from the store and disk once served (or after
+ * the 15-min TTL / session end, whichever comes first). Same token rules as the
+ * MCP surface. Only meaningful for httpStream; in stdio the file is already on
+ * the local disk at the path getDownloads reported.
+ */
+export function registerDownloadRoute(
+ server: FastMCP,
+ config: McpConfig,
+): void {
+ const app = server.getApp();
+
+ app.get('/download/:id', async (c) => {
+ try {
+ await resolveBrowserlessAuth(
+ {
+ authHeader: c.req.header('authorization'),
+ tokenQuery: c.req.query('token'),
+ apiUrlHeader: c.req.header('x-browserless-api-url'),
+ browserlessUrlQuery: c.req.query('browserlessUrl'),
+ },
+ config,
+ );
+ } catch {
+ return c.json({ ok: false, error: 'Unauthorized' }, 401);
+ }
+
+ // Single-use: consume removes it from the registry so a second GET 404s.
+ const record = consumeDownload(c.req.param('id'));
+ if (!record) {
+ return c.json(
+ {
+ ok: false,
+ error: 'Not found (already fetched, expired, or unknown)',
+ },
+ 404,
+ );
+ }
+
+ try {
+ const data = await readFile(record.path);
+ c.header('Content-Type', record.mimeType);
+ c.header(
+ 'Content-Disposition',
+ `attachment; filename="${record.filename.replace(/"/g, '')}"`,
+ );
+ return c.body(data);
+ } catch {
+ return c.json({ ok: false, error: 'File no longer available' }, 410);
+ } finally {
+ // Drop the bytes once served (or on read failure) — single use.
+ void rm(record.path, { force: true }).catch(() => {});
+ }
+ });
+}
diff --git a/src/resources/upload-route.ts b/src/resources/upload-route.ts
new file mode 100644
index 0000000..d045a43
--- /dev/null
+++ b/src/resources/upload-route.ts
@@ -0,0 +1,79 @@
+import type { FastMCP } from 'fastmcp';
+import {
+ downloadUri,
+ storeDownload,
+ FILE_TRANSFER_MAX_BYTES,
+} from '../lib/download-store.js';
+import { resolveBrowserlessAuth } from '../lib/http-auth.js';
+import type { McpConfig } from '../@types/types.js';
+
+// Registers `POST /upload` (httpStream only): clients push a file's bytes over
+// plain HTTP and get back a handle to pass to the agent's `uploadFile`.
+// curl -s -F file=@/path/to/file "/upload?token="
+// Same token as the MCP surface; the base64 never enters the model's context.
+export function registerUploadRoute(server: FastMCP, config: McpConfig): void {
+ const app = server.getApp();
+
+ app.post('/upload', async (c) => {
+ // Raw Hono routes bypass FastMCP's authenticate, so gate the route on the
+ // same Browserless token rules as the MCP surface — no anonymous drops.
+ try {
+ await resolveBrowserlessAuth(
+ {
+ authHeader: c.req.header('authorization'),
+ tokenQuery: c.req.query('token'),
+ apiUrlHeader: c.req.header('x-browserless-api-url'),
+ browserlessUrlQuery: c.req.query('browserlessUrl'),
+ },
+ config,
+ );
+ } catch {
+ return c.json({ ok: false, error: 'Unauthorized' }, 401);
+ }
+
+ let file: unknown;
+ try {
+ const body = await c.req.parseBody();
+ file = body.file;
+ } catch {
+ return c.json(
+ {
+ ok: false,
+ error: 'Expected multipart/form-data with a "file" field',
+ },
+ 400,
+ );
+ }
+
+ if (!(file instanceof File)) {
+ return c.json(
+ {
+ ok: false,
+ error: 'Missing multipart "file" field (use -F file=@path)',
+ },
+ 400,
+ );
+ }
+
+ const buf = Buffer.from(await file.arrayBuffer());
+ if (buf.byteLength > FILE_TRANSFER_MAX_BYTES) {
+ return c.json(
+ { ok: false, error: 'FileTooLarge', maxBytes: FILE_TRANSFER_MAX_BYTES },
+ 413,
+ );
+ }
+
+ const record = await storeDownload(
+ file.name || 'upload',
+ file.type || 'application/octet-stream',
+ buf,
+ );
+ return c.json({
+ ok: true,
+ handle: downloadUri(record.id),
+ filename: record.filename,
+ mimeType: record.mimeType,
+ size: record.size,
+ });
+ });
+}
diff --git a/src/skills/file-transfers.md b/src/skills/file-transfers.md
new file mode 100644
index 0000000..6a9b00b
--- /dev/null
+++ b/src/skills/file-transfers.md
@@ -0,0 +1,88 @@
+# File Uploads & Downloads
+
+Transferring files to/from the browser. Two methods: `uploadFile` (attach files to an ``) and `getDownloads` (retrieve files Chrome downloaded).
+
+**Do not `curl`/`wget`/`fetch` a file yourself to download it.** That only works for a public, static, directly-addressable URL — the easy case. The general case (files behind login/cookies, generated server-side on demand, or served by a click via `Content-Disposition` headers) has **no URL you can fetch**, and a direct fetch silently returns the wrong bytes, an HTML page, or a 403. **Drive the browser** (click/goto), and the file is captured for you. A direct fetch is only correct when this flow _hands you_ a URL (the single-use `/download/` URL, or an over-cap `sourceUrl`).
+
+**Key idea — never move bytes through this conversation.** Large files as base64 blow up the context. So downloads come back as a _handle_ (a path or a `browserless-download://` URI), and uploads take that handle (or a local path) instead of base64. The MCP server reads/writes the actual bytes on disk; you only pass small references. Only fall back to base64 `content` when you genuinely have raw bytes and no handle.
+
+## Downloading
+
+Just trigger the download in the agent — navigate to the file URL, or click a download link/button:
+
+```json
+{
+ "commands": [
+ { "method": "goto", "params": { "url": "https://example.com/report.csv" } }
+ ]
+}
+```
+
+- Captured downloads **auto-surface**: every agent response carries the current download ledger — **never the bytes**. You don't need to call anything to see it.
+- 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.
+- 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.
+- You decide whether to save each file. (`getDownloads` still exists for an explicit poll, but it's rarely needed.)
+
+**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.
+
+**Remote (HTTP) mode:** the server can't write to your disk, so each file comes with a **single-use** GET URL. Fetch it with `curl` to save locally — works **once**:
+
+```bash
+curl -s "/download/?token=" -o "report.csv"
+```
+
+The exact command (with id + token + URL) is in the `getDownloads` response. Alternatively, reuse the handle as `uploadFile { files: [{ handle: "browserless-download://" }] }` to re-upload it elsewhere without ever fetching the bytes. A file is dropped after one GET, after 15 minutes, or when the session ends — whichever comes first.
+
+## Uploading
+
+```json
+{
+ "method": "uploadFile",
+ "params": {
+ "selector": "input[type=file]",
+ "files": [
+ { "handle": "browserless-download://abc-1", "name": "report.pdf" }
+ ]
+ }
+}
+```
+
+Each file is resolved in this order — pick the first you have:
+
+- **`handle`** — a handle from a previous `getDownloads`, or from staging a local file (below). The server reads the stored file. Works in **both** transports. This is how you re-upload a file you just downloaded — zero bytes through the conversation.
+- **`path`** — a local filesystem path. **stdio only** (HTTP can't read your filesystem). The server reads and encodes it.
+- **`content`** — base64 bytes. Last resort; avoid for large files.
+
+### Uploading a NEW local file in HTTP mode
+
+The server can't read your filesystem, so stage the file once over HTTP (bytes go via `curl`, never through the conversation), then use the returned handle:
+
+```bash
+curl -s -F file=@"/path/to/file.png" "/upload?token="
+# → { "ok": true, "handle": "browserless-download://abc-1", "filename": "file.png", ... }
+```
+
+The `/upload` route requires your Browserless token (`?token=` or `Authorization: Bearer`). The `uploadFile` path-rejection error gives you the exact command with the token filled in.
+
+```json
+{
+ "method": "uploadFile",
+ "params": {
+ "selector": "input[type=file]",
+ "files": [{ "handle": "browserless-download://abc-1" }]
+ }
+}
+```
+
+Staged files share the download store (15-minute TTL). **Never** base64 a file into `content` by hand — that's what staging avoids.
+
+Other params:
+
+- `selector` — the file input. If hidden behind a styled button, the input still exists in the DOM; target it directly (use a deep selector — prefix `<` followed by a space — for shadow DOM).
+- `name` / `mimeType` — optional; default from the handle/path, mimeType inferred from the extension.
+- Triggers native `input`/`change` events, so frameworks (React, etc.) see the file.
+- Returns `{ "ok": true }`, or `{ "ok": false, "error": "SelectorNotFound" | "InvalidTarget" | "FileTooLarge" }`.
+
+## Size limits
+
+Uploads and downloads are capped (server default 10MB, hard max 50MB). Oversized downloads report `error: "FileTooLarge"` (metadata, no data); oversized uploads return `ok: false, error: "FileTooLarge"`.
diff --git a/src/skills/index.ts b/src/skills/index.ts
index 1632a70..47048ec 100644
--- a/src/skills/index.ts
+++ b/src/skills/index.ts
@@ -35,6 +35,8 @@ const LOGIN_NUDGE_RE =
const TAB_ERROR_CODES = ['TAB_NOT_FOUND', 'TAB_CLOSED', 'TAB_LIMIT_EXCEEDED'];
const TAB_COMMAND_METHODS = ['getTabs', 'switchTab', 'createTab', 'closeTab'];
+const FILE_TRANSFER_METHODS = ['uploadFile', 'getDownloads'];
+
const evalPredicate = (p: Predicate, ctx: DetectContext): boolean => {
switch (p.kind) {
case 'snapshot.has-element': {
@@ -174,6 +176,16 @@ const SKILL_SPECS: SkillSpec[] = [
path: 'src/skills/auth-profile.md',
triggers: [],
},
+ {
+ id: 'file-transfers',
+ path: 'src/skills/file-transfers.md',
+ triggers: [
+ // A file input on the page — uploads are likely next.
+ [{ kind: 'snapshot.has-input-type', type: 'file' }],
+ // The model issued an upload/download command.
+ [{ kind: 'command.method', methods: FILE_TRANSFER_METHODS }],
+ ],
+ },
{
id: 'captchas',
path: 'src/skills/captchas.md',
diff --git a/src/skills/system-prompt.ts b/src/skills/system-prompt.ts
index eac67d3..8e69007 100644
--- a/src/skills/system-prompt.ts
+++ b/src/skills/system-prompt.ts
@@ -69,6 +69,17 @@ Only click when href is \`javascript:\` / \`#\` / missing.
3. **evaluate** { content } — JS (IIFE): \`(() => { return ... })()\`
4. **html** { selector } — raw HTML
+## Files (upload / download)
+**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/\` URL, or an over-cap \`sourceUrl\`. Reaching for \`curl\` first is a bug, not a shortcut.
+**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.
+- **Upload a local file (stdio)**: \`uploadFile { selector, files: [{ path }] }\` — the server reads + encodes it.
+- **Upload a local file (HTTP)**: the server can't read your disk. Stage it once over HTTP, then use the handle:
+ \`curl -s -F file=@"/path/to/file" "/upload?token="\` → returns \`{ "handle": "browserless-download://…" }\` → \`uploadFile { files: [{ handle }] }\`. (The path-rejection error gives you the exact command with your token + URL filled in.)
+- **Re-upload something from \`getDownloads\`**: pass its \`handle\` (works in both modes).
+- **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/?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.)
+- base64 \`content\` is a LAST RESORT — tiny inline data only.
+- Full recipe: \`file-transfers\` skill.
+
## Batching — Maximize Per Call
Plan ALL actions from snapshot before next snapshot.
@@ -116,6 +127,26 @@ Never retry same failed action without re-snapshot.
`;
+// Transport-specific file-transfer guidance, appended to the agent tool
+// description so the model knows its mode UP FRONT — instead of guessing (and
+// base64-ing files it should pass by path). The server knows the transport; the
+// model can't introspect it.
+export const fileTransferModeNote = (
+ transport: 'stdio' | 'httpStream',
+ mcpBaseUrl: string,
+): string =>
+ transport === 'stdio'
+ ? `\n\n## Runtime: LOCAL (stdio)\n` +
+ `Before any file transfer, know your mode: this server runs over **stdio**, on the same machine as your files. ` +
+ `To UPLOAD a local file, pass its **\`path\`** straight to \`uploadFile\` (\`files: [{ path: "/abs/file" }]\`) — the server reads it. ` +
+ `**Do NOT base64 the file or read its bytes into the conversation.** ` +
+ `DOWNLOADS are saved to local disk; the agent response gives you the path.`
+ : `\n\n## Runtime: REMOTE (HTTP)\n` +
+ `Before any file transfer, know your mode: this server runs over **HTTP** and **cannot read your filesystem**. ` +
+ `To UPLOAD a local file, stage it once over HTTP, then use the handle:\n` +
+ ` \`curl -s -F file=@"/abs/file" "${mcpBaseUrl}/upload?token="\` -> { "handle": "browserless-download://..." } -> \`uploadFile { files: [{ handle }] }\`.\n` +
+ `**Never base64 a file through the conversation.** DOWNLOADS come back with a single-use \`${mcpBaseUrl}/download/\` URL.`;
+
export const SKILL_TOOL_DESCRIPTION = `Load a Browserless agent skill on demand.
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.
@@ -129,4 +160,5 @@ Available skills:
- **screenshots** — when to screenshot vs. snapshot, scope and format choices
- **tabs** — multi-tab workflows, peek-without-switching
- **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.
-- **captchas** — the \`solve\` command, response semantics, escalation path (Cloud-only)`;
+- **captchas** — the \`solve\` command, response semantics, escalation path (Cloud-only)
+- **file-transfers** — \`uploadFile\` / \`getDownloads\`, stdio-path vs. base64 content, size caps`;
diff --git a/src/tools/agent.ts b/src/tools/agent.ts
index f6d34b9..a5ab040 100644
--- a/src/tools/agent.ts
+++ b/src/tools/agent.ts
@@ -1,6 +1,15 @@
import { FastMCP, UserError } from 'fastmcp';
import type { Content } from 'fastmcp';
+import { readFile } from 'node:fs/promises';
+import { basename } from 'node:path';
import { z } from 'zod';
+import {
+ downloadUri,
+ getDownload,
+ storeDownload,
+ FILE_TRANSFER_MAX_BYTES,
+ type StoredDownload,
+} from '../lib/download-store.js';
import {
getOrCreateSession,
send,
@@ -28,6 +37,7 @@ import { AgentParamsSchema } from './schemas.js';
import {
AGENT_SYSTEM_PROMPT,
SKILL_TOOL_DESCRIPTION,
+ fileTransferModeNote,
} from '../skills/system-prompt.js';
import {
buildCrossOriginNotice,
@@ -102,12 +112,185 @@ export const formatScreenshotContent = (
return content;
};
-// Zod parses params at the tool boundary, so this only needs to supply the {}
-// default when the field was omitted — the schema never delivers a string,
-// array, or null here.
-const coerceParams = (
- params: Record | undefined,
-): Record => params ?? {};
+type DownloadEntry = {
+ filename?: string;
+ mimeType?: string;
+ size?: number;
+ data?: string;
+ error?: string;
+ maxBytes?: number;
+ sourceUrl?: string;
+ inProgress?: boolean;
+ receivedBytes?: number;
+ totalBytes?: number;
+};
+
+const fmtBytes = (n?: number): string =>
+ typeof n !== 'number'
+ ? '?'
+ : n >= 1_048_576
+ ? `${(n / 1_048_576).toFixed(1)}MB`
+ : `${Math.round(n / 1024)}KB`;
+
+// Still-downloading entry: report progress so the caller knows to touch the
+// browser again to collect it (no bytes, nothing to save yet).
+const describeInProgressDownload = (d: DownloadEntry): string => {
+ const got = fmtBytes(d.receivedBytes);
+ const total =
+ d.totalBytes && d.totalBytes > 0 ? ` / ${fmtBytes(d.totalBytes)}` : '';
+ return `${d.filename ?? 'file'} — downloading (${got}${total}); touch the browser again to collect it`;
+};
+
+// Resolve each uploadFile entry to base64 `content` (from `content`, a prior
+// `handle`, or a local `path` in stdio) so the model never emits base64 itself.
+export const normalizeUploadCommand = async (
+ cmd: { method: string; params: Record },
+ transport: McpConfig['transport'],
+ mcpBaseUrl?: string,
+): Promise => {
+ if (cmd.method !== 'uploadFile') return;
+ const files = cmd.params.files;
+ if (!Array.isArray(files)) return;
+ for (const file of files) {
+ if (!file || typeof file !== 'object') continue;
+ const f = file as Record;
+ if (typeof f.content === 'string' && f.content) continue;
+
+ let buf: Buffer;
+ let defaultName: string;
+
+ if (typeof f.handle === 'string' && f.handle) {
+ const record = getDownload(f.handle);
+ if (!record) {
+ throw new UserError(
+ `Unknown upload handle "${f.handle}". Pass a handle returned by ` +
+ `getDownloads, or supply base64 "content".`,
+ );
+ }
+ buf = await readFile(record.path);
+ defaultName = record.filename;
+ delete f.handle;
+ } else if (typeof f.path === 'string' && f.path) {
+ if (transport !== 'stdio') {
+ const base = mcpBaseUrl ?? '';
+ const tokenQ = '?token=';
+ throw new UserError(
+ 'uploadFile "path" is not available in HTTP mode (the server can\'t ' +
+ 'read your filesystem). Stage the file once over HTTP, then pass the ' +
+ 'returned handle — do NOT base64 it through the conversation:\n' +
+ ` curl -s -F file=@"${f.path}" "${base}/upload${tokenQ}"\n` +
+ 'then: uploadFile { files: [{ handle: "" }] }',
+ );
+ }
+ const path = f.path;
+ buf = await readFile(path).catch((e: unknown) => {
+ throw new UserError(
+ `Failed to read upload file "${path}": ` +
+ (e instanceof Error ? e.message : String(e)),
+ );
+ });
+ defaultName = basename(path);
+ delete f.path;
+ } else {
+ continue;
+ }
+
+ if (buf.byteLength > FILE_TRANSFER_MAX_BYTES) {
+ throw new UserError(
+ `Upload file "${defaultName}" is ${buf.byteLength} bytes, over the ` +
+ `50MB limit.`,
+ );
+ }
+ f.content = buf.toString('base64');
+ if (!f.name) f.name = defaultName;
+ }
+};
+
+const describeFailedDownload = (d: DownloadEntry): string => {
+ let s =
+ `${d.filename ?? 'unknown'}: ${d.error ?? 'no data'}` +
+ (d.maxBytes ? ` (max ${d.maxBytes} bytes)` : '');
+ // Over-cap files can't go through the transfer flow — point at the source so
+ // the caller can fetch it directly (e.g. curl) if it has network access.
+ if (d.error === 'FileTooLarge' && d.sourceUrl) {
+ s += ` — too large to transfer; fetch directly: ${d.sourceUrl}`;
+ }
+ return s;
+};
+
+// Persist a download to the server's filesystem (out of the model's context),
+// tagged to the MCP session for cleanup. Returns null for failed/empty entries.
+const persistDownload = async (
+ d: DownloadEntry,
+ sessionId?: string,
+): Promise> | null> => {
+ if (d.error || !d.data || !d.filename) return null;
+ return storeDownload(
+ d.filename,
+ d.mimeType ?? 'application/octet-stream',
+ Buffer.from(d.data, 'base64'),
+ sessionId,
+ );
+};
+
+type FormatOpts = {
+ transport: McpConfig['transport'];
+ sessionId?: string;
+ mcpBaseUrl?: string;
+ token?: string;
+};
+
+// stdio: file is already on the local disk → return its path (reuse as
+// uploadFile { path }). http: return a single-use GET URL + handle; base64
+// never enters context, and fetching consumes the file.
+const describeReadyDownload = (
+ record: StoredDownload,
+ opts: FormatOpts,
+): string => {
+ if (opts.transport === 'stdio') {
+ return (
+ `${record.path} (${record.mimeType}, ${record.size} bytes) — ` +
+ `reuse as uploadFile { path: "${record.path}" }`
+ );
+ }
+ const base = opts.mcpBaseUrl ?? '';
+ const tokenQ = `?token=${opts.token ?? ''}`;
+ return (
+ `${record.filename} (${record.mimeType}, ${record.size} bytes)\n` +
+ ` save it: curl -s "${base}/download/${record.id}${tokenQ}" -o "${record.filename}" (single use)\n` +
+ ` or reuse: uploadFile { files: [{ handle: "${downloadUri(record.id)}" }] }`
+ );
+};
+
+// Surface captured downloads as metadata + how to retrieve them (never bytes).
+export const formatDownloads = async (
+ downloads: DownloadEntry[],
+ prefix: string,
+ skills: string,
+ opts: FormatOpts,
+): Promise => {
+ const lines: string[] = [];
+ for (const d of downloads) {
+ if (d.inProgress) {
+ lines.push(`- ${describeInProgressDownload(d)}`);
+ continue;
+ }
+ const record = await persistDownload(d, opts.sessionId);
+ lines.push(
+ `- ${record ? describeReadyDownload(record, opts) : describeFailedDownload(d)}`,
+ );
+ }
+ const header =
+ opts.transport === 'stdio'
+ ? 'Downloads:'
+ : 'Downloads (save the ones you need — each GET works once):';
+ const text = downloads.length
+ ? `${prefix}${header}\n${lines.join('\n')}`
+ : `${prefix}No new downloads.`;
+ const content: Content[] = [{ type: 'text', text }];
+ if (skills) content.push({ type: 'text', text: skills });
+ return content;
+};
const SkillIdSchema = z.enum(
skillsRegistry.map((s) => s.id) as [SkillId, ...SkillId[]],
@@ -147,7 +330,9 @@ export function registerAgentTools(
defineTool(server, config, analytics, {
name: 'browserless_agent',
- description: AGENT_SYSTEM_PROMPT,
+ description:
+ AGENT_SYSTEM_PROMPT +
+ fileTransferModeNote(config.transport, config.mcpBaseUrl),
parameters: AgentParamsSchema,
annotations: {
title: 'Browserless Agent',
@@ -171,9 +356,9 @@ export function registerAgentTools(
params.commands && params.commands.length > 0
? params.commands.map((c) => ({
method: c.method,
- params: coerceParams(c.params),
+ params: c.params ?? {},
}))
- : [{ method: params.method, params: coerceParams(params.params) }];
+ : [{ method: params.method, params: params.params ?? {} }];
const proxy = params.proxy;
const profile = params.profile;
@@ -438,15 +623,32 @@ export function registerAgentTools(
return [{ type: 'text' as const, text: 'Browser session closed.' }];
}
- // Snapshot: format as compact ref-based text
+ // Auto-surface files Chrome captured this batch so the model needn't call
+ // getDownloads. Skipped on explicit drain/close; a failed poll is ignored.
+ let autoDownloads: DownloadEntry[] = [];
+ if (!closedDuringBatch && last.method !== 'getDownloads') {
+ try {
+ const dl = await send(agentSession, 'getDownloads', {});
+ autoDownloads =
+ (dl.result as { downloads?: DownloadEntry[] } | undefined)
+ ?.downloads ?? [];
+ } catch {
+ // ignore — downloads will surface on a later call
+ }
+ }
+
+ const skillsText = triggered.length > 0 ? renderSkills(triggered) : '';
+ let baseContent: Content[];
+
if (lastSnapshot) {
+ // Snapshot: compact ref-based text.
const notice = buildCrossOriginNotice(
crossOriginBaseline,
lastSnapshot.url,
);
const noticeBlock = notice ? `${notice}\n\n` : '';
if (lastSnapshot.url) agentSession.lastUrl = lastSnapshot.url;
- return [
+ baseContent = [
{
type: 'text' as const,
text: appendSkills(
@@ -458,33 +660,64 @@ export function registerAgentTools(
),
},
];
+ } else if (last.method === 'getDownloads') {
+ // Explicit drain.
+ const downloads =
+ (lastResult?.downloads as DownloadEntry[] | undefined) ?? [];
+ const prefix =
+ batchPrefix + (closedSuffix ? `${closedSuffix}\n\n` : '');
+ return await formatDownloads(downloads, prefix, skillsText, {
+ transport: config.transport,
+ sessionId: mcpSessionId,
+ mcpBaseUrl: config.mcpBaseUrl,
+ token,
+ });
+ } else {
+ // Screenshot → image content block; otherwise JSON text.
+ const shot =
+ last.method === 'screenshot'
+ ? formatScreenshotContent(
+ lastResult,
+ lastCmd,
+ batchPrefix,
+ skillsText,
+ )
+ : null;
+ baseContent = shot ?? [
+ {
+ type: 'text' as const,
+ text: appendSkills(
+ batchPrefix + JSON.stringify(lastResult, null, 2),
+ triggered,
+ ),
+ },
+ ];
}
- // Screenshot: return as image content block (vision input ≈ 1.5K tokens
- // vs. ~67K tokens if we dumped the base64 inline as text).
- if (last.method === 'screenshot') {
- const content = formatScreenshotContent(
- lastResult,
- lastCmd,
- batchPrefix,
- triggered.length > 0 ? renderSkills(triggered) : '',
- );
- if (content) return content;
+ // Append the captured-download notification (metadata only, no bytes).
+ if (autoDownloads.length > 0) {
+ const notice = await formatDownloads(autoDownloads, '', '', {
+ transport: config.transport,
+ sessionId: mcpSessionId,
+ mcpBaseUrl: config.mcpBaseUrl,
+ token,
+ });
+ baseContent = [...baseContent, ...notice];
}
- // Everything else: return as JSON text
- return [
- {
- type: 'text' as const,
- text: appendSkills(
- batchPrefix + JSON.stringify(lastResult, null, 2),
- triggered,
- ),
- },
- ];
+ return baseContent;
};
try {
+ // Resolve any local upload paths to base64 once, before the (possibly
+ // retried) send loop runs.
+ for (const cmd of commands) {
+ await normalizeUploadCommand(
+ cmd,
+ config.transport,
+ config.mcpBaseUrl,
+ );
+ }
const result = await runCommands(false);
sendAnalytics(true);
return result;
diff --git a/src/tools/download.ts b/src/tools/download.ts
deleted file mode 100644
index cde2524..0000000
--- a/src/tools/download.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { FastMCP, UserError } from 'fastmcp';
-import type { Content } from 'fastmcp';
-import { z } from 'zod';
-import { defineTool } from '../lib/define-tool.js';
-import { profileField } from './schemas.js';
-import { AnalyticsHelper } from '../lib/analytics.js';
-import type {
- DownloadParams,
- GenericApiResult,
- McpConfig,
-} from '../@types/types.js';
-
-export const DownloadParamsSchema = z.object({
- code: z
- .string()
- .describe(
- 'JavaScript (ESM) code to execute. The default export receives ' +
- '{ page, context }. During execution the code should trigger a ' +
- 'file download in the browser (e.g. clicking a download link).',
- ),
- context: z
- .record(z.string(), z.unknown())
- .optional()
- .describe('Optional context object passed to the function.'),
- timeout: z
- .number()
- .int()
- .positive()
- .optional()
- .describe('Request timeout in milliseconds'),
- profile: profileField('before the download script runs'),
-});
-
-export function registerDownloadTool(
- server: FastMCP,
- config: McpConfig,
- analytics?: AnalyticsHelper,
-): void {
- defineTool(server, config, analytics, {
- name: 'browserless_download',
- description:
- 'Run custom Puppeteer code on Browserless and return the file that ' +
- 'Chrome downloads during execution. Your code should trigger a file ' +
- 'download (e.g. clicking a download link). The downloaded file is ' +
- 'returned with its original Content-Type. Useful for downloading ' +
- 'CSVs, PDFs, images, or any file from a website.',
- parameters: DownloadParamsSchema,
- annotations: {
- title: 'Browserless Download',
- readOnlyHint: false,
- destructiveHint: true,
- openWorldHint: true,
- },
- profileNotFoundMessage: (profile) =>
- `Profile "${profile}" was not found for the configured API ` +
- `token. Create the profile with Browserless.saveProfile in a ` +
- `live session first, or omit the profile parameter to run the ` +
- `download anonymously.`,
- run: async ({ client, params, log }) => {
- const response = await client.download({
- code: params.code,
- context: params.context,
- timeout: params.timeout,
- profile: params.profile,
- });
- log.debug(
- `Download response: ok=${response.ok}, status=${response.statusCode}, ` +
- `contentType=${response.contentType}, size=${response.size}, ` +
- `disposition=${response.contentDisposition}`,
- );
- return response;
- },
- analyticsProps: (params, result) => ({
- ok: result.ok,
- status_code: result.statusCode,
- content_type: result.contentType,
- size: result.size,
- profile_used: !!params.profile,
- }),
- format: (response) => {
- if (!response.ok) {
- throw new UserError(
- `Download failed (status ${response.statusCode}): ${response.data.slice(0, 500)}`,
- );
- }
- const filenameMatch = response.contentDisposition?.match(
- /filename[^;=\n]*=["']?([^"';\n]*)["']?/,
- );
- const filename = filenameMatch?.[1] ?? 'downloaded-file';
- const blocks: Content[] = [];
- if (response.isBinary) {
- blocks.push({
- type: 'text' as const,
- text:
- `[Downloaded file: "${filename}" – ${response.contentType}, ` +
- `${response.size} bytes, base64-encoded]\n${response.data}`,
- });
- } else {
- blocks.push({ type: 'text' as const, text: response.data });
- }
- blocks.push({
- type: 'text' as const,
- text: [
- '---',
- `Filename: ${filename}`,
- `Content-Type: ${response.contentType}`,
- `Status: ${response.statusCode}`,
- `Size: ${response.size} bytes`,
- '---',
- ].join('\n'),
- });
- return blocks;
- },
- });
-}
diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts
index 1d9169c..5ed4824 100644
--- a/src/tools/schemas.ts
+++ b/src/tools/schemas.ts
@@ -476,6 +476,78 @@ const SolveCommandSchema = z.object({
.default({}),
});
+const UploadFileCommandSchema = z.object({
+ method: z.literal('uploadFile'),
+ params: z.object({
+ selector: z
+ .string()
+ .describe('CSS selector of the element'),
+ files: z
+ .array(
+ z
+ .object({
+ content: z
+ .string()
+ .optional()
+ .describe(
+ 'Base64-encoded file content. LAST RESORT — only for tiny data ' +
+ 'you already hold inline. Do NOT read a file into the ' +
+ 'conversation, and never split/reassemble base64 by hand: use ' +
+ '`path` (stdio) or `handle` so the server moves the bytes.',
+ ),
+ handle: z
+ .string()
+ .optional()
+ .describe(
+ 'A download handle from a prior getDownloads (a path in stdio ' +
+ 'mode, a `browserless-download://` URI in HTTP mode). The MCP ' +
+ 'server reads the stored file — works in both transports and ' +
+ 'keeps the bytes out of the conversation. Use this to re-upload ' +
+ 'a file you just downloaded.',
+ ),
+ path: z
+ .string()
+ .optional()
+ .describe(
+ 'Local filesystem path to read and upload. stdio (local) mode ' +
+ 'only — the MCP server reads and base64-encodes it. In HTTP ' +
+ 'mode use `handle` or `content` instead.',
+ ),
+ name: z
+ .string()
+ .optional()
+ .describe(
+ 'Filename reported to the page. Defaults to the basename of ' +
+ '`path`, else "file".',
+ ),
+ mimeType: z
+ .string()
+ .optional()
+ .describe('MIME type; inferred from the extension when omitted.'),
+ })
+ .refine(
+ (f) =>
+ [f.content, f.handle, f.path].filter((s) => s !== undefined)
+ .length === 1,
+ {
+ message:
+ 'Provide exactly one of "content", "handle", or "path" per file.',
+ },
+ ),
+ )
+ .min(1)
+ .describe(
+ 'Files to attach. Combined decoded size is capped (server default ' +
+ '10MB, hard max 50MB).',
+ ),
+ }),
+});
+
+const GetDownloadsCommandSchema = z.object({
+ method: z.literal('getDownloads'),
+ params: z.object({}).optional().default({}),
+});
+
const CloseCommandSchema = z.object({
method: z.literal('close'),
params: z.object({}).optional().default({}),
@@ -511,6 +583,8 @@ const specificCommandSchemas = [
LiveURLCommandSchema,
SolveCommandSchema,
ScreenshotCommandSchema,
+ UploadFileCommandSchema,
+ GetDownloadsCommandSchema,
CloseCommandSchema,
] as const;
diff --git a/test/lib/download-store.spec.ts b/test/lib/download-store.spec.ts
new file mode 100644
index 0000000..935f8b4
--- /dev/null
+++ b/test/lib/download-store.spec.ts
@@ -0,0 +1,62 @@
+import { expect } from 'chai';
+import { mkdtemp } from 'node:fs/promises';
+import { existsSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+ clearSession,
+ consumeDownload,
+ downloadUri,
+ getDownload,
+ storeDownload,
+} from '../../src/lib/download-store.js';
+
+describe('download-store', () => {
+ let prev: string | undefined;
+
+ beforeEach(async () => {
+ prev = process.env.BROWSERLESS_DOWNLOAD_DIR;
+ process.env.BROWSERLESS_DOWNLOAD_DIR = await mkdtemp(
+ join(tmpdir(), 'mcp-store-'),
+ );
+ });
+
+ afterEach(() => {
+ if (prev === undefined) delete process.env.BROWSERLESS_DOWNLOAD_DIR;
+ else process.env.BROWSERLESS_DOWNLOAD_DIR = prev;
+ });
+
+ it('stores bytes and resolves by id, uri, and path', async () => {
+ const rec = await storeDownload('a.txt', 'text/plain', Buffer.from('hi'));
+ expect(existsSync(rec.path)).to.be.true;
+ expect(getDownload(rec.id)?.id).to.equal(rec.id);
+ expect(getDownload(downloadUri(rec.id))?.id).to.equal(rec.id);
+ expect(getDownload(rec.path)?.id).to.equal(rec.id);
+ });
+
+ it('consumeDownload is single-use (second resolve misses)', async () => {
+ const rec = await storeDownload('b.txt', 'text/plain', Buffer.from('hi'));
+ const first = consumeDownload(downloadUri(rec.id));
+ expect(first?.id).to.equal(rec.id);
+ expect(consumeDownload(downloadUri(rec.id))).to.be.undefined;
+ expect(getDownload(rec.id)).to.be.undefined;
+ });
+
+ it('clearSession drops files owned by the session', async () => {
+ const mine = await storeDownload(
+ 'c.txt',
+ 'text/plain',
+ Buffer.from('x'),
+ 's1',
+ );
+ const other = await storeDownload(
+ 'd.txt',
+ 'text/plain',
+ Buffer.from('y'),
+ 's2',
+ );
+ clearSession('s1');
+ expect(getDownload(mine.id)).to.be.undefined;
+ expect(getDownload(other.id)?.id).to.equal(other.id);
+ });
+});
diff --git a/test/lib/http-auth.spec.ts b/test/lib/http-auth.spec.ts
new file mode 100644
index 0000000..d01708a
--- /dev/null
+++ b/test/lib/http-auth.spec.ts
@@ -0,0 +1,54 @@
+import { expect } from 'chai';
+import { resolveBrowserlessAuth } from '../../src/lib/http-auth.js';
+
+const config = {
+ browserlessApiUrl: 'https://api.example.com',
+ supabaseUrl: 'https://supabase.example.com',
+ supabaseServiceRoleKey: 'service-role',
+};
+
+describe('resolveBrowserlessAuth', () => {
+ it('accepts a plain API key from the Authorization header', async () => {
+ const auth = await resolveBrowserlessAuth(
+ { authHeader: 'Bearer plain-token' },
+ config,
+ );
+ expect(auth.token).to.equal('plain-token');
+ expect(auth.apiUrl).to.equal('https://api.example.com');
+ });
+
+ it('accepts a bare (non-Bearer) Authorization header', async () => {
+ const auth = await resolveBrowserlessAuth(
+ { authHeader: 'plain-token' },
+ config,
+ );
+ expect(auth.token).to.equal('plain-token');
+ });
+
+ it('accepts a ?token= query param', async () => {
+ const auth = await resolveBrowserlessAuth(
+ { tokenQuery: 'query-token' },
+ config,
+ );
+ expect(auth.token).to.equal('query-token');
+ });
+
+ it('honors an explicit api url override', async () => {
+ const auth = await resolveBrowserlessAuth(
+ { tokenQuery: 't', apiUrlHeader: 'https://eu.example.com' },
+ config,
+ );
+ expect(auth.apiUrl).to.equal('https://eu.example.com');
+ });
+
+ it('throws when no token is present', async () => {
+ let threw = false;
+ try {
+ await resolveBrowserlessAuth({}, config);
+ } catch (e) {
+ threw = true;
+ expect((e as Error).message).to.match(/No Browserless API token/);
+ }
+ expect(threw).to.be.true;
+ });
+});
diff --git a/test/skills/skills.spec.ts b/test/skills/skills.spec.ts
index 2092833..6bef5fe 100644
--- a/test/skills/skills.spec.ts
+++ b/test/skills/skills.spec.ts
@@ -36,8 +36,8 @@ const CLOUD = 'https://production.browserless.io';
const SELF_HOSTED = 'https://browserless.example.com';
describe('skills/registry', () => {
- it('loads all ten skill bodies', () => {
- expect(skillsRegistry).to.have.lengthOf(10);
+ it('loads all eleven skill bodies', () => {
+ expect(skillsRegistry).to.have.lengthOf(11);
const ids = skillsRegistry.map((s) => s.id);
expect(ids).to.have.members([
'shadow-dom',
@@ -50,6 +50,7 @@ describe('skills/registry', () => {
'tabs',
'autonomous-login',
'auth-profile',
+ 'file-transfers',
]);
for (const skill of skillsRegistry) {
expect(skill.body, `${skill.id} body`).to.be.a('string').and.not.empty;
@@ -171,6 +172,34 @@ describe('skills/detectSkills - modals', () => {
});
});
+describe('skills/detectSkills - file-transfers', () => {
+ it('fires when a file input is present', () => {
+ const ctx = {
+ snapshot: snapshot([el({ type: 'file', selector: 'input[type=file]' })]),
+ };
+ expect(detectSkills(ctx, createSkillState())).to.include('file-transfers');
+ });
+
+ it('fires on an uploadFile command', () => {
+ const ctx = {
+ cmd: { method: 'uploadFile', params: { selector: 'input' } },
+ };
+ expect(detectSkills(ctx, createSkillState())).to.include('file-transfers');
+ });
+
+ it('fires on a getDownloads command', () => {
+ const ctx = { cmd: { method: 'getDownloads', params: {} } };
+ expect(detectSkills(ctx, createSkillState())).to.include('file-transfers');
+ });
+
+ it('does not fire without a file input or transfer command', () => {
+ const ctx = { snapshot: snapshot([el({ role: 'button', name: 'OK' })]) };
+ expect(detectSkills(ctx, createSkillState())).to.not.include(
+ 'file-transfers',
+ );
+ });
+});
+
describe('skills/detectSkills - captchas', () => {
it('fires on a Cloudflare challenge URL when on cloud', () => {
const ctx = {
diff --git a/test/tools/agent.spec.ts b/test/tools/agent.spec.ts
index 88f0183..8c394db 100644
--- a/test/tools/agent.spec.ts
+++ b/test/tools/agent.spec.ts
@@ -5,12 +5,19 @@ import type { Content } from 'fastmcp';
import {
buildCrossOriginNotice,
formatConnectError,
+ formatDownloads,
formatErrorMessage,
formatScreenshotContent,
formatSnapshot,
+ normalizeUploadCommand,
registerAgentTools,
sanitizeUpgradeBody,
} from '../../src/tools/agent.js';
+import { fileTransferModeNote } from '../../src/skills/system-prompt.js';
+import { mkdtemp, readFile as fsReadFile, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { downloadUri, storeDownload } from '../../src/lib/download-store.js';
import {
ProfileNotFoundError,
UpgradeError,
@@ -201,6 +208,225 @@ describe('formatScreenshotContent', () => {
});
});
+describe('fileTransferModeNote', () => {
+ it('tells the model to use a local path in stdio mode (no base64)', () => {
+ const note = fileTransferModeNote('stdio', 'https://mcp.example.com');
+ expect(note).to.match(/stdio/i);
+ expect(note).to.include('path');
+ expect(note).to.match(/do NOT base64/i);
+ expect(note).to.not.include('/upload');
+ });
+
+ it('tells the model to stage via /upload in HTTP mode', () => {
+ const note = fileTransferModeNote('httpStream', 'https://mcp.example.com');
+ expect(note).to.match(/HTTP/);
+ expect(note).to.include('https://mcp.example.com/upload');
+ expect(note).to.match(/never base64/i);
+ });
+});
+
+describe('normalizeUploadCommand', () => {
+ it('reads a local path into base64 content (stdio)', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'mcp-upload-'));
+ const path = join(dir, 'hello.txt');
+ await writeFile(path, 'Hello World!');
+
+ const cmd = {
+ method: 'uploadFile',
+ params: { selector: 'input', files: [{ path }] },
+ };
+ await normalizeUploadCommand(cmd, 'stdio');
+
+ const file = (cmd.params.files as Record[])[0];
+ expect(file.path).to.be.undefined;
+ expect(file.name).to.equal('hello.txt');
+ expect(Buffer.from(file.content as string, 'base64').toString()).to.equal(
+ 'Hello World!',
+ );
+ });
+
+ it('rejects a local path in httpStream mode with a staging recipe', async () => {
+ const cmd = {
+ method: 'uploadFile',
+ params: { selector: 'input', files: [{ path: '/etc/hosts' }] },
+ };
+ let threw = false;
+ try {
+ await normalizeUploadCommand(
+ cmd,
+ 'httpStream',
+ 'https://mcp.example.com',
+ );
+ } catch (e) {
+ threw = true;
+ const msg = (e as Error).message;
+ expect(msg).to.match(/not available in HTTP mode/);
+ expect(msg).to.include('curl -s -F file=@"/etc/hosts"');
+ expect(msg).to.include(
+ 'https://mcp.example.com/upload?token=',
+ );
+ }
+ expect(threw, 'expected normalizeUploadCommand to throw').to.be.true;
+ });
+
+ it('leaves base64 content and non-upload commands untouched', async () => {
+ const cmd = {
+ method: 'uploadFile',
+ params: { selector: 'input', files: [{ content: 'YWJj', name: 'a' }] },
+ };
+ await normalizeUploadCommand(cmd, 'httpStream');
+ const file = (cmd.params.files as Record[])[0];
+ expect(file.content).to.equal('YWJj');
+
+ const other = { method: 'click', params: { selector: 'a' } };
+ await normalizeUploadCommand(other, 'stdio');
+ expect(other.params.selector).to.equal('a');
+ });
+
+ it('resolves a download handle to base64 content (any transport)', async () => {
+ const record = await storeDownload(
+ 'grabbed.bin',
+ 'application/octet-stream',
+ Buffer.from('Hello World!'),
+ );
+ const cmd = {
+ method: 'uploadFile',
+ params: {
+ selector: 'input',
+ files: [{ handle: downloadUri(record.id) }],
+ },
+ };
+ await normalizeUploadCommand(cmd, 'httpStream');
+ const file = (cmd.params.files as Record[])[0];
+ expect(file.handle).to.be.undefined;
+ expect(file.name).to.equal('grabbed.bin');
+ expect(Buffer.from(file.content as string, 'base64').toString()).to.equal(
+ 'Hello World!',
+ );
+ });
+
+ it('throws on an unknown upload handle', async () => {
+ const cmd = {
+ method: 'uploadFile',
+ params: { selector: 'input', files: [{ handle: 'nope://missing' }] },
+ };
+ let threw = false;
+ try {
+ await normalizeUploadCommand(cmd, 'stdio');
+ } catch (e) {
+ threw = true;
+ expect((e as Error).message).to.match(/Unknown upload handle/);
+ }
+ expect(threw).to.be.true;
+ });
+});
+
+describe('formatDownloads (httpStream)', () => {
+ it('surfaces a notification + single-use GET URL, never the base64 bytes', async () => {
+ const content = await formatDownloads(
+ [{ filename: 'report.csv', mimeType: 'text/csv', size: 3, data: 'YWJj' }],
+ '',
+ '',
+ {
+ transport: 'httpStream',
+ mcpBaseUrl: 'https://mcp.example.com',
+ token: 'tok-1',
+ },
+ );
+ const text = (content[0] as Extract).text;
+ expect(text).to.include('report.csv');
+ // GET recipe with the real base URL + token, marked single use.
+ expect(text).to.match(
+ /curl -s "https:\/\/mcp\.example\.com\/download\/[^"]+\?token=tok-1"/,
+ );
+ expect(text).to.include('single use');
+ // The base64 must never appear in the returned content.
+ expect(JSON.stringify(content)).to.not.include('YWJj');
+ });
+
+ it('degrades oversized/failed downloads to a text note with the source URL', async () => {
+ const content = await formatDownloads(
+ [
+ {
+ filename: 'big.bin',
+ error: 'FileTooLarge',
+ maxBytes: 1048576,
+ sourceUrl: 'https://example.com/big.bin',
+ },
+ ],
+ '',
+ '',
+ {
+ transport: 'httpStream',
+ mcpBaseUrl: 'https://mcp.example.com',
+ token: 'tok-1',
+ },
+ );
+ const text = (content[0] as Extract).text;
+ expect(text).to.match(/big\.bin: FileTooLarge/);
+ expect(text).to.include('fetch directly: https://example.com/big.bin');
+ expect(text).to.not.include('/download/');
+ });
+
+ it('reports an in-progress download as a progress line, no fetch URL', async () => {
+ const content = await formatDownloads(
+ [
+ {
+ filename: 'movie.mov',
+ inProgress: true,
+ receivedBytes: 2 * 1048576,
+ totalBytes: 10 * 1048576,
+ },
+ ],
+ '',
+ '',
+ {
+ transport: 'httpStream',
+ mcpBaseUrl: 'https://mcp.example.com',
+ token: 'tok-1',
+ },
+ );
+ const text = (content[0] as Extract).text;
+ expect(text).to.match(/movie\.mov — downloading \(2\.0MB \/ 10\.0MB\)/);
+ expect(text).to.include('touch the browser again');
+ expect(text).to.not.include('/download/');
+ });
+});
+
+describe('formatDownloads (stdio)', () => {
+ it('writes the file to disk and reports a reusable path, no base64', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'mcp-dl-'));
+ const prev = process.env.BROWSERLESS_DOWNLOAD_DIR;
+ process.env.BROWSERLESS_DOWNLOAD_DIR = dir;
+ try {
+ const content = await formatDownloads(
+ [
+ {
+ filename: 'report.csv',
+ mimeType: 'text/csv',
+ size: 3,
+ data: 'YWJj',
+ },
+ ],
+ '',
+ '',
+ { transport: 'stdio' },
+ );
+ const text = (content[0] as Extract).text;
+ expect(text).to.include('report.csv');
+ expect(text).to.include(dir);
+ expect(text).to.not.include('YWJj');
+ // The reported path points at the written bytes.
+ const reported = text.split('- ')[1].split(' (')[0];
+ const written = await fsReadFile(reported);
+ expect(written.toString()).to.equal('abc');
+ } finally {
+ if (prev === undefined) delete process.env.BROWSERLESS_DOWNLOAD_DIR;
+ else process.env.BROWSERLESS_DOWNLOAD_DIR = prev;
+ }
+ });
+});
+
describe('formatSnapshot', () => {
const baseSnap = (
overrides: Partial = {},
diff --git a/test/tools/annotations.spec.ts b/test/tools/annotations.spec.ts
index f7169cc..a921cd1 100644
--- a/test/tools/annotations.spec.ts
+++ b/test/tools/annotations.spec.ts
@@ -3,7 +3,6 @@ import sinon from 'sinon';
import { FastMCP } from 'fastmcp';
import { registerSmartScraperTool } from '../../src/tools/smartscraper.js';
import { registerFunctionTool } from '../../src/tools/function.js';
-import { registerDownloadTool } from '../../src/tools/download.js';
import { registerExportTool } from '../../src/tools/export.js';
import { registerAgentTools } from '../../src/tools/agent.js';
import { registerSearchTool } from '../../src/tools/search.js';
@@ -36,7 +35,6 @@ const mockConfig: McpConfig = {
const registrars = [
registerSmartScraperTool,
registerFunctionTool,
- registerDownloadTool,
registerExportTool,
registerAgentTools,
registerSearchTool,
diff --git a/test/tools/download.spec.ts b/test/tools/download.spec.ts
deleted file mode 100644
index b69f8d6..0000000
--- a/test/tools/download.spec.ts
+++ /dev/null
@@ -1,326 +0,0 @@
-import { expect } from 'chai';
-import sinon from 'sinon';
-import { FastMCP, UserError } from 'fastmcp';
-import type { Content } from 'fastmcp';
-import { registerDownloadTool } from '../../src/tools/download.js';
-import type { McpConfig } from '../../src/@types/types.js';
-
-const mockConfig: McpConfig = {
- browserlessToken: 'test-token',
- browserlessApiUrl: 'https://api.example.com',
- transport: 'stdio',
- port: 8080,
- requestTimeout: 30000,
- maxRetries: 0,
- cacheTtlMs: 0,
- analyticsEnabled: false,
- sqsRegion: 'us-east-1',
- oauthEnabled: false,
- supabaseUrl: '',
- supabaseOAuthClientId: '',
- supabaseOAuthClientSecret: '',
- supabaseServiceRoleKey: '',
- mcpBaseUrl: '',
- oauthAllowedRedirectUriPatterns: [],
-};
-
-const mockContext = {
- reportProgress: sinon.stub().resolves(),
- log: {
- debug: sinon.stub(),
- error: sinon.stub(),
- info: sinon.stub(),
- warn: sinon.stub(),
- },
- session: undefined,
- client: { version: undefined },
- streamContent: sinon.stub().resolves(),
-};
-
-describe('browserless_download tool', () => {
- let fetchStub: sinon.SinonStub;
-
- beforeEach(() => {
- fetchStub = sinon.stub(globalThis, 'fetch');
- mockContext.reportProgress.resetHistory();
- });
-
- afterEach(() => {
- sinon.restore();
- });
-
- function getToolExecute(server: FastMCP) {
- const addToolSpy = sinon.spy(server, 'addTool');
- registerDownloadTool(server, mockConfig);
- return addToolSpy.firstCall.args[0].execute;
- }
-
- it('registers the tool on the server', () => {
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- expect(() => registerDownloadTool(server, mockConfig)).to.not.throw();
- });
-
- it('returns text content for text/csv download', async () => {
- const csvData = 'Title,Price\nBook A,10.00\nBook B,15.00';
- fetchStub.resolves(
- new Response(csvData, {
- status: 200,
- headers: {
- 'Content-Type': 'text/csv',
- 'Content-Disposition': 'attachment; filename="books.csv"',
- },
- }),
- );
-
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- const execute = getToolExecute(server);
-
- const result = await execute(
- {
- code: 'export default async ({ page }) => { await page.goto("https://example.com"); }',
- },
- mockContext,
- );
-
- const content = (result as { content: Content[] }).content;
- expect(content).to.be.an('array');
- expect(content.length).to.be.at.least(2);
-
- const mainContent = content[0] as { type: string; text: string };
- expect(mainContent.text).to.include('Title,Price');
-
- const metadata = content[1] as { type: string; text: string };
- expect(metadata.text).to.include('Filename: books.csv');
- expect(metadata.text).to.include('Content-Type: text/csv');
- });
-
- it('sends code and context to /download endpoint', async () => {
- fetchStub.resolves(
- new Response('file data', {
- status: 200,
- headers: { 'Content-Type': 'text/plain' },
- }),
- );
-
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- const execute = getToolExecute(server);
-
- await execute(
- {
- code: 'export default async ({ page, context }) => { await page.goto(context.url); }',
- context: { url: 'https://filesamples.com' },
- },
- mockContext,
- );
-
- expect(fetchStub.calledOnce).to.be.true;
- const [url, options] = fetchStub.firstCall.args;
- expect(url).to.include('/download');
- expect(url).to.include('token=test-token');
- const body = JSON.parse(options.body);
- expect(body.code).to.include('context');
- expect(body.context).to.deep.equal({ url: 'https://filesamples.com' });
- });
-
- it('handles binary file downloads with base64 encoding', async () => {
- const imageBuffer = Buffer.from('fake-png-data');
- fetchStub.resolves(
- new Response(imageBuffer, {
- status: 200,
- headers: {
- 'Content-Type': 'image/png',
- 'Content-Disposition': 'attachment; filename="screenshot.png"',
- },
- }),
- );
-
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- const execute = getToolExecute(server);
-
- const result = await execute(
- { code: 'export default async ({ page }) => {}' },
- mockContext,
- );
-
- const content = (result as { content: Content[] }).content;
- const mainContent = content[0] as { type: string; text: string };
- expect(mainContent.text).to.include('Downloaded file');
- expect(mainContent.text).to.include('screenshot.png');
- expect(mainContent.text).to.include(imageBuffer.toString('base64'));
- });
-
- it('throws UserError on failed download', async () => {
- fetchStub.resolves(
- new Response('No file downloaded', {
- status: 400,
- headers: { 'Content-Type': 'text/plain' },
- }),
- );
-
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- const execute = getToolExecute(server);
-
- try {
- await execute(
- { code: 'export default async ({ page }) => {}' },
- mockContext,
- );
- expect.fail('should have thrown');
- } catch (err) {
- expect(err).to.be.instanceOf(UserError);
- expect((err as Error).message).to.include('400');
- }
- });
-
- it('throws UserError when no token is provided', async () => {
- const noTokenConfig = { ...mockConfig, browserlessToken: undefined };
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- const addToolSpy = sinon.spy(server, 'addTool');
- registerDownloadTool(server, noTokenConfig);
- const execute = addToolSpy.firstCall.args[0].execute;
-
- try {
- await execute({ code: 'export default async () => {}' }, mockContext);
- expect.fail('should have thrown');
- } catch (err) {
- expect(err).to.be.instanceOf(UserError);
- expect((err as Error).message).to.include('No Browserless API token');
- }
- });
-
- it('uses default filename when Content-Disposition is absent', async () => {
- fetchStub.resolves(
- new Response('some data', {
- status: 200,
- headers: { 'Content-Type': 'application/octet-stream' },
- }),
- );
-
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- const execute = getToolExecute(server);
-
- const result = await execute(
- { code: 'export default async () => {}' },
- mockContext,
- );
-
- const content = (result as { content: Content[] }).content;
- const metadata = content[1] as { type: string; text: string };
- expect(metadata.text).to.include('Filename: downloaded-file');
- });
-
- it('reports progress during execution', async () => {
- fetchStub.resolves(
- new Response('data', {
- status: 200,
- headers: { 'Content-Type': 'text/plain' },
- }),
- );
-
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- const execute = getToolExecute(server);
-
- await execute({ code: 'export default async () => {}' }, mockContext);
-
- expect(mockContext.reportProgress.calledTwice).to.be.true;
- expect(mockContext.reportProgress.firstCall.args[0]).to.deep.equal({
- progress: 0,
- total: 100,
- });
- expect(mockContext.reportProgress.secondCall.args[0]).to.deep.equal({
- progress: 100,
- total: 100,
- });
- });
-
- it('does not include profile in the outbound URL when omitted', async () => {
- fetchStub.resolves(
- new Response('data', {
- status: 200,
- headers: { 'Content-Type': 'text/plain' },
- }),
- );
-
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- const execute = getToolExecute(server);
-
- await execute({ code: 'export default async () => {}' }, mockContext);
-
- expect(fetchStub.calledOnce).to.be.true;
- const [url] = fetchStub.firstCall.args;
- expect(url).to.not.include('profile=');
- });
-
- it('forwards profile as a query parameter to /download', async () => {
- fetchStub.resolves(
- new Response('data', {
- status: 200,
- headers: { 'Content-Type': 'text/plain' },
- }),
- );
-
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- const execute = getToolExecute(server);
-
- await execute(
- {
- code: 'export default async () => {}',
- profile: 'my-login',
- },
- mockContext,
- );
-
- const [url] = fetchStub.firstCall.args;
- expect(url).to.include('profile=my-login');
- });
-
- it('URL-encodes the profile name', async () => {
- fetchStub.resolves(
- new Response('data', {
- status: 200,
- headers: { 'Content-Type': 'text/plain' },
- }),
- );
-
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- const execute = getToolExecute(server);
-
- await execute(
- {
- code: 'export default async () => {}',
- profile: 'profile with spaces',
- },
- mockContext,
- );
-
- const [url] = fetchStub.firstCall.args;
- expect(url).to.include('profile=profile+with+spaces');
- });
-
- it('throws UserError when the profile does not exist', async () => {
- fetchStub.resolves(
- new Response(
- JSON.stringify({ error: 'Profile "missing" was not found' }),
- { status: 404, headers: { 'Content-Type': 'application/json' } },
- ),
- );
-
- const server = new FastMCP({ name: 'test', version: '0.1.0' });
- const execute = getToolExecute(server);
-
- try {
- await execute(
- {
- code: 'export default async () => {}',
- profile: 'missing',
- },
- mockContext,
- );
- expect.fail('expected UserError');
- } catch (err) {
- expect(err).to.be.instanceOf(UserError);
- expect((err as Error).message).to.include('Profile "missing"');
- expect((err as Error).message).to.include('Browserless.saveProfile');
- }
- });
-});