firecrawl-mcp 3.23.0 → 3.23.1

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 +393 -291
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -8,7 +8,61 @@ 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.
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
+ }),
52
+ execute: async (args2, { session }) => {
53
+ const { query, k } = args2;
54
+ const params = new URLSearchParams();
55
+ params.append("query", query);
56
+ if (k != null) params.append("k", String(k));
57
+ const client = getClient2(session);
58
+ const res = await client.http.get(
59
+ `${BASE}?${params.toString()}`,
60
+ ORIGIN_HEADERS
61
+ );
62
+ return fmtDeveloper(res.data?.results);
63
+ }
64
+ });
65
+ }
12
66
 
13
67
  // src/keyless-client-ip.ts
14
68
  import net from "net";
@@ -25,7 +79,7 @@ function extractSingleTrustedClientIp(rawForwardedFor) {
25
79
  }
26
80
 
27
81
  // src/monitor.ts
28
- import { z } from "zod";
82
+ import { z as z2 } from "zod";
29
83
 
30
84
  // src/session-credential.ts
31
85
  import { createHmac } from "crypto";
@@ -124,8 +178,8 @@ async function monitorRequest(session, path2, init = {}) {
124
178
  function asText(data) {
125
179
  return JSON.stringify(data, null, 2);
126
180
  }
127
- var pageStatusSchema = z.enum(["same", "new", "changed", "removed", "error"]);
128
- var checkStatusSchema = z.enum([
181
+ var pageStatusSchema = z2.enum(["same", "new", "changed", "removed", "error"]);
182
+ var checkStatusSchema = z2.enum([
129
183
  "queued",
130
184
  "running",
131
185
  "completed",
@@ -218,22 +272,22 @@ Create a recurring scrape, crawl, or search monitor that compares each check wit
218
272
 
219
273
  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
274
  `,
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()
275
+ parameters: z2.object({
276
+ body: z2.record(z2.string(), z2.any()).optional(),
277
+ page: z2.string().optional(),
278
+ pages: z2.array(z2.string()).optional(),
279
+ queries: z2.array(z2.string()).optional(),
280
+ searchWindow: z2.enum(["5m", "15m", "1h", "6h", "24h", "7d"]).optional(),
281
+ maxResults: z2.number().int().min(1).max(50).optional(),
282
+ includeDomains: z2.array(z2.string()).optional(),
283
+ excludeDomains: z2.array(z2.string()).optional(),
284
+ goal: z2.string().optional(),
285
+ name: z2.string().optional(),
286
+ scheduleText: z2.string().optional(),
287
+ timezone: z2.string().optional(),
288
+ email: z2.string().optional(),
289
+ includeDiffs: z2.boolean().optional(),
290
+ webhookUrl: z2.string().optional()
237
291
  }),
238
292
  execute: async (args2, { session, log }) => {
239
293
  const body = buildMonitorCreateBody(args2);
@@ -259,9 +313,9 @@ In the simple form, a \`goal\` is required. If \`queries\` contains one or more
259
313
  description: `
260
314
  List monitors for the authenticated account with optional pagination controls. Returns one page of monitor records and pagination metadata.
261
315
  `,
262
- parameters: z.object({
263
- limit: z.number().int().positive().optional(),
264
- offset: z.number().int().nonnegative().optional()
316
+ parameters: z2.object({
317
+ limit: z2.number().int().positive().optional(),
318
+ offset: z2.number().int().nonnegative().optional()
265
319
  }),
266
320
  execute: async (args2, { session }) => {
267
321
  const { limit, offset } = args2;
@@ -285,7 +339,7 @@ List monitors for the authenticated account with optional pagination controls. R
285
339
  description: `
286
340
  Retrieve one monitor by ID, including its configuration and current state. This does not run or modify the monitor.
287
341
  `,
288
- parameters: z.object({ id: z.string() }),
342
+ parameters: z2.object({ id: z2.string() }),
289
343
  execute: async (args2, { session }) => {
290
344
  const { id } = args2;
291
345
  const res = await monitorRequest(
@@ -311,9 +365,9 @@ Patch an existing monitor by ID. The body can change its name, active/paused sta
311
365
 
312
366
  Returns the updated monitor.
313
367
  `,
314
- parameters: z.object({
315
- id: z.string(),
316
- body: z.record(z.string(), z.any())
368
+ parameters: z2.object({
369
+ id: z2.string(),
370
+ body: z2.record(z2.string(), z2.any())
317
371
  }),
318
372
  execute: async (args2, { session }) => {
319
373
  const { id, body } = args2;
@@ -339,7 +393,7 @@ Returns the updated monitor.
339
393
  description: `
340
394
  Permanently delete a monitor by ID and stop its future schedule. This operation cannot be undone and returns deletion status.
341
395
  `,
342
- parameters: z.object({ id: z.string() }),
396
+ parameters: z2.object({ id: z2.string() }),
343
397
  execute: async (args2, { session, log }) => {
344
398
  const { id } = args2;
345
399
  log.info("Deleting monitor", { id });
@@ -365,7 +419,7 @@ Permanently delete a monitor by ID and stop its future schedule. This operation
365
419
  description: `
366
420
  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
421
  `,
368
- parameters: z.object({ id: z.string() }),
422
+ parameters: z2.object({ id: z2.string() }),
369
423
  execute: async (args2, { session }) => {
370
424
  const { id } = args2;
371
425
  const res = await monitorRequest(
@@ -390,10 +444,10 @@ Queue an immediate check for a monitor outside its normal schedule. This starts
390
444
  description: `
391
445
  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
446
  `,
393
- parameters: z.object({
394
- id: z.string(),
395
- limit: z.number().int().positive().optional(),
396
- offset: z.number().int().nonnegative().optional(),
447
+ parameters: z2.object({
448
+ id: z2.string(),
449
+ limit: z2.number().int().positive().optional(),
450
+ offset: z2.number().int().nonnegative().optional(),
397
451
  status: checkStatusSchema.optional()
398
452
  }),
399
453
  execute: async (args2, { session }) => {
@@ -422,11 +476,11 @@ Retrieve one monitor check and its page-level results, optionally filtered by pa
422
476
 
423
477
  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
478
  `,
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(),
479
+ parameters: z2.object({
480
+ id: z2.string(),
481
+ checkId: z2.string(),
482
+ limit: z2.number().int().positive().optional(),
483
+ skip: z2.number().int().nonnegative().optional(),
430
484
  pageStatus: pageStatusSchema.optional()
431
485
  }),
432
486
  execute: async (args2, { session }) => {
@@ -442,9 +496,9 @@ Markdown tracking returns a unified text diff, JSON tracking returns field paths
442
496
  }
443
497
 
444
498
  // src/research.ts
445
- import { z as z2 } from "zod";
446
- var BASE = "/v2/search/research";
447
- var ORIGIN_HEADERS = { "X-Origin": "mcp-fastmcp" };
499
+ import { z as z3 } from "zod";
500
+ var BASE2 = "/v2/search/research";
501
+ var ORIGIN_HEADERS2 = { "X-Origin": "mcp-fastmcp" };
448
502
  function appendParam(params, key, value) {
449
503
  if (value == null) return;
450
504
  if (Array.isArray(value)) {
@@ -566,21 +620,21 @@ For topics represented in the indexed corpus, search paper metadata and abstract
566
620
 
567
621
  Returns ranked papers with canonical IDs, titles, authors, and abstracts.
568
622
  `,
569
- parameters: z2.object({
570
- query: z2.string().min(1).describe(
623
+ parameters: z3.object({
624
+ query: z3.string().min(1).describe(
571
625
  "Natural-language research topic or question, including methods, systems, conditions, populations, interventions, or outcomes when relevant."
572
626
  ),
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(
627
+ k: z3.number().int().min(1).max(500).optional().describe("Number of ranked papers to return (default 40)."),
628
+ authors: z3.array(z3.string()).optional().describe(
575
629
  "Author substring filter(s); ALL must match (case-insensitive)."
576
630
  ),
577
- categories: z2.array(z2.string()).optional().describe(
631
+ categories: z3.array(z3.string()).optional().describe(
578
632
  "Paper category filter(s) (e.g. `cs.LG`); ALL provided values must match."
579
633
  ),
580
- from: z2.string().optional().describe(
634
+ from: z3.string().optional().describe(
581
635
  "Inclusive lower bound on created/updated date (`YYYY-MM-DD`)."
582
636
  ),
583
- to: z2.string().optional().describe(
637
+ to: z3.string().optional().describe(
584
638
  "Inclusive upper bound on created/updated date (`YYYY-MM-DD`)."
585
639
  )
586
640
  }),
@@ -595,8 +649,8 @@ Returns ranked papers with canonical IDs, titles, authors, and abstracts.
595
649
  appendParam(params, "to", to);
596
650
  const client = getClient2(session);
597
651
  const res = await client.http.get(
598
- withQuery(`${BASE}/papers`, params),
599
- ORIGIN_HEADERS
652
+ withQuery(`${BASE2}/papers`, params),
653
+ ORIGIN_HEADERS2
600
654
  );
601
655
  return fmtHits(res.data?.results);
602
656
  }
@@ -615,8 +669,8 @@ Returns ranked papers with canonical IDs, titles, authors, and abstracts.
615
669
  description: `
616
670
  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
671
  `,
618
- parameters: z2.object({
619
- paperId: z2.string().min(1).describe(
672
+ parameters: z3.object({
673
+ paperId: z3.string().min(1).describe(
620
674
  "Canonical paperId or primaryId such as `arxiv:1706.03762`, `pmcid:PMC12530322`, `pmid:40953549`, or `doi:10.1016/j.neunet.2025.108095`."
621
675
  )
622
676
  }),
@@ -624,8 +678,8 @@ Retrieve canonical metadata for one paper ID, such as an arXiv, PMC, PMID, or DO
624
678
  const { paperId } = args2;
625
679
  const client = getClient2(session);
626
680
  const res = await client.http.get(
627
- `${BASE}/papers/${encodeURIComponent(paperId)}`,
628
- ORIGIN_HEADERS
681
+ `${BASE2}/papers/${encodeURIComponent(paperId)}`,
682
+ ORIGIN_HEADERS2
629
683
  );
630
684
  return fmtPaperMetadata(res.data?.paper);
631
685
  }
@@ -646,12 +700,12 @@ Find citation-graph candidates from one to ten \`seed_ids\`; the first ID is the
646
700
 
647
701
  Returns ranked candidates and the evaluated pool size.
648
702
  `,
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.")
703
+ parameters: z3.object({
704
+ seed_ids: z3.array(z3.string()).min(1).max(10),
705
+ intent: z3.string().min(1),
706
+ mode: z3.enum(["similar", "citers", "references"]).optional(),
707
+ k: z3.number().int().min(1).max(500).optional(),
708
+ rerank: z3.boolean().optional().describe("Apply an additional rerank over the fused candidates.")
655
709
  }),
656
710
  execute: async (args2, { session }) => {
657
711
  const { seed_ids, intent, mode, k, rerank } = args2;
@@ -665,10 +719,10 @@ Returns ranked candidates and the evaluated pool size.
665
719
  const client = getClient2(session);
666
720
  const res = await client.http.get(
667
721
  withQuery(
668
- `${BASE}/papers/${encodeURIComponent(primary)}/similar`,
722
+ `${BASE2}/papers/${encodeURIComponent(primary)}/similar`,
669
723
  params
670
724
  ),
671
- ORIGIN_HEADERS
725
+ ORIGIN_HEADERS2
672
726
  );
673
727
  const note = res.data?.note ? `
674
728
  note: ${res.data.note}` : "";
@@ -692,12 +746,12 @@ Retrieve in-body passages from one paper that are relevant to a specific questio
692
746
 
693
747
  Returns matching passages or a notice when full text is unavailable.
694
748
  `,
695
- parameters: z2.object({
696
- paperId: z2.string().min(1).describe(
749
+ parameters: z3.object({
750
+ paperId: z3.string().min(1).describe(
697
751
  "Canonical paperId or primaryId such as `arxiv:1706.03762`, `pmcid:PMC12530322`, `pmid:40953549`, or `doi:10.1016/j.neunet.2025.108095`."
698
752
  ),
699
- question: z2.string().min(1),
700
- k: z2.number().int().min(1).max(50).optional().describe("Number of passages to return (default 4).")
753
+ question: z3.string().min(1),
754
+ k: z3.number().int().min(1).max(50).optional().describe("Number of passages to return (default 4).")
701
755
  }),
702
756
  execute: async (args2, { session }) => {
703
757
  const { paperId, question, k } = args2;
@@ -706,8 +760,8 @@ Returns matching passages or a notice when full text is unavailable.
706
760
  appendParam(params, "k", k);
707
761
  const client = getClient2(session);
708
762
  const res = await client.http.get(
709
- withQuery(`${BASE}/papers/${encodeURIComponent(paperId)}`, params),
710
- ORIGIN_HEADERS
763
+ withQuery(`${BASE2}/papers/${encodeURIComponent(paperId)}`, params),
764
+ ORIGIN_HEADERS2
711
765
  );
712
766
  const passages = res.data?.passages ?? [];
713
767
  return passages.length ? passages.map((p) => p.text).join("\n---\n") : "(no full-text passages available for this paper)";
@@ -727,9 +781,9 @@ Returns matching passages or a notice when full text is unavailable.
727
781
  description: `
728
782
  Search indexed public GitHub issue, pull-request, and README content. Returns ranked matches with repository, URL, snippet, and full matched markdown when available.
729
783
  `,
730
- parameters: z2.object({
731
- query: z2.string().min(1),
732
- k: z2.number().int().min(1).max(100).optional()
784
+ parameters: z3.object({
785
+ query: z3.string().min(1),
786
+ k: z3.number().int().min(1).max(100).optional()
733
787
  }),
734
788
  execute: async (args2, { session }) => {
735
789
  const { query, k } = args2;
@@ -738,8 +792,8 @@ Search indexed public GitHub issue, pull-request, and README content. Returns ra
738
792
  appendParam(params, "k", k);
739
793
  const client = getClient2(session);
740
794
  const res = await client.http.get(
741
- withQuery(`${BASE}/github`, params),
742
- ORIGIN_HEADERS
795
+ withQuery(`${BASE2}/github`, params),
796
+ ORIGIN_HEADERS2
743
797
  );
744
798
  return fmtGithub(res.data?.results);
745
799
  }
@@ -1160,7 +1214,7 @@ function removeEmptyTopLevel(obj) {
1160
1214
  }
1161
1215
  return out;
1162
1216
  }
1163
- var searchDomainSchema = z3.string().trim().toLowerCase().min(1).max(253).regex(
1217
+ var searchDomainSchema = z4.string().trim().toLowerCase().min(1).max(253).regex(
1164
1218
  /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/,
1165
1219
  "Domain must be a valid hostname without protocol or path"
1166
1220
  );
@@ -1174,21 +1228,21 @@ function buildSearchQueryWithDomains(query, includeDomains, excludeDomains) {
1174
1228
  return query;
1175
1229
  }
1176
1230
  var searchToolBaseFields = {
1177
- query: z3.string().min(1),
1178
- highlights: z3.boolean().optional().describe(
1231
+ query: z4.string().min(1),
1232
+ highlights: z4.boolean().optional().describe(
1179
1233
  "Return query-relevant highlights for each search result. Set to false to keep the original search snippets."
1180
1234
  ),
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."
1235
+ limit: z4.number().optional(),
1236
+ tbs: z4.string().optional(),
1237
+ filter: z4.string().optional(),
1238
+ location: z4.string().optional(),
1239
+ includeDomains: z4.array(searchDomainSchema).optional(),
1240
+ excludeDomains: z4.array(searchDomainSchema).optional(),
1241
+ sources: z4.array(z4.object({ type: z4.enum(["web", "images", "news"]) })).optional(),
1242
+ categories: z4.array(z4.enum(["github", "research", "pdf", "developer"])).optional().describe(
1243
+ "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
1244
  ),
1191
- enterprise: z3.array(z3.enum(["default", "anon", "zdr"])).optional()
1245
+ enterprise: z4.array(z4.enum(["default", "anon", "zdr"])).optional()
1192
1246
  };
1193
1247
  function searchDomainsAreExclusive(args2) {
1194
1248
  return !(args2.includeDomains?.length && args2.excludeDomains?.length);
@@ -1227,9 +1281,10 @@ var openAiAppsChallengeToken = normalizeHeader(
1227
1281
  );
1228
1282
  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
1283
  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.`;
1284
+ 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
1285
  var SEARCH_PROFILE_TOOLS = /* @__PURE__ */ new Set([
1232
1286
  "firecrawl_search",
1287
+ "firecrawl_developer_search",
1233
1288
  "firecrawl_research_search_papers",
1234
1289
  "firecrawl_research_inspect_paper",
1235
1290
  "firecrawl_research_related_papers",
@@ -1318,24 +1373,34 @@ var KEYLESS_TOOL_NAMES = /* @__PURE__ */ new Set([
1318
1373
  function isHostedKeylessSession(session) {
1319
1374
  return process.env.CLOUD_SERVICE === "true" && session?.authType === "keyless" && !session.firecrawlApiKey;
1320
1375
  }
1321
- function recoveryPayload(code, requestId = randomUUID()) {
1376
+ function isLocalKeylessStartup() {
1377
+ return process.env.CLOUD_SERVICE !== "true" && !isHttpStreamingTransport() && !resolveCredentialFromEnv() && !normalizeHeader(process.env.FIRECRAWL_API_URL);
1378
+ }
1379
+ function recoveryPayload(code, requestId = randomUUID(), options = {}) {
1380
+ const retryAfterSeconds = options.retryAfterSeconds;
1381
+ const isQuotaExhausted = code === "KEYLESS_QUOTA_EXHAUSTED" || code === "KEYLESS_LIMIT_REACHED";
1382
+ const isToolUnavailable = code === "KEYLESS_TOOL_NOT_AVAILABLE";
1383
+ const isKeylessAccessUnavailable = code === "KEYLESS_ACCESS_NOT_AVAILABLE";
1384
+ const isKeylessEligibilityUnavailable = code === "KEYLESS_ELIGIBILITY_UNAVAILABLE";
1385
+ const oauthUrl = "https://mcp.firecrawl.dev/v2/mcp-oauth";
1322
1386
  return {
1323
1387
  code,
1324
1388
  request_id: requestId,
1325
1389
  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],
1390
+ 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.",
1391
+ ...isKeylessAccessUnavailable ? {} : { available_tools: [...KEYLESS_TOOL_NAMES] },
1328
1392
  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
- }
1393
+ ...retryAfterSeconds ? { retry_after_seconds: retryAfterSeconds } : {},
1394
+ next_actions: isKeylessEligibilityUnavailable ? [{ kind: "retry_later", after_seconds: 30 }] : isQuotaExhausted || isKeylessAccessUnavailable ? [
1395
+ { kind: "connect_oauth", url: oauthUrl, client_commands: [{ client: "claude-code", command: "claude mcp add --transport http firecrawl https://mcp.firecrawl.dev/v2/mcp-oauth" }] },
1396
+ { kind: "configure_api_key", header: "Authorization: Bearer <FIRECRAWL_API_KEY>" }
1397
+ ] : isToolUnavailable ? [{ kind: "continue_keyless", tools: [...KEYLESS_TOOL_NAMES] }] : [
1398
+ { kind: "connect_oauth", url: oauthUrl },
1399
+ { kind: "configure_api_key", header: "Authorization: Bearer <FIRECRAWL_API_KEY>" }
1335
1400
  ]
1336
1401
  };
1337
1402
  }
1338
- function emitActionLog(toolName, status, session, error, requestId = randomUUID()) {
1403
+ function emitActionLog(toolName, status, session, error, requestId = randomUUID(), code) {
1339
1404
  if (process.env.CLOUD_SERVICE !== "true") return;
1340
1405
  const payload = {
1341
1406
  team_id: session?.teamId,
@@ -1347,20 +1412,23 @@ function emitActionLog(toolName, status, session, error, requestId = randomUUID(
1347
1412
  status,
1348
1413
  request_id: requestId,
1349
1414
  resource: primaryProfile.resourceUrl,
1350
- ...error ? { error_class: error instanceof Error ? error.name : typeof error } : {}
1415
+ ...error ? { error_class: error instanceof Error ? error.name : typeof error } : {},
1416
+ ...code ? { code } : {}
1351
1417
  };
1352
1418
  console.error("[MCP_ACTION]", JSON.stringify(payload));
1353
1419
  const secret = normalizeHeader(process.env.FIRECRAWL_MCP_ACTION_LOG_SECRET);
1354
1420
  const apiUrl = normalizeHeader(process.env.FIRECRAWL_API_URL);
1355
1421
  const endpoint = normalizeHeader(process.env.FIRECRAWL_MCP_ACTION_LOG_URL) ?? (apiUrl ? `${withoutTrailingSlash(apiUrl)}/v2/mcp/action-logs` : void 0);
1356
1422
  if (!secret || !endpoint || !payload.team_id || status === "started") return;
1423
+ const actionLogPayload = { ...payload };
1424
+ delete actionLogPayload.code;
1357
1425
  void fetch(endpoint, {
1358
1426
  method: "POST",
1359
1427
  headers: {
1360
1428
  Authorization: `Bearer ${secret}`,
1361
1429
  "Content-Type": "application/json"
1362
1430
  },
1363
- body: JSON.stringify(payload),
1431
+ body: JSON.stringify(actionLogPayload),
1364
1432
  signal: AbortSignal.timeout(1500)
1365
1433
  }).catch(() => void 0);
1366
1434
  }
@@ -1373,7 +1441,11 @@ function guardHostedTool(tool, { logActions }) {
1373
1441
  beforeValidate: (_args, session) => {
1374
1442
  const code = session?.credentialError ? "CREDENTIAL_INVALID" : isHostedKeylessSession(session) && !keylessTool ? "KEYLESS_TOOL_NOT_AVAILABLE" : void 0;
1375
1443
  if (!code) return void 0;
1376
- const payload = recoveryPayload(code);
1444
+ const requestId = randomUUID();
1445
+ const payload = recoveryPayload(code, requestId);
1446
+ if (logActions) {
1447
+ emitActionLog(tool.name, "error", session, new UserError(String(payload.message), payload), requestId, code);
1448
+ }
1377
1449
  return {
1378
1450
  content: [{ type: "text", text: String(payload.message) }],
1379
1451
  isError: true,
@@ -1392,11 +1464,15 @@ function guardHostedTool(tool, { logActions }) {
1392
1464
  session: invocationSession
1393
1465
  };
1394
1466
  if (invocationSession.credentialError) {
1395
- const payload = recoveryPayload("CREDENTIAL_INVALID", requestId);
1467
+ const code = "CREDENTIAL_INVALID";
1468
+ const payload = recoveryPayload(code, requestId);
1469
+ if (logActions) emitActionLog(tool.name, "error", invocationSession, new UserError(String(payload.message), payload), requestId, code);
1396
1470
  throw new UserError(String(payload.message), payload);
1397
1471
  }
1398
1472
  if (isHostedKeylessSession(invocationSession) && !keylessTool) {
1399
- const payload = recoveryPayload("KEYLESS_TOOL_NOT_AVAILABLE", requestId);
1473
+ const code = "KEYLESS_TOOL_NOT_AVAILABLE";
1474
+ const payload = recoveryPayload(code, requestId);
1475
+ if (logActions) emitActionLog(tool.name, "error", invocationSession, new UserError(String(payload.message), payload), requestId, code);
1400
1476
  throw new UserError(String(payload.message), payload);
1401
1477
  }
1402
1478
  if (!logActions) return execute(args2, invocationContext);
@@ -1465,7 +1541,7 @@ function createClient(apiKey) {
1465
1541
  return new FirecrawlApp(config);
1466
1542
  }
1467
1543
  var ORIGIN = "mcp-fastmcp";
1468
- var ORIGIN_HEADERS2 = { "X-Origin": ORIGIN };
1544
+ var ORIGIN_HEADERS3 = { "X-Origin": ORIGIN };
1469
1545
  var SAFE_MODE = process.env.CLOUD_SERVICE === "true";
1470
1546
  function getClient(session) {
1471
1547
  if (process.env.CLOUD_SERVICE === "true" && !hasCredential(session)) {
@@ -1563,10 +1639,10 @@ function transformScrapeParams(args2) {
1563
1639
  delete out.pdfOptions;
1564
1640
  return out;
1565
1641
  }
1566
- var scrapeParamsSchema = z3.object({
1567
- url: z3.string().url(),
1568
- formats: z3.array(
1569
- z3.enum([
1642
+ var scrapeParamsSchema = z4.object({
1643
+ url: z4.string().url(),
1644
+ formats: z4.array(
1645
+ z4.enum([
1570
1646
  "markdown",
1571
1647
  "html",
1572
1648
  "rawHtml",
@@ -1580,62 +1656,62 @@ var scrapeParamsSchema = z3.object({
1580
1656
  "audio"
1581
1657
  ])
1582
1658
  ).optional(),
1583
- jsonOptions: z3.object({
1584
- prompt: z3.string().optional(),
1585
- schema: z3.record(z3.string(), z3.any()).optional()
1659
+ jsonOptions: z4.object({
1660
+ prompt: z4.string().optional(),
1661
+ schema: z4.record(z4.string(), z4.any()).optional()
1586
1662
  }).optional(),
1587
- queryOptions: z3.object({
1588
- prompt: z3.string().max(1e4),
1589
- mode: z3.enum(["directQuote", "freeform"]).default("freeform")
1663
+ queryOptions: z4.object({
1664
+ prompt: z4.string().max(1e4),
1665
+ mode: z4.enum(["directQuote", "freeform"]).default("freeform")
1590
1666
  }).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()
1667
+ screenshotOptions: z4.object({
1668
+ fullPage: z4.boolean().optional(),
1669
+ quality: z4.number().optional(),
1670
+ viewport: z4.object({ width: z4.number(), height: z4.number() }).optional()
1595
1671
  }).optional(),
1596
- parsers: z3.array(z3.enum(["pdf"])).optional(),
1597
- pdfOptions: z3.object({
1598
- maxPages: z3.number().int().min(1).max(1e4).optional()
1672
+ parsers: z4.array(z4.enum(["pdf"])).optional(),
1673
+ pdfOptions: z4.object({
1674
+ maxPages: z4.number().int().min(1).max(1e4).optional()
1599
1675
  }).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(),
1676
+ onlyMainContent: z4.boolean().optional(),
1677
+ redactPII: z4.boolean().optional(),
1678
+ includeTags: z4.array(z4.string()).optional(),
1679
+ excludeTags: z4.array(z4.string()).optional(),
1680
+ waitFor: z4.number().optional(),
1605
1681
  ...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()
1682
+ actions: z4.array(
1683
+ z4.object({
1684
+ type: z4.enum(allowedActionTypes),
1685
+ selector: z4.string().optional(),
1686
+ milliseconds: z4.number().optional(),
1687
+ text: z4.string().optional(),
1688
+ key: z4.string().optional(),
1689
+ direction: z4.enum(["up", "down"]).optional(),
1690
+ script: z4.string().optional(),
1691
+ fullPage: z4.boolean().optional()
1616
1692
  })
1617
1693
  ).optional()
1618
1694
  },
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()
1695
+ mobile: z4.boolean().optional(),
1696
+ skipTlsVerification: z4.boolean().optional(),
1697
+ removeBase64Images: z4.boolean().optional(),
1698
+ location: z4.object({
1699
+ country: z4.string().optional(),
1700
+ languages: z4.array(z4.string()).optional()
1625
1701
  }).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()
1702
+ storeInCache: z4.boolean().optional(),
1703
+ zeroDataRetention: z4.boolean().optional(),
1704
+ maxAge: z4.number().optional(),
1705
+ lockdown: z4.boolean().optional(),
1706
+ proxy: z4.enum(["basic", "stealth", "enhanced", "auto"]).optional(),
1707
+ profile: z4.object({
1708
+ name: z4.string(),
1709
+ saveChanges: z4.boolean().optional()
1634
1710
  }).optional()
1635
1711
  });
1636
- var parseOptionParamsSchema = z3.object({
1637
- formats: z3.array(
1638
- z3.enum([
1712
+ var parseOptionParamsSchema = z4.object({
1713
+ formats: z4.array(
1714
+ z4.enum([
1639
1715
  "markdown",
1640
1716
  "html",
1641
1717
  "rawHtml",
@@ -1645,48 +1721,48 @@ var parseOptionParamsSchema = z3.object({
1645
1721
  "query"
1646
1722
  ])
1647
1723
  ).optional(),
1648
- jsonOptions: z3.object({
1649
- prompt: z3.string().optional(),
1650
- schema: z3.record(z3.string(), z3.any()).optional()
1724
+ jsonOptions: z4.object({
1725
+ prompt: z4.string().optional(),
1726
+ schema: z4.record(z4.string(), z4.any()).optional()
1651
1727
  }).optional(),
1652
- queryOptions: z3.object({
1653
- prompt: z3.string().max(1e4),
1654
- mode: z3.enum(["directQuote", "freeform"]).default("freeform")
1728
+ queryOptions: z4.object({
1729
+ prompt: z4.string().max(1e4),
1730
+ mode: z4.enum(["directQuote", "freeform"]).default("freeform")
1655
1731
  }).optional(),
1656
- parsers: z3.array(z3.enum(["pdf"])).optional(),
1657
- pdfOptions: z3.object({
1658
- maxPages: z3.number().int().min(1).max(1e4).optional()
1732
+ parsers: z4.array(z4.enum(["pdf"])).optional(),
1733
+ pdfOptions: z4.object({
1734
+ maxPages: z4.number().int().min(1).max(1e4).optional()
1659
1735
  }).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()
1736
+ onlyMainContent: z4.boolean().optional(),
1737
+ redactPII: z4.boolean().optional(),
1738
+ includeTags: z4.array(z4.string()).optional(),
1739
+ excludeTags: z4.array(z4.string()).optional(),
1740
+ removeBase64Images: z4.boolean().optional(),
1741
+ skipTlsVerification: z4.boolean().optional(),
1742
+ storeInCache: z4.boolean().optional(),
1743
+ zeroDataRetention: z4.boolean().optional(),
1744
+ maxAge: z4.number().optional(),
1745
+ proxy: z4.enum(["basic", "auto"]).optional()
1670
1746
  });
1671
1747
  var localParseParamsSchema = parseOptionParamsSchema.extend({
1672
- filePath: z3.string().min(1).describe(
1748
+ filePath: z4.string().min(1).describe(
1673
1749
  "Absolute or relative path to a local file to parse. Supported: .html, .htm, .pdf, .docx, .doc, .odt, .rtf, .xlsx, .xls"
1674
1750
  ),
1675
- contentType: z3.string().optional().describe(
1751
+ contentType: z4.string().optional().describe(
1676
1752
  "Optional MIME type override. If omitted, the server infers the file kind from the extension."
1677
1753
  )
1678
1754
  });
1679
1755
  var hostedParseParamsSchema = parseOptionParamsSchema.extend({
1680
- filePath: z3.string().min(1).optional().describe(
1756
+ filePath: z4.string().min(1).optional().describe(
1681
1757
  "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
1758
  ),
1683
- uploadRef: z3.string().min(1).optional().describe(
1759
+ uploadRef: z4.string().min(1).optional().describe(
1684
1760
  "Phase 2 only: short-lived upload reference returned by phase 1 after the local PUT upload completes."
1685
1761
  ),
1686
- contentType: z3.string().optional().describe(
1762
+ contentType: z4.string().optional().describe(
1687
1763
  "Phase 1 MIME type override. If omitted, the server infers it from the file extension without reading the file."
1688
1764
  ),
1689
- declaredSizeBytes: z3.number().int().positive().optional().describe(
1765
+ declaredSizeBytes: z4.number().int().positive().optional().describe(
1690
1766
  "Optional phase 1 size declaration. Hosted MCP does not stat the file; provide this only if the caller already knows it."
1691
1767
  )
1692
1768
  }).superRefine((value, ctx) => {
@@ -1694,7 +1770,7 @@ var hostedParseParamsSchema = parseOptionParamsSchema.extend({
1694
1770
  const hasUploadRef = typeof value.uploadRef === "string" && value.uploadRef.length > 0;
1695
1771
  if (hasFilePath === hasUploadRef) {
1696
1772
  ctx.addIssue({
1697
- code: z3.ZodIssueCode.custom,
1773
+ code: z4.ZodIssueCode.custom,
1698
1774
  message: "Hosted firecrawl_parse requires exactly one of filePath (phase 1) or uploadRef (phase 2).",
1699
1775
  path: hasFilePath && hasUploadRef ? ["uploadRef"] : ["filePath"]
1700
1776
  });
@@ -1953,13 +2029,13 @@ Enumerate URLs indexed under one website through Firecrawl without fetching each
1953
2029
 
1954
2030
  Returns matching URLs rather than page bodies. Retrieve one page with \`firecrawl_scrape\`; collect content across multiple pages with \`firecrawl_crawl\`.
1955
2031
  `,
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()
2032
+ parameters: z4.object({
2033
+ url: z4.string().url(),
2034
+ search: z4.string().optional(),
2035
+ sitemap: z4.enum(["include", "skip", "only"]).optional(),
2036
+ includeSubdomains: z4.boolean().optional(),
2037
+ limit: z4.number().optional(),
2038
+ ignoreQueryParameters: z4.boolean().optional()
1963
2039
  }),
1964
2040
  execute: async (args2, { session, log }) => {
1965
2041
  const { url, ...options } = args2;
@@ -1985,11 +2061,13 @@ server.addTool({
1985
2061
  // Query-only; no destructive side effects on external entities.
1986
2062
  },
1987
2063
  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.
2064
+ 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
2065
 
1990
- \`scrapeOptions\` can attach extracted page content. Returns source-type result groups, an \`id\` for optional search feedback, and usage metadata.
2066
+ 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.
2067
+
2068
+ \`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
2069
  `,
1992
- parameters: z3.object({
2070
+ parameters: z4.object({
1993
2071
  ...searchToolBaseFields,
1994
2072
  scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional()
1995
2073
  }).refine(searchDomainsAreExclusive, SEARCH_DOMAINS_CONFLICT_MESSAGE),
@@ -2019,7 +2097,9 @@ Search web, news, or image sources and return ranked results. Operators include
2019
2097
  };
2020
2098
  if (isKeylessMode(session)) {
2021
2099
  const json = await keylessPost("/v2/search", searchBody, session);
2022
- return asText2(json ?? {});
2100
+ const keylessResponse = { ...json ?? {} };
2101
+ delete keylessResponse.id;
2102
+ return asText2(keylessResponse);
2023
2103
  }
2024
2104
  const client = getClient(session);
2025
2105
  const httpRes = await client.http.post("/v2/search", searchBody);
@@ -2036,25 +2116,35 @@ function resolveApiBaseUrl() {
2036
2116
  function extractClientIp(request) {
2037
2117
  return extractSingleTrustedClientIp(request?.headers?.["x-forwarded-for"]);
2038
2118
  }
2119
+ function keylessQuotaReason(reason) {
2120
+ return reason === "requests" || reason === "credits";
2121
+ }
2039
2122
  async function keylessEligible(clientIp) {
2040
2123
  const secret = process.env.KEYLESS_PROXY_SECRET;
2041
- if (!secret) return false;
2124
+ if (!secret) return { eligible: false, unavailable: true };
2042
2125
  try {
2043
2126
  const response = await fetch(
2044
2127
  `${resolveApiBaseUrl()}/v2/keyless/eligibility`,
2045
2128
  {
2046
2129
  headers: {
2047
- ...ORIGIN_HEADERS2,
2130
+ ...ORIGIN_HEADERS3,
2048
2131
  "x-firecrawl-keyless-ip": clientIp,
2049
2132
  "x-firecrawl-keyless-secret": secret
2050
2133
  }
2051
2134
  }
2052
2135
  );
2053
- if (!response.ok) return false;
2054
- const json = await response.json().catch(() => ({}));
2055
- return json?.eligible === true;
2136
+ if (!response.ok) return { eligible: false, unavailable: true };
2137
+ const json = await response.json().catch(() => null);
2138
+ if (typeof json?.eligible !== "boolean") {
2139
+ return { eligible: false, unavailable: true };
2140
+ }
2141
+ return {
2142
+ eligible: json?.eligible === true,
2143
+ ...typeof json?.reason === "string" ? { reason: json.reason } : {},
2144
+ ...Number.isFinite(json?.retryAfterSeconds) && json.retryAfterSeconds > 0 ? { retryAfterSeconds: json.retryAfterSeconds } : {}
2145
+ };
2056
2146
  } catch {
2057
- return false;
2147
+ return { eligible: false, unavailable: true };
2058
2148
  }
2059
2149
  }
2060
2150
  function isKeylessMode(session) {
@@ -2065,15 +2155,18 @@ function isKeylessMode(session) {
2065
2155
  return !process.env.FIRECRAWL_API_URL;
2066
2156
  }
2067
2157
  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);
2158
+ if (isHostedKeylessSession(session)) {
2159
+ const eligibility = session?.keylessClientIp ? await keylessEligible(session.keylessClientIp) : { eligible: false };
2160
+ if (!eligibility.eligible) {
2161
+ const code = eligibility.unavailable ? "KEYLESS_ELIGIBILITY_UNAVAILABLE" : keylessQuotaReason(eligibility.reason) ? "KEYLESS_QUOTA_EXHAUSTED" : "KEYLESS_ACCESS_NOT_AVAILABLE";
2162
+ const payload = recoveryPayload(code, session?.requestId, {
2163
+ retryAfterSeconds: eligibility.retryAfterSeconds
2164
+ });
2165
+ throw new UserError(String(payload.message), payload);
2166
+ }
2074
2167
  }
2075
2168
  const headers = {
2076
- ...ORIGIN_HEADERS2,
2169
+ ...ORIGIN_HEADERS3,
2077
2170
  "Content-Type": "application/json"
2078
2171
  };
2079
2172
  if (session?.keylessClientIp && process.env.KEYLESS_PROXY_SECRET) {
@@ -2087,11 +2180,11 @@ async function keylessPost(path2, body, session) {
2087
2180
  });
2088
2181
  const json = await response.json().catch(() => ({}));
2089
2182
  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
- );
2183
+ if (isKeylessMode(session) && response.status === 429) {
2184
+ const code = keylessQuotaReason(json?.reason) ? "KEYLESS_QUOTA_EXHAUSTED" : "KEYLESS_LIMIT_REACHED";
2185
+ const payload = recoveryPayload(code, session?.requestId, {
2186
+ retryAfterSeconds: Number.isFinite(json?.retry_after_seconds) && json.retry_after_seconds > 0 ? json.retry_after_seconds : void 0
2187
+ });
2095
2188
  throw new UserError(String(payload.message), payload);
2096
2189
  }
2097
2190
  throw new Error(
@@ -2103,7 +2196,7 @@ async function keylessPost(path2, body, session) {
2103
2196
  async function getCrawlStatusWithOrigin(client, jobId) {
2104
2197
  const res = await client.http.get(
2105
2198
  `/v2/crawl/${encodeURIComponent(jobId)}`,
2106
- ORIGIN_HEADERS2
2199
+ ORIGIN_HEADERS3
2107
2200
  );
2108
2201
  const body = res?.data ?? {};
2109
2202
  const initialDocs = Array.isArray(body.data) ? body.data : [];
@@ -2122,7 +2215,7 @@ async function getCrawlStatusWithOrigin(client, jobId) {
2122
2215
  const docs = initialDocs.slice();
2123
2216
  let current = body.next;
2124
2217
  while (current) {
2125
- const pageRes = await client.http.get(current, ORIGIN_HEADERS2);
2218
+ const pageRes = await client.http.get(current, ORIGIN_HEADERS3);
2126
2219
  const payload = pageRes?.data ?? {};
2127
2220
  if (!payload.success) break;
2128
2221
  const pageData = Array.isArray(payload.data) ? payload.data : payload.data?.pages || [];
@@ -2155,17 +2248,17 @@ async function waitForCrawlCompletionWithOrigin(client, jobId, pollInterval = 2,
2155
2248
  );
2156
2249
  }
2157
2250
  }
2158
- var feedbackIssueSchema = z3.string().trim().min(1).max(80).regex(
2251
+ var feedbackIssueSchema = z4.string().trim().min(1).max(80).regex(
2159
2252
  /^[a-z0-9][a-z0-9_-]*$/,
2160
2253
  "Issue codes must use lowercase letters, numbers, underscores, or hyphens"
2161
2254
  );
2162
- var valuableSourceSchema = z3.object({
2163
- url: z3.string().url(),
2164
- reason: z3.string().max(1e3).optional()
2255
+ var valuableSourceSchema = z4.object({
2256
+ url: z4.string().url(),
2257
+ reason: z4.string().max(1e3).optional()
2165
2258
  });
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()
2259
+ var missingContentSchema = z4.object({
2260
+ topic: z4.string().min(1, "topic must not be empty").max(200, "topic must be 200 characters or fewer"),
2261
+ description: z4.string().max(2e3).optional()
2169
2262
  });
2170
2263
  var FEEDBACK_DISABLED_VALUES = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
2171
2264
  function feedbackEnvEnabled(...keys) {
@@ -2186,7 +2279,7 @@ if (SEARCH_FEEDBACK_DISABLED) {
2186
2279
  "[firecrawl-mcp] Search feedback tool disabled by FIRECRAWL_NO_SEARCH_FEEDBACK; firecrawl_search_feedback will not be registered."
2187
2280
  );
2188
2281
  }
2189
- if (!SEARCH_FEEDBACK_DISABLED) {
2282
+ if (!SEARCH_FEEDBACK_DISABLED && !isLocalKeylessStartup()) {
2190
2283
  server.addTool({
2191
2284
  name: "firecrawl_search_feedback",
2192
2285
  annotations: {
@@ -2199,28 +2292,28 @@ if (!SEARCH_FEEDBACK_DISABLED) {
2199
2292
  // Additive only; records feedback and may refund credits, does not delete data.
2200
2293
  },
2201
2294
  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.
2295
+ 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
2296
 
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.
2297
+ 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
2298
  `,
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()
2299
+ parameters: z4.object({
2300
+ searchId: z4.string().uuid("searchId must be the UUID returned by firecrawl_search"),
2301
+ rating: z4.enum(["good", "bad", "partial"]),
2302
+ valuableSources: z4.array(
2303
+ z4.object({
2304
+ url: z4.string().url(),
2305
+ reason: z4.string().max(1e3).optional()
2213
2306
  })
2214
2307
  ).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()
2308
+ missingContent: z4.array(
2309
+ z4.object({
2310
+ topic: z4.string().min(1, "topic must not be empty").max(200, "topic must be 200 characters or fewer"),
2311
+ description: z4.string().max(2e3).optional()
2219
2312
  })
2220
2313
  ).max(20).optional().describe(
2221
2314
  "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
2315
  ),
2223
- querySuggestions: z3.string().max(2e3).optional()
2316
+ querySuggestions: z4.string().max(2e3).optional()
2224
2317
  }),
2225
2318
  execute: async (args2, { session, log }) => {
2226
2319
  const {
@@ -2246,7 +2339,7 @@ Eligibility is limited to successful searches within the feedback age window. Th
2246
2339
  }
2247
2340
  if (querySuggestions) body.querySuggestions = querySuggestions;
2248
2341
  const headers = {
2249
- ...ORIGIN_HEADERS2,
2342
+ ...ORIGIN_HEADERS3,
2250
2343
  "Content-Type": "application/json"
2251
2344
  };
2252
2345
  const credential = credentialForOutboundRequest(session);
@@ -2290,7 +2383,7 @@ if (ENDPOINT_FEEDBACK_DISABLED) {
2290
2383
  "[firecrawl-mcp] Endpoint feedback tool disabled by FIRECRAWL_NO_ENDPOINT_FEEDBACK; firecrawl_feedback will not be registered."
2291
2384
  );
2292
2385
  }
2293
- if (!ENDPOINT_FEEDBACK_DISABLED) {
2386
+ if (!ENDPOINT_FEEDBACK_DISABLED && !isLocalKeylessStartup()) {
2294
2387
  server.addTool({
2295
2388
  name: "firecrawl_feedback",
2296
2389
  annotations: {
@@ -2307,19 +2400,19 @@ Submit concise quality feedback for a completed search, scrape, parse, or map jo
2307
2400
 
2308
2401
  Returns submission status, feedback ID, and accounting fields.
2309
2402
  `,
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()
2403
+ parameters: z4.object({
2404
+ endpoint: z4.enum(["search", "scrape", "parse", "map"]),
2405
+ jobId: z4.string().uuid("jobId must be the UUID returned by Firecrawl"),
2406
+ rating: z4.enum(["good", "bad", "partial"]),
2407
+ issues: z4.array(feedbackIssueSchema).max(20).optional(),
2408
+ tags: z4.array(feedbackIssueSchema).max(20).optional(),
2409
+ note: z4.string().max(4e3).optional(),
2410
+ valuableSources: z4.array(valuableSourceSchema).max(50).optional(),
2411
+ missingContent: z4.array(missingContentSchema).max(50).optional(),
2412
+ querySuggestions: z4.string().max(2e3).optional(),
2413
+ url: z4.string().url().optional(),
2414
+ pageNumbers: z4.array(z4.number().int().positive()).max(100).optional(),
2415
+ metadata: z4.record(z4.string(), z4.unknown()).optional()
2323
2416
  }),
2324
2417
  execute: async (args2, { session, log }) => {
2325
2418
  const {
@@ -2338,7 +2431,7 @@ Returns submission status, feedback ID, and accounting fields.
2338
2431
  } = args2;
2339
2432
  const apiBase = resolveApiBaseUrl();
2340
2433
  const headers = {
2341
- ...ORIGIN_HEADERS2,
2434
+ ...ORIGIN_HEADERS3,
2342
2435
  "Content-Type": "application/json"
2343
2436
  };
2344
2437
  const credential = credentialForOutboundRequest(session);
@@ -2408,25 +2501,25 @@ Start a multi-page crawl at a website URL, poll it to a terminal state, and retu
2408
2501
 
2409
2502
  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
2503
  `,
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(),
2504
+ parameters: z4.object({
2505
+ url: z4.string(),
2506
+ prompt: z4.string().optional(),
2507
+ excludePaths: z4.array(z4.string()).optional(),
2508
+ includePaths: z4.array(z4.string()).optional(),
2509
+ maxDiscoveryDepth: z4.number().optional(),
2510
+ sitemap: z4.enum(["skip", "include", "only"]).optional(),
2511
+ limit: z4.number().optional(),
2512
+ allowExternalLinks: z4.boolean().optional(),
2513
+ allowSubdomains: z4.boolean().optional(),
2514
+ crawlEntireDomain: z4.boolean().optional(),
2515
+ delay: z4.number().optional(),
2516
+ maxConcurrency: z4.number().optional(),
2424
2517
  ...SAFE_MODE ? {} : {
2425
- webhook: z3.string().optional(),
2426
- webhookHeaders: z3.record(z3.string(), z3.string()).optional()
2518
+ webhook: z4.string().optional(),
2519
+ webhookHeaders: z4.record(z4.string(), z4.string()).optional()
2427
2520
  },
2428
- deduplicateSimilarURLs: z3.boolean().optional(),
2429
- ignoreQueryParameters: z3.boolean().optional(),
2521
+ deduplicateSimilarURLs: z4.boolean().optional(),
2522
+ ignoreQueryParameters: z4.boolean().optional(),
2430
2523
  scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional()
2431
2524
  }),
2432
2525
  execute: async (args2, { session, log }) => {
@@ -2479,7 +2572,7 @@ server.addTool({
2479
2572
  description: `
2480
2573
  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
2574
  `,
2482
- parameters: z3.object({ id: z3.string() }),
2575
+ parameters: z4.object({ id: z4.string() }),
2483
2576
  execute: async (args2, { session }) => {
2484
2577
  const client = getClient(session);
2485
2578
  const id = args2.id;
@@ -2503,13 +2596,13 @@ Extract structured information from one or more URLs with an optional natural-la
2503
2596
 
2504
2597
  Use this for a defined structured result rather than full page content. Returns data shaped by the supplied schema or prompt.
2505
2598
  `,
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()
2599
+ parameters: z4.object({
2600
+ urls: z4.array(z4.string()),
2601
+ prompt: z4.string().optional(),
2602
+ schema: z4.record(z4.string(), z4.any()).optional(),
2603
+ allowExternalLinks: z4.boolean().optional(),
2604
+ enableWebSearch: z4.boolean().optional(),
2605
+ includeSubdomains: z4.boolean().optional()
2513
2606
  }),
2514
2607
  execute: async (args2, { session, log }) => {
2515
2608
  const client = getClient(session);
@@ -2546,10 +2639,10 @@ Start an asynchronous web research job from a prompt, optional seed URLs, and an
2546
2639
 
2547
2640
  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
2641
  `,
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()
2642
+ parameters: z4.object({
2643
+ prompt: z4.string().min(1).max(1e4),
2644
+ urls: z4.array(z4.string().url()).optional(),
2645
+ schema: z4.record(z4.string(), z4.any()).optional()
2553
2646
  }),
2554
2647
  execute: async (args2, { session, log }) => {
2555
2648
  const client = getClient(session);
@@ -2586,14 +2679,14 @@ Retrieve progress or final results for a \`firecrawl_agent\` job ID. A \`process
2586
2679
 
2587
2680
  Returns job status, progress information, and result data when completed.
2588
2681
  `,
2589
- parameters: z3.object({ id: z3.string() }),
2682
+ parameters: z4.object({ id: z4.string() }),
2590
2683
  execute: async (args2, { session, log }) => {
2591
2684
  const client = getClient(session);
2592
2685
  const { id } = args2;
2593
2686
  log.info("Checking agent status", { id });
2594
2687
  const res = await client.http.get(
2595
2688
  `/v2/agent/${encodeURIComponent(id)}`,
2596
- ORIGIN_HEADERS2
2689
+ ORIGIN_HEADERS3
2597
2690
  );
2598
2691
  return asText2(res?.data ?? {});
2599
2692
  }
@@ -2614,13 +2707,13 @@ Open or reuse a live browser session to navigate a page, click controls, fill fi
2614
2707
 
2615
2708
  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
2709
  `,
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(),
2710
+ parameters: z4.object({
2711
+ scrapeId: z4.string().trim().min(1).optional(),
2712
+ url: z4.string().trim().url().optional(),
2713
+ prompt: z4.string().trim().min(1).optional(),
2714
+ code: z4.string().trim().min(1).optional(),
2715
+ language: z4.enum(["bash", "python", "node"]).optional(),
2716
+ timeout: z4.number().min(1).max(300).optional(),
2624
2717
  scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional()
2625
2718
  }).refine((data) => Boolean(data.scrapeId) !== Boolean(data.url), {
2626
2719
  message: "Provide either 'url' (interact directly) or 'scrapeId' (reuse a previous scrape), not both."
@@ -2697,8 +2790,8 @@ server.addTool({
2697
2790
  description: `
2698
2791
  Stop the live interact session associated with a \`scrapeId\` and release its resources. Returns a success confirmation.
2699
2792
  `,
2700
- parameters: z3.object({
2701
- scrapeId: z3.string()
2793
+ parameters: z4.object({
2794
+ scrapeId: z4.string()
2702
2795
  }),
2703
2796
  execute: async (args2, { session, log }) => {
2704
2797
  const client = getClient(session);
@@ -2706,7 +2799,7 @@ Stop the live interact session associated with a \`scrapeId\` and release its re
2706
2799
  log.info("Stopping interact session", { scrapeId });
2707
2800
  const res = await client.http.delete(
2708
2801
  `/v2/scrape/${encodeURIComponent(scrapeId)}/interact`,
2709
- ORIGIN_HEADERS2
2802
+ ORIGIN_HEADERS3
2710
2803
  );
2711
2804
  return asText2(res?.data ?? {});
2712
2805
  }
@@ -2758,7 +2851,7 @@ Set \`redactPII\` to request redaction of personally identifiable information in
2758
2851
  });
2759
2852
  form.append("file", blob, filename);
2760
2853
  form.append("options", JSON.stringify(optionsPayload));
2761
- const headers = { ...ORIGIN_HEADERS2 };
2854
+ const headers = { ...ORIGIN_HEADERS3 };
2762
2855
  const credential = credentialForOutboundRequest(session);
2763
2856
  if (credential) {
2764
2857
  headers["Authorization"] = `Bearer ${credential}`;
@@ -2797,11 +2890,13 @@ function registerMarketplaceSearchTool(registrar, getClientFn) {
2797
2890
  destructiveHint: false
2798
2891
  },
2799
2892
  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\`.
2893
+ 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\`.
2894
+
2895
+ 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
2896
 
2802
2897
  Returns \`{ success, data, id, creditsUsed }\`, with source arrays in \`data\`.
2803
2898
  `,
2804
- parameters: z3.object({ ...searchToolBaseFields }).strict().refine(searchDomainsAreExclusive, SEARCH_DOMAINS_CONFLICT_MESSAGE),
2899
+ parameters: z4.object({ ...searchToolBaseFields }).strict().refine(searchDomainsAreExclusive, SEARCH_DOMAINS_CONFLICT_MESSAGE),
2805
2900
  execute: async (args2, { session, log }) => {
2806
2901
  const {
2807
2902
  query,
@@ -2862,6 +2957,12 @@ if (process.env.CLOUD_SERVICE === "true" || process.env.SSE_LOCAL === "true" ||
2862
2957
  }
2863
2958
  registerMonitorTools(server);
2864
2959
  registerResearchTools(server, getClient);
2960
+ registerDeveloperTools(server, getClient);
2961
+ if (process.env.CLOUD_SERVICE === "true" && primaryProfile.allowKeyless && !normalizeHeader(process.env.KEYLESS_PROXY_SECRET)) {
2962
+ console.warn(
2963
+ "[firecrawl-mcp] KEYLESS_PROXY_SECRET is missing; keyless requests will be unavailable and /ready will fail."
2964
+ );
2965
+ }
2865
2966
  if (primaryProfile.id === "search") {
2866
2967
  const primarySearchRegistrar = {
2867
2968
  addTool: ((tool) => {
@@ -2887,6 +2988,7 @@ if (searchProfileEnabled) {
2887
2988
  })
2888
2989
  };
2889
2990
  registerResearchTools(searchRegistrar, getClient);
2991
+ registerDeveloperTools(searchRegistrar, getClient);
2890
2992
  registerMarketplaceSearchTool(searchRegistrar, getClient);
2891
2993
  try {
2892
2994
  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.1",
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",