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'); - } - }); -});