firecrawl-mcp 3.21.0 → 3.21.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.
- package/dist/index.js +3235 -1153
- package/package.json +19 -7
- package/dist/monitor.js +0 -478
- package/dist/research.js +0 -344
package/dist/research.js
DELETED
|
@@ -1,344 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Firecrawl Research tools (experimental).
|
|
3
|
-
*
|
|
4
|
-
* Thin MCP wrappers over the `/v2/search/research/*` endpoints (arXiv papers + GitHub
|
|
5
|
-
* history/readmes).
|
|
6
|
-
*
|
|
7
|
-
* The installed `@mendable/firecrawl-js` predates the SDK's `research` client,
|
|
8
|
-
* so we call the endpoints directly through the SDK's HTTP layer (auth +
|
|
9
|
-
* retries) via `client.http.get(...)`, mirroring how the search tool reaches
|
|
10
|
-
* `/v2/search`.
|
|
11
|
-
*/
|
|
12
|
-
import { z } from 'zod';
|
|
13
|
-
const BASE = '/v2/search/research';
|
|
14
|
-
/** Append a value (or repeated array values) to a URLSearchParams instance. */
|
|
15
|
-
function appendParam(params, key, value) {
|
|
16
|
-
if (value == null)
|
|
17
|
-
return;
|
|
18
|
-
if (Array.isArray(value)) {
|
|
19
|
-
for (const v of value) {
|
|
20
|
-
if (v != null && String(v).length > 0)
|
|
21
|
-
params.append(key, String(v));
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
else {
|
|
25
|
-
params.append(key, String(value));
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
function withQuery(path, params) {
|
|
29
|
-
const qs = params.toString();
|
|
30
|
-
return qs ? `${path}?${qs}` : path;
|
|
31
|
-
}
|
|
32
|
-
// --- result formatting (ported from research-index-front/src/agent_eval.ts) ---
|
|
33
|
-
// Max authors to print per paper (with affiliations); the rest collapse to a
|
|
34
|
-
// "+N more" tail so a large collaboration doesn't flood the context.
|
|
35
|
-
const MAX_AUTHORS = 15;
|
|
36
|
-
// Cap each abstract so a page of hits stays within the MCP output-token limit.
|
|
37
|
-
const MAX_ABSTRACT_CHARS = 600;
|
|
38
|
-
// Per-affiliation char cap — keeps one long org string (e.g. a full multi-dept
|
|
39
|
-
// university address) from bloating the authors line.
|
|
40
|
-
const MAX_AFFIL_CHARS = 60;
|
|
41
|
-
// Hard ceiling on the whole authors line, as a final guard.
|
|
42
|
-
const MAX_AUTHORS_LINE_CHARS = 400;
|
|
43
|
-
/** Display id supplied by the API, already ordered for citation/fetch use. */
|
|
44
|
-
function displayId(p) {
|
|
45
|
-
return p.primaryId ?? 'missing-primary-id';
|
|
46
|
-
}
|
|
47
|
-
/** Format the authors line, accepting either the string or structured form. */
|
|
48
|
-
function fmtAuthors(authors) {
|
|
49
|
-
if (!authors)
|
|
50
|
-
return null;
|
|
51
|
-
let shown;
|
|
52
|
-
let total;
|
|
53
|
-
if (typeof authors === 'string') {
|
|
54
|
-
const names = authors
|
|
55
|
-
.split(',')
|
|
56
|
-
.map((s) => s.trim())
|
|
57
|
-
.filter(Boolean);
|
|
58
|
-
if (names.length === 0)
|
|
59
|
-
return null;
|
|
60
|
-
total = names.length;
|
|
61
|
-
shown = names.slice(0, MAX_AUTHORS);
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
if (authors.length === 0)
|
|
65
|
-
return null;
|
|
66
|
-
total = authors.length;
|
|
67
|
-
shown = authors.slice(0, MAX_AUTHORS).map((a) => {
|
|
68
|
-
const aff = a.affiliation?.trim();
|
|
69
|
-
return aff ? `${a.name} (${aff.slice(0, MAX_AFFIL_CHARS)})` : a.name;
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
const extra = total > MAX_AUTHORS ? `; +${total - MAX_AUTHORS} more` : '';
|
|
73
|
-
return ('Authors: ' + shown.join('; ') + extra).slice(0, MAX_AUTHORS_LINE_CHARS);
|
|
74
|
-
}
|
|
75
|
-
/** Render ranked papers as `[id] title` / authors / abstract blocks. */
|
|
76
|
-
function fmtHits(results) {
|
|
77
|
-
if (!results || results.length === 0)
|
|
78
|
-
return '(no results)';
|
|
79
|
-
return results
|
|
80
|
-
.map((r) => {
|
|
81
|
-
const lines = [`## [${displayId(r)}] ${r.title ?? '(untitled)'}`];
|
|
82
|
-
const authors = fmtAuthors(r.authors);
|
|
83
|
-
if (authors)
|
|
84
|
-
lines.push(authors);
|
|
85
|
-
lines.push((r.abstract || '(no abstract)')
|
|
86
|
-
.replace(/\s+/g, ' ')
|
|
87
|
-
.slice(0, MAX_ABSTRACT_CHARS));
|
|
88
|
-
return lines.join('\n');
|
|
89
|
-
})
|
|
90
|
-
.join('\n\n');
|
|
91
|
-
}
|
|
92
|
-
function fmtPaperMetadata(paper) {
|
|
93
|
-
if (!paper)
|
|
94
|
-
return '(paper not found)';
|
|
95
|
-
const lines = [`# ${paper.title ?? '(untitled)'}`];
|
|
96
|
-
lines.push('');
|
|
97
|
-
lines.push(`Paper ID: ${paper.paperId ?? '?'}`);
|
|
98
|
-
const ids = Object.entries(paper.ids ?? {})
|
|
99
|
-
.flatMap(([namespace, values]) => values.map((value) => `${namespace}:${value}`))
|
|
100
|
-
.join(', ');
|
|
101
|
-
if (ids)
|
|
102
|
-
lines.push(`IDs: ${ids}`);
|
|
103
|
-
const authors = fmtAuthors(paper.authors);
|
|
104
|
-
if (authors)
|
|
105
|
-
lines.push(authors);
|
|
106
|
-
if (paper.categories?.length) {
|
|
107
|
-
lines.push(`Categories: ${paper.categories.join(', ')}`);
|
|
108
|
-
}
|
|
109
|
-
const dates = [
|
|
110
|
-
paper.createdDate ? `created ${paper.createdDate}` : '',
|
|
111
|
-
paper.updateDate ? `updated ${paper.updateDate}` : '',
|
|
112
|
-
]
|
|
113
|
-
.filter(Boolean)
|
|
114
|
-
.join('; ');
|
|
115
|
-
if (dates)
|
|
116
|
-
lines.push(`Dates: ${dates}`);
|
|
117
|
-
lines.push('');
|
|
118
|
-
lines.push('## Abstract');
|
|
119
|
-
lines.push((paper.abstract || '(no abstract)').replace(/\s+/g, ' '));
|
|
120
|
-
return lines.join('\n');
|
|
121
|
-
}
|
|
122
|
-
// Cap GitHub matched content so a page of results stays within the MCP
|
|
123
|
-
// output-token limit. Higher than abstracts since issue/PR threads carry the
|
|
124
|
-
// signal (repro steps, stack traces) the agent actually needs to verify.
|
|
125
|
-
const MAX_GITHUB_CONTENT_CHARS = 1200;
|
|
126
|
-
/**
|
|
127
|
-
* Render GitHub history/readme hits as `[repo#number] (kind)` / url / body
|
|
128
|
-
* blocks — the same shape as `fmtHits`, but tuned for issues/PRs and readmes.
|
|
129
|
-
* Markdown content keeps its newlines (so tables/code survive); only readmes and
|
|
130
|
-
* snippets fall back when full content is absent.
|
|
131
|
-
*/
|
|
132
|
-
function fmtGithub(results) {
|
|
133
|
-
if (!results || results.length === 0)
|
|
134
|
-
return '(no results)';
|
|
135
|
-
return results
|
|
136
|
-
.map((r) => {
|
|
137
|
-
const lines = [];
|
|
138
|
-
if (r.resultType === 'repo_readme') {
|
|
139
|
-
lines.push(`[${r.repo ?? '?'}] README`);
|
|
140
|
-
}
|
|
141
|
-
else {
|
|
142
|
-
const ref = r.number != null ? `#${r.number}` : '';
|
|
143
|
-
const meta = [
|
|
144
|
-
r.pageType,
|
|
145
|
-
r.segmentCount ? `${r.segmentCount} segments` : '',
|
|
146
|
-
]
|
|
147
|
-
.filter(Boolean)
|
|
148
|
-
.join(', ');
|
|
149
|
-
lines.push(`[${r.repo ?? '?'}${ref}]${meta ? ` (${meta})` : ''}`);
|
|
150
|
-
}
|
|
151
|
-
const url = r.readmeUrl ?? r.url;
|
|
152
|
-
if (url)
|
|
153
|
-
lines.push(url);
|
|
154
|
-
const body = (r.contentMd || r.snippet || '').trim();
|
|
155
|
-
lines.push(body ? body.slice(0, MAX_GITHUB_CONTENT_CHARS) : '(no content)');
|
|
156
|
-
return lines.join('\n');
|
|
157
|
-
})
|
|
158
|
-
.join('\n\n');
|
|
159
|
-
}
|
|
160
|
-
export function registerResearchTools(server, getClient) {
|
|
161
|
-
// --- search_papers ---
|
|
162
|
-
server.addTool({
|
|
163
|
-
name: 'firecrawl_research_search_papers',
|
|
164
|
-
annotations: {
|
|
165
|
-
title: 'Search arXiv papers',
|
|
166
|
-
readOnlyHint: true, // Semantic search over indexed arXiv metadata; returns ranked results only.
|
|
167
|
-
openWorldHint: true, // Searches the public arXiv research corpus.
|
|
168
|
-
destructiveHint: false, // Query-only; no writes to arXiv or the research index.
|
|
169
|
-
},
|
|
170
|
-
description: 'Primary entry point for finding arXiv papers by topic. Semantic (HyDE) search over arXiv ' +
|
|
171
|
-
'abstracts; returns ranked papers with arXiv id, title, and abstract. The query should be a ' +
|
|
172
|
-
'natural-language description of what you want. Run SEVERAL distinct framings of the question ' +
|
|
173
|
-
'(sibling domains, rival methods, dataset/benchmark names) rather than one query — recall ' +
|
|
174
|
-
'improves markedly with diverse framings. Returns up to `k` results (default 40).',
|
|
175
|
-
parameters: z.object({
|
|
176
|
-
query: z.string().min(1),
|
|
177
|
-
k: z.number().int().min(1).max(500).optional(),
|
|
178
|
-
authors: z
|
|
179
|
-
.array(z.string())
|
|
180
|
-
.optional()
|
|
181
|
-
.describe('Author substring filter(s); ALL must match (case-insensitive).'),
|
|
182
|
-
categories: z
|
|
183
|
-
.array(z.string())
|
|
184
|
-
.optional()
|
|
185
|
-
.describe('arXiv category filter(s) (e.g. `cs.LG`); ALL must match.'),
|
|
186
|
-
from: z
|
|
187
|
-
.string()
|
|
188
|
-
.optional()
|
|
189
|
-
.describe('Inclusive lower bound on created/updated date (`YYYY-MM-DD`).'),
|
|
190
|
-
to: z
|
|
191
|
-
.string()
|
|
192
|
-
.optional()
|
|
193
|
-
.describe('Inclusive upper bound on created/updated date (`YYYY-MM-DD`).'),
|
|
194
|
-
}),
|
|
195
|
-
execute: async (args, { session }) => {
|
|
196
|
-
const { query, k, authors, categories, from, to } = args;
|
|
197
|
-
const params = new URLSearchParams();
|
|
198
|
-
appendParam(params, 'query', query);
|
|
199
|
-
appendParam(params, 'k', k);
|
|
200
|
-
appendParam(params, 'authors', authors);
|
|
201
|
-
appendParam(params, 'categories', categories);
|
|
202
|
-
appendParam(params, 'from', from);
|
|
203
|
-
appendParam(params, 'to', to);
|
|
204
|
-
const client = getClient(session);
|
|
205
|
-
const res = await client.http.get(withQuery(`${BASE}/papers`, params));
|
|
206
|
-
return fmtHits(res.data?.results);
|
|
207
|
-
},
|
|
208
|
-
});
|
|
209
|
-
// --- inspect_paper ---
|
|
210
|
-
server.addTool({
|
|
211
|
-
name: 'firecrawl_research_inspect_paper',
|
|
212
|
-
annotations: {
|
|
213
|
-
title: 'Inspect a paper',
|
|
214
|
-
readOnlyHint: true, // Fetches canonical metadata (title, abstract, authors) for one paper by ID.
|
|
215
|
-
openWorldHint: true, // Retrieves metadata for papers in public indexes (arXiv, PMC, DOI, etc.).
|
|
216
|
-
destructiveHint: false, // Read-only metadata lookup.
|
|
217
|
-
},
|
|
218
|
-
description: 'Fetch canonical metadata for one paper by primaryId or canonical paperId. ' +
|
|
219
|
-
'Use this after search/related results when you need the full title, abstract, authors, ' +
|
|
220
|
-
'categories, source ids, and dates rendered as markdown.',
|
|
221
|
-
parameters: z.object({
|
|
222
|
-
paperId: z
|
|
223
|
-
.string()
|
|
224
|
-
.min(1)
|
|
225
|
-
.describe('Canonical paperId or primaryId such as `arxiv:1706.03762`, `pmcid:PMC12530322`, `pmid:40953549`, or `doi:10.1016/j.neunet.2025.108095`.'),
|
|
226
|
-
}),
|
|
227
|
-
execute: async (args, { session }) => {
|
|
228
|
-
const { paperId } = args;
|
|
229
|
-
const client = getClient(session);
|
|
230
|
-
const res = await client.http.get(`${BASE}/papers/${encodeURIComponent(paperId)}`);
|
|
231
|
-
return fmtPaperMetadata(res.data?.paper);
|
|
232
|
-
},
|
|
233
|
-
});
|
|
234
|
-
// --- related_papers ---
|
|
235
|
-
server.addTool({
|
|
236
|
-
name: 'firecrawl_research_related_papers',
|
|
237
|
-
annotations: {
|
|
238
|
-
title: 'Find related arXiv papers',
|
|
239
|
-
readOnlyHint: true, // Finds related papers via citation graph expansion; returns candidates only.
|
|
240
|
-
openWorldHint: true, // Traverses relationships across the public research paper corpus.
|
|
241
|
-
destructiveHint: false, // Read-only graph query; no modifications.
|
|
242
|
-
},
|
|
243
|
-
description: 'Expand from anchor papers you have already found, via the citation graph, ranked and filtered ' +
|
|
244
|
-
'to a natural-language `intent`. Pass arXiv ids of your strongest hits as `seed_ids`. Modes: ' +
|
|
245
|
-
'`similar` (cocitation/coupling — papers in the same niche; the default), `citers` (papers ' +
|
|
246
|
-
'that cite the anchors), `references` (papers the anchors cite). This reaches relevant papers ' +
|
|
247
|
-
'that plain search misses, so use it on your best hits before finishing. A `similar` call ' +
|
|
248
|
-
'already runs a DEEP multi-round expansion internally (re-seeding from each round’s best ' +
|
|
249
|
-
'finds), so one call reaches the wider neighborhood — no need to chain many. Returns the ' +
|
|
250
|
-
'candidates plus the pool size.',
|
|
251
|
-
parameters: z.object({
|
|
252
|
-
seed_ids: z.array(z.string()).min(1).max(10),
|
|
253
|
-
intent: z.string().min(1),
|
|
254
|
-
mode: z.enum(['similar', 'citers', 'references']).optional(),
|
|
255
|
-
k: z.number().int().min(1).max(500).optional(),
|
|
256
|
-
rerank: z
|
|
257
|
-
.boolean()
|
|
258
|
-
.optional()
|
|
259
|
-
.describe('Apply an additional rerank over the fused candidates.'),
|
|
260
|
-
}),
|
|
261
|
-
execute: async (args, { session }) => {
|
|
262
|
-
const { seed_ids, intent, mode, k, rerank } = args;
|
|
263
|
-
// The endpoint takes a single primary seed in the path; any additional
|
|
264
|
-
// seeds ride along as repeated `anchor` params.
|
|
265
|
-
const [primary, ...anchors] = seed_ids;
|
|
266
|
-
const params = new URLSearchParams();
|
|
267
|
-
appendParam(params, 'intent', intent);
|
|
268
|
-
appendParam(params, 'mode', mode);
|
|
269
|
-
appendParam(params, 'k', k);
|
|
270
|
-
if (rerank != null)
|
|
271
|
-
appendParam(params, 'rerank', rerank);
|
|
272
|
-
appendParam(params, 'anchor', anchors);
|
|
273
|
-
const client = getClient(session);
|
|
274
|
-
const res = await client.http.get(withQuery(`${BASE}/papers/${encodeURIComponent(primary)}/similar`, params));
|
|
275
|
-
const note = res.data?.note ? `\nnote: ${res.data.note}` : '';
|
|
276
|
-
return `${fmtHits(res.data?.results)}\n(poolSize=${res.data?.poolSize ?? 0})${note}`;
|
|
277
|
-
},
|
|
278
|
-
});
|
|
279
|
-
// --- read_paper ---
|
|
280
|
-
server.addTool({
|
|
281
|
-
name: 'firecrawl_research_read_paper',
|
|
282
|
-
annotations: {
|
|
283
|
-
title: 'Read a paper',
|
|
284
|
-
readOnlyHint: true, // Retrieves relevant full-text passages from a paper; does not modify the paper.
|
|
285
|
-
openWorldHint: true, // Reads from publicly indexed paper full text when available.
|
|
286
|
-
destructiveHint: false, // Read-only passage retrieval.
|
|
287
|
-
},
|
|
288
|
-
description: 'Read the most relevant in-body (full-text) passages of ONE specific paper for a question. Use ' +
|
|
289
|
-
'this to VERIFY whether a candidate actually satisfies a constraint before you include or ' +
|
|
290
|
-
"reject it (e.g. 'does this paper actually use technique X / report a score on benchmark Y'). " +
|
|
291
|
-
"Returns the best-matching passages, or a notice if the paper's full text is unavailable.",
|
|
292
|
-
parameters: z.object({
|
|
293
|
-
paperId: z
|
|
294
|
-
.string()
|
|
295
|
-
.min(1)
|
|
296
|
-
.describe('Canonical paperId or primaryId such as `arxiv:1706.03762`, `pmcid:PMC12530322`, `pmid:40953549`, or `doi:10.1016/j.neunet.2025.108095`.'),
|
|
297
|
-
question: z.string().min(1),
|
|
298
|
-
k: z
|
|
299
|
-
.number()
|
|
300
|
-
.int()
|
|
301
|
-
.min(1)
|
|
302
|
-
.max(50)
|
|
303
|
-
.optional()
|
|
304
|
-
.describe('Number of passages to return (default 4).'),
|
|
305
|
-
}),
|
|
306
|
-
execute: async (args, { session }) => {
|
|
307
|
-
const { paperId, question, k } = args;
|
|
308
|
-
const params = new URLSearchParams();
|
|
309
|
-
appendParam(params, 'query', question);
|
|
310
|
-
appendParam(params, 'k', k);
|
|
311
|
-
const client = getClient(session);
|
|
312
|
-
const res = await client.http.get(withQuery(`${BASE}/papers/${encodeURIComponent(paperId)}`, params));
|
|
313
|
-
const passages = res.data?.passages ?? [];
|
|
314
|
-
return passages.length
|
|
315
|
-
? passages.map((p) => p.text).join('\n---\n')
|
|
316
|
-
: '(no full-text passages available for this paper)';
|
|
317
|
-
},
|
|
318
|
-
});
|
|
319
|
-
// --- search_github ---
|
|
320
|
-
server.addTool({
|
|
321
|
-
name: 'firecrawl_research_search_github',
|
|
322
|
-
annotations: {
|
|
323
|
-
title: 'Search GitHub history',
|
|
324
|
-
readOnlyHint: true, // Searches indexed GitHub issue/PR history and READMEs; returns matches only.
|
|
325
|
-
openWorldHint: true, // Searches public GitHub content.
|
|
326
|
-
destructiveHint: false, // Query-only; does not create issues, PRs, or modify repositories.
|
|
327
|
-
},
|
|
328
|
-
description: 'Search GitHub issue/PR history and repository readmes. Returns ranked matches with repo, ' +
|
|
329
|
-
'url, a short snippet, and (when available) the full matched content in markdown.',
|
|
330
|
-
parameters: z.object({
|
|
331
|
-
query: z.string().min(1),
|
|
332
|
-
k: z.number().int().min(1).max(100).optional(),
|
|
333
|
-
}),
|
|
334
|
-
execute: async (args, { session }) => {
|
|
335
|
-
const { query, k } = args;
|
|
336
|
-
const params = new URLSearchParams();
|
|
337
|
-
appendParam(params, 'query', query);
|
|
338
|
-
appendParam(params, 'k', k);
|
|
339
|
-
const client = getClient(session);
|
|
340
|
-
const res = await client.http.get(withQuery(`${BASE}/github`, params));
|
|
341
|
-
return fmtGithub(res.data?.results);
|
|
342
|
-
},
|
|
343
|
-
});
|
|
344
|
-
}
|