firecrawl-mcp 3.23.0 → 3.23.2

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 (2) hide show
  1. package/dist/index.js +395 -291
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8,7 +8,63 @@ import { readFile } from "fs/promises";
8
8
  import { createRequire } from "module";
9
9
  import { randomUUID } from "crypto";
10
10
  import path from "path";
11
- import { z as z3 } from "zod";
11
+ import { z as z4 } from "zod";
12
+
13
+ // src/developer.ts
14
+ import { z } from "zod";
15
+ var BASE = "/v2/developer/search";
16
+ var ORIGIN_HEADERS = { "X-Origin": "mcp-fastmcp" };
17
+ var MAX_PASSAGE_CHARS = 1200;
18
+ function fmtDeveloper(results) {
19
+ if (!results || results.length === 0) return "(no results)";
20
+ return results.map((r) => {
21
+ const kind = r.type ? ` (${r.type})` : "";
22
+ const lines = [`## [${r.id ?? "?"}]${kind} ${r.title ?? "(untitled)"}`];
23
+ if (r.url) lines.push(r.url);
24
+ const body = (r.passages ?? []).map((p) => p.text ?? "").join("\n---\n").trim();
25
+ lines.push(body ? body.slice(0, MAX_PASSAGE_CHARS) : "(no content)");
26
+ return lines.join("\n");
27
+ }).join("\n\n");
28
+ }
29
+ function registerDeveloperTools(server2, getClient2) {
30
+ server2.addTool({
31
+ name: "firecrawl_developer_search",
32
+ annotations: {
33
+ title: "Search developer sources",
34
+ readOnlyHint: true,
35
+ // Semantic search over an indexed developer corpus; returns ranked results only.
36
+ openWorldHint: true,
37
+ // Searches the Firecrawl developer index of public GitHub and documentation content.
38
+ destructiveHint: false
39
+ // Query-only; no writes to external sources or the developer index.
40
+ },
41
+ description: `
42
+ For a developer question \u2014 code behaviour, a library or framework, an API contract, an error message, or a known bug \u2014 search an index built for coding agents. The index covers GitHub issues, merged pull requests, repository READMEs, and curated documentation sites. Set skills to "only" to limit the search to agent-skill files.
43
+
44
+ Returns ranked results with an ID, source type, URL, title, and the matched passages in markdown.
45
+ `,
46
+ parameters: z.object({
47
+ query: z.string().min(1).describe(
48
+ "Natural-language developer question or search phrase, including the library, error message, or API involved when relevant."
49
+ ),
50
+ k: z.number().int().min(1).max(100).optional().describe("Number of ranked results to return (default 20)."),
51
+ skills: z.enum(["only"]).optional().describe('Set to "only" to search only agent-skill files.')
52
+ }),
53
+ execute: async (args2, { session }) => {
54
+ const { query, k, skills } = args2;
55
+ const params = new URLSearchParams();
56
+ params.append("query", query);
57
+ if (k != null) params.append("k", String(k));
58
+ if (skills != null) params.append("skills", skills);
59
+ const client = getClient2(session);
60
+ const res = await client.http.get(
61
+ `${BASE}?${params.toString()}`,
62
+ ORIGIN_HEADERS
63
+ );
64
+ return fmtDeveloper(res.data?.results);
65
+ }
66
+ });
67
+ }
12
68
 
13
69
  // src/keyless-client-ip.ts
14
70
  import net from "net";
@@ -25,7 +81,7 @@ function extractSingleTrustedClientIp(rawForwardedFor) {
25
81
  }
26
82
 
27
83
  // src/monitor.ts
28
- import { z } from "zod";
84
+ import { z as z2 } from "zod";
29
85
 
30
86
  // src/session-credential.ts
31
87
  import { createHmac } from "crypto";
@@ -124,8 +180,8 @@ async function monitorRequest(session, path2, init = {}) {
124
180
  function asText(data) {
125
181
  return JSON.stringify(data, null, 2);
126
182
  }
127
- var pageStatusSchema = z.enum(["same", "new", "changed", "removed", "error"]);
128
- var checkStatusSchema = z.enum([
183
+ var pageStatusSchema = z2.enum(["same", "new", "changed", "removed", "error"]);
184
+ var checkStatusSchema = z2.enum([
129
185
  "queued",
130
186
  "running",
131
187
  "completed",
@@ -218,22 +274,22 @@ Create a recurring scrape, crawl, or search monitor that compares each check wit
218
274
 
219
275
  In the simple form, a \`goal\` is required. If \`queries\` contains one or more non-empty values and is supplied with \`page\`/\`pages\`, \`queries\` create the search target and page targets are ignored. A monitor schedules future network checks and can send configured email or webhook notifications. Returns the created monitor.
220
276
  `,
221
- parameters: z.object({
222
- body: z.record(z.string(), z.any()).optional(),
223
- page: z.string().optional(),
224
- pages: z.array(z.string()).optional(),
225
- queries: z.array(z.string()).optional(),
226
- searchWindow: z.enum(["5m", "15m", "1h", "6h", "24h", "7d"]).optional(),
227
- maxResults: z.number().int().min(1).max(50).optional(),
228
- includeDomains: z.array(z.string()).optional(),
229
- excludeDomains: z.array(z.string()).optional(),
230
- goal: z.string().optional(),
231
- name: z.string().optional(),
232
- scheduleText: z.string().optional(),
233
- timezone: z.string().optional(),
234
- email: z.string().optional(),
235
- includeDiffs: z.boolean().optional(),
236
- webhookUrl: z.string().optional()
277
+ parameters: z2.object({
278
+ body: z2.record(z2.string(), z2.any()).optional(),
279
+ page: z2.string().optional(),
280
+ pages: z2.array(z2.string()).optional(),
281
+ queries: z2.array(z2.string()).optional(),
282
+ searchWindow: z2.enum(["5m", "15m", "1h", "6h", "24h", "7d"]).optional(),
283
+ maxResults: z2.number().int().min(1).max(50).optional(),
284
+ includeDomains: z2.array(z2.string()).optional(),
285
+ excludeDomains: z2.array(z2.string()).optional(),
286
+ goal: z2.string().optional(),
287
+ name: z2.string().optional(),
288
+ scheduleText: z2.string().optional(),
289
+ timezone: z2.string().optional(),
290
+ email: z2.string().optional(),
291
+ includeDiffs: z2.boolean().optional(),
292
+ webhookUrl: z2.string().optional()
237
293
  }),
238
294
  execute: async (args2, { session, log }) => {
239
295
  const body = buildMonitorCreateBody(args2);
@@ -259,9 +315,9 @@ In the simple form, a \`goal\` is required. If \`queries\` contains one or more
259
315
  description: `
260
316
  List monitors for the authenticated account with optional pagination controls. Returns one page of monitor records and pagination metadata.
261
317
  `,
262
- parameters: z.object({
263
- limit: z.number().int().positive().optional(),
264
- offset: z.number().int().nonnegative().optional()
318
+ parameters: z2.object({
319
+ limit: z2.number().int().positive().optional(),
320
+ offset: z2.number().int().nonnegative().optional()
265
321
  }),
266
322
  execute: async (args2, { session }) => {
267
323
  const { limit, offset } = args2;
@@ -285,7 +341,7 @@ List monitors for the authenticated account with optional pagination controls. R
285
341
  description: `
286
342
  Retrieve one monitor by ID, including its configuration and current state. This does not run or modify the monitor.
287
343
  `,
288
- parameters: z.object({ id: z.string() }),
344
+ parameters: z2.object({ id: z2.string() }),
289
345
  execute: async (args2, { session }) => {
290
346
  const { id } = args2;
291
347
  const res = await monitorRequest(
@@ -311,9 +367,9 @@ Patch an existing monitor by ID. The body can change its name, active/paused sta
311
367
 
312
368
  Returns the updated monitor.
313
369
  `,
314
- parameters: z.object({
315
- id: z.string(),
316
- body: z.record(z.string(), z.any())
370
+ parameters: z2.object({
371
+ id: z2.string(),
372
+ body: z2.record(z2.string(), z2.any())
317
373
  }),
318
374
  execute: async (args2, { session }) => {
319
375
  const { id, body } = args2;
@@ -339,7 +395,7 @@ Returns the updated monitor.
339
395
  description: `
340
396
  Permanently delete a monitor by ID and stop its future schedule. This operation cannot be undone and returns deletion status.
341
397
  `,
342
- parameters: z.object({ id: z.string() }),
398
+ parameters: z2.object({ id: z2.string() }),
343
399
  execute: async (args2, { session, log }) => {
344
400
  const { id } = args2;
345
401
  log.info("Deleting monitor", { id });
@@ -365,7 +421,7 @@ Permanently delete a monitor by ID and stop its future schedule. This operation
365
421
  description: `
366
422
  Queue an immediate check for a monitor outside its normal schedule. This starts network work for the monitor's configured targets and returns the queued check.
367
423
  `,
368
- parameters: z.object({ id: z.string() }),
424
+ parameters: z2.object({ id: z2.string() }),
369
425
  execute: async (args2, { session }) => {
370
426
  const { id } = args2;
371
427
  const res = await monitorRequest(
@@ -390,10 +446,10 @@ Queue an immediate check for a monitor outside its normal schedule. This starts
390
446
  description: `
391
447
  List historical checks for a monitor, optionally filtered by status and bounded by a result limit. Returns one page of check summaries and pagination metadata.
392
448
  `,
393
- parameters: z.object({
394
- id: z.string(),
395
- limit: z.number().int().positive().optional(),
396
- offset: z.number().int().nonnegative().optional(),
449
+ parameters: z2.object({
450
+ id: z2.string(),
451
+ limit: z2.number().int().positive().optional(),
452
+ offset: z2.number().int().nonnegative().optional(),
397
453
  status: checkStatusSchema.optional()
398
454
  }),
399
455
  execute: async (args2, { session }) => {
@@ -422,11 +478,11 @@ Retrieve one monitor check and its page-level results, optionally filtered by pa
422
478
 
423
479
  Markdown tracking returns a unified text diff, JSON tracking returns field paths with previous/current values and a current snapshot, and mixed tracking returns both. Returns one page of results plus a \`next\` URL when more pages exist.
424
480
  `,
425
- parameters: z.object({
426
- id: z.string(),
427
- checkId: z.string(),
428
- limit: z.number().int().positive().optional(),
429
- skip: z.number().int().nonnegative().optional(),
481
+ parameters: z2.object({
482
+ id: z2.string(),
483
+ checkId: z2.string(),
484
+ limit: z2.number().int().positive().optional(),
485
+ skip: z2.number().int().nonnegative().optional(),
430
486
  pageStatus: pageStatusSchema.optional()
431
487
  }),
432
488
  execute: async (args2, { session }) => {
@@ -442,9 +498,9 @@ Markdown tracking returns a unified text diff, JSON tracking returns field paths
442
498
  }
443
499
 
444
500
  // src/research.ts
445
- import { z as z2 } from "zod";
446
- var BASE = "/v2/search/research";
447
- var ORIGIN_HEADERS = { "X-Origin": "mcp-fastmcp" };
501
+ import { z as z3 } from "zod";
502
+ var BASE2 = "/v2/search/research";
503
+ var ORIGIN_HEADERS2 = { "X-Origin": "mcp-fastmcp" };
448
504
  function appendParam(params, key, value) {
449
505
  if (value == null) return;
450
506
  if (Array.isArray(value)) {
@@ -566,21 +622,21 @@ For topics represented in the indexed corpus, search paper metadata and abstract
566
622
 
567
623
  Returns ranked papers with canonical IDs, titles, authors, and abstracts.
568
624
  `,
569
- parameters: z2.object({
570
- query: z2.string().min(1).describe(
625
+ parameters: z3.object({
626
+ query: z3.string().min(1).describe(
571
627
  "Natural-language research topic or question, including methods, systems, conditions, populations, interventions, or outcomes when relevant."
572
628
  ),
573
- k: z2.number().int().min(1).max(500).optional().describe("Number of ranked papers to return (default 40)."),
574
- authors: z2.array(z2.string()).optional().describe(
629
+ k: z3.number().int().min(1).max(500).optional().describe("Number of ranked papers to return (default 40)."),
630
+ authors: z3.array(z3.string()).optional().describe(
575
631
  "Author substring filter(s); ALL must match (case-insensitive)."
576
632
  ),
577
- categories: z2.array(z2.string()).optional().describe(
633
+ categories: z3.array(z3.string()).optional().describe(
578
634
  "Paper category filter(s) (e.g. `cs.LG`); ALL provided values must match."
579
635
  ),
580
- from: z2.string().optional().describe(
636
+ from: z3.string().optional().describe(
581
637
  "Inclusive lower bound on created/updated date (`YYYY-MM-DD`)."
582
638
  ),
583
- to: z2.string().optional().describe(
639
+ to: z3.string().optional().describe(
584
640
  "Inclusive upper bound on created/updated date (`YYYY-MM-DD`)."
585
641
  )
586
642
  }),
@@ -595,8 +651,8 @@ Returns ranked papers with canonical IDs, titles, authors, and abstracts.
595
651
  appendParam(params, "to", to);
596
652
  const client = getClient2(session);
597
653
  const res = await client.http.get(
598
- withQuery(`${BASE}/papers`, params),
599
- ORIGIN_HEADERS
654
+ withQuery(`${BASE2}/papers`, params),
655
+ ORIGIN_HEADERS2
600
656
  );
601
657
  return fmtHits(res.data?.results);
602
658
  }
@@ -615,8 +671,8 @@ Returns ranked papers with canonical IDs, titles, authors, and abstracts.
615
671
  description: `
616
672
  Retrieve canonical metadata for one paper ID, such as an arXiv, PMC, PMID, or DOI identifier. Returns the title, abstract, authors, categories, source IDs, and dates as markdown.
617
673
  `,
618
- parameters: z2.object({
619
- paperId: z2.string().min(1).describe(
674
+ parameters: z3.object({
675
+ paperId: z3.string().min(1).describe(
620
676
  "Canonical paperId or primaryId such as `arxiv:1706.03762`, `pmcid:PMC12530322`, `pmid:40953549`, or `doi:10.1016/j.neunet.2025.108095`."
621
677
  )
622
678
  }),
@@ -624,8 +680,8 @@ Retrieve canonical metadata for one paper ID, such as an arXiv, PMC, PMID, or DO
624
680
  const { paperId } = args2;
625
681
  const client = getClient2(session);
626
682
  const res = await client.http.get(
627
- `${BASE}/papers/${encodeURIComponent(paperId)}`,
628
- ORIGIN_HEADERS
683
+ `${BASE2}/papers/${encodeURIComponent(paperId)}`,
684
+ ORIGIN_HEADERS2
629
685
  );
630
686
  return fmtPaperMetadata(res.data?.paper);
631
687
  }
@@ -646,12 +702,12 @@ Find citation-graph candidates from one to ten \`seed_ids\`; the first ID is the
646
702
 
647
703
  Returns ranked candidates and the evaluated pool size.
648
704
  `,
649
- parameters: z2.object({
650
- seed_ids: z2.array(z2.string()).min(1).max(10),
651
- intent: z2.string().min(1),
652
- mode: z2.enum(["similar", "citers", "references"]).optional(),
653
- k: z2.number().int().min(1).max(500).optional(),
654
- rerank: z2.boolean().optional().describe("Apply an additional rerank over the fused candidates.")
705
+ parameters: z3.object({
706
+ seed_ids: z3.array(z3.string()).min(1).max(10),
707
+ intent: z3.string().min(1),
708
+ mode: z3.enum(["similar", "citers", "references"]).optional(),
709
+ k: z3.number().int().min(1).max(500).optional(),
710
+ rerank: z3.boolean().optional().describe("Apply an additional rerank over the fused candidates.")
655
711
  }),
656
712
  execute: async (args2, { session }) => {
657
713
  const { seed_ids, intent, mode, k, rerank } = args2;
@@ -665,10 +721,10 @@ Returns ranked candidates and the evaluated pool size.
665
721
  const client = getClient2(session);
666
722
  const res = await client.http.get(
667
723
  withQuery(
668
- `${BASE}/papers/${encodeURIComponent(primary)}/similar`,
724
+ `${BASE2}/papers/${encodeURIComponent(primary)}/similar`,
669
725
  params
670
726
  ),
671
- ORIGIN_HEADERS
727
+ ORIGIN_HEADERS2
672
728
  );
673
729
  const note = res.data?.note ? `
674
730
  note: ${res.data.note}` : "";
@@ -692,12 +748,12 @@ Retrieve in-body passages from one paper that are relevant to a specific questio
692
748
 
693
749
  Returns matching passages or a notice when full text is unavailable.
694
750
  `,
695
- parameters: z2.object({
696
- paperId: z2.string().min(1).describe(
751
+ parameters: z3.object({
752
+ paperId: z3.string().min(1).describe(
697
753
  "Canonical paperId or primaryId such as `arxiv:1706.03762`, `pmcid:PMC12530322`, `pmid:40953549`, or `doi:10.1016/j.neunet.2025.108095`."
698
754
  ),
699
- question: z2.string().min(1),
700
- k: z2.number().int().min(1).max(50).optional().describe("Number of passages to return (default 4).")
755
+ question: z3.string().min(1),
756
+ k: z3.number().int().min(1).max(50).optional().describe("Number of passages to return (default 4).")
701
757
  }),
702
758
  execute: async (args2, { session }) => {
703
759
  const { paperId, question, k } = args2;
@@ -706,8 +762,8 @@ Returns matching passages or a notice when full text is unavailable.
706
762
  appendParam(params, "k", k);
707
763
  const client = getClient2(session);
708
764
  const res = await client.http.get(
709
- withQuery(`${BASE}/papers/${encodeURIComponent(paperId)}`, params),
710
- ORIGIN_HEADERS
765
+ withQuery(`${BASE2}/papers/${encodeURIComponent(paperId)}`, params),
766
+ ORIGIN_HEADERS2
711
767
  );
712
768
  const passages = res.data?.passages ?? [];
713
769
  return passages.length ? passages.map((p) => p.text).join("\n---\n") : "(no full-text passages available for this paper)";
@@ -727,9 +783,9 @@ Returns matching passages or a notice when full text is unavailable.
727
783
  description: `
728
784
  Search indexed public GitHub issue, pull-request, and README content. Returns ranked matches with repository, URL, snippet, and full matched markdown when available.
729
785
  `,
730
- parameters: z2.object({
731
- query: z2.string().min(1),
732
- k: z2.number().int().min(1).max(100).optional()
786
+ parameters: z3.object({
787
+ query: z3.string().min(1),
788
+ k: z3.number().int().min(1).max(100).optional()
733
789
  }),
734
790
  execute: async (args2, { session }) => {
735
791
  const { query, k } = args2;
@@ -738,8 +794,8 @@ Search indexed public GitHub issue, pull-request, and README content. Returns ra
738
794
  appendParam(params, "k", k);
739
795
  const client = getClient2(session);
740
796
  const res = await client.http.get(
741
- withQuery(`${BASE}/github`, params),
742
- ORIGIN_HEADERS
797
+ withQuery(`${BASE2}/github`, params),
798
+ ORIGIN_HEADERS2
743
799
  );
744
800
  return fmtGithub(res.data?.results);
745
801
  }
@@ -1160,7 +1216,7 @@ function removeEmptyTopLevel(obj) {
1160
1216
  }
1161
1217
  return out;
1162
1218
  }
1163
- var searchDomainSchema = z3.string().trim().toLowerCase().min(1).max(253).regex(
1219
+ var searchDomainSchema = z4.string().trim().toLowerCase().min(1).max(253).regex(
1164
1220
  /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/,
1165
1221
  "Domain must be a valid hostname without protocol or path"
1166
1222
  );
@@ -1174,21 +1230,21 @@ function buildSearchQueryWithDomains(query, includeDomains, excludeDomains) {
1174
1230
  return query;
1175
1231
  }
1176
1232
  var searchToolBaseFields = {
1177
- query: z3.string().min(1),
1178
- highlights: z3.boolean().optional().describe(
1233
+ query: z4.string().min(1),
1234
+ highlights: z4.boolean().optional().describe(
1179
1235
  "Return query-relevant highlights for each search result. Set to false to keep the original search snippets."
1180
1236
  ),
1181
- limit: z3.number().optional(),
1182
- tbs: z3.string().optional(),
1183
- filter: z3.string().optional(),
1184
- location: z3.string().optional(),
1185
- includeDomains: z3.array(searchDomainSchema).optional(),
1186
- excludeDomains: z3.array(searchDomainSchema).optional(),
1187
- sources: z3.array(z3.object({ type: z3.enum(["web", "images", "news"]) })).optional(),
1188
- categories: z3.array(z3.enum(["github", "research", "pdf"])).optional().describe(
1189
- "Limit results to specific source types. `github` searches GitHub repositories, code, issues, and docs; `research` searches academic and research sources; `pdf` searches PDF results."
1237
+ limit: z4.number().optional(),
1238
+ tbs: z4.string().optional(),
1239
+ filter: z4.string().optional(),
1240
+ location: z4.string().optional(),
1241
+ includeDomains: z4.array(searchDomainSchema).optional(),
1242
+ excludeDomains: z4.array(searchDomainSchema).optional(),
1243
+ sources: z4.array(z4.object({ type: z4.enum(["web", "images", "news"]) })).optional(),
1244
+ categories: z4.array(z4.enum(["github", "research", "pdf", "developer"])).optional().describe(
1245
+ "Limit results to specific source types. `github` searches GitHub repositories, code, issues, and docs; `research` searches academic and research sources; `pdf` searches PDF results; `developer` searches an index built for coding agents over GitHub issues, merged pull requests, repository READMEs, and curated documentation sites. `developer` adds a `data.developer` group of `{ url, title, description }` results, where `description` holds the matched passage; the other categories filter `data.web`."
1190
1246
  ),
1191
- enterprise: z3.array(z3.enum(["default", "anon", "zdr"])).optional()
1247
+ enterprise: z4.array(z4.enum(["default", "anon", "zdr"])).optional()
1192
1248
  };
1193
1249
  function searchDomainsAreExclusive(args2) {
1194
1250
  return !(args2.includeDomains?.length && args2.excludeDomains?.length);
@@ -1227,9 +1283,10 @@ var openAiAppsChallengeToken = normalizeHeader(
1227
1283
  );
1228
1284
  var FULL_PROFILE_INSTRUCTIONS = `Firecrawl provides web search, page retrieval, site URL discovery, multi-page collection, structured extraction, monitoring, and asynchronous research. Match the requested operation to the tool boundary: firecrawl_scrape retrieves one supplied page, firecrawl_map enumerates URLs under a site without retrieving their content, and firecrawl_agent starts multi-source research whose result is read with firecrawl_agent_status. Provide only the required inputs and account for stated network or external side effects.`;
1229
1285
  var KEYLESS_PROFILE_INSTRUCTIONS = `Without authentication, this endpoint exposes Search, Scrape, and Parse with usage limits. An OAuth connection or Authorization bearer API key exposes account tools; unavailable tools return connection guidance. Firecrawl provides web search, page retrieval, site URL discovery, multi-page collection, structured extraction, monitoring, and asynchronous research. Match the requested operation to the tool boundary: firecrawl_scrape retrieves one supplied page, firecrawl_map enumerates URLs under a site without retrieving their content, and firecrawl_agent starts multi-source research whose result is read with firecrawl_agent_status. Provide only the required inputs.`;
1230
- var SEARCH_PROFILE_INSTRUCTIONS = `Firecrawl provides web and research search. Use firecrawl_search to find relevant results across the web and specialized indexes. Use the firecrawl_research_* tools to search academic and research literature, expand from anchor papers via the citation graph, read full-text passages from a specific paper, and search public code repositories. All tools are read-only and return ranked results.`;
1286
+ var SEARCH_PROFILE_INSTRUCTIONS = `Firecrawl provides web, developer, and research search. Use firecrawl_search to find relevant results across the web and specialized indexes. For a programming question, call firecrawl_developer_search \u2014 or firecrawl_search with categories: ["developer"] \u2014 to search indexed GitHub issues, merged pull requests, READMEs, and documentation. Use the firecrawl_research_* tools to search academic and research literature, expand from anchor papers via the citation graph, read full-text passages from a specific paper, and search public code repositories. All tools are read-only and return ranked results.`;
1231
1287
  var SEARCH_PROFILE_TOOLS = /* @__PURE__ */ new Set([
1232
1288
  "firecrawl_search",
1289
+ "firecrawl_developer_search",
1233
1290
  "firecrawl_research_search_papers",
1234
1291
  "firecrawl_research_inspect_paper",
1235
1292
  "firecrawl_research_related_papers",
@@ -1318,24 +1375,34 @@ var KEYLESS_TOOL_NAMES = /* @__PURE__ */ new Set([
1318
1375
  function isHostedKeylessSession(session) {
1319
1376
  return process.env.CLOUD_SERVICE === "true" && session?.authType === "keyless" && !session.firecrawlApiKey;
1320
1377
  }
1321
- function recoveryPayload(code, requestId = randomUUID()) {
1378
+ function isLocalKeylessStartup() {
1379
+ return process.env.CLOUD_SERVICE !== "true" && !isHttpStreamingTransport() && !resolveCredentialFromEnv() && !normalizeHeader(process.env.FIRECRAWL_API_URL);
1380
+ }
1381
+ function recoveryPayload(code, requestId = randomUUID(), options = {}) {
1382
+ const retryAfterSeconds = options.retryAfterSeconds;
1383
+ const isQuotaExhausted = code === "KEYLESS_QUOTA_EXHAUSTED" || code === "KEYLESS_LIMIT_REACHED";
1384
+ const isToolUnavailable = code === "KEYLESS_TOOL_NOT_AVAILABLE";
1385
+ const isKeylessAccessUnavailable = code === "KEYLESS_ACCESS_NOT_AVAILABLE";
1386
+ const isKeylessEligibilityUnavailable = code === "KEYLESS_ELIGIBILITY_UNAVAILABLE";
1387
+ const oauthUrl = "https://mcp.firecrawl.dev/v2/mcp-oauth";
1322
1388
  return {
1323
1389
  code,
1324
1390
  request_id: requestId,
1325
1391
  auth_mode: code === "CREDENTIAL_INVALID" ? "credential_error" : "keyless",
1326
- message: code === "CREDENTIAL_INVALID" ? "The supplied Firecrawl credential is invalid or revoked. Replace it or reconnect the account, then retry." : "This tool requires a Firecrawl account or API key. Connect an account or configure Authorization: Bearer <FIRECRAWL_API_KEY>, then retry.",
1327
- available_tools: [...KEYLESS_TOOL_NAMES],
1392
+ message: code === "CREDENTIAL_INVALID" ? "The supplied Firecrawl credential is invalid or revoked. Replace it or reconnect the account, then retry." : isQuotaExhausted ? `The free daily limit for this network has been reached${retryAfterSeconds ? `; try again in about ${retryAfterSeconds} seconds` : ""}. To continue now, connect a Firecrawl account via OAuth or configure an API key.` : isToolUnavailable ? "The free tier includes Search, Scrape, and Parse. This tool needs a connected account; Search, Scrape, and Parse still work, so no action is required." : isKeylessAccessUnavailable ? "Anonymous keyless access is unavailable for this request. To continue, connect a Firecrawl account via OAuth or configure an API key." : isKeylessEligibilityUnavailable ? "The anonymous keyless eligibility check is temporarily unavailable. Retry shortly." : "This tool requires a Firecrawl account or API key. Connect an account or configure Authorization: Bearer <FIRECRAWL_API_KEY>, then retry.",
1393
+ ...isKeylessAccessUnavailable ? {} : { available_tools: [...KEYLESS_TOOL_NAMES] },
1328
1394
  docs_url: "https://docs.firecrawl.dev/mcp-server",
1329
- next_actions: [
1330
- { kind: "connect_account", url: "https://firecrawl.dev/connect/mcp" },
1331
- {
1332
- kind: "configure_api_key",
1333
- header: "Authorization: Bearer <FIRECRAWL_API_KEY>"
1334
- }
1395
+ ...retryAfterSeconds ? { retry_after_seconds: retryAfterSeconds } : {},
1396
+ next_actions: isKeylessEligibilityUnavailable ? [{ kind: "retry_later", after_seconds: 30 }] : isQuotaExhausted || isKeylessAccessUnavailable ? [
1397
+ { kind: "connect_oauth", url: oauthUrl, client_commands: [{ client: "claude-code", command: "claude mcp add --transport http firecrawl https://mcp.firecrawl.dev/v2/mcp-oauth" }] },
1398
+ { kind: "configure_api_key", header: "Authorization: Bearer <FIRECRAWL_API_KEY>" }
1399
+ ] : isToolUnavailable ? [{ kind: "continue_keyless", tools: [...KEYLESS_TOOL_NAMES] }] : [
1400
+ { kind: "connect_oauth", url: oauthUrl },
1401
+ { kind: "configure_api_key", header: "Authorization: Bearer <FIRECRAWL_API_KEY>" }
1335
1402
  ]
1336
1403
  };
1337
1404
  }
1338
- function emitActionLog(toolName, status, session, error, requestId = randomUUID()) {
1405
+ function emitActionLog(toolName, status, session, error, requestId = randomUUID(), code) {
1339
1406
  if (process.env.CLOUD_SERVICE !== "true") return;
1340
1407
  const payload = {
1341
1408
  team_id: session?.teamId,
@@ -1347,20 +1414,23 @@ function emitActionLog(toolName, status, session, error, requestId = randomUUID(
1347
1414
  status,
1348
1415
  request_id: requestId,
1349
1416
  resource: primaryProfile.resourceUrl,
1350
- ...error ? { error_class: error instanceof Error ? error.name : typeof error } : {}
1417
+ ...error ? { error_class: error instanceof Error ? error.name : typeof error } : {},
1418
+ ...code ? { code } : {}
1351
1419
  };
1352
1420
  console.error("[MCP_ACTION]", JSON.stringify(payload));
1353
1421
  const secret = normalizeHeader(process.env.FIRECRAWL_MCP_ACTION_LOG_SECRET);
1354
1422
  const apiUrl = normalizeHeader(process.env.FIRECRAWL_API_URL);
1355
1423
  const endpoint = normalizeHeader(process.env.FIRECRAWL_MCP_ACTION_LOG_URL) ?? (apiUrl ? `${withoutTrailingSlash(apiUrl)}/v2/mcp/action-logs` : void 0);
1356
1424
  if (!secret || !endpoint || !payload.team_id || status === "started") return;
1425
+ const actionLogPayload = { ...payload };
1426
+ delete actionLogPayload.code;
1357
1427
  void fetch(endpoint, {
1358
1428
  method: "POST",
1359
1429
  headers: {
1360
1430
  Authorization: `Bearer ${secret}`,
1361
1431
  "Content-Type": "application/json"
1362
1432
  },
1363
- body: JSON.stringify(payload),
1433
+ body: JSON.stringify(actionLogPayload),
1364
1434
  signal: AbortSignal.timeout(1500)
1365
1435
  }).catch(() => void 0);
1366
1436
  }
@@ -1373,7 +1443,11 @@ function guardHostedTool(tool, { logActions }) {
1373
1443
  beforeValidate: (_args, session) => {
1374
1444
  const code = session?.credentialError ? "CREDENTIAL_INVALID" : isHostedKeylessSession(session) && !keylessTool ? "KEYLESS_TOOL_NOT_AVAILABLE" : void 0;
1375
1445
  if (!code) return void 0;
1376
- const payload = recoveryPayload(code);
1446
+ const requestId = randomUUID();
1447
+ const payload = recoveryPayload(code, requestId);
1448
+ if (logActions) {
1449
+ emitActionLog(tool.name, "error", session, new UserError(String(payload.message), payload), requestId, code);
1450
+ }
1377
1451
  return {
1378
1452
  content: [{ type: "text", text: String(payload.message) }],
1379
1453
  isError: true,
@@ -1392,11 +1466,15 @@ function guardHostedTool(tool, { logActions }) {
1392
1466
  session: invocationSession
1393
1467
  };
1394
1468
  if (invocationSession.credentialError) {
1395
- const payload = recoveryPayload("CREDENTIAL_INVALID", requestId);
1469
+ const code = "CREDENTIAL_INVALID";
1470
+ const payload = recoveryPayload(code, requestId);
1471
+ if (logActions) emitActionLog(tool.name, "error", invocationSession, new UserError(String(payload.message), payload), requestId, code);
1396
1472
  throw new UserError(String(payload.message), payload);
1397
1473
  }
1398
1474
  if (isHostedKeylessSession(invocationSession) && !keylessTool) {
1399
- const payload = recoveryPayload("KEYLESS_TOOL_NOT_AVAILABLE", requestId);
1475
+ const code = "KEYLESS_TOOL_NOT_AVAILABLE";
1476
+ const payload = recoveryPayload(code, requestId);
1477
+ if (logActions) emitActionLog(tool.name, "error", invocationSession, new UserError(String(payload.message), payload), requestId, code);
1400
1478
  throw new UserError(String(payload.message), payload);
1401
1479
  }
1402
1480
  if (!logActions) return execute(args2, invocationContext);
@@ -1465,7 +1543,7 @@ function createClient(apiKey) {
1465
1543
  return new FirecrawlApp(config);
1466
1544
  }
1467
1545
  var ORIGIN = "mcp-fastmcp";
1468
- var ORIGIN_HEADERS2 = { "X-Origin": ORIGIN };
1546
+ var ORIGIN_HEADERS3 = { "X-Origin": ORIGIN };
1469
1547
  var SAFE_MODE = process.env.CLOUD_SERVICE === "true";
1470
1548
  function getClient(session) {
1471
1549
  if (process.env.CLOUD_SERVICE === "true" && !hasCredential(session)) {
@@ -1563,10 +1641,10 @@ function transformScrapeParams(args2) {
1563
1641
  delete out.pdfOptions;
1564
1642
  return out;
1565
1643
  }
1566
- var scrapeParamsSchema = z3.object({
1567
- url: z3.string().url(),
1568
- formats: z3.array(
1569
- z3.enum([
1644
+ var scrapeParamsSchema = z4.object({
1645
+ url: z4.string().url(),
1646
+ formats: z4.array(
1647
+ z4.enum([
1570
1648
  "markdown",
1571
1649
  "html",
1572
1650
  "rawHtml",
@@ -1580,62 +1658,62 @@ var scrapeParamsSchema = z3.object({
1580
1658
  "audio"
1581
1659
  ])
1582
1660
  ).optional(),
1583
- jsonOptions: z3.object({
1584
- prompt: z3.string().optional(),
1585
- schema: z3.record(z3.string(), z3.any()).optional()
1661
+ jsonOptions: z4.object({
1662
+ prompt: z4.string().optional(),
1663
+ schema: z4.record(z4.string(), z4.any()).optional()
1586
1664
  }).optional(),
1587
- queryOptions: z3.object({
1588
- prompt: z3.string().max(1e4),
1589
- mode: z3.enum(["directQuote", "freeform"]).default("freeform")
1665
+ queryOptions: z4.object({
1666
+ prompt: z4.string().max(1e4),
1667
+ mode: z4.enum(["directQuote", "freeform"]).default("freeform")
1590
1668
  }).optional(),
1591
- screenshotOptions: z3.object({
1592
- fullPage: z3.boolean().optional(),
1593
- quality: z3.number().optional(),
1594
- viewport: z3.object({ width: z3.number(), height: z3.number() }).optional()
1669
+ screenshotOptions: z4.object({
1670
+ fullPage: z4.boolean().optional(),
1671
+ quality: z4.number().optional(),
1672
+ viewport: z4.object({ width: z4.number(), height: z4.number() }).optional()
1595
1673
  }).optional(),
1596
- parsers: z3.array(z3.enum(["pdf"])).optional(),
1597
- pdfOptions: z3.object({
1598
- maxPages: z3.number().int().min(1).max(1e4).optional()
1674
+ parsers: z4.array(z4.enum(["pdf"])).optional(),
1675
+ pdfOptions: z4.object({
1676
+ maxPages: z4.number().int().min(1).max(1e4).optional()
1599
1677
  }).optional(),
1600
- onlyMainContent: z3.boolean().optional(),
1601
- redactPII: z3.boolean().optional(),
1602
- includeTags: z3.array(z3.string()).optional(),
1603
- excludeTags: z3.array(z3.string()).optional(),
1604
- waitFor: z3.number().optional(),
1678
+ onlyMainContent: z4.boolean().optional(),
1679
+ redactPII: z4.boolean().optional(),
1680
+ includeTags: z4.array(z4.string()).optional(),
1681
+ excludeTags: z4.array(z4.string()).optional(),
1682
+ waitFor: z4.number().optional(),
1605
1683
  ...SAFE_MODE ? {} : {
1606
- actions: z3.array(
1607
- z3.object({
1608
- type: z3.enum(allowedActionTypes),
1609
- selector: z3.string().optional(),
1610
- milliseconds: z3.number().optional(),
1611
- text: z3.string().optional(),
1612
- key: z3.string().optional(),
1613
- direction: z3.enum(["up", "down"]).optional(),
1614
- script: z3.string().optional(),
1615
- fullPage: z3.boolean().optional()
1684
+ actions: z4.array(
1685
+ z4.object({
1686
+ type: z4.enum(allowedActionTypes),
1687
+ selector: z4.string().optional(),
1688
+ milliseconds: z4.number().optional(),
1689
+ text: z4.string().optional(),
1690
+ key: z4.string().optional(),
1691
+ direction: z4.enum(["up", "down"]).optional(),
1692
+ script: z4.string().optional(),
1693
+ fullPage: z4.boolean().optional()
1616
1694
  })
1617
1695
  ).optional()
1618
1696
  },
1619
- mobile: z3.boolean().optional(),
1620
- skipTlsVerification: z3.boolean().optional(),
1621
- removeBase64Images: z3.boolean().optional(),
1622
- location: z3.object({
1623
- country: z3.string().optional(),
1624
- languages: z3.array(z3.string()).optional()
1697
+ mobile: z4.boolean().optional(),
1698
+ skipTlsVerification: z4.boolean().optional(),
1699
+ removeBase64Images: z4.boolean().optional(),
1700
+ location: z4.object({
1701
+ country: z4.string().optional(),
1702
+ languages: z4.array(z4.string()).optional()
1625
1703
  }).optional(),
1626
- storeInCache: z3.boolean().optional(),
1627
- zeroDataRetention: z3.boolean().optional(),
1628
- maxAge: z3.number().optional(),
1629
- lockdown: z3.boolean().optional(),
1630
- proxy: z3.enum(["basic", "stealth", "enhanced", "auto"]).optional(),
1631
- profile: z3.object({
1632
- name: z3.string(),
1633
- saveChanges: z3.boolean().optional()
1704
+ storeInCache: z4.boolean().optional(),
1705
+ zeroDataRetention: z4.boolean().optional(),
1706
+ maxAge: z4.number().optional(),
1707
+ lockdown: z4.boolean().optional(),
1708
+ proxy: z4.enum(["basic", "stealth", "enhanced", "auto"]).optional(),
1709
+ profile: z4.object({
1710
+ name: z4.string(),
1711
+ saveChanges: z4.boolean().optional()
1634
1712
  }).optional()
1635
1713
  });
1636
- var parseOptionParamsSchema = z3.object({
1637
- formats: z3.array(
1638
- z3.enum([
1714
+ var parseOptionParamsSchema = z4.object({
1715
+ formats: z4.array(
1716
+ z4.enum([
1639
1717
  "markdown",
1640
1718
  "html",
1641
1719
  "rawHtml",
@@ -1645,48 +1723,48 @@ var parseOptionParamsSchema = z3.object({
1645
1723
  "query"
1646
1724
  ])
1647
1725
  ).optional(),
1648
- jsonOptions: z3.object({
1649
- prompt: z3.string().optional(),
1650
- schema: z3.record(z3.string(), z3.any()).optional()
1726
+ jsonOptions: z4.object({
1727
+ prompt: z4.string().optional(),
1728
+ schema: z4.record(z4.string(), z4.any()).optional()
1651
1729
  }).optional(),
1652
- queryOptions: z3.object({
1653
- prompt: z3.string().max(1e4),
1654
- mode: z3.enum(["directQuote", "freeform"]).default("freeform")
1730
+ queryOptions: z4.object({
1731
+ prompt: z4.string().max(1e4),
1732
+ mode: z4.enum(["directQuote", "freeform"]).default("freeform")
1655
1733
  }).optional(),
1656
- parsers: z3.array(z3.enum(["pdf"])).optional(),
1657
- pdfOptions: z3.object({
1658
- maxPages: z3.number().int().min(1).max(1e4).optional()
1734
+ parsers: z4.array(z4.enum(["pdf"])).optional(),
1735
+ pdfOptions: z4.object({
1736
+ maxPages: z4.number().int().min(1).max(1e4).optional()
1659
1737
  }).optional(),
1660
- onlyMainContent: z3.boolean().optional(),
1661
- redactPII: z3.boolean().optional(),
1662
- includeTags: z3.array(z3.string()).optional(),
1663
- excludeTags: z3.array(z3.string()).optional(),
1664
- removeBase64Images: z3.boolean().optional(),
1665
- skipTlsVerification: z3.boolean().optional(),
1666
- storeInCache: z3.boolean().optional(),
1667
- zeroDataRetention: z3.boolean().optional(),
1668
- maxAge: z3.number().optional(),
1669
- proxy: z3.enum(["basic", "auto"]).optional()
1738
+ onlyMainContent: z4.boolean().optional(),
1739
+ redactPII: z4.boolean().optional(),
1740
+ includeTags: z4.array(z4.string()).optional(),
1741
+ excludeTags: z4.array(z4.string()).optional(),
1742
+ removeBase64Images: z4.boolean().optional(),
1743
+ skipTlsVerification: z4.boolean().optional(),
1744
+ storeInCache: z4.boolean().optional(),
1745
+ zeroDataRetention: z4.boolean().optional(),
1746
+ maxAge: z4.number().optional(),
1747
+ proxy: z4.enum(["basic", "auto"]).optional()
1670
1748
  });
1671
1749
  var localParseParamsSchema = parseOptionParamsSchema.extend({
1672
- filePath: z3.string().min(1).describe(
1750
+ filePath: z4.string().min(1).describe(
1673
1751
  "Absolute or relative path to a local file to parse. Supported: .html, .htm, .pdf, .docx, .doc, .odt, .rtf, .xlsx, .xls"
1674
1752
  ),
1675
- contentType: z3.string().optional().describe(
1753
+ contentType: z4.string().optional().describe(
1676
1754
  "Optional MIME type override. If omitted, the server infers the file kind from the extension."
1677
1755
  )
1678
1756
  });
1679
1757
  var hostedParseParamsSchema = parseOptionParamsSchema.extend({
1680
- filePath: z3.string().min(1).optional().describe(
1758
+ filePath: z4.string().min(1).optional().describe(
1681
1759
  "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."
1682
1760
  ),
1683
- uploadRef: z3.string().min(1).optional().describe(
1761
+ uploadRef: z4.string().min(1).optional().describe(
1684
1762
  "Phase 2 only: short-lived upload reference returned by phase 1 after the local PUT upload completes."
1685
1763
  ),
1686
- contentType: z3.string().optional().describe(
1764
+ contentType: z4.string().optional().describe(
1687
1765
  "Phase 1 MIME type override. If omitted, the server infers it from the file extension without reading the file."
1688
1766
  ),
1689
- declaredSizeBytes: z3.number().int().positive().optional().describe(
1767
+ declaredSizeBytes: z4.number().int().positive().optional().describe(
1690
1768
  "Optional phase 1 size declaration. Hosted MCP does not stat the file; provide this only if the caller already knows it."
1691
1769
  )
1692
1770
  }).superRefine((value, ctx) => {
@@ -1694,7 +1772,7 @@ var hostedParseParamsSchema = parseOptionParamsSchema.extend({
1694
1772
  const hasUploadRef = typeof value.uploadRef === "string" && value.uploadRef.length > 0;
1695
1773
  if (hasFilePath === hasUploadRef) {
1696
1774
  ctx.addIssue({
1697
- code: z3.ZodIssueCode.custom,
1775
+ code: z4.ZodIssueCode.custom,
1698
1776
  message: "Hosted firecrawl_parse requires exactly one of filePath (phase 1) or uploadRef (phase 2).",
1699
1777
  path: hasFilePath && hasUploadRef ? ["uploadRef"] : ["filePath"]
1700
1778
  });
@@ -1953,13 +2031,13 @@ Enumerate URLs indexed under one website through Firecrawl without fetching each
1953
2031
 
1954
2032
  Returns matching URLs rather than page bodies. Retrieve one page with \`firecrawl_scrape\`; collect content across multiple pages with \`firecrawl_crawl\`.
1955
2033
  `,
1956
- parameters: z3.object({
1957
- url: z3.string().url(),
1958
- search: z3.string().optional(),
1959
- sitemap: z3.enum(["include", "skip", "only"]).optional(),
1960
- includeSubdomains: z3.boolean().optional(),
1961
- limit: z3.number().optional(),
1962
- ignoreQueryParameters: z3.boolean().optional()
2034
+ parameters: z4.object({
2035
+ url: z4.string().url(),
2036
+ search: z4.string().optional(),
2037
+ sitemap: z4.enum(["include", "skip", "only"]).optional(),
2038
+ includeSubdomains: z4.boolean().optional(),
2039
+ limit: z4.number().optional(),
2040
+ ignoreQueryParameters: z4.boolean().optional()
1963
2041
  }),
1964
2042
  execute: async (args2, { session, log }) => {
1965
2043
  const { url, ...options } = args2;
@@ -1985,11 +2063,13 @@ server.addTool({
1985
2063
  // Query-only; no destructive side effects on external entities.
1986
2064
  },
1987
2065
  description: `
1988
- Search web, news, or image sources and return ranked results. Operators include quoted phrases, \`-term\`, \`site:host\`, \`inurl:term\`, \`intitle:term\`, and \`related:host\`; the set is non-exhaustive. \`includeDomains\` and \`excludeDomains\` are mutually exclusive hostname filters; categories limit results to GitHub, research, or PDF sources.
2066
+ Search web, news, or image sources and return ranked results. Operators include quoted phrases, \`-term\`, \`site:host\`, \`inurl:term\`, \`intitle:term\`, and \`related:host\`; the set is non-exhaustive. \`includeDomains\` and \`excludeDomains\` are mutually exclusive hostname filters; categories limit results to GitHub, research, PDF, or developer sources.
1989
2067
 
1990
- \`scrapeOptions\` can attach extracted page content. Returns source-type result groups, an \`id\` for optional search feedback, and usage metadata.
2068
+ For a programming question, add \`categories: ["developer"]\`. It searches an index of GitHub issues, merged pull requests, repository READMEs, and curated documentation sites, and returns the hits in \`data.developer\` beside the web results.
2069
+
2070
+ \`scrapeOptions\` can attach extracted page content. Returns source-type result groups and usage metadata. Authenticated responses can include an \`id\` for optional search feedback.
1991
2071
  `,
1992
- parameters: z3.object({
2072
+ parameters: z4.object({
1993
2073
  ...searchToolBaseFields,
1994
2074
  scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional()
1995
2075
  }).refine(searchDomainsAreExclusive, SEARCH_DOMAINS_CONFLICT_MESSAGE),
@@ -2019,7 +2099,9 @@ Search web, news, or image sources and return ranked results. Operators include
2019
2099
  };
2020
2100
  if (isKeylessMode(session)) {
2021
2101
  const json = await keylessPost("/v2/search", searchBody, session);
2022
- return asText2(json ?? {});
2102
+ const keylessResponse = { ...json ?? {} };
2103
+ delete keylessResponse.id;
2104
+ return asText2(keylessResponse);
2023
2105
  }
2024
2106
  const client = getClient(session);
2025
2107
  const httpRes = await client.http.post("/v2/search", searchBody);
@@ -2036,25 +2118,35 @@ function resolveApiBaseUrl() {
2036
2118
  function extractClientIp(request) {
2037
2119
  return extractSingleTrustedClientIp(request?.headers?.["x-forwarded-for"]);
2038
2120
  }
2121
+ function keylessQuotaReason(reason) {
2122
+ return reason === "requests" || reason === "credits";
2123
+ }
2039
2124
  async function keylessEligible(clientIp) {
2040
2125
  const secret = process.env.KEYLESS_PROXY_SECRET;
2041
- if (!secret) return false;
2126
+ if (!secret) return { eligible: false, unavailable: true };
2042
2127
  try {
2043
2128
  const response = await fetch(
2044
2129
  `${resolveApiBaseUrl()}/v2/keyless/eligibility`,
2045
2130
  {
2046
2131
  headers: {
2047
- ...ORIGIN_HEADERS2,
2132
+ ...ORIGIN_HEADERS3,
2048
2133
  "x-firecrawl-keyless-ip": clientIp,
2049
2134
  "x-firecrawl-keyless-secret": secret
2050
2135
  }
2051
2136
  }
2052
2137
  );
2053
- if (!response.ok) return false;
2054
- const json = await response.json().catch(() => ({}));
2055
- return json?.eligible === true;
2138
+ if (!response.ok) return { eligible: false, unavailable: true };
2139
+ const json = await response.json().catch(() => null);
2140
+ if (typeof json?.eligible !== "boolean") {
2141
+ return { eligible: false, unavailable: true };
2142
+ }
2143
+ return {
2144
+ eligible: json?.eligible === true,
2145
+ ...typeof json?.reason === "string" ? { reason: json.reason } : {},
2146
+ ...Number.isFinite(json?.retryAfterSeconds) && json.retryAfterSeconds > 0 ? { retryAfterSeconds: json.retryAfterSeconds } : {}
2147
+ };
2056
2148
  } catch {
2057
- return false;
2149
+ return { eligible: false, unavailable: true };
2058
2150
  }
2059
2151
  }
2060
2152
  function isKeylessMode(session) {
@@ -2065,15 +2157,18 @@ function isKeylessMode(session) {
2065
2157
  return !process.env.FIRECRAWL_API_URL;
2066
2158
  }
2067
2159
  async function keylessPost(path2, body, session) {
2068
- if (isHostedKeylessSession(session) && (!session?.keylessClientIp || !await keylessEligible(session.keylessClientIp))) {
2069
- const payload = recoveryPayload(
2070
- "KEYLESS_ACCESS_NOT_AVAILABLE",
2071
- session?.requestId
2072
- );
2073
- throw new UserError(String(payload.message), payload);
2160
+ if (isHostedKeylessSession(session)) {
2161
+ const eligibility = session?.keylessClientIp ? await keylessEligible(session.keylessClientIp) : { eligible: false };
2162
+ if (!eligibility.eligible) {
2163
+ const code = eligibility.unavailable ? "KEYLESS_ELIGIBILITY_UNAVAILABLE" : keylessQuotaReason(eligibility.reason) ? "KEYLESS_QUOTA_EXHAUSTED" : "KEYLESS_ACCESS_NOT_AVAILABLE";
2164
+ const payload = recoveryPayload(code, session?.requestId, {
2165
+ retryAfterSeconds: eligibility.retryAfterSeconds
2166
+ });
2167
+ throw new UserError(String(payload.message), payload);
2168
+ }
2074
2169
  }
2075
2170
  const headers = {
2076
- ...ORIGIN_HEADERS2,
2171
+ ...ORIGIN_HEADERS3,
2077
2172
  "Content-Type": "application/json"
2078
2173
  };
2079
2174
  if (session?.keylessClientIp && process.env.KEYLESS_PROXY_SECRET) {
@@ -2087,11 +2182,11 @@ async function keylessPost(path2, body, session) {
2087
2182
  });
2088
2183
  const json = await response.json().catch(() => ({}));
2089
2184
  if (!response.ok) {
2090
- if (isHostedKeylessSession(session) && [401, 402, 429].includes(response.status)) {
2091
- const payload = recoveryPayload(
2092
- "KEYLESS_QUOTA_EXHAUSTED",
2093
- session?.requestId
2094
- );
2185
+ if (isKeylessMode(session) && response.status === 429) {
2186
+ const code = keylessQuotaReason(json?.reason) ? "KEYLESS_QUOTA_EXHAUSTED" : "KEYLESS_LIMIT_REACHED";
2187
+ const payload = recoveryPayload(code, session?.requestId, {
2188
+ retryAfterSeconds: Number.isFinite(json?.retry_after_seconds) && json.retry_after_seconds > 0 ? json.retry_after_seconds : void 0
2189
+ });
2095
2190
  throw new UserError(String(payload.message), payload);
2096
2191
  }
2097
2192
  throw new Error(
@@ -2103,7 +2198,7 @@ async function keylessPost(path2, body, session) {
2103
2198
  async function getCrawlStatusWithOrigin(client, jobId) {
2104
2199
  const res = await client.http.get(
2105
2200
  `/v2/crawl/${encodeURIComponent(jobId)}`,
2106
- ORIGIN_HEADERS2
2201
+ ORIGIN_HEADERS3
2107
2202
  );
2108
2203
  const body = res?.data ?? {};
2109
2204
  const initialDocs = Array.isArray(body.data) ? body.data : [];
@@ -2122,7 +2217,7 @@ async function getCrawlStatusWithOrigin(client, jobId) {
2122
2217
  const docs = initialDocs.slice();
2123
2218
  let current = body.next;
2124
2219
  while (current) {
2125
- const pageRes = await client.http.get(current, ORIGIN_HEADERS2);
2220
+ const pageRes = await client.http.get(current, ORIGIN_HEADERS3);
2126
2221
  const payload = pageRes?.data ?? {};
2127
2222
  if (!payload.success) break;
2128
2223
  const pageData = Array.isArray(payload.data) ? payload.data : payload.data?.pages || [];
@@ -2155,17 +2250,17 @@ async function waitForCrawlCompletionWithOrigin(client, jobId, pollInterval = 2,
2155
2250
  );
2156
2251
  }
2157
2252
  }
2158
- var feedbackIssueSchema = z3.string().trim().min(1).max(80).regex(
2253
+ var feedbackIssueSchema = z4.string().trim().min(1).max(80).regex(
2159
2254
  /^[a-z0-9][a-z0-9_-]*$/,
2160
2255
  "Issue codes must use lowercase letters, numbers, underscores, or hyphens"
2161
2256
  );
2162
- var valuableSourceSchema = z3.object({
2163
- url: z3.string().url(),
2164
- reason: z3.string().max(1e3).optional()
2257
+ var valuableSourceSchema = z4.object({
2258
+ url: z4.string().url(),
2259
+ reason: z4.string().max(1e3).optional()
2165
2260
  });
2166
- var missingContentSchema = z3.object({
2167
- topic: z3.string().min(1, "topic must not be empty").max(200, "topic must be 200 characters or fewer"),
2168
- description: z3.string().max(2e3).optional()
2261
+ var missingContentSchema = z4.object({
2262
+ topic: z4.string().min(1, "topic must not be empty").max(200, "topic must be 200 characters or fewer"),
2263
+ description: z4.string().max(2e3).optional()
2169
2264
  });
2170
2265
  var FEEDBACK_DISABLED_VALUES = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
2171
2266
  function feedbackEnvEnabled(...keys) {
@@ -2186,7 +2281,7 @@ if (SEARCH_FEEDBACK_DISABLED) {
2186
2281
  "[firecrawl-mcp] Search feedback tool disabled by FIRECRAWL_NO_SEARCH_FEEDBACK; firecrawl_search_feedback will not be registered."
2187
2282
  );
2188
2283
  }
2189
- if (!SEARCH_FEEDBACK_DISABLED) {
2284
+ if (!SEARCH_FEEDBACK_DISABLED && !isLocalKeylessStartup()) {
2190
2285
  server.addTool({
2191
2286
  name: "firecrawl_search_feedback",
2192
2287
  annotations: {
@@ -2199,28 +2294,28 @@ if (!SEARCH_FEEDBACK_DISABLED) {
2199
2294
  // Additive only; records feedback and may refund credits, does not delete data.
2200
2295
  },
2201
2296
  description: `
2202
- Records schema-validated quality feedback for a prior \`firecrawl_search\` UUID \`searchId\`. A \`good\` rating requires a valuable source, \`partial\` a valuable source or missing topic, and \`bad\` a missing topic or query suggestion; caps are 50 \`valuableSources\` and 20 \`missingContent\` entries.
2297
+ Records schema-validated quality feedback for a prior \`firecrawl_search\` UUID \`searchId\`. A \`good\` rating requires a valuable source, \`partial\` a valuable source or at least one \`missingContent\` entry, and \`bad\` at least one \`missingContent\` entry or a query suggestion; caps are 50 \`valuableSources\` and 20 \`missingContent\` entries.
2203
2298
 
2204
- Eligibility is limited to successful searches within the feedback age window. The record is idempotent per search ID. Returns submission and daily-cap status with accounting fields.
2299
+ Eligibility is limited to successful searches within the feedback age window. The record is idempotent per search ID. Eligible first feedback for a search can refund 1 credit; refunds are subject to the team's daily cap. The response reports whether a refund was applied, along with submission and daily-cap status.
2205
2300
  `,
2206
- parameters: z3.object({
2207
- searchId: z3.string().uuid("searchId must be the UUID returned by firecrawl_search"),
2208
- rating: z3.enum(["good", "bad", "partial"]),
2209
- valuableSources: z3.array(
2210
- z3.object({
2211
- url: z3.string().url(),
2212
- reason: z3.string().max(1e3).optional()
2301
+ parameters: z4.object({
2302
+ searchId: z4.string().uuid("searchId must be the UUID returned by firecrawl_search"),
2303
+ rating: z4.enum(["good", "bad", "partial"]),
2304
+ valuableSources: z4.array(
2305
+ z4.object({
2306
+ url: z4.string().url(),
2307
+ reason: z4.string().max(1e3).optional()
2213
2308
  })
2214
2309
  ).max(50).optional(),
2215
- missingContent: z3.array(
2216
- z3.object({
2217
- topic: z3.string().min(1, "topic must not be empty").max(200, "topic must be 200 characters or fewer"),
2218
- description: z3.string().max(2e3).optional()
2310
+ missingContent: z4.array(
2311
+ z4.object({
2312
+ topic: z4.string().min(1, "topic must not be empty").max(200, "topic must be 200 characters or fewer"),
2313
+ description: z4.string().max(2e3).optional()
2219
2314
  })
2220
2315
  ).max(20).optional().describe(
2221
2316
  "Array of specific pieces of content the agent expected to find but did not. One entry per distinct topic. Each entry has a short `topic` and optional longer `description`."
2222
2317
  ),
2223
- querySuggestions: z3.string().max(2e3).optional()
2318
+ querySuggestions: z4.string().max(2e3).optional()
2224
2319
  }),
2225
2320
  execute: async (args2, { session, log }) => {
2226
2321
  const {
@@ -2246,7 +2341,7 @@ Eligibility is limited to successful searches within the feedback age window. Th
2246
2341
  }
2247
2342
  if (querySuggestions) body.querySuggestions = querySuggestions;
2248
2343
  const headers = {
2249
- ...ORIGIN_HEADERS2,
2344
+ ...ORIGIN_HEADERS3,
2250
2345
  "Content-Type": "application/json"
2251
2346
  };
2252
2347
  const credential = credentialForOutboundRequest(session);
@@ -2290,7 +2385,7 @@ if (ENDPOINT_FEEDBACK_DISABLED) {
2290
2385
  "[firecrawl-mcp] Endpoint feedback tool disabled by FIRECRAWL_NO_ENDPOINT_FEEDBACK; firecrawl_feedback will not be registered."
2291
2386
  );
2292
2387
  }
2293
- if (!ENDPOINT_FEEDBACK_DISABLED) {
2388
+ if (!ENDPOINT_FEEDBACK_DISABLED && !isLocalKeylessStartup()) {
2294
2389
  server.addTool({
2295
2390
  name: "firecrawl_feedback",
2296
2391
  annotations: {
@@ -2307,19 +2402,19 @@ Submit concise quality feedback for a completed search, scrape, parse, or map jo
2307
2402
 
2308
2403
  Returns submission status, feedback ID, and accounting fields.
2309
2404
  `,
2310
- parameters: z3.object({
2311
- endpoint: z3.enum(["search", "scrape", "parse", "map"]),
2312
- jobId: z3.string().uuid("jobId must be the UUID returned by Firecrawl"),
2313
- rating: z3.enum(["good", "bad", "partial"]),
2314
- issues: z3.array(feedbackIssueSchema).max(20).optional(),
2315
- tags: z3.array(feedbackIssueSchema).max(20).optional(),
2316
- note: z3.string().max(4e3).optional(),
2317
- valuableSources: z3.array(valuableSourceSchema).max(50).optional(),
2318
- missingContent: z3.array(missingContentSchema).max(50).optional(),
2319
- querySuggestions: z3.string().max(2e3).optional(),
2320
- url: z3.string().url().optional(),
2321
- pageNumbers: z3.array(z3.number().int().positive()).max(100).optional(),
2322
- metadata: z3.record(z3.string(), z3.unknown()).optional()
2405
+ parameters: z4.object({
2406
+ endpoint: z4.enum(["search", "scrape", "parse", "map"]),
2407
+ jobId: z4.string().uuid("jobId must be the UUID returned by Firecrawl"),
2408
+ rating: z4.enum(["good", "bad", "partial"]),
2409
+ issues: z4.array(feedbackIssueSchema).max(20).optional(),
2410
+ tags: z4.array(feedbackIssueSchema).max(20).optional(),
2411
+ note: z4.string().max(4e3).optional(),
2412
+ valuableSources: z4.array(valuableSourceSchema).max(50).optional(),
2413
+ missingContent: z4.array(missingContentSchema).max(50).optional(),
2414
+ querySuggestions: z4.string().max(2e3).optional(),
2415
+ url: z4.string().url().optional(),
2416
+ pageNumbers: z4.array(z4.number().int().positive()).max(100).optional(),
2417
+ metadata: z4.record(z4.string(), z4.unknown()).optional()
2323
2418
  }),
2324
2419
  execute: async (args2, { session, log }) => {
2325
2420
  const {
@@ -2338,7 +2433,7 @@ Returns submission status, feedback ID, and accounting fields.
2338
2433
  } = args2;
2339
2434
  const apiBase = resolveApiBaseUrl();
2340
2435
  const headers = {
2341
- ...ORIGIN_HEADERS2,
2436
+ ...ORIGIN_HEADERS3,
2342
2437
  "Content-Type": "application/json"
2343
2438
  };
2344
2439
  const credential = credentialForOutboundRequest(session);
@@ -2408,25 +2503,25 @@ Start a multi-page crawl at a website URL, poll it to a terminal state, and retu
2408
2503
 
2409
2504
  Crawl results can be large; use conservative limits when full-site coverage is unnecessary. Webhooks and interactive scrape actions are unavailable in safe mode. Returns the crawl ID, status, and page data.
2410
2505
  `,
2411
- parameters: z3.object({
2412
- url: z3.string(),
2413
- prompt: z3.string().optional(),
2414
- excludePaths: z3.array(z3.string()).optional(),
2415
- includePaths: z3.array(z3.string()).optional(),
2416
- maxDiscoveryDepth: z3.number().optional(),
2417
- sitemap: z3.enum(["skip", "include", "only"]).optional(),
2418
- limit: z3.number().optional(),
2419
- allowExternalLinks: z3.boolean().optional(),
2420
- allowSubdomains: z3.boolean().optional(),
2421
- crawlEntireDomain: z3.boolean().optional(),
2422
- delay: z3.number().optional(),
2423
- maxConcurrency: z3.number().optional(),
2506
+ parameters: z4.object({
2507
+ url: z4.string(),
2508
+ prompt: z4.string().optional(),
2509
+ excludePaths: z4.array(z4.string()).optional(),
2510
+ includePaths: z4.array(z4.string()).optional(),
2511
+ maxDiscoveryDepth: z4.number().optional(),
2512
+ sitemap: z4.enum(["skip", "include", "only"]).optional(),
2513
+ limit: z4.number().optional(),
2514
+ allowExternalLinks: z4.boolean().optional(),
2515
+ allowSubdomains: z4.boolean().optional(),
2516
+ crawlEntireDomain: z4.boolean().optional(),
2517
+ delay: z4.number().optional(),
2518
+ maxConcurrency: z4.number().optional(),
2424
2519
  ...SAFE_MODE ? {} : {
2425
- webhook: z3.string().optional(),
2426
- webhookHeaders: z3.record(z3.string(), z3.string()).optional()
2520
+ webhook: z4.string().optional(),
2521
+ webhookHeaders: z4.record(z4.string(), z4.string()).optional()
2427
2522
  },
2428
- deduplicateSimilarURLs: z3.boolean().optional(),
2429
- ignoreQueryParameters: z3.boolean().optional(),
2523
+ deduplicateSimilarURLs: z4.boolean().optional(),
2524
+ ignoreQueryParameters: z4.boolean().optional(),
2430
2525
  scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional()
2431
2526
  }),
2432
2527
  execute: async (args2, { session, log }) => {
@@ -2479,7 +2574,7 @@ server.addTool({
2479
2574
  description: `
2480
2575
  Retrieve the current status, progress, and available results for an existing crawl ID. This only reads Firecrawl job state and does not start or modify the crawl.
2481
2576
  `,
2482
- parameters: z3.object({ id: z3.string() }),
2577
+ parameters: z4.object({ id: z4.string() }),
2483
2578
  execute: async (args2, { session }) => {
2484
2579
  const client = getClient(session);
2485
2580
  const id = args2.id;
@@ -2503,13 +2598,13 @@ Extract structured information from one or more URLs with an optional natural-la
2503
2598
 
2504
2599
  Use this for a defined structured result rather than full page content. Returns data shaped by the supplied schema or prompt.
2505
2600
  `,
2506
- parameters: z3.object({
2507
- urls: z3.array(z3.string()),
2508
- prompt: z3.string().optional(),
2509
- schema: z3.record(z3.string(), z3.any()).optional(),
2510
- allowExternalLinks: z3.boolean().optional(),
2511
- enableWebSearch: z3.boolean().optional(),
2512
- includeSubdomains: z3.boolean().optional()
2601
+ parameters: z4.object({
2602
+ urls: z4.array(z4.string()),
2603
+ prompt: z4.string().optional(),
2604
+ schema: z4.record(z4.string(), z4.any()).optional(),
2605
+ allowExternalLinks: z4.boolean().optional(),
2606
+ enableWebSearch: z4.boolean().optional(),
2607
+ includeSubdomains: z4.boolean().optional()
2513
2608
  }),
2514
2609
  execute: async (args2, { session, log }) => {
2515
2610
  const client = getClient(session);
@@ -2546,10 +2641,10 @@ Start an asynchronous web research job from a prompt, optional seed URLs, and an
2546
2641
 
2547
2642
  This call returns only a job ID, not the research result. Read the job with \`firecrawl_agent_status\` until it reaches \`completed\` or \`failed\`; research commonly takes several minutes. If the job cannot finish within the task's available time, \`firecrawl_search\` and \`firecrawl_scrape\` can gather evidence synchronously.
2548
2643
  `,
2549
- parameters: z3.object({
2550
- prompt: z3.string().min(1).max(1e4),
2551
- urls: z3.array(z3.string().url()).optional(),
2552
- schema: z3.record(z3.string(), z3.any()).optional()
2644
+ parameters: z4.object({
2645
+ prompt: z4.string().min(1).max(1e4),
2646
+ urls: z4.array(z4.string().url()).optional(),
2647
+ schema: z4.record(z4.string(), z4.any()).optional()
2553
2648
  }),
2554
2649
  execute: async (args2, { session, log }) => {
2555
2650
  const client = getClient(session);
@@ -2586,14 +2681,14 @@ Retrieve progress or final results for a \`firecrawl_agent\` job ID. A \`process
2586
2681
 
2587
2682
  Returns job status, progress information, and result data when completed.
2588
2683
  `,
2589
- parameters: z3.object({ id: z3.string() }),
2684
+ parameters: z4.object({ id: z4.string() }),
2590
2685
  execute: async (args2, { session, log }) => {
2591
2686
  const client = getClient(session);
2592
2687
  const { id } = args2;
2593
2688
  log.info("Checking agent status", { id });
2594
2689
  const res = await client.http.get(
2595
2690
  `/v2/agent/${encodeURIComponent(id)}`,
2596
- ORIGIN_HEADERS2
2691
+ ORIGIN_HEADERS3
2597
2692
  );
2598
2693
  return asText2(res?.data ?? {});
2599
2694
  }
@@ -2614,13 +2709,13 @@ Open or reuse a live browser session to navigate a page, click controls, fill fi
2614
2709
 
2615
2710
  This acts on the live site, so actions such as form submission can create persistent external side effects. Returns execution output, stdout/stderr, exit status, and session viewing URLs.
2616
2711
  `,
2617
- parameters: z3.object({
2618
- scrapeId: z3.string().trim().min(1).optional(),
2619
- url: z3.string().trim().url().optional(),
2620
- prompt: z3.string().trim().min(1).optional(),
2621
- code: z3.string().trim().min(1).optional(),
2622
- language: z3.enum(["bash", "python", "node"]).optional(),
2623
- timeout: z3.number().min(1).max(300).optional(),
2712
+ parameters: z4.object({
2713
+ scrapeId: z4.string().trim().min(1).optional(),
2714
+ url: z4.string().trim().url().optional(),
2715
+ prompt: z4.string().trim().min(1).optional(),
2716
+ code: z4.string().trim().min(1).optional(),
2717
+ language: z4.enum(["bash", "python", "node"]).optional(),
2718
+ timeout: z4.number().min(1).max(300).optional(),
2624
2719
  scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional()
2625
2720
  }).refine((data) => Boolean(data.scrapeId) !== Boolean(data.url), {
2626
2721
  message: "Provide either 'url' (interact directly) or 'scrapeId' (reuse a previous scrape), not both."
@@ -2697,8 +2792,8 @@ server.addTool({
2697
2792
  description: `
2698
2793
  Stop the live interact session associated with a \`scrapeId\` and release its resources. Returns a success confirmation.
2699
2794
  `,
2700
- parameters: z3.object({
2701
- scrapeId: z3.string()
2795
+ parameters: z4.object({
2796
+ scrapeId: z4.string()
2702
2797
  }),
2703
2798
  execute: async (args2, { session, log }) => {
2704
2799
  const client = getClient(session);
@@ -2706,7 +2801,7 @@ Stop the live interact session associated with a \`scrapeId\` and release its re
2706
2801
  log.info("Stopping interact session", { scrapeId });
2707
2802
  const res = await client.http.delete(
2708
2803
  `/v2/scrape/${encodeURIComponent(scrapeId)}/interact`,
2709
- ORIGIN_HEADERS2
2804
+ ORIGIN_HEADERS3
2710
2805
  );
2711
2806
  return asText2(res?.data ?? {});
2712
2807
  }
@@ -2758,7 +2853,7 @@ Set \`redactPII\` to request redaction of personally identifiable information in
2758
2853
  });
2759
2854
  form.append("file", blob, filename);
2760
2855
  form.append("options", JSON.stringify(optionsPayload));
2761
- const headers = { ...ORIGIN_HEADERS2 };
2856
+ const headers = { ...ORIGIN_HEADERS3 };
2762
2857
  const credential = credentialForOutboundRequest(session);
2763
2858
  if (credential) {
2764
2859
  headers["Authorization"] = `Bearer ${credential}`;
@@ -2797,11 +2892,13 @@ function registerMarketplaceSearchTool(registrar, getClientFn) {
2797
2892
  destructiveHint: false
2798
2893
  },
2799
2894
  description: `
2800
- Search web and specialized indexes, returning ranked results. Operators include quoted phrases, \`-term\`, \`site:host\`, \`inurl:term\`, \`intitle:term\`, and \`related:host\`; the set is non-exhaustive. \`includeDomains\` and \`excludeDomains\` are mutually exclusive hostname filters; categories limit result types to \`github\`, \`research\`, or \`pdf\`.
2895
+ Search web and specialized indexes, returning ranked results. Operators include quoted phrases, \`-term\`, \`site:host\`, \`inurl:term\`, \`intitle:term\`, and \`related:host\`; the set is non-exhaustive. \`includeDomains\` and \`excludeDomains\` are mutually exclusive hostname filters; categories limit result types to \`github\`, \`research\`, \`pdf\`, or \`developer\`.
2896
+
2897
+ For a programming question, add \`categories: ["developer"]\`. It searches an index of GitHub issues, merged pull requests, repository READMEs, and curated documentation sites, and returns the hits in \`data.developer\` beside the web results.
2801
2898
 
2802
2899
  Returns \`{ success, data, id, creditsUsed }\`, with source arrays in \`data\`.
2803
2900
  `,
2804
- parameters: z3.object({ ...searchToolBaseFields }).strict().refine(searchDomainsAreExclusive, SEARCH_DOMAINS_CONFLICT_MESSAGE),
2901
+ parameters: z4.object({ ...searchToolBaseFields }).strict().refine(searchDomainsAreExclusive, SEARCH_DOMAINS_CONFLICT_MESSAGE),
2805
2902
  execute: async (args2, { session, log }) => {
2806
2903
  const {
2807
2904
  query,
@@ -2862,6 +2959,12 @@ if (process.env.CLOUD_SERVICE === "true" || process.env.SSE_LOCAL === "true" ||
2862
2959
  }
2863
2960
  registerMonitorTools(server);
2864
2961
  registerResearchTools(server, getClient);
2962
+ registerDeveloperTools(server, getClient);
2963
+ if (process.env.CLOUD_SERVICE === "true" && primaryProfile.allowKeyless && !normalizeHeader(process.env.KEYLESS_PROXY_SECRET)) {
2964
+ console.warn(
2965
+ "[firecrawl-mcp] KEYLESS_PROXY_SECRET is missing; keyless requests will be unavailable and /ready will fail."
2966
+ );
2967
+ }
2865
2968
  if (primaryProfile.id === "search") {
2866
2969
  const primarySearchRegistrar = {
2867
2970
  addTool: ((tool) => {
@@ -2887,6 +2990,7 @@ if (searchProfileEnabled) {
2887
2990
  })
2888
2991
  };
2889
2992
  registerResearchTools(searchRegistrar, getClient);
2993
+ registerDeveloperTools(searchRegistrar, getClient);
2890
2994
  registerMarketplaceSearchTool(searchRegistrar, getClient);
2891
2995
  try {
2892
2996
  await searchServer.start({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firecrawl-mcp",
3
- "version": "3.23.0",
3
+ "version": "3.23.2",
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",