firecrawl-mcp 3.21.3 → 3.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +20 -2
  2. package/dist/index.js +392 -179
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -26,6 +26,24 @@ A Model Context Protocol (MCP) server that brings [Firecrawl](https://github.com
26
26
 
27
27
  ## Installation
28
28
 
29
+ ### Hosted MCP (keyless free tier)
30
+
31
+ Connect to the remote hosted server with no setup:
32
+
33
+ ```
34
+ https://mcp.firecrawl.dev/v2/mcp
35
+ ```
36
+
37
+ On the keyless free tier, `scrape`, `search`, and `interact` work without an API key (rate-limited). Other tools such as `crawl`, `map`, `agent`, and `extract` still need a key.
38
+
39
+ Prefer an API key or OAuth whenever the human can sign up. It unlocks the full tool set and higher limits. With a key, use:
40
+
41
+ ```
42
+ https://mcp.firecrawl.dev/{FIRECRAWL_API_KEY}/v2/mcp
43
+ ```
44
+
45
+ See the [MCP server docs](https://docs.firecrawl.dev/mcp-server) and the [agent onboarding guide](https://www.firecrawl.dev/agent-onboarding/SKILL.md) for setup details.
46
+
29
47
  ### Running with npx
30
48
 
31
49
  ```bash
@@ -326,14 +344,14 @@ Use this guide to select the right tool for your task:
326
344
  - **If you want to search the web for info:** use **search**
327
345
  - **If you need complex research across multiple unknown sources:** use **agent**
328
346
  - **If you want to analyze a whole site or section:** use **crawl** (with limits!)
329
- - **If you need interactive browser automation** (click, type, navigate): use **scrape** + **interact**
347
+ - **If you need interactive browser automation** (click, type, navigate): use **interact** with a URL for a fresh page, or **scrape** + **interact** when you already scraped the page or need tighter scrape control
330
348
 
331
349
  ### Quick Reference Table
332
350
 
333
351
  | Tool | Best for | Returns |
334
352
  | ------------ | ---------------------------------------------- | ------------------------------ |
335
353
  | scrape | Single page content | JSON (preferred) or markdown |
336
- | interact | Interact with a scraped page | Execution result |
354
+ | interact | Interact with a URL or scraped page | Execution result + scrapeId for URL mode |
337
355
  | batch_scrape | Multiple known URLs | JSON (preferred) or markdown[] |
338
356
  | map | Discovering URLs on a site | URL[] |
339
357
  | crawl | Multi-page extraction (with limits) | markdown/html[] |
package/dist/index.js CHANGED
@@ -2487,6 +2487,247 @@ var scrapeParamsSchema = z4.object({
2487
2487
  saveChanges: z4.boolean().optional()
2488
2488
  }).optional()
2489
2489
  });
2490
+ var parseOptionParamsSchema = z4.object({
2491
+ formats: z4.array(
2492
+ z4.enum([
2493
+ "markdown",
2494
+ "html",
2495
+ "rawHtml",
2496
+ "links",
2497
+ "summary",
2498
+ "json",
2499
+ "query"
2500
+ ])
2501
+ ).optional(),
2502
+ jsonOptions: z4.object({
2503
+ prompt: z4.string().optional(),
2504
+ schema: z4.record(z4.string(), z4.any()).optional()
2505
+ }).optional(),
2506
+ queryOptions: z4.object({
2507
+ prompt: z4.string().max(1e4),
2508
+ mode: z4.enum(["directQuote", "freeform"]).default("freeform")
2509
+ }).optional(),
2510
+ parsers: z4.array(z4.enum(["pdf"])).optional(),
2511
+ pdfOptions: z4.object({
2512
+ maxPages: z4.number().int().min(1).max(1e4).optional()
2513
+ }).optional(),
2514
+ onlyMainContent: z4.boolean().optional(),
2515
+ redactPII: z4.boolean().optional(),
2516
+ includeTags: z4.array(z4.string()).optional(),
2517
+ excludeTags: z4.array(z4.string()).optional(),
2518
+ removeBase64Images: z4.boolean().optional(),
2519
+ skipTlsVerification: z4.boolean().optional(),
2520
+ storeInCache: z4.boolean().optional(),
2521
+ zeroDataRetention: z4.boolean().optional(),
2522
+ maxAge: z4.number().optional(),
2523
+ proxy: z4.enum(["basic", "auto"]).optional()
2524
+ });
2525
+ var localParseParamsSchema = parseOptionParamsSchema.extend({
2526
+ filePath: z4.string().min(1).describe(
2527
+ "Absolute or relative path to a local file to parse. Supported: .html, .htm, .pdf, .docx, .doc, .odt, .rtf, .xlsx, .xls"
2528
+ ),
2529
+ contentType: z4.string().optional().describe(
2530
+ "Optional MIME type override. If omitted, the server infers the file kind from the extension."
2531
+ )
2532
+ });
2533
+ var hostedParseParamsSchema = parseOptionParamsSchema.extend({
2534
+ filePath: z4.string().min(1).optional().describe(
2535
+ "Phase 1 only: path to the local file on the caller/harness machine. Hosted MCP will not read or stat this path; it is used only to produce upload instructions."
2536
+ ),
2537
+ uploadRef: z4.string().min(1).optional().describe(
2538
+ "Phase 2 only: short-lived upload reference returned by phase 1 after the local PUT upload completes."
2539
+ ),
2540
+ contentType: z4.string().optional().describe(
2541
+ "Phase 1 MIME type override. If omitted, the server infers it from the file extension without reading the file."
2542
+ ),
2543
+ declaredSizeBytes: z4.number().int().positive().optional().describe(
2544
+ "Optional phase 1 size declaration. Hosted MCP does not stat the file; provide this only if the caller already knows it."
2545
+ )
2546
+ }).superRefine((value, ctx) => {
2547
+ const hasFilePath = typeof value.filePath === "string" && value.filePath.length > 0;
2548
+ const hasUploadRef = typeof value.uploadRef === "string" && value.uploadRef.length > 0;
2549
+ if (hasFilePath === hasUploadRef) {
2550
+ ctx.addIssue({
2551
+ code: z4.ZodIssueCode.custom,
2552
+ message: "Hosted firecrawl_parse requires exactly one of filePath (phase 1) or uploadRef (phase 2).",
2553
+ path: hasFilePath && hasUploadRef ? ["uploadRef"] : ["filePath"]
2554
+ });
2555
+ }
2556
+ });
2557
+ var parseParamsSchema = process.env.CLOUD_SERVICE === "true" ? hostedParseParamsSchema : localParseParamsSchema;
2558
+ var EXTENSION_CONTENT_TYPES = {
2559
+ ".html": "text/html",
2560
+ ".htm": "text/html",
2561
+ ".xhtml": "application/xhtml+xml",
2562
+ ".pdf": "application/pdf",
2563
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
2564
+ ".doc": "application/msword",
2565
+ ".odt": "application/vnd.oasis.opendocument.text",
2566
+ ".rtf": "application/rtf",
2567
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
2568
+ ".xls": "application/vnd.ms-excel"
2569
+ };
2570
+ function inferContentType(filename) {
2571
+ const ext = path.extname(filename).toLowerCase();
2572
+ return EXTENSION_CONTENT_TYPES[ext] ?? "application/octet-stream";
2573
+ }
2574
+ function extractParseOptions(args2) {
2575
+ const { filePath, uploadRef, contentType, declaredSizeBytes, ...options } = args2;
2576
+ return options;
2577
+ }
2578
+ function buildParseOptionsPayload(options) {
2579
+ const transformed = transformScrapeParams(options);
2580
+ const cleaned = removeEmptyTopLevel(transformed);
2581
+ return { origin: ORIGIN, ...cleaned };
2582
+ }
2583
+ function buildContinuationArguments(uploadRef, options) {
2584
+ return {
2585
+ uploadRef,
2586
+ ...removeEmptyTopLevel(options)
2587
+ };
2588
+ }
2589
+ function shellQuote(value) {
2590
+ if (value.length === 0) return "''";
2591
+ return "'" + value.replace(/'/g, "'\\''") + "'";
2592
+ }
2593
+ function parseApiData(json) {
2594
+ return json && typeof json === "object" && "data" in json ? json.data : json;
2595
+ }
2596
+ async function apiPostJson(pathName, body, apiKey) {
2597
+ const response = await fetch(`${resolveApiBaseUrl()}${pathName}`, {
2598
+ method: "POST",
2599
+ headers: {
2600
+ "Content-Type": "application/json",
2601
+ Authorization: `Bearer ${apiKey}`
2602
+ },
2603
+ body: JSON.stringify(body)
2604
+ });
2605
+ const responseText = await response.text();
2606
+ let parsed;
2607
+ try {
2608
+ parsed = responseText ? JSON.parse(responseText) : {};
2609
+ } catch {
2610
+ parsed = { raw: responseText };
2611
+ }
2612
+ if (!response.ok) {
2613
+ throw new Error(
2614
+ parsed?.error || parsed?.message || `Firecrawl request failed (HTTP ${response.status})`
2615
+ );
2616
+ }
2617
+ return parsed;
2618
+ }
2619
+ async function apiPostJsonForSession(pathName, body, session) {
2620
+ if (session?.firecrawlApiKey) {
2621
+ return apiPostJson(pathName, body, session.firecrawlApiKey);
2622
+ }
2623
+ if (isKeylessMode(session)) {
2624
+ return keylessPost(pathName, body, session);
2625
+ }
2626
+ throw new Error(
2627
+ "Firecrawl credentials or keyless eligibility required for hosted parse."
2628
+ );
2629
+ }
2630
+ function buildCurlUploadCommand(filePath, upload) {
2631
+ const method = upload.method ?? "PUT";
2632
+ const headerArgs = Object.entries(upload.headers ?? {}).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `-H ${shellQuote(`${key}: ${value}`)}`);
2633
+ if (method.toUpperCase() === "POST" && upload.fields) {
2634
+ const fieldArgs = Object.entries(upload.fields).sort(([a], [b]) => a.localeCompare(b)).flatMap(([key, value]) => ["-F", shellQuote(`${key}=${value}`)]);
2635
+ return [
2636
+ "curl",
2637
+ "-X",
2638
+ shellQuote("POST"),
2639
+ ...headerArgs,
2640
+ ...fieldArgs,
2641
+ "-F",
2642
+ shellQuote(`file=@${filePath}`),
2643
+ shellQuote(upload.uploadUrl)
2644
+ ].join(" ");
2645
+ }
2646
+ return [
2647
+ "curl",
2648
+ "-X",
2649
+ shellQuote(method),
2650
+ ...headerArgs,
2651
+ "--upload-file",
2652
+ shellQuote(filePath),
2653
+ shellQuote(upload.uploadUrl)
2654
+ ].join(" ");
2655
+ }
2656
+ async function executeHostedParse(args2, session, log) {
2657
+ const hasFilePath = typeof args2.filePath === "string" && args2.filePath.length > 0;
2658
+ const hasUploadRef = typeof args2.uploadRef === "string" && args2.uploadRef.length > 0;
2659
+ if (hasFilePath === hasUploadRef) {
2660
+ throw new Error(
2661
+ "Hosted firecrawl_parse requires exactly one of filePath or uploadRef."
2662
+ );
2663
+ }
2664
+ if (!session?.firecrawlApiKey && !isKeylessMode(session)) {
2665
+ return asText2({
2666
+ success: false,
2667
+ mode: "hosted-upload-ref-auth-required",
2668
+ message: "Hosted firecrawl_parse requires an authenticated Firecrawl session or keyless eligibility before a local file upload URL can be minted. Connect a Firecrawl account, provide an API key, or use keyless hosted MCP while eligible, then call firecrawl_parse again."
2669
+ });
2670
+ }
2671
+ const options = extractParseOptions(args2);
2672
+ if (hasFilePath && args2.filePath) {
2673
+ const filename = path.basename(args2.filePath);
2674
+ const contentType = typeof args2.contentType === "string" && args2.contentType.length > 0 ? args2.contentType : inferContentType(filename);
2675
+ const uploadRequest = removeEmptyTopLevel({
2676
+ filename,
2677
+ contentType,
2678
+ declaredSizeBytes: args2.declaredSizeBytes
2679
+ });
2680
+ log.info("Creating hosted parse upload URL", { filename, contentType });
2681
+ const uploadJson = await apiPostJsonForSession(
2682
+ "/v2/parse/upload-url",
2683
+ uploadRequest,
2684
+ session
2685
+ );
2686
+ const upload = parseApiData(uploadJson);
2687
+ if (!upload?.uploadUrl || !upload?.uploadRef) {
2688
+ throw new Error(
2689
+ "Firecrawl upload-url response did not include uploadUrl and uploadRef"
2690
+ );
2691
+ }
2692
+ const uploadHeaders = upload.headers && Object.keys(upload.headers).length > 0 ? upload.headers : (upload.method ?? "PUT").toUpperCase() === "POST" ? {} : { "Content-Type": contentType };
2693
+ const uploadForCommand = { ...upload, headers: uploadHeaders };
2694
+ return asText2({
2695
+ success: true,
2696
+ mode: "hosted-upload-ref-awaiting-upload",
2697
+ message: "Hosted MCP cannot read local files. Run the local upload command, then call firecrawl_parse again with uploadRef. No Firecrawl API key is included in this command.",
2698
+ upload: {
2699
+ command: buildCurlUploadCommand(args2.filePath, uploadForCommand),
2700
+ method: upload.method ?? "PUT",
2701
+ headers: uploadHeaders,
2702
+ fields: upload.fields,
2703
+ uploadUrl: upload.uploadUrl,
2704
+ uploadRef: upload.uploadRef,
2705
+ expiresAt: upload.expiresAt,
2706
+ maxSizeBytes: upload.maxSizeBytes
2707
+ },
2708
+ nextToolCall: {
2709
+ name: "firecrawl_parse",
2710
+ arguments: buildContinuationArguments(upload.uploadRef, options)
2711
+ },
2712
+ notes: [
2713
+ "Run the curl command on the machine that can read filePath.",
2714
+ "After the PUT succeeds, use nextToolCall as the second MCP tool call.",
2715
+ "Clients without a local upload mechanism cannot complete hosted parse for local files."
2716
+ ]
2717
+ });
2718
+ }
2719
+ const parsePayload = {
2720
+ uploadRef: args2.uploadRef,
2721
+ ...buildParseOptionsPayload(options)
2722
+ };
2723
+ log.info("Parsing hosted upload reference");
2724
+ const parseJson = await apiPostJsonForSession(
2725
+ "/v2/parse",
2726
+ parsePayload,
2727
+ session
2728
+ );
2729
+ return asText2(parseJson);
2730
+ }
2490
2731
  server.addTool({
2491
2732
  name: "firecrawl_scrape",
2492
2733
  annotations: {
@@ -3605,24 +3846,28 @@ server.addTool({
3605
3846
  // Transient page interactions only; does not delete monitors, jobs, or external sites.
3606
3847
  },
3607
3848
  description: `
3608
- Interact with a previously scraped page in a live browser session. Scrape a page first with firecrawl_scrape, then use the returned scrapeId to click buttons, fill forms, extract dynamic content, or navigate deeper.
3849
+ Interact with a page in a live browser session: click buttons, fill forms, extract dynamic content, or navigate deeper.
3609
3850
 
3610
3851
  **Best for:** Multi-step workflows on a single page \u2014 searching a site, clicking through results, filling forms, extracting data that requires interaction.
3611
- **Requires:** A scrapeId from a previous firecrawl_scrape call (found in the metadata of the scrape response).
3852
+ **Two ways to target a page:**
3853
+ - Pass a \`url\` to interact directly. The session is opened for you in one call (use this for a fresh page).
3854
+ - Pass a \`scrapeId\` from a previous firecrawl_scrape to reuse that already-loaded page (cheaper when you just scraped it).
3612
3855
 
3613
3856
  **Arguments:**
3614
- - scrapeId: The scrape job ID from a previous scrape (required)
3857
+ - url: Page to interact with; opens a session for you (use this OR scrapeId)
3858
+ - scrapeId: Scrape job ID from a previous scrape, found in its metadata (use this OR url)
3615
3859
  - prompt: Natural language instruction describing the action to take (use this OR code)
3616
3860
  - code: Code to execute in the browser session (use this OR prompt)
3617
3861
  - language: "bash", "python", or "node" (optional, defaults to "node", only used with code)
3618
- - timeout: Execution timeout in seconds, 1-300 (optional, defaults to 30)
3862
+ - timeout: Interact execution timeout in seconds, 1-300 (optional, defaults to 30)
3863
+ - scrapeOptions: Optional scrape controls used only with url mode, such as waitFor, maxAge, proxy, or zeroDataRetention
3619
3864
 
3620
- **Usage Example (prompt):**
3865
+ **Usage Example (prompt, direct via url):**
3621
3866
  \`\`\`json
3622
3867
  {
3623
3868
  "name": "firecrawl_interact",
3624
3869
  "arguments": {
3625
- "scrapeId": "scrape-id-from-previous-scrape",
3870
+ "url": "https://example.com/products",
3626
3871
  "prompt": "Click on the first product and tell me its price"
3627
3872
  }
3628
3873
  }
@@ -3642,24 +3887,71 @@ Interact with a previously scraped page in a live browser session. Scrape a page
3642
3887
  **Returns:** Execution result including output, stdout, stderr, exit code, and live view URLs.
3643
3888
  `,
3644
3889
  parameters: z4.object({
3645
- scrapeId: z4.string(),
3646
- prompt: z4.string().optional(),
3647
- code: z4.string().optional(),
3890
+ scrapeId: z4.string().trim().min(1).optional(),
3891
+ url: z4.string().trim().url().optional(),
3892
+ prompt: z4.string().trim().min(1).optional(),
3893
+ code: z4.string().trim().min(1).optional(),
3648
3894
  language: z4.enum(["bash", "python", "node"]).optional(),
3649
- timeout: z4.number().min(1).max(300).optional()
3895
+ timeout: z4.number().min(1).max(300).optional(),
3896
+ scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional()
3897
+ }).refine((data) => Boolean(data.scrapeId) !== Boolean(data.url), {
3898
+ message: "Provide either 'url' (interact directly) or 'scrapeId' (reuse a previous scrape), not both."
3899
+ }).refine((data) => !data.scrapeOptions || Boolean(data.url), {
3900
+ message: "scrapeOptions can only be used with 'url' mode."
3650
3901
  }).refine((data) => data.code || data.prompt, {
3651
3902
  message: "Either 'code' or 'prompt' must be provided."
3652
3903
  }),
3653
3904
  execute: async (args2, { session, log }) => {
3654
3905
  const client = getClient(session);
3655
- const { scrapeId, prompt, code, language, timeout } = args2;
3656
- log.info("Interacting with scraped page", { scrapeId });
3906
+ const {
3907
+ scrapeId: providedScrapeId,
3908
+ url,
3909
+ prompt,
3910
+ code,
3911
+ language,
3912
+ timeout,
3913
+ scrapeOptions
3914
+ } = args2;
3915
+ let scrapeId = providedScrapeId;
3916
+ const openedFromUrl = !scrapeId;
3917
+ if (openedFromUrl) {
3918
+ log.info("Opening interact session from url", { url });
3919
+ const cleanedScrapeOptions = removeEmptyTopLevel(scrapeOptions ?? {});
3920
+ const scraped = await client.scrape(String(url), {
3921
+ ...cleanedScrapeOptions,
3922
+ origin: ORIGIN
3923
+ });
3924
+ scrapeId = scraped?.metadata?.scrapeId;
3925
+ if (!scrapeId) {
3926
+ return asText2({
3927
+ error: "Could not open an interact session: the scrape did not return a scrapeId. Try firecrawl_scrape first, then pass its scrapeId.",
3928
+ url
3929
+ });
3930
+ }
3931
+ }
3932
+ if (!scrapeId) {
3933
+ return asText2({
3934
+ error: "Could not open an interact session: missing scrapeId.",
3935
+ url
3936
+ });
3937
+ }
3938
+ const activeScrapeId = scrapeId;
3939
+ log.info("Interacting with page", { scrapeId: activeScrapeId });
3657
3940
  const interactArgs = { origin: ORIGIN };
3658
3941
  if (prompt) interactArgs.prompt = prompt;
3659
3942
  if (code) interactArgs.code = code;
3660
3943
  if (language) interactArgs.language = language;
3661
3944
  if (timeout != null) interactArgs.timeout = timeout;
3662
- const res = await client.interact(scrapeId, interactArgs);
3945
+ const res = await client.interact(activeScrapeId, interactArgs);
3946
+ if (openedFromUrl && res && typeof res === "object" && !Array.isArray(res)) {
3947
+ return asText2({
3948
+ ...res,
3949
+ scrapeId: activeScrapeId
3950
+ });
3951
+ }
3952
+ if (openedFromUrl) {
3953
+ return asText2({ scrapeId: activeScrapeId, result: res });
3954
+ }
3663
3955
  return asText2(res);
3664
3956
  }
3665
3957
  });
@@ -3702,82 +3994,29 @@ Stop an interact session for a scraped page. Call this when you are done interac
3702
3994
  return asText2(res?.data ?? {});
3703
3995
  }
3704
3996
  });
3705
- if (process.env.CLOUD_SERVICE !== "true") {
3706
- let inferContentType = function(filename) {
3707
- const ext = path.extname(filename).toLowerCase();
3708
- return EXTENSION_CONTENT_TYPES[ext] ?? "application/octet-stream";
3709
- };
3710
- inferContentType2 = inferContentType;
3711
- const parseParamsSchema = z4.object({
3712
- filePath: z4.string().min(1).describe(
3713
- "Absolute or relative path to a local file to parse. Supported: .html, .htm, .pdf, .docx, .doc, .odt, .rtf, .xlsx, .xls"
3714
- ),
3715
- contentType: z4.string().optional().describe(
3716
- "Optional MIME type override. If omitted, the server infers the file kind from the extension."
3717
- ),
3718
- formats: z4.array(
3719
- z4.enum([
3720
- "markdown",
3721
- "html",
3722
- "rawHtml",
3723
- "links",
3724
- "summary",
3725
- "json",
3726
- "query"
3727
- ])
3728
- ).optional(),
3729
- jsonOptions: z4.object({
3730
- prompt: z4.string().optional(),
3731
- schema: z4.record(z4.string(), z4.any()).optional()
3732
- }).optional(),
3733
- queryOptions: z4.object({
3734
- prompt: z4.string().max(1e4),
3735
- mode: z4.enum(["directQuote", "freeform"]).default("freeform")
3736
- }).optional(),
3737
- parsers: z4.array(z4.enum(["pdf"])).optional(),
3738
- pdfOptions: z4.object({
3739
- maxPages: z4.number().int().min(1).max(1e4).optional()
3740
- }).optional(),
3741
- onlyMainContent: z4.boolean().optional(),
3742
- redactPII: z4.boolean().optional(),
3743
- includeTags: z4.array(z4.string()).optional(),
3744
- excludeTags: z4.array(z4.string()).optional(),
3745
- removeBase64Images: z4.boolean().optional(),
3746
- skipTlsVerification: z4.boolean().optional(),
3747
- storeInCache: z4.boolean().optional(),
3748
- zeroDataRetention: z4.boolean().optional(),
3749
- maxAge: z4.number().optional(),
3750
- proxy: z4.enum(["basic", "auto"]).optional()
3751
- });
3752
- const EXTENSION_CONTENT_TYPES = {
3753
- ".html": "text/html",
3754
- ".htm": "text/html",
3755
- ".pdf": "application/pdf",
3756
- ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
3757
- ".doc": "application/msword",
3758
- ".odt": "application/vnd.oasis.opendocument.text",
3759
- ".rtf": "application/rtf",
3760
- ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
3761
- ".xls": "application/vnd.ms-excel"
3762
- };
3763
- server.addTool({
3764
- name: "firecrawl_parse",
3765
- annotations: {
3766
- title: "Parse a local file",
3767
- readOnlyHint: true,
3768
- // Reads and parses a local file; does not modify the file on disk.
3769
- openWorldHint: false,
3770
- // Operates on a local filesystem path, not the open web.
3771
- destructiveHint: false
3772
- // Read-only parsing; no deletion or writes to the source file.
3773
- },
3774
- description: `
3775
- Parse a file from the local filesystem using a self-hosted Firecrawl API's /v2/parse endpoint.
3776
- This is the fastest and most reliable way to extract content from a document on disk \u2014 if the file lives locally and the MCP is pointed at a self-hosted Firecrawl instance, you should always prefer this tool over uploading the file elsewhere and then scraping it.
3997
+ server.addTool({
3998
+ name: "firecrawl_parse",
3999
+ annotations: {
4000
+ title: "Parse a local file",
4001
+ readOnlyHint: true,
4002
+ // Local mode reads a file; hosted mode only returns upload instructions or parses an uploadRef.
4003
+ openWorldHint: false,
4004
+ // Operates on a local filesystem path/upload reference, not an arbitrary web URL.
4005
+ destructiveHint: false
4006
+ // Read-only parsing; no deletion or writes to the source file.
4007
+ },
4008
+ description: `
4009
+ Parse a file using Firecrawl's /v2/parse endpoint.
4010
+
4011
+ In local/non-cloud MCP mode, this tool reads filePath from the MCP server filesystem and posts multipart data to the configured self-hosted FIRECRAWL_API_URL, preserving the existing direct-read behavior.
3777
4012
 
3778
- **Best for:** Extracting content from a local document (PDF, Word, Excel, HTML, etc.) when you don't want to host it on the public web first; pulling structured data out of a file with JSON format; converting binary documents into markdown for downstream reasoning.
4013
+ In hosted CLOUD_SERVICE mode, this tool is a two-call flow because hosted MCP cannot read your local filesystem:
4014
+ 1. Call with filePath, contentType, parse options, and optional declaredSizeBytes. The hosted server mints a short-lived upload URL and returns a safe local curl PUT command plus nextToolCall.
4015
+ 2. Run the returned curl command locally, then call firecrawl_parse again with uploadRef and the desired parse options. The hosted server calls /v2/parse server-side with your session credential.
4016
+
4017
+ **Best for:** Extracting content from a local document (PDF, Word, Excel, HTML, etc.); pulling structured data out of a file with JSON format; converting binary documents into markdown for downstream reasoning.
3779
4018
  **Not recommended for:** Remote URLs (use firecrawl_scrape); multiple files at once (call parse multiple times); documents that require interactive actions, screenshots, or change tracking \u2014 those aren't supported by the parse endpoint.
3780
- **Common mistakes:** Passing a URL instead of a local file path; requesting an unsupported format (screenshot, branding, changeTracking); setting waitFor, location, mobile, or a non-basic/auto proxy \u2014 parse uploads reject all of those.
4019
+ **Common mistakes:** In hosted mode, do not pass both filePath and uploadRef. Phase 1 uses filePath only to generate upload instructions; phase 2 uses uploadRef only to parse server-side.
3781
4020
 
3782
4021
  **Supported file types:** .html, .htm, .xhtml, .pdf, .docx, .doc, .odt, .rtf, .xlsx, .xls
3783
4022
  **Unsupported options:** actions, screenshot/branding/changeTracking formats, waitFor > 0, location, mobile, proxy values other than "auto" or "basic".
@@ -3786,122 +4025,96 @@ This is the fastest and most reliable way to extract content from a document on
3786
4025
  **CRITICAL - Format Selection (same rules as firecrawl_scrape):**
3787
4026
  When the user asks for SPECIFIC data points from a document, you MUST use JSON format with a schema. Only use markdown when the user needs the ENTIRE document content.
3788
4027
 
3789
- **Use JSON format when the user asks for:**
3790
- - Specific fields, parameters, or values from a form / PDF / spreadsheet
3791
- - Prices, numbers, or other structured data
3792
- - Lists of items or properties
3793
-
3794
- **Use markdown format when:**
3795
- - User wants to read, summarize, or analyze the full document
3796
- - User explicitly asks for the complete content
3797
-
3798
4028
  **Handling PDFs:**
3799
4029
  Add \`"parsers": ["pdf"]\` (optionally with \`pdfOptions.maxPages\`) when parsing a PDF so the PDF engine is invoked explicitly. For very long documents, cap \`maxPages\` to keep the response within token limits.
3800
4030
 
3801
- **Usage Example (markdown from a local PDF):**
4031
+ **Hosted phase 1 example:**
3802
4032
  \`\`\`json
3803
4033
  {
3804
4034
  "name": "firecrawl_parse",
3805
4035
  "arguments": {
3806
4036
  "filePath": "/absolute/path/to/document.pdf",
4037
+ "contentType": "application/pdf",
3807
4038
  "formats": ["markdown"],
3808
4039
  "parsers": ["pdf"],
3809
- "onlyMainContent": true
4040
+ "zeroDataRetention": true
3810
4041
  }
3811
4042
  }
3812
4043
  \`\`\`
3813
4044
 
3814
- **Usage Example (structured JSON extraction from a local HTML file):**
4045
+ **Hosted phase 2 example:**
3815
4046
  \`\`\`json
3816
4047
  {
3817
4048
  "name": "firecrawl_parse",
3818
4049
  "arguments": {
3819
- "filePath": "./invoice.html",
3820
- "formats": ["json"],
3821
- "jsonOptions": {
3822
- "prompt": "Extract the invoice number, total, and line items",
3823
- "schema": {
3824
- "type": "object",
3825
- "properties": {
3826
- "invoiceNumber": { "type": "string" },
3827
- "total": { "type": "number" },
3828
- "lineItems": {
3829
- "type": "array",
3830
- "items": {
3831
- "type": "object",
3832
- "properties": {
3833
- "description": { "type": "string" },
3834
- "amount": { "type": "number" }
3835
- }
3836
- }
3837
- }
3838
- }
3839
- }
3840
- }
4050
+ "uploadRef": "upload-ref-from-phase-1",
4051
+ "formats": ["markdown"],
4052
+ "parsers": ["pdf"],
4053
+ "zeroDataRetention": true
3841
4054
  }
3842
4055
  }
3843
4056
  \`\`\`
3844
- **Returns:** A parsed document with markdown, html, links, summary, json, or query results depending on the requested formats.
4057
+
4058
+ **Returns:** Phase 1 hosted upload instructions or a parsed document with markdown, html, links, summary, json, or query results depending on the requested formats.
3845
4059
  `,
3846
- parameters: parseParamsSchema,
3847
- execute: async (args2, { session, log }) => {
3848
- const apiUrl = process.env.FIRECRAWL_API_URL;
3849
- if (!apiUrl) {
3850
- throw new Error(
3851
- "firecrawl_parse requires FIRECRAWL_API_URL to be set to a self-hosted Firecrawl API instance."
3852
- );
3853
- }
3854
- const {
3855
- filePath,
3856
- contentType: overrideContentType,
3857
- ...options
3858
- } = args2;
3859
- const absPath = path.resolve(filePath);
3860
- const buffer = await readFile(absPath);
3861
- const filename = path.basename(absPath);
3862
- const fileContentType = overrideContentType && overrideContentType.length > 0 ? overrideContentType : inferContentType(filename);
3863
- const transformed = transformScrapeParams(
3864
- options
4060
+ parameters: parseParamsSchema,
4061
+ execute: async (args2, { session, log }) => {
4062
+ if (process.env.CLOUD_SERVICE === "true") {
4063
+ return executeHostedParse(args2, session, log);
4064
+ }
4065
+ const apiUrl = process.env.FIRECRAWL_API_URL;
4066
+ if (!apiUrl) {
4067
+ throw new Error(
4068
+ "firecrawl_parse requires FIRECRAWL_API_URL to be set to a self-hosted Firecrawl API instance."
3865
4069
  );
3866
- const cleaned = removeEmptyTopLevel(transformed);
3867
- const optionsPayload = { origin: ORIGIN, ...cleaned };
3868
- const form = new FormData();
3869
- const blob = new Blob([new Uint8Array(buffer)], {
3870
- type: fileContentType
3871
- });
3872
- form.append("file", blob, filename);
3873
- form.append("options", JSON.stringify(optionsPayload));
3874
- const headers = { ...ORIGIN_HEADERS2 };
3875
- const apiKey = session?.firecrawlApiKey;
3876
- if (apiKey) {
3877
- headers["Authorization"] = `Bearer ${apiKey}`;
3878
- }
3879
- const endpoint = `${apiUrl.replace(/\/$/, "")}/v2/parse`;
3880
- log.info("Parsing local file", {
3881
- endpoint,
3882
- filename,
3883
- size: buffer.length
3884
- });
3885
- const response = await fetch(endpoint, {
3886
- method: "POST",
3887
- headers,
3888
- body: form
3889
- });
3890
- const responseText = await response.text();
3891
- if (!response.ok) {
3892
- throw new Error(
3893
- `Parse request failed with status ${response.status}: ${responseText}`
3894
- );
3895
- }
3896
- try {
3897
- return asText2(JSON.parse(responseText));
3898
- } catch {
3899
- return responseText;
3900
- }
3901
4070
  }
3902
- });
3903
- }
3904
- var inferContentType2;
4071
+ const {
4072
+ filePath,
4073
+ contentType: overrideContentType,
4074
+ ...options
4075
+ } = args2;
4076
+ const absPath = path.resolve(filePath);
4077
+ const buffer = await readFile(absPath);
4078
+ const filename = path.basename(absPath);
4079
+ const fileContentType = overrideContentType && overrideContentType.length > 0 ? overrideContentType : inferContentType(filename);
4080
+ const optionsPayload = buildParseOptionsPayload(
4081
+ options
4082
+ );
4083
+ const form = new FormData();
4084
+ const blob = new Blob([new Uint8Array(buffer)], {
4085
+ type: fileContentType
4086
+ });
4087
+ form.append("file", blob, filename);
4088
+ form.append("options", JSON.stringify(optionsPayload));
4089
+ const headers = { ...ORIGIN_HEADERS2 };
4090
+ const apiKey = session?.firecrawlApiKey;
4091
+ if (apiKey) {
4092
+ headers["Authorization"] = `Bearer ${apiKey}`;
4093
+ }
4094
+ const endpoint = `${apiUrl.replace(/\/$/, "")}/v2/parse`;
4095
+ log.info("Parsing local file", {
4096
+ endpoint,
4097
+ filename,
4098
+ size: buffer.length
4099
+ });
4100
+ const response = await fetch(endpoint, {
4101
+ method: "POST",
4102
+ headers,
4103
+ body: form
4104
+ });
4105
+ const responseText = await response.text();
4106
+ if (!response.ok) {
4107
+ throw new Error(
4108
+ `Parse request failed with status ${response.status}: ${responseText}`
4109
+ );
4110
+ }
4111
+ try {
4112
+ return asText2(JSON.parse(responseText));
4113
+ } catch {
4114
+ return responseText;
4115
+ }
4116
+ }
4117
+ });
3905
4118
  var PORT = Number(process.env.PORT || 3e3);
3906
4119
  var HOST = process.env.CLOUD_SERVICE === "true" ? "0.0.0.0" : process.env.HOST || "localhost";
3907
4120
  var args;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firecrawl-mcp",
3
- "version": "3.21.3",
3
+ "version": "3.22.0",
4
4
  "description": "MCP server for Firecrawl — search, scrape, and interact with the web. Supports both cloud and self-hosted instances. Features include web search, scraping, page interaction, batch processing, and LLM-powered content analysis.",
5
5
  "type": "module",
6
6
  "mcpName": "io.github.firecrawl/firecrawl-mcp-server",