Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"]}'
Expand All @@ -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

Expand All @@ -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"
]
}
]
Expand Down
27 changes: 24 additions & 3 deletions bin/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const CAPABILITIES = [
"firecrawl.search",
"firecrawl.crawl",
"firecrawl.map",
"firecrawl.parse",
];

// command.run request type -> Firecrawl endpoint
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
},
});
}
Expand Down Expand Up @@ -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 || {});
Expand Down
46 changes: 44 additions & 2 deletions test/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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, "<h1>HELLO_PARSE</h1>");
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'/);
});
Loading