firecrawl-mcp 3.20.3 → 3.20.5

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.
package/dist/index.js CHANGED
@@ -223,6 +223,19 @@ const server = new FastMCP({
223
223
  const envCred = resolveCredentialFromEnv();
224
224
  if (process.env.CLOUD_SERVICE === 'true') {
225
225
  if (!headerCred) {
226
+ // Keyless free tier over the hosted MCP: serve it only when a forwarding
227
+ // secret is configured, we know the end-user's client IP (so the API can
228
+ // rate-limit per real IP, not the shared server IP), AND that IP still
229
+ // has free quota. If the IP is out of quota (or keyless is off), fall
230
+ // through to throw so FastMCP emits the OAuth 401 + WWW-Authenticate
231
+ // challenge — i.e. prompt the user to connect an account exactly when
232
+ // their free quota runs out.
233
+ const clientIp = extractClientIp(request);
234
+ if (process.env.KEYLESS_PROXY_SECRET &&
235
+ clientIp &&
236
+ (await keylessEligible(clientIp))) {
237
+ return { firecrawlApiKey: undefined, research, keylessClientIp: clientIp };
238
+ }
226
239
  throw new Error('Firecrawl credentials required: OAuth access token (Authorization: Bearer fco_...) or API key (x-firecrawl-api-key)');
227
240
  }
228
241
  return { firecrawlApiKey: headerCred, research };
@@ -233,8 +246,12 @@ const server = new FastMCP({
233
246
  if (!httpStreaming &&
234
247
  !process.env.FIRECRAWL_API_KEY &&
235
248
  !process.env.FIRECRAWL_API_URL) {
236
- console.error('Either FIRECRAWL_API_KEY or FIRECRAWL_API_URL must be provided');
237
- process.exit(1);
249
+ // No credential and no self-hosted URL: run in keyless mode. scrape and
250
+ // search work for free (rate-limited per IP) against the Firecrawl cloud;
251
+ // every other tool needs an API key and will return Unauthorized.
252
+ console.error('No FIRECRAWL_API_KEY or FIRECRAWL_API_URL set — running in keyless mode. ' +
253
+ 'firecrawl_scrape and firecrawl_search are free (rate-limited per IP) against the Firecrawl cloud; ' +
254
+ 'other tools require an API key (get one free at https://firecrawl.dev).');
238
255
  }
239
256
  if (httpStreaming && !credential && !process.env.FIRECRAWL_API_URL) {
240
257
  console.error('HTTP MCP transport requires FIRECRAWL_API_URL and/or credentials (OAuth: Authorization Bearer fco_..., or FIRECRAWL_API_KEY / FIRECRAWL_OAUTH_TOKEN)');
@@ -559,7 +576,6 @@ ${SAFE_MODE
559
576
  parameters: scrapeParamsSchema,
560
577
  execute: async (args, { session, log }) => {
561
578
  const { url, ...options } = args;
562
- const client = getClient(session);
563
579
  const transformed = transformScrapeParams(options);
564
580
  const cleaned = removeEmptyTopLevel(transformed);
565
581
  if (cleaned.lockdown) {
@@ -568,6 +584,15 @@ ${SAFE_MODE
568
584
  else {
569
585
  log.info('Scraping URL', { url: String(url) });
570
586
  }
587
+ if (isKeylessMode(session)) {
588
+ const json = await keylessPost('/v2/scrape', {
589
+ url: String(url),
590
+ ...cleaned,
591
+ origin: ORIGIN,
592
+ }, session);
593
+ return asText(json?.data ?? json);
594
+ }
595
+ const client = getClient(session);
571
596
  const res = await client.scrape(String(url), {
572
597
  ...cleaned,
573
598
  origin: ORIGIN,
@@ -724,7 +749,6 @@ The query also supports search operators, that you can use if needed to refine t
724
749
  })
725
750
  .refine((args) => !(args.includeDomains?.length && args.excludeDomains?.length), 'includeDomains and excludeDomains cannot both be specified'),
726
751
  execute: async (args, { session, log }) => {
727
- const client = getClient(session);
728
752
  const { query, ...opts } = args;
729
753
  const searchOpts = { ...opts };
730
754
  const includeDomains = searchOpts.includeDomains;
@@ -737,16 +761,22 @@ The query also supports search operators, that you can use if needed to refine t
737
761
  const cleaned = removeEmptyTopLevel(searchOpts);
738
762
  const searchQuery = buildSearchQueryWithDomains(query, includeDomains, excludeDomains);
739
763
  log.info('Searching', { query: searchQuery });
764
+ const searchBody = {
765
+ query: searchQuery,
766
+ ...cleaned,
767
+ origin: ORIGIN,
768
+ };
769
+ if (isKeylessMode(session)) {
770
+ const json = await keylessPost('/v2/search', searchBody, session);
771
+ return asText(json ?? {});
772
+ }
740
773
  // Call /v2/search through the SDK's HTTP layer (auth + retries) instead
741
774
  // of `client.search()` so we preserve the full response envelope. The
742
775
  // high-level `search()` helper strips `id` and `creditsUsed`, which
743
776
  // breaks the `firecrawl_search_feedback` workflow that this server
744
777
  // explicitly tells the LLM to use after every search.
745
- const httpRes = await client.http.post('/v2/search', {
746
- query: searchQuery,
747
- ...cleaned,
748
- origin: ORIGIN,
749
- });
778
+ const client = getClient(session);
779
+ const httpRes = await client.http.post('/v2/search', searchBody);
750
780
  return asText(httpRes?.data ?? {});
751
781
  },
752
782
  });
@@ -754,6 +784,74 @@ const DEFAULT_CLOUD_API_URL = 'https://api.firecrawl.dev';
754
784
  function resolveApiBaseUrl() {
755
785
  return (process.env.FIRECRAWL_API_URL || DEFAULT_CLOUD_API_URL).replace(/\/$/, '');
756
786
  }
787
+ // Keyless free tier: when no credential is configured and we're targeting the
788
+ // Firecrawl cloud (not self-hosted via FIRECRAWL_API_URL, not the multi-tenant
789
+ // CLOUD_SERVICE deployment), scrape and search are free, rate-limited per IP.
790
+ // The cloud only grants this when NO Authorization header is sent, so we bypass
791
+ // the SDK — which always attaches a Bearer header — and post directly.
792
+ /** Best-effort end-user client IP from the incoming MCP request headers. */
793
+ function extractClientIp(request) {
794
+ const xff = request?.headers?.['x-forwarded-for'];
795
+ const raw = Array.isArray(xff) ? xff[0] : xff;
796
+ const first = typeof raw === 'string' ? raw.split(',')[0].trim() : undefined;
797
+ return first || undefined;
798
+ }
799
+ /**
800
+ * Read-only check (no quota consumed) of whether a client IP can still use the
801
+ * keyless free tier, via the API's secret-gated eligibility endpoint. Fails
802
+ * closed: anything other than a clear "eligible: true" means fall through to the
803
+ * OAuth challenge rather than silently granting keyless.
804
+ */
805
+ async function keylessEligible(clientIp) {
806
+ const secret = process.env.KEYLESS_PROXY_SECRET;
807
+ if (!secret)
808
+ return false;
809
+ try {
810
+ const response = await fetch(`${resolveApiBaseUrl()}/v2/keyless/eligibility`, {
811
+ headers: {
812
+ 'x-firecrawl-keyless-ip': clientIp,
813
+ 'x-firecrawl-keyless-secret': secret,
814
+ },
815
+ });
816
+ if (!response.ok)
817
+ return false;
818
+ const json = await response.json().catch(() => ({}));
819
+ return json?.eligible === true;
820
+ }
821
+ catch {
822
+ return false;
823
+ }
824
+ }
825
+ function isKeylessMode(session) {
826
+ if (session?.firecrawlApiKey)
827
+ return false;
828
+ if (process.env.CLOUD_SERVICE === 'true') {
829
+ // Hosted: keyless only for secret-gated sessions carrying the forwarded
830
+ // client IP (so the per-IP cap is meaningful, not the shared server IP).
831
+ return !!session?.keylessClientIp;
832
+ }
833
+ // Local/stdio against the cloud (not a self-hosted FIRECRAWL_API_URL).
834
+ return !process.env.FIRECRAWL_API_URL;
835
+ }
836
+ async function keylessPost(path, body, session) {
837
+ const headers = { 'Content-Type': 'application/json' };
838
+ // Forward the real client IP (secret-authenticated) when proxying keyless
839
+ // requests through the hosted MCP, so the API rate-limits per real IP.
840
+ if (session?.keylessClientIp && process.env.KEYLESS_PROXY_SECRET) {
841
+ headers['x-firecrawl-keyless-ip'] = session.keylessClientIp;
842
+ headers['x-firecrawl-keyless-secret'] = process.env.KEYLESS_PROXY_SECRET;
843
+ }
844
+ const response = await fetch(`${resolveApiBaseUrl()}${path}`, {
845
+ method: 'POST',
846
+ headers,
847
+ body: JSON.stringify(body),
848
+ });
849
+ const json = await response.json().catch(() => ({}));
850
+ if (!response.ok) {
851
+ throw new Error(json?.error || `Firecrawl request failed (HTTP ${response.status})`);
852
+ }
853
+ return json;
854
+ }
757
855
  const SEARCH_FEEDBACK_DISABLED = ['1', 'true', 'yes', 'on'].includes((process.env.FIRECRAWL_NO_SEARCH_FEEDBACK ||
758
856
  process.env.FIRECRAWL_DISABLE_SEARCH_FEEDBACK ||
759
857
  '')
package/dist/research.js CHANGED
@@ -14,9 +14,6 @@
14
14
  */
15
15
  import { z } from 'zod';
16
16
  const BASE = '/v2/research';
17
- function asText(data) {
18
- return JSON.stringify(data, null, 2);
19
- }
20
17
  /** Append a value (or repeated array values) to a URLSearchParams instance. */
21
18
  function appendParam(params, key, value) {
22
19
  if (value == null)
@@ -35,6 +32,104 @@ function withQuery(path, params) {
35
32
  const qs = params.toString();
36
33
  return qs ? `${path}?${qs}` : path;
37
34
  }
35
+ // --- result formatting (ported from research-index-front/src/agent_eval.ts) ---
36
+ // Max authors to print per paper (with affiliations); the rest collapse to a
37
+ // "+N more" tail so a large collaboration doesn't flood the context.
38
+ const MAX_AUTHORS = 15;
39
+ // Cap each abstract so a page of hits stays within the MCP output-token limit.
40
+ const MAX_ABSTRACT_CHARS = 600;
41
+ // Per-affiliation char cap — keeps one long org string (e.g. a full multi-dept
42
+ // university address) from bloating the authors line.
43
+ const MAX_AFFIL_CHARS = 60;
44
+ // Hard ceiling on the whole authors line, as a final guard.
45
+ const MAX_AUTHORS_LINE_CHARS = 400;
46
+ /** Best display id for a paper: its arXiv id, falling back to the canonical id. */
47
+ function displayId(p) {
48
+ return p.ids?.arxiv?.[0] ?? p.paper_id ?? '?';
49
+ }
50
+ /** Format the authors line, accepting either the string or structured form. */
51
+ function fmtAuthors(authors) {
52
+ if (!authors)
53
+ return null;
54
+ let shown;
55
+ let total;
56
+ if (typeof authors === 'string') {
57
+ const names = authors
58
+ .split(',')
59
+ .map((s) => s.trim())
60
+ .filter(Boolean);
61
+ if (names.length === 0)
62
+ return null;
63
+ total = names.length;
64
+ shown = names.slice(0, MAX_AUTHORS);
65
+ }
66
+ else {
67
+ if (authors.length === 0)
68
+ return null;
69
+ total = authors.length;
70
+ shown = authors.slice(0, MAX_AUTHORS).map((a) => {
71
+ const aff = a.affiliation?.trim();
72
+ return aff ? `${a.name} (${aff.slice(0, MAX_AFFIL_CHARS)})` : a.name;
73
+ });
74
+ }
75
+ const extra = total > MAX_AUTHORS ? `; +${total - MAX_AUTHORS} more` : '';
76
+ return ('Authors: ' + shown.join('; ') + extra).slice(0, MAX_AUTHORS_LINE_CHARS);
77
+ }
78
+ /** Render ranked papers as `[id] title` / authors / abstract blocks. */
79
+ function fmtHits(results) {
80
+ if (!results || results.length === 0)
81
+ return '(no results)';
82
+ return results
83
+ .map((r) => {
84
+ const lines = [`[${displayId(r)}] ${r.title ?? '(untitled)'}`];
85
+ const authors = fmtAuthors(r.authors);
86
+ if (authors)
87
+ lines.push(authors);
88
+ lines.push((r.abstract || '(no abstract)')
89
+ .replace(/\s+/g, ' ')
90
+ .slice(0, MAX_ABSTRACT_CHARS));
91
+ return lines.join('\n');
92
+ })
93
+ .join('\n\n');
94
+ }
95
+ // Cap GitHub matched content so a page of results stays within the MCP
96
+ // output-token limit. Higher than abstracts since issue/PR threads carry the
97
+ // signal (repro steps, stack traces) the agent actually needs to verify.
98
+ const MAX_GITHUB_CONTENT_CHARS = 1200;
99
+ /**
100
+ * Render GitHub history/readme hits as `[repo#number] (kind)` / url / body
101
+ * blocks — the same shape as `fmtHits`, but tuned for issues/PRs and readmes.
102
+ * Markdown content keeps its newlines (so tables/code survive); only readmes and
103
+ * snippets fall back when full content is absent.
104
+ */
105
+ function fmtGithub(results) {
106
+ if (!results || results.length === 0)
107
+ return '(no results)';
108
+ return results
109
+ .map((r) => {
110
+ const lines = [];
111
+ if (r.resultType === 'repo_readme') {
112
+ lines.push(`[${r.repo ?? '?'}] README`);
113
+ }
114
+ else {
115
+ const ref = r.number != null ? `#${r.number}` : '';
116
+ const meta = [
117
+ r.pageType,
118
+ r.segmentCount ? `${r.segmentCount} segments` : '',
119
+ ]
120
+ .filter(Boolean)
121
+ .join(', ');
122
+ lines.push(`[${r.repo ?? '?'}${ref}]${meta ? ` (${meta})` : ''}`);
123
+ }
124
+ const url = r.readmeUrl ?? r.url;
125
+ if (url)
126
+ lines.push(url);
127
+ const body = (r.contentMd || r.snippet || '').trim();
128
+ lines.push(body ? body.slice(0, MAX_GITHUB_CONTENT_CHARS) : '(no content)');
129
+ return lines.join('\n');
130
+ })
131
+ .join('\n\n');
132
+ }
38
133
  /** Only present these tools when the session has research enabled. */
39
134
  const canAccess = (session) => session?.research === true;
40
135
  export function registerResearchTools(server, getClient) {
@@ -83,7 +178,7 @@ export function registerResearchTools(server, getClient) {
83
178
  appendParam(params, 'to', to);
84
179
  const client = getClient(session);
85
180
  const res = await client.http.get(withQuery(`${BASE}/papers`, params));
86
- return asText(res.data);
181
+ return fmtHits(res.data?.results);
87
182
  },
88
183
  });
89
184
  // --- related_papers ---
@@ -127,7 +222,8 @@ export function registerResearchTools(server, getClient) {
127
222
  appendParam(params, 'anchor', anchors);
128
223
  const client = getClient(session);
129
224
  const res = await client.http.get(withQuery(`${BASE}/papers/${encodeURIComponent(primary)}/similar`, params));
130
- return asText(res.data);
225
+ const note = res.data?.note ? `\nnote: ${res.data.note}` : '';
226
+ return `${fmtHits(res.data?.results)}\n(pool_size=${res.data?.pool_size ?? 0})${note}`;
131
227
  },
132
228
  });
133
229
  // --- read_paper ---
@@ -161,11 +257,13 @@ export function registerResearchTools(server, getClient) {
161
257
  appendParam(params, 'k', k);
162
258
  const client = getClient(session);
163
259
  const res = await client.http.get(withQuery(`${BASE}/papers/${encodeURIComponent(arxiv_id)}`, params));
164
- return asText(res.data);
260
+ const passages = res.data?.passages ?? [];
261
+ return passages.length
262
+ ? passages.map((p) => p.text).join('\n---\n')
263
+ : '(no full-text passages available for this paper)';
165
264
  },
166
265
  });
167
266
  // --- search_github ---
168
- // TODO: description pending — the user is writing this one.
169
267
  server.addTool({
170
268
  name: 'firecrawl_research_search_github',
171
269
  canAccess,
@@ -187,7 +285,7 @@ export function registerResearchTools(server, getClient) {
187
285
  appendParam(params, 'k', k);
188
286
  const client = getClient(session);
189
287
  const res = await client.http.get(withQuery(`${BASE}/github`, params));
190
- return asText(res.data);
288
+ return fmtGithub(res.data?.results);
191
289
  },
192
290
  });
193
291
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firecrawl-mcp",
3
- "version": "3.20.3",
3
+ "version": "3.20.5",
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",