firecrawl-mcp
Version:
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.
1,295 lines (1,279 loc) • 126 kB
JavaScript
#!/usr/bin/env node
// src/index.ts
import FirecrawlApp from "@mendable/firecrawl-js";
import dotenv from "dotenv";
import { FastMCP, UserError } from "fastmcp";
import { readFile } from "fs/promises";
import { createRequire } from "module";
import { randomUUID } from "crypto";
import path from "path";
import { z as z4 } from "zod";
// src/developer.ts
import { z } from "zod";
var BASE = "/v2/search/developer";
var ORIGIN_HEADERS = { "X-Origin": "mcp-fastmcp" };
var MAX_PASSAGE_CHARS = 1200;
function fmtDeveloper(results) {
if (!results || results.length === 0) return "(no results)";
return results.map((r) => {
const kind = r.type ? ` (${r.type})` : "";
const lines = [`## [${r.id ?? "?"}]${kind} ${r.title ?? "(untitled)"}`];
if (r.url) lines.push(r.url);
const body = (r.passages ?? []).map((p) => p.text ?? "").join("\n---\n").trim();
lines.push(body ? body.slice(0, MAX_PASSAGE_CHARS) : "(no content)");
return lines.join("\n");
}).join("\n\n");
}
function registerDeveloperTools(server2, getClient2) {
server2.addTool({
name: "firecrawl_developer_search",
annotations: {
title: "Search developer sources",
readOnlyHint: true,
// Semantic search over an indexed developer corpus; returns ranked results only.
openWorldHint: true,
// Searches the Firecrawl developer index of public GitHub and documentation content.
destructiveHint: false
// Query-only; no writes to external sources or the developer index.
},
description: `
For a developer question \u2014 code behaviour, a library or framework, an API contract, an error message, or a known bug \u2014 search an index built for coding agents. The index covers GitHub issues, merged pull requests, repository READMEs, and curated documentation sites. Set skills to "only" to limit the search to agent-skill files.
Returns ranked results with an ID, source type, URL, title, and the matched passages in markdown.
`,
parameters: z.object({
query: z.string().min(1).describe(
"Natural-language developer question or search phrase, including the library, error message, or API involved when relevant."
),
k: z.number().int().min(1).max(100).optional().describe("Number of ranked results to return (default 10)."),
skills: z.enum(["only"]).optional().describe('Set to "only" to search only agent-skill files.')
}),
execute: async (args2, { session }) => {
const { query, k, skills } = args2;
const params = new URLSearchParams();
params.append("query", query);
if (k != null) params.append("k", String(k));
if (skills != null) params.append("skills", skills);
const client = getClient2(session);
const res = await client.http.get(
`${BASE}?${params.toString()}`,
ORIGIN_HEADERS
);
return fmtDeveloper(res.data?.results);
}
});
}
// src/keyless-client-ip.ts
import net from "net";
function extractSingleTrustedClientIp(rawForwardedFor) {
if (Array.isArray(rawForwardedFor) && rawForwardedFor.length !== 1) {
return void 0;
}
const raw = Array.isArray(rawForwardedFor) ? rawForwardedFor[0] : rawForwardedFor;
if (typeof raw !== "string") return void 0;
const parts = raw.split(",").map((part) => part.trim()).filter(Boolean);
if (parts.length !== 1) return void 0;
const candidate = parts[0].replace(/^\[(.*)\]$/, "$1").toLowerCase();
return net.isIP(candidate) ? candidate : void 0;
}
// src/monitor.ts
import { z as z2 } from "zod";
// src/session-credential.ts
import { createHmac } from "crypto";
var managedOAuthApiKey = /* @__PURE__ */ Symbol("firecrawlManagedOAuthApiKey");
var CredentialValidationUnavailableError = class extends Error {
constructor() {
super("Firecrawl credential validation is temporarily unavailable");
this.name = "CredentialValidationUnavailableError";
}
};
function delegationSecret() {
const secret = process.env.MCP_DELEGATED_CREDENTIAL_SECRET?.trim();
if (!secret) throw new CredentialValidationUnavailableError();
return secret;
}
function requireDelegatedCredentialSigning() {
delegationSecret();
}
function setManagedOAuthApiKey(session, apiKey) {
Object.defineProperty(session, managedOAuthApiKey, {
configurable: false,
enumerable: false,
value: apiKey,
writable: false
});
return session;
}
function copyManagedOAuthApiKey(source, target) {
const apiKey = source?.[managedOAuthApiKey];
if (apiKey) setManagedOAuthApiKey(target, apiKey);
}
function hasCredential(session) {
return Boolean(session?.firecrawlApiKey || session?.[managedOAuthApiKey]);
}
function hasManagedOAuthCredential(session) {
return Boolean(session?.[managedOAuthApiKey]);
}
function credentialForOutboundRequest(session) {
const managedApiKey = session?.[managedOAuthApiKey];
if (!managedApiKey) return session?.firecrawlApiKey;
const iat = Math.floor(Date.now() / 1e3);
const payload = {
v: 1,
aud: "firecrawl-core",
purpose: "hosted_mcp_oauth",
api_key: managedApiKey,
iat,
exp: iat + 60
};
const encodedPayload = Buffer.from(JSON.stringify(payload)).toString(
"base64url"
);
const signature = createHmac("sha256", delegationSecret()).update(encodedPayload).digest("base64url");
return `fcmcp_${encodedPayload}.${signature}`;
}
// src/monitor.ts
var DEFAULT_API_URL = "https://api.firecrawl.dev";
function resolveAuth(session) {
const apiKey = session === void 0 ? process.env.FIRECRAWL_API_KEY : credentialForOutboundRequest(session);
const baseUrl = (process.env.FIRECRAWL_API_URL ?? DEFAULT_API_URL).replace(
/\/$/,
""
);
return { apiKey, baseUrl };
}
async function monitorRequest(session, path2, init = {}) {
const { apiKey, baseUrl } = resolveAuth(session);
if (!apiKey && !process.env.FIRECRAWL_API_URL) {
throw new Error("Unauthorized: API key is required for monitor requests");
}
let url = `${baseUrl}/v2${path2}`;
if (init.query) {
const qs = new URLSearchParams();
for (const [k, v] of Object.entries(init.query)) {
if (v !== void 0 && v !== null && v !== "") qs.set(k, String(v));
}
const s = qs.toString();
if (s) url += `?${s}`;
}
const headers = { "X-Origin": "mcp-fastmcp" };
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
if (init.body !== void 0) headers["Content-Type"] = "application/json";
const response = await fetch(url, {
method: init.method ?? "GET",
headers,
body: init.body !== void 0 ? JSON.stringify(init.body) : void 0
});
const payload = await response.json().catch(() => ({}));
if (!response.ok || payload?.success === false) {
const message = payload?.error || `HTTP ${response.status}: ${response.statusText || "Request failed"}`;
throw new Error(message);
}
return payload;
}
function asText(data) {
return JSON.stringify(data, null, 2);
}
var pageStatusSchema = z2.enum(["same", "new", "changed", "removed", "error"]);
var checkStatusSchema = z2.enum([
"queued",
"running",
"completed",
"failed",
"partial",
"skipped_overlap"
]);
function splitPages(page, pages) {
return [page, ...pages ?? []].filter((url) => typeof url === "string").map((url) => url.trim()).filter(Boolean);
}
function buildMonitorCreateBody(args2) {
if (args2.body && typeof args2.body === "object" && !Array.isArray(args2.body)) {
return args2.body;
}
const urls = splitPages(
args2.page,
args2.pages
);
const queries = Array.isArray(args2.queries) ? args2.queries.filter((q) => typeof q === "string").map((q) => q.trim()).filter(Boolean) : [];
const isSearch = queries.length > 0;
if (urls.length === 0 && !isSearch) {
throw new Error(
"firecrawl_monitor_create requires either `body`, `page`/`pages`, or `queries`."
);
}
const goal = typeof args2.goal === "string" ? args2.goal.trim() : "";
if (!goal) {
throw new Error(
"firecrawl_monitor_create shorthand requires `goal`. Use `body` for advanced requests without a goal."
);
}
let target;
if (isSearch) {
const includeDomains = Array.isArray(args2.includeDomains) ? args2.includeDomains.filter(
(d) => typeof d === "string"
) : void 0;
const excludeDomains = Array.isArray(args2.excludeDomains) ? args2.excludeDomains.filter(
(d) => typeof d === "string"
) : void 0;
target = {
type: "search",
queries,
...typeof args2.searchWindow === "string" && args2.searchWindow.trim() ? { searchWindow: args2.searchWindow.trim() } : {},
...typeof args2.maxResults === "number" ? { maxResults: args2.maxResults } : {},
...includeDomains && includeDomains.length > 0 ? { includeDomains } : {},
...excludeDomains && excludeDomains.length > 0 ? { excludeDomains } : {}
};
} else {
target = { type: "scrape", urls };
}
const webhookUrl = typeof args2.webhookUrl === "string" ? args2.webhookUrl.trim() : "";
const email = typeof args2.email === "string" && args2.email.trim() ? {
email: {
enabled: true,
recipients: [args2.email.trim()],
includeDiffs: Boolean(args2.includeDiffs)
}
} : void 0;
return {
name: typeof args2.name === "string" && args2.name.trim() ? args2.name.trim() : isSearch ? `Monitor ${queries[0]}` : `Monitor ${urls[0]}`,
schedule: {
text: typeof args2.scheduleText === "string" && args2.scheduleText.trim() ? args2.scheduleText.trim() : "every 30 minutes",
timezone: typeof args2.timezone === "string" && args2.timezone.trim() ? args2.timezone.trim() : "UTC"
},
goal,
targets: [target],
...email ? { notification: email } : {},
...webhookUrl ? {
webhook: {
url: webhookUrl,
events: ["monitor.page", "monitor.check.completed"]
}
} : {}
};
}
function registerMonitorTools(server2) {
server2.addTool({
name: "firecrawl_monitor_create",
annotations: {
title: "Create monitor",
readOnlyHint: false,
// Creates a new recurring monitor configuration on the Firecrawl API.
openWorldHint: true,
// Monitors user-specified URLs on the public web on a recurring schedule.
destructiveHint: false
// Additive; creates a new monitor without deleting existing monitors or external content.
},
description: `
Create a recurring scrape, crawl, or search monitor that compares each check with its retained predecessor. The simple form accepts \`page\`/\`pages\` or \`queries\` plus a plain-language \`goal\`; the advanced \`body\` form controls targets, schedule, change-tracking formats, judging, retention, webhook, and notifications.
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.
`,
parameters: z2.object({
body: z2.record(z2.string(), z2.any()).optional(),
page: z2.string().optional(),
pages: z2.array(z2.string()).optional(),
queries: z2.array(z2.string()).optional(),
searchWindow: z2.enum(["5m", "15m", "1h", "6h", "24h", "7d"]).optional(),
maxResults: z2.number().int().min(1).max(50).optional(),
includeDomains: z2.array(z2.string()).optional(),
excludeDomains: z2.array(z2.string()).optional(),
goal: z2.string().optional(),
name: z2.string().optional(),
scheduleText: z2.string().optional(),
timezone: z2.string().optional(),
email: z2.string().optional(),
includeDiffs: z2.boolean().optional(),
webhookUrl: z2.string().optional()
}),
execute: async (args2, { session, log }) => {
const body = buildMonitorCreateBody(args2);
log.info("Creating monitor", { name: String(body.name) });
const res = await monitorRequest(session, "/monitor", {
method: "POST",
body
});
return asText(res);
}
});
server2.addTool({
name: "firecrawl_monitor_list",
annotations: {
title: "List monitors",
readOnlyHint: true,
// Lists monitors for the authenticated account; no mutations.
openWorldHint: false,
// Returns only the user's Firecrawl monitor records, not arbitrary web content.
destructiveHint: false
// Read-only listing.
},
description: `
List monitors for the authenticated account with optional pagination controls. Returns one page of monitor records and pagination metadata.
`,
parameters: z2.object({
limit: z2.number().int().positive().optional(),
offset: z2.number().int().nonnegative().optional()
}),
execute: async (args2, { session }) => {
const { limit, offset } = args2;
const res = await monitorRequest(session, "/monitor", {
query: { limit, offset }
});
return asText(res);
}
});
server2.addTool({
name: "firecrawl_monitor_get",
annotations: {
title: "Get monitor",
readOnlyHint: true,
// Fetches a single monitor by ID; no mutations.
openWorldHint: false,
// Reads a specific monitor resource in the user's Firecrawl account.
destructiveHint: false
// Read-only retrieval.
},
description: `
Retrieve one monitor by ID, including its configuration and current state. This does not run or modify the monitor.
`,
parameters: z2.object({ id: z2.string() }),
execute: async (args2, { session }) => {
const { id } = args2;
const res = await monitorRequest(
session,
`/monitor/${encodeURIComponent(id)}`
);
return asText(res);
}
});
server2.addTool({
name: "firecrawl_monitor_update",
annotations: {
title: "Update monitor",
readOnlyHint: false,
// PATCHes an existing monitor (status, schedule, targets, webhooks, etc.).
openWorldHint: true,
// Can change which external URLs are monitored and how recurring scrapes run.
destructiveHint: true
// Can pause, replace, or remove monitor configuration; changes overwrite prior settings.
},
description: `
Patch an existing monitor by ID. The body can change its name, active/paused status, schedule, targets, goal, judging, webhook, notifications, or retention; these changes affect future scheduled checks.
Returns the updated monitor.
`,
parameters: z2.object({
id: z2.string(),
body: z2.record(z2.string(), z2.any())
}),
execute: async (args2, { session }) => {
const { id, body } = args2;
const res = await monitorRequest(
session,
`/monitor/${encodeURIComponent(id)}`,
{ method: "PATCH", body }
);
return asText(res);
}
});
server2.addTool({
name: "firecrawl_monitor_delete",
annotations: {
title: "Delete monitor",
readOnlyHint: false,
// Permanently deletes a monitor via DELETE on the API.
openWorldHint: true,
// Deletes a monitor that tracked open-web URLs.
destructiveHint: true
// Irreversibly removes the monitor and stops its schedule.
},
description: `
Permanently delete a monitor by ID and stop its future schedule. This operation cannot be undone and returns deletion status.
`,
parameters: z2.object({ id: z2.string() }),
execute: async (args2, { session, log }) => {
const { id } = args2;
log.info("Deleting monitor", { id });
const res = await monitorRequest(
session,
`/monitor/${encodeURIComponent(id)}`,
{ method: "DELETE" }
);
return asText(res);
}
});
server2.addTool({
name: "firecrawl_monitor_run",
annotations: {
title: "Run monitor now",
readOnlyHint: false,
// Triggers an immediate monitor check, queueing a new scrape/diff run.
openWorldHint: true,
// The triggered check scrapes external URLs configured on the monitor.
destructiveHint: false
// Starts a read-only check job; does not delete the monitor or external sites.
},
description: `
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.
`,
parameters: z2.object({ id: z2.string() }),
execute: async (args2, { session }) => {
const { id } = args2;
const res = await monitorRequest(
session,
`/monitor/${encodeURIComponent(id)}/run`,
{ method: "POST" }
);
return asText(res);
}
});
server2.addTool({
name: "firecrawl_monitor_checks",
annotations: {
title: "List monitor checks",
readOnlyHint: true,
// Lists historical check runs for a monitor; no mutations.
openWorldHint: false,
// Returns check history for a known monitor ID within the user's account.
destructiveHint: false
// Read-only listing.
},
description: `
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.
`,
parameters: z2.object({
id: z2.string(),
limit: z2.number().int().positive().optional(),
offset: z2.number().int().nonnegative().optional(),
status: checkStatusSchema.optional()
}),
execute: async (args2, { session }) => {
const { id, limit, offset, status } = args2;
const res = await monitorRequest(
session,
`/monitor/${encodeURIComponent(id)}/checks`,
{ query: { limit, offset, status } }
);
return asText(res);
}
});
server2.addTool({
name: "firecrawl_monitor_check",
annotations: {
title: "Get monitor check",
readOnlyHint: true,
// Retrieves a single check run with page-level diff results; no mutations.
openWorldHint: false,
// Reads stored check results for a known monitor/check ID in the user's account.
destructiveHint: false
// Read-only retrieval of diff snapshots and judgments.
},
description: `
Retrieve one monitor check and its page-level results, optionally filtered by page status. Pages report \`same\`, \`new\`, \`changed\`, \`removed\`, or \`error\`; configured goal judging can add a meaningful-change decision.
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.
`,
parameters: z2.object({
id: z2.string(),
checkId: z2.string(),
limit: z2.number().int().positive().optional(),
skip: z2.number().int().nonnegative().optional(),
pageStatus: pageStatusSchema.optional()
}),
execute: async (args2, { session }) => {
const { id, checkId, limit, skip, pageStatus } = args2;
const res = await monitorRequest(
session,
`/monitor/${encodeURIComponent(id)}/checks/${encodeURIComponent(checkId)}`,
{ query: { limit, skip, status: pageStatus } }
);
return asText(res);
}
});
}
// src/research.ts
import { z as z3 } from "zod";
var BASE2 = "/v2/search/research";
var ORIGIN_HEADERS2 = { "X-Origin": "mcp-fastmcp" };
function appendParam(params, key, value) {
if (value == null) return;
if (Array.isArray(value)) {
for (const v of value) {
if (v != null && String(v).length > 0) params.append(key, String(v));
}
} else {
params.append(key, String(value));
}
}
function withQuery(path2, params) {
const qs = params.toString();
return qs ? `${path2}?${qs}` : path2;
}
var MAX_AUTHORS = 15;
var MAX_ABSTRACT_CHARS = 600;
var MAX_AFFIL_CHARS = 60;
var MAX_AUTHORS_LINE_CHARS = 400;
function displayId(p) {
return p.primaryId ?? "missing-primary-id";
}
function fmtAuthors(authors) {
if (!authors) return null;
let shown;
let total;
if (typeof authors === "string") {
const names = authors.split(",").map((s) => s.trim()).filter(Boolean);
if (names.length === 0) return null;
total = names.length;
shown = names.slice(0, MAX_AUTHORS);
} else {
if (authors.length === 0) return null;
total = authors.length;
shown = authors.slice(0, MAX_AUTHORS).map((a) => {
const aff = a.affiliation?.trim();
return aff ? `${a.name} (${aff.slice(0, MAX_AFFIL_CHARS)})` : a.name;
});
}
const extra = total > MAX_AUTHORS ? `; +${total - MAX_AUTHORS} more` : "";
return ("Authors: " + shown.join("; ") + extra).slice(
0,
MAX_AUTHORS_LINE_CHARS
);
}
function fmtHits(results) {
if (!results || results.length === 0) return "(no results)";
return results.map((r) => {
const lines = [`## [${displayId(r)}] ${r.title ?? "(untitled)"}`];
const authors = fmtAuthors(r.authors);
if (authors) lines.push(authors);
lines.push(
(r.abstract || "(no abstract)").replace(/\s+/g, " ").slice(0, MAX_ABSTRACT_CHARS)
);
return lines.join("\n");
}).join("\n\n");
}
function fmtPaperMetadata(paper) {
if (!paper) return "(paper not found)";
const lines = [`# ${paper.title ?? "(untitled)"}`];
lines.push("");
lines.push(`Paper ID: ${paper.paperId ?? "?"}`);
const ids = Object.entries(paper.ids ?? {}).flatMap(
([namespace, values2]) => values2.map((value) => `${namespace}:${value}`)
).join(", ");
if (ids) lines.push(`IDs: ${ids}`);
const authors = fmtAuthors(paper.authors);
if (authors) lines.push(authors);
if (paper.categories?.length) {
lines.push(`Categories: ${paper.categories.join(", ")}`);
}
const dates = [
paper.createdDate ? `created ${paper.createdDate}` : "",
paper.updateDate ? `updated ${paper.updateDate}` : ""
].filter(Boolean).join("; ");
if (dates) lines.push(`Dates: ${dates}`);
lines.push("");
lines.push("## Abstract");
lines.push((paper.abstract || "(no abstract)").replace(/\s+/g, " "));
return lines.join("\n");
}
var MAX_GITHUB_CONTENT_CHARS = 1200;
function fmtGithub(results) {
if (!results || results.length === 0) return "(no results)";
return results.map((r) => {
const lines = [];
if (r.resultType === "repo_readme") {
lines.push(`[${r.repo ?? "?"}] README`);
} else {
const ref = r.number != null ? `#${r.number}` : "";
const meta = [
r.pageType,
r.segmentCount ? `${r.segmentCount} segments` : ""
].filter(Boolean).join(", ");
lines.push(`[${r.repo ?? "?"}${ref}]${meta ? ` (${meta})` : ""}`);
}
const url = r.readmeUrl ?? r.url;
if (url) lines.push(url);
const body = (r.contentMd || r.snippet || "").trim();
lines.push(
body ? body.slice(0, MAX_GITHUB_CONTENT_CHARS) : "(no content)"
);
return lines.join("\n");
}).join("\n\n");
}
function registerResearchTools(server2, getClient2) {
server2.addTool({
name: "firecrawl_research_search_papers",
annotations: {
title: "Search research papers",
readOnlyHint: true,
// Semantic search over indexed paper metadata; returns ranked results only.
openWorldHint: true,
// Searches the Firecrawl research paper index.
destructiveHint: false
// Query-only; no writes to external sources or the research index.
},
description: `
For topics represented in the indexed corpus, search paper metadata and abstracts with a natural-language query. Optional author, category, and date filters constrain results.
Returns ranked papers with canonical IDs, titles, authors, and abstracts.
`,
parameters: z3.object({
query: z3.string().min(1).describe(
"Natural-language research topic or question, including methods, systems, conditions, populations, interventions, or outcomes when relevant."
),
k: z3.number().int().min(1).max(500).optional().describe("Number of ranked papers to return (default 40)."),
authors: z3.array(z3.string()).optional().describe(
"Author substring filter(s); ALL must match (case-insensitive)."
),
categories: z3.array(z3.string()).optional().describe(
"Paper category filter(s) (e.g. `cs.LG`); ALL provided values must match."
),
from: z3.string().optional().describe(
"Inclusive lower bound on created/updated date (`YYYY-MM-DD`)."
),
to: z3.string().optional().describe(
"Inclusive upper bound on created/updated date (`YYYY-MM-DD`)."
)
}),
execute: async (args2, { session }) => {
const { query, k, authors, categories, from, to } = args2;
const params = new URLSearchParams();
appendParam(params, "query", query);
appendParam(params, "k", k);
appendParam(params, "authors", authors);
appendParam(params, "categories", categories);
appendParam(params, "from", from);
appendParam(params, "to", to);
const client = getClient2(session);
const res = await client.http.get(
withQuery(`${BASE2}/papers`, params),
ORIGIN_HEADERS2
);
return fmtHits(res.data?.results);
}
});
server2.addTool({
name: "firecrawl_research_inspect_paper",
annotations: {
title: "Inspect a paper",
readOnlyHint: true,
// Fetches canonical metadata (title, abstract, authors) for one paper by ID.
openWorldHint: true,
// Retrieves metadata for papers in public indexes (arXiv, PMC, DOI, etc.).
destructiveHint: false
// Read-only metadata lookup.
},
description: `
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.
`,
parameters: z3.object({
paperId: z3.string().min(1).describe(
"Canonical paperId or primaryId such as `arxiv:1706.03762`, `pmcid:PMC12530322`, `pmid:40953549`, or `doi:10.1016/j.neunet.2025.108095`."
)
}),
execute: async (args2, { session }) => {
const { paperId } = args2;
const client = getClient2(session);
const res = await client.http.get(
`${BASE2}/papers/${encodeURIComponent(paperId)}`,
ORIGIN_HEADERS2
);
return fmtPaperMetadata(res.data?.paper);
}
});
server2.addTool({
name: "firecrawl_research_related_papers",
annotations: {
title: "Find related arXiv papers",
readOnlyHint: true,
// Finds related papers via citation graph expansion; returns candidates only.
openWorldHint: true,
// Traverses relationships across the public research paper corpus.
destructiveHint: false
// Read-only graph query; no modifications.
},
description: `
Find citation-graph candidates from one to ten \`seed_ids\`; the first ID is the primary seed and later IDs are anchors. \`mode\` defaults to \`similar\` (co-citation/bibliographic coupling); \`citers\` returns papers citing a seed and \`references\` papers cited by a seed. \`intent\` ranks candidates.
Returns ranked candidates and the evaluated pool size.
`,
parameters: z3.object({
seed_ids: z3.array(z3.string()).min(1).max(10),
intent: z3.string().min(1),
mode: z3.enum(["similar", "citers", "references"]).optional(),
k: z3.number().int().min(1).max(500).optional(),
rerank: z3.boolean().optional().describe("Apply an additional rerank over the fused candidates.")
}),
execute: async (args2, { session }) => {
const { seed_ids, intent, mode, k, rerank } = args2;
const [primary, ...anchors] = seed_ids;
const params = new URLSearchParams();
appendParam(params, "intent", intent);
appendParam(params, "mode", mode);
appendParam(params, "k", k);
if (rerank != null) appendParam(params, "rerank", rerank);
appendParam(params, "anchor", anchors);
const client = getClient2(session);
const res = await client.http.get(
withQuery(
`${BASE2}/papers/${encodeURIComponent(primary)}/similar`,
params
),
ORIGIN_HEADERS2
);
const note = res.data?.note ? `
note: ${res.data.note}` : "";
return `${fmtHits(res.data?.results)}
(poolSize=${res.data?.poolSize ?? 0})${note}`;
}
});
server2.addTool({
name: "firecrawl_research_read_paper",
annotations: {
title: "Read a paper",
readOnlyHint: true,
// Retrieves relevant full-text passages from a paper; does not modify the paper.
openWorldHint: true,
// Reads from publicly indexed paper full text when available.
destructiveHint: false
// Read-only passage retrieval.
},
description: `
Retrieve in-body passages from one paper that are relevant to a specific question. Full text is available only for indexed papers; \`k\` controls the number of passages.
Returns matching passages or a notice when full text is unavailable.
`,
parameters: z3.object({
paperId: z3.string().min(1).describe(
"Canonical paperId or primaryId such as `arxiv:1706.03762`, `pmcid:PMC12530322`, `pmid:40953549`, or `doi:10.1016/j.neunet.2025.108095`."
),
question: z3.string().min(1),
k: z3.number().int().min(1).max(50).optional().describe("Number of passages to return (default 4).")
}),
execute: async (args2, { session }) => {
const { paperId, question, k } = args2;
const params = new URLSearchParams();
appendParam(params, "query", question);
appendParam(params, "k", k);
const client = getClient2(session);
const res = await client.http.get(
withQuery(`${BASE2}/papers/${encodeURIComponent(paperId)}`, params),
ORIGIN_HEADERS2
);
const passages = res.data?.passages ?? [];
return passages.length ? passages.map((p) => p.text).join("\n---\n") : "(no full-text passages available for this paper)";
}
});
server2.addTool({
name: "firecrawl_research_search_github",
annotations: {
title: "Search GitHub history",
readOnlyHint: true,
// Searches indexed GitHub issue/PR history and READMEs; returns matches only.
openWorldHint: true,
// Searches public GitHub content.
destructiveHint: false
// Query-only; does not create issues, PRs, or modify repositories.
},
description: `
Search indexed public GitHub issue, pull-request, and README content. Returns ranked matches with repository, URL, snippet, and full matched markdown when available.
`,
parameters: z3.object({
query: z3.string().min(1),
k: z3.number().int().min(1).max(100).optional()
}),
execute: async (args2, { session }) => {
const { query, k } = args2;
const params = new URLSearchParams();
appendParam(params, "query", query);
appendParam(params, "k", k);
const client = getClient2(session);
const res = await client.http.get(
withQuery(`${BASE2}/github`, params),
ORIGIN_HEADERS2
);
return fmtGithub(res.data?.results);
}
});
}
// src/www-authenticate.ts
function escapeWWWAuthenticateValue(value) {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
// src/index.ts
dotenv.config({ debug: false, quiet: true });
var require2 = createRequire(import.meta.url);
var { version: packageVersion } = require2("../package.json");
var authResultByRequest = /* @__PURE__ */ Symbol("firecrawlMcpAuthResult");
function normalizeHeader(value) {
if (value == null) return void 0;
const v = Array.isArray(value) ? value[0] : value;
const trimmed = typeof v === "string" ? v.trim() : "";
return trimmed || void 0;
}
function extractBearerToken(headers) {
const headerAuth = normalizeHeader(headers["authorization"]);
if (!headerAuth?.toLowerCase().startsWith("bearer ")) return void 0;
const raw = headerAuth.slice(7).trim();
return raw || void 0;
}
function isFirecrawlOAuthAccessToken(token) {
return token.startsWith("fco_");
}
function isFirecrawlApiKey(token) {
return token.startsWith("fc-");
}
function isLegacyKeyPathRequest(request) {
return normalizeHeader(request?.headers?.["x-firecrawl-key-transport"]) === "path";
}
function requestShouldReceiveOAuthChallenge(request, profile) {
if (!profile.acceptApiKeys) return true;
if (!request?.headers) return true;
const headerApiKey = normalizeHeader(
request.headers["x-firecrawl-api-key"] ?? request.headers["x-api-key"]
);
if (headerApiKey) return false;
const bearer = extractBearerToken(request.headers);
return !bearer || isFirecrawlOAuthAccessToken(bearer);
}
function resolveCredentialFromEnv() {
return normalizeHeader(process.env.FIRECRAWL_OAUTH_TOKEN) ?? normalizeHeader(process.env.FIRECRAWL_API_KEY);
}
function isHttpStreamingTransport() {
return process.env.HTTP_STREAMABLE_SERVER === "true" || process.env.SSE_LOCAL === "true";
}
var DEFAULT_OAUTH_ISSUER = "https://www.firecrawl.dev";
var DEFAULT_MCP_RESOURCE_URL = "https://mcp.firecrawl.dev/v2/mcp";
var DEFAULT_MCP_OAUTH_RESOURCE_URL = "https://mcp.firecrawl.dev/v2/mcp-oauth";
var DEFAULT_MCP_SEARCH_RESOURCE_URL = "https://mcp.firecrawl.dev/v2/mcp-search";
var DEFAULT_MCP_SEARCH_ENDPOINT = "/v2/mcp-search";
var MCP_CONNECTION_GUIDE_URL = "https://docs.firecrawl.dev/mcp-server";
var MCP_OAUTH_SERVER_URL = "https://mcp.firecrawl.dev/v2/mcp-oauth";
var API_KEY_SIGNUP_URL = "https://www.firecrawl.dev/app/api-keys";
function withoutTrailingSlash(value) {
return value.replace(/\/+$/, "");
}
function getOAuthIssuer() {
return withoutTrailingSlash(
normalizeHeader(process.env.FIRECRAWL_OAUTH_ISSUER) ?? DEFAULT_OAUTH_ISSUER
);
}
function getMcpResourceUrl() {
return normalizeHeader(process.env.FIRECRAWL_MCP_RESOURCE_URL) ?? DEFAULT_MCP_RESOURCE_URL;
}
function getPrimaryEndpoint() {
const endpoint = normalizeHeader(process.env.FASTMCP_ENDPOINT) ?? "/v2/mcp";
if (endpoint === "/v2/mcp" || endpoint === "/v2/mcp-oauth" || endpoint === "/v2/mcp-search") {
return endpoint;
}
throw new Error(
`Unsupported FASTMCP_ENDPOINT: ${endpoint}. Expected /v2/mcp, /v2/mcp-oauth, or /v2/mcp-search.`
);
}
function getSearchMcpResourceUrl() {
return normalizeHeader(process.env.FIRECRAWL_MCP_SEARCH_RESOURCE_URL) ?? DEFAULT_MCP_SEARCH_RESOURCE_URL;
}
function getSearchMcpEndpoint() {
const configured = normalizeHeader(process.env.FIRECRAWL_MCP_SEARCH_ENDPOINT);
if (configured && configured.startsWith("/")) {
return configured;
}
return DEFAULT_MCP_SEARCH_ENDPOINT;
}
function getOAuthProtectedResourceMetadataUrl(profile) {
const resource = new URL(profile.resourceUrl);
const base = `${resource.origin}/.well-known/oauth-protected-resource`;
return profile.id === "full" ? base : `${base}${resource.pathname}`;
}
function createOAuthChallengeResponse(error, profile, details = {}) {
if (!isMcpOAuthEnabled()) {
return void 0;
}
const errorMessage = error instanceof Error ? error.message : String(error || "Unauthorized");
const wwwAuthenticate = [
...profile.advertiseOAuth ? [
`resource_metadata="${escapeWWWAuthenticateValue(getOAuthProtectedResourceMetadataUrl(profile))}"`
] : [],
'error="invalid_token"',
`error_description="${escapeWWWAuthenticateValue(errorMessage)}"`
].join(", ");
return new Response(
JSON.stringify({
error: "invalid_token",
error_description: errorMessage,
...details
}),
{
headers: {
"Content-Type": "application/json",
"WWW-Authenticate": `Bearer ${wwwAuthenticate}`
},
status: 401
}
);
}
function createInvalidCredentialResponse(_error) {
const recovery = invalidApiKeyRecoveryPayload();
return new Response(
JSON.stringify({
error: "invalid_api_key",
error_description: recovery.message,
...recovery
}),
{
headers: { "Content-Type": "application/json" },
status: 401
}
);
}
function createInvalidOAuthRecoveryResponse(recovery) {
return new Response(
JSON.stringify({
error: "invalid_token",
error_description: recovery.message,
...recovery
}),
{
headers: { "Content-Type": "application/json" },
status: 401
}
);
}
function getOAuthIntrospectionEndpoint() {
return `${getOAuthIssuer()}/api/oauth/introspect`;
}
function getOAuthIntrospectionSecret() {
return normalizeHeader(process.env.FIRECRAWL_OAUTH_INTROSPECT_SECRET);
}
function isMcpOAuthEnabled() {
return process.env.CLOUD_SERVICE === "true";
}
function isOAuthCredentialPurpose(value) {
return value === "general" || value === "hosted_mcp_oauth";
}
var InvalidFirecrawlCredentialError = class extends Error {
constructor() {
super("The supplied Firecrawl credential is invalid or revoked. Replace it and retry.");
this.name = "InvalidFirecrawlCredentialError";
}
};
var InvalidOAuthCredentialError = class extends Error {
constructor() {
super("Invalid OAuth access token");
this.name = "InvalidOAuthCredentialError";
}
};
var MCP_GLOBAL_SCOPE = "firecrawl:global";
function values(value) {
if (typeof value === "string") return value.split(/\s+/).filter(Boolean);
return Array.isArray(value) ? value.flatMap((item) => item.split(/\s+/).filter(Boolean)) : [];
}
function audienceMatchesResource(aud, resourceUrl) {
const target = withoutTrailingSlash(resourceUrl);
return values(aud).some((entry) => withoutTrailingSlash(entry) === target);
}
function credentialMetadata(data) {
return {
teamId: typeof data.team_id === "string" ? data.team_id : void 0,
userId: typeof data.sub === "string" ? data.sub : void 0,
apiKeyId: typeof data.api_key_id === "string" ? data.api_key_id : void 0,
oauthClientId: typeof data.client_id === "string" ? data.client_id : void 0,
resource: typeof data.aud === "string" ? data.aud : void 0
};
}
async function introspectToken(token, expectedResource) {
const introspectionSecret = getOAuthIntrospectionSecret();
if (!introspectionSecret) throw new CredentialValidationUnavailableError();
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 1500);
let response;
try {
response = await fetch(getOAuthIntrospectionEndpoint(), {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${introspectionSecret}`
},
body: new URLSearchParams({
resource: expectedResource,
token,
token_type_hint: "access_token"
}),
signal: controller.signal
});
} catch {
throw new CredentialValidationUnavailableError();
} finally {
clearTimeout(timeout);
}
if (!response.ok) throw new CredentialValidationUnavailableError();
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.includes("application/json")) {
throw new CredentialValidationUnavailableError();
}
const data = await response.json();
if (typeof data.active !== "boolean") {
throw new CredentialValidationUnavailableError();
}
if (data.active && (!data.api_key || !isOAuthCredentialPurpose(data.credential_purpose) || !values(data.scope).includes(MCP_GLOBAL_SCOPE))) {
throw new CredentialValidationUnavailableError();
}
return data;
}
async function resolveCredentialFromHeaders(headers, profile) {
const bearer = extractBearerToken(headers);
const headerApiKey = normalizeHeader(
headers["x-firecrawl-api-key"] ?? headers["x-api-key"]
);
const token = headerApiKey ?? bearer;
if (!token) return void 0;
if (!profile.acceptApiKeys && !isFirecrawlOAuthAccessToken(token)) {
throw new Error(
`OAuth access token required for the Firecrawl MCP resource ${profile.endpoint}`
);
}
if (!isFirecrawlOAuthAccessToken(token) && !isFirecrawlApiKey(token)) {
return { invalid: true };
}
let data = await introspectToken(token, profile.resourceUrl);
if (isFirecrawlOAuthAccessToken(token) && !data.active && profile.acceptLegacyAudience) {
data = await introspectToken(token, DEFAULT_MCP_RESOURCE_URL);
}
if (!data.active || !data.api_key) {
if (isFirecrawlOAuthAccessToken(token)) {
throw new InvalidOAuthCredentialError();
}
return { invalid: true };
}
if (isFirecrawlApiKey(token)) {
return data.credential_purpose === "general" ? {
credential: data.api_key,
source: "api-key",
metadata: credentialMetadata(data)
} : { invalid: true };
}
const expectedAudience = profile.acceptLegacyAudience && audienceMatchesResource(data.aud, DEFAULT_MCP_RESOURCE_URL) ? DEFAULT_MCP_RESOURCE_URL : profile.resourceUrl;
if (!audienceMatchesResource(data.aud, expectedAudience)) {
throw new Error("OAuth token audience does not match this resource");
}
if (profile.requireManagedOAuth && data.credential_purpose !== "hosted_mcp_oauth") {
throw new Error("OAuth token is not a managed Firecrawl MCP credential");
}
if (data.credential_purpose === "hosted_mcp_oauth") {
requireDelegatedCredentialSigning();
return {
managedOAuthApiKey: data.api_key,
source: "oauth",
metadata: credentialMetadata(data)
};
}
return {
credential: data.api_key,
source: "oauth",
metadata: credentialMetadata(data)
};
}
async function authenticateRequest(request, profile) {
const resolved = request?.headers ? await resolveCredentialFromHeaders(request.headers, profile) : void 0;
const headerCred = resolved?.credential;
const managedCred = resolved?.managedOAuthApiKey;
const envCred = resolveCredentialFromEnv();
if (process.env.CLOUD_SERVICE === "true") {
if (!headerCred && !managedCred) {
if (resolved?.invalid) {
if (profile.allowKeyless) {
return {
authType: "api-key",
credentialError: "CREDENTIAL_INVALID",
firecrawlApiKey: void 0,
keylessClientIp: extractClientIp(request)
};
}
throw new InvalidFirecrawlCredentialError();
}
if (profile.allowKeyless) {
return {
authType: "keyless",
firecrawlApiKey: void 0,
keylessClientIp: extractClientIp(request)
};
}
if (!profile.acceptApiKeys) {
throw new Error(
`OAuth access token required for the Firecrawl MCP resource ${profile.endpoint}`
);
}
throw new Error(
"Firecrawl credentials required: OAuth access token (Authorization: Bearer fco_...) or API key (x-firecrawl-api-key)"
);
}
const session2 = {
authType: resolved?.source === "oauth" ? "oauth" : "api-key",
firecrawlApiKey: headerCred,
...isLegacyKeyPathRequest(request) ? { keyTransport: "path" } : {},
...resolved?.metadata
};
return managedCred ? setManagedOAuthApiKey(session2, managedCred) : session2;
}
const credential = headerCred ?? managedCred ?? envCred;
const httpStreaming = isHttpStreamingTransport();
if (!httpStreaming && !process.env.FIRECRAWL_API_KEY && !process.env.FIRECRAWL_API_URL) {
console.error(
"No FIRECRAWL_API_KEY or FIRECRAWL_API_URL set \u2014 running in keyless mode. firecrawl_scrape and firecrawl_search are free (rate-limited per IP) against the Firecrawl cloud; other tools require an API key (get one free at https://firecrawl.dev)."
);
}
if (httpStreaming && !credential && !process.env.FIRECRAWL_API_URL) {
console.error(
"HTTP MCP transport requires FIRECRAWL_API_URL and/or credentials (OAuth: Authorization Bearer fco_..., or FIRECRAWL_API_KEY / FIRECRAWL_OAUTH_TOKEN)"
);
process.exit(1);
}
const session = {
authType: resolved?.source === "oauth" ? "oauth" : credential ? "env" : "none",
firecrawlApiKey: headerCred ?? envCred,
...resolved?.metadata
};
return managedCred ? setManagedOAuthApiKey(session, managedCred) : session;
}
function searchCompanionAuthMode(request, session) {
if (session?.authType === "oauth") return "oauth";
if (session?.authType === "api-key") return "api-key";
const headerApiKey = normalizeHeader(
request?.headers?.["x-firecrawl-api-key"] ?? request?.headers?.["x-api-key"]
);
if (headerApiKey) return "api-key";
const bearer = request?.headers ? extractBearerToken(request.headers) : void 0;
if (bearer?.startsWith("fco_")) return "oauth";
if (bearer) return "api-key";
return "none";
}
function emitSearchCompanionAuthTelemetry(profile, request, outcome, session) {
if (process.env.CLOUD_SERVICE !== "true" || profile.id !== "search" || profile.primary === true) {
return;
}
console.log(
"[MCP_SEARCH_AUTH]",
JSON.stringify({
auth_mode: searchCompanionAuthMode(request, session),
outcome,
profile: "companion",
// Unique only to this telemetry record; it is not a cross-service
// correlation ID and does not accept client-controlled identifiers.
event_id: randomUUID(),
route: DEFAULT_MCP_SEARCH_ENDPOINT
})
);
}
function emitLegacyKeyPathTelemetry(profile, request, outcome, session) {
if (profile.id !== "full" || !isLegacyKeyPathRequest(request)) return;
console.log(
"[MCP_LEGACY_KEY_PATH]",
JSON.stringify({
auth_type: session?.authType ?? "none",
key_transport: "path",
outcome,
resource: profile.resourceUrl
})
);
}
function makeAuthenticate(profile) {
return async function authenticateWithOAuthChallenge(request) {
if (request?.[authResultByRequest]) {
return request[authResultByRequest];
}
const authResult = authenticateRequest(request, profile).then((session) => {
emitSearchCompanionAuthTelemetry(
profile,
request,
session.credentialError ? "rejected" : "accepted",
session
);
emitLegacyKeyPathTelemetry(
profile,
request,
session.credentialError ? "rejected" : "accepted",
session
);
return session;
}).catch((error) => {
emitSearchCompanionAuthTelemetry(profile, request, "rejected");
emitLegacyKeyPathTelemetry(profile, request, "rejected");
if (error instanceof InvalidFirecrawlCredentialError) {
throw createInvalidCredentialResponse(error);
}
if (error instanceof InvalidOAuthCredentialError) {
const recovery = invalidOAuthRecoveryPayload(profile);
const oauthChallenge2 = createOAuthChallengeResponse(
new Error(recovery.message),
profile,
recovery
);
throw oauthChallenge2 ?? createInvalidOAuthRecoveryResponse(recovery);
}
if (error instanceof CredentialValidationUnavailableError) {
throw new Response(
JSON.stringify({
error: "temporarily_unavailable",
error_description: error.message
}),
{
headers: { "Content-Type": "application/json" },
status: 503
}
);
}
const shouldChallenge = requestShouldReceiveOAuthChallenge(request, profile);
const oauthChallenge = shouldChallenge ? createOAuthChallengeResponse(error, profile) : void 0;
if (oauthChallenge) {
throw oauthChallenge;
}
throw error;
});
if (request) {
request[authResultByRequest] = authResult;
}
return authResult;
};
}
function removeEmptyTopLevel(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
if (v == null) continue;
if (typeof v === "string" && v.trim() === "") continue;
if (Array.isArray(v) && v.length === 0) continue;
if (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0)
continue;
out[k] = v;
}
return out;
}
var searchDomainSchema = z4.string().trim().toLowerCase().min(1).max(253).regex(
/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/,
"Domain must be a valid hostname without protocol or path"
);
function buildSearchQueryWithDomains(query, includeDomains, excludeDomains) {
if (includeDomains?.length) {
return `${query} (${includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`;
}
if (excludeDomains?.length) {
return `${query} ${excludeDomains.map((domain) => `-site:${domain}`).join(" ")}`;
}
return query;
}
var searchToolBaseFields = {
query: z4.string().min(1),
highlights: z4.boolean().optional().describe(
"Return query-relevant highlights for each search result. Set to false to keep the original search snippets."
),
limit: z4.number().optional(),
tbs: z4.string().optional(),
filter: z4.string().optional(),
location: z4.string().optional(),
includeDomains: z4.array(searchDomainSchema).optional(),
excludeDomains: z4.array(searchDomainSchema).optional(),
sources: z4.array(z4.object({ type: z4.enum(["web", "images", "news"]) })).optional(),
categories: z4.array(z4.enum(["github", "research", "pdf", "developer"])).optional().describe(
"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`."
),
enterprise: z4.array(z4.enum(["default", "anon", "zdr"])).optional()
};
function searchDomainsAreExclusive(args2) {
return !(args2.includeDomains?.length && args2.excludeDomains?.length);
}
var SEARCH_DOMAINS_CONFLICT_MESSAGE = "includeDomains and excludeDomains cannot both be specified";
var ConsoleLogger = class {
shouldLog = process.env.CLOUD_SERVICE === "true" || process.env.SSE_LOCAL === "true" || process.env.HTTP_STREAMABLE_SERVER === "true";
debug(...args2) {
if (this.shouldLog) {
console.debug("[DEBUG]", (/* @__PURE__ */ new Date()).toISOString()