From adc628edf5fe130b03cf7806475d382c235d17cf Mon Sep 17 00:00:00 2001 From: rramprakash Date: Wed, 17 Jun 2026 09:01:55 +1000 Subject: [PATCH] Add firecrawl.parse command (local document upload) New command.run capability `firecrawl.parse` -> POST /v2/parse. Unlike the JSON endpoints, parse is multipart/form-data: the plugin reads a local `file` path and uploads it with optional `options` (sent as a text field, not a file part). api() generalized to pass FormData through without forcing a JSON Content-Type. +2 hermetic tests (multipart upload, missing-file error). Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 17 +++++++++++++---- bin/plugin.js | 27 +++++++++++++++++++++++--- test/plugin.test.js | 46 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 27a1a99..6955b90 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A [agent-browser](https://github.com/vercel-labs/agent-browser) plugin that integrates [Firecrawl](https://firecrawl.dev). It speaks the `agent-browser.plugin.v1` stdio protocol and exposes Firecrawl two ways: - **`browser.provider`** — launches a Firecrawl **cloud browser session** and returns its CDP WebSocket URL, so agent-browser drives Firecrawl's managed Chrome (proxies, anti-bot, persistent login profiles, live view). Firecrawl becomes a remote browser backend, peer to Browserbase/Browserless/Kernel. -- **`command.run`** — calls Firecrawl's **scrape / search / crawl / map** endpoints directly, without launching a browser session. +- **`command.run`** — calls Firecrawl's **scrape / search / crawl / map / parse** endpoints directly, without launching a browser session. ## Requirements @@ -47,7 +47,7 @@ agent-browser close agent-browser asks the plugin for a session (`POST /v2/browser`), connects to the returned `cdpUrl`, and drives it with its full command surface. On `close` (or a failed connect) the plugin deletes the session (`DELETE /v2/browser/{id}`). -### As scrape/search/crawl/map commands +### As scrape/search/crawl/map/parse commands ```bash agent-browser plugin run firecrawl firecrawl.scrape --payload '{"url":"https://example.com","formats":["markdown"]}' @@ -56,7 +56,15 @@ agent-browser plugin run firecrawl firecrawl.crawl --payload '{"url":"https://d agent-browser plugin run firecrawl firecrawl.map --payload '{"url":"https://example.com"}' ``` -The `--payload` JSON is forwarded as the request body to the matching Firecrawl `/v2` endpoint; the Firecrawl response is returned under `data`. +For these, the `--payload` JSON is forwarded as the request body to the matching Firecrawl `/v2` endpoint; the response is returned under `data`. + +**Parse a local document** (`POST /v2/parse`, `multipart/form-data`). Pass a local `file` path (read from disk by the plugin) and optional scrape-style `options`: + +```bash +agent-browser plugin run firecrawl firecrawl.parse --payload '{"file":"./report.pdf","options":{"formats":["markdown"]}}' +``` + +Use `parse` for local or non-public files (PDF, DOCX, XLSX, HTML, …); for a public document URL, prefer `firecrawl.scrape`. ## Configuration @@ -74,7 +82,8 @@ The `--payload` JSON is forwarded as the request body to the matching Firecrawl "firecrawl.scrape", "firecrawl.search", "firecrawl.crawl", - "firecrawl.map" + "firecrawl.map", + "firecrawl.parse" ] } ] diff --git a/bin/plugin.js b/bin/plugin.js index 6d4526c..c649cb2 100755 --- a/bin/plugin.js +++ b/bin/plugin.js @@ -65,6 +65,7 @@ const CAPABILITIES = [ "firecrawl.search", "firecrawl.crawl", "firecrawl.map", + "firecrawl.parse", ]; // command.run request type -> Firecrawl endpoint @@ -101,15 +102,18 @@ async function api(method, path, body) { const controller = new AbortController(); const timeoutMs = Number(process.env.FIRECRAWL_HTTP_TIMEOUT_MS) || 60000; const timer = setTimeout(() => controller.abort(), timeoutMs); + // FormData bodies (e.g. /v2/parse uploads) must keep fetch's auto-generated + // multipart Content-Type/boundary — only set JSON content-type otherwise. + const isForm = typeof FormData !== "undefined" && body instanceof FormData; let res; try { res = await fetch(`${apiBase}${path}`, { method, headers: { Authorization: `Bearer ${apiKey}`, - ...(body ? { "Content-Type": "application/json" } : {}), + ...(body && !isForm ? { "Content-Type": "application/json" } : {}), }, - body: body ? JSON.stringify(body) : undefined, + body: body ? (isForm ? body : JSON.stringify(body)) : undefined, signal: controller.signal, }); } catch (err) { @@ -171,7 +175,7 @@ async function handle(input) { name: "firecrawl", capabilities: CAPABILITIES, description: - "Firecrawl cloud browser provider (CDP) plus scrape/search/crawl/map commands", + "Firecrawl cloud browser provider (CDP) plus scrape/search/crawl/map/parse commands", }, }); } @@ -204,6 +208,23 @@ async function handle(input) { return ok({}); } + // --- command.run: parse a local document (multipart upload) --- + if (type === "firecrawl.parse") { + const r = input.request && typeof input.request === "object" ? input.request : {}; + const filePath = r.file || r.path; + if (!filePath) throw new Error("firecrawl.parse requires a 'file' path"); + const bytes = await fs.promises.readFile(filePath); + const form = new FormData(); + form.append("file", new Blob([bytes]), nodePath.basename(filePath)); + // `options` must be a plain text field, not a file part, or the server's + // multipart parser rejects it ("Unexpected field"). + if (r.options != null) { + form.append("options", JSON.stringify(r.options)); + } + const data = await api("POST", "/v2/parse", form); + return ok({ data }); + } + // --- command.run (scrape/search/crawl/map) --- if (COMMANDS[type]) { const data = await api("POST", COMMANDS[type], input.request || {}); diff --git a/test/plugin.test.js b/test/plugin.test.js index 281c7de..4156c60 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -42,14 +42,27 @@ before(async () => { let body = ""; req.on("data", (c) => (body += c)); req.on("end", () => { + const contentType = req.headers["content-type"] || ""; + let parsed = null; + if (contentType.includes("application/json") && body) { + try { + parsed = JSON.parse(body); + } catch { + /* leave null */ + } + } lastReq = { method: req.method, url: req.url, auth: req.headers["authorization"], - body: body ? JSON.parse(body) : null, + contentType, + rawBody: body, + body: parsed, }; res.setHeader("content-type", "application/json"); - if (req.method === "POST" && req.url === "/v2/browser") { + if (req.method === "POST" && req.url === "/v2/parse") { + res.end(JSON.stringify({ success: true, data: { markdown: "# Parsed" } })); + } else if (req.method === "POST" && req.url === "/v2/browser") { // ttl 999 simulates a malformed response (cdpUrl but no id) const noId = lastReq.body && lastReq.body.ttl === 999; res.end( @@ -253,3 +266,32 @@ test("firecrawl.scrape forwards payload and returns data", async () => { assert.equal(lastReq.url, "/v2/scrape"); assert.equal(lastReq.body.url, "https://example.com"); }); + +test("firecrawl.parse uploads a local file as multipart with options", async () => { + const file = path.join(emptyHome, "doc.html"); + fs.writeFileSync(file, "

HELLO_PARSE

"); + const { json } = await run( + { + protocol: PROTOCOL, + type: "firecrawl.parse", + request: { file, options: { formats: ["markdown"] } }, + }, + env() + ); + assert.equal(json.success, true); + assert.equal(json.data.data.markdown, "# Parsed"); + assert.equal(lastReq.url, "/v2/parse"); + assert.match(lastReq.contentType, /multipart\/form-data/); + // the file bytes and options rode along in the multipart body + assert.match(lastReq.rawBody, /HELLO_PARSE/); + assert.match(lastReq.rawBody, /markdown/); +}); + +test("firecrawl.parse without a file path fails clearly", async () => { + const { json } = await run( + { protocol: PROTOCOL, type: "firecrawl.parse", request: { options: {} } }, + env() + ); + assert.equal(json.success, false); + assert.match(json.error, /requires a 'file'/); +});