import { ALEXANDRIA_FEEDBACK_HINT, alexandriaCallsWarrantFeedback, alexandriaFeedbackFields, alexandriaFeedbackTokens, alexandriaSessionFeedbackSchema, withAlexandriaFeedbackHint, } from './alexandria-feedback.js'; import FirecrawlApp from 'firecrawl'; import type { Hono } from 'hono'; import { AjvJsonSchemaValidator } from '@modelcontextprotocol/server/validators/ajv'; import type { JsonSchemaType, JsonSchemaValidator } from '@modelcontextprotocol/server'; import { type ContentResult, type DeferredResult, type Logger, McpRuntime, type ModernRequest, type Tool, UserError as RuntimeUserError, } from './mcp/runtime.js'; import { jobFailed, tasksExtension, type TaskHandler } from './tasks.js'; import { strictJsonSchema, toJsonSchema } from 'xsschema'; import type { IncomingHttpHeaders } from 'http'; import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import { randomUUID } from 'node:crypto'; import path from 'node:path'; import { z } from 'zod'; import { agentOutputSchema, agentStatusOutputSchema, crawlOutputSchema, deprecatedToolOutputSchema, feedbackOutputSchema, findToolsOutputSchema, interactOutputSchema, interactStopOutputSchema, mapOutputSchema, parseOutputSchema, scrapeOutputSchema, searchOutputSchema, structuredCompact, structuredJsonText, structuredText, withStructured, } from './tool-output'; import { findToolsSchema, findToolsOptions, withFindToolsNavigation, withoutDiscoveredToolPrices, withoutFindToolsPrices, withoutResultPrices, hasAlexandria, defaultDomainTools, normalizeSearchSources, withAlexandriaSource, enterpriseAllowsAlexandria, searchQueryIsValid, ALEXANDRIA_SEARCH_INSTRUCTIONS, ALEXANDRIA_CONTRACT_GUIDANCE, ALEXANDRIA_TERMS_GUIDANCE, ALEXANDRIA_CATALOGUE_SENTENCE, ALEXANDRIA_CATALOGUE_VERTICALS, ALEXANDRIA_SOURCES_ALWAYS_ON, } from './alexandria'; import { alexandriaOutput } from './alexandria-output'; import { FeedbackTokenRegistry, FeedbackTokenStore, withFeedbackRefs } from './alexandria-feedback-tokens.js'; import { registerDeveloperTools } from './developer'; import { registerGovTools } from './gov'; import { registerEnrichTools } from './enrich'; import { registerMonitorTools } from './monitor'; import { registerResearchTools } from './research'; import { fitSearchResponse, searchByteBudget } from './search-budget'; import { registerUsageTools } from './usage'; import { DECLARED_ORIGIN_HEADER, originHeaders, requestOrigin, type McpClient, } from './origin'; import { integrationFrom, withIntegration } from './integration'; import { apiErrorDetails, CoreHttpError } from './core-http-error.js'; import { normalizeHeader } from './headers.js'; import { FIRECRAWL_CREDENTIAL_REJECTED, KEYLESS_TOOL_NAMES, keylessQuotaReason, keylessSignupUrlFrom, recoveryPayload, runWithCredentialRecovery, } from './recovery.js'; import { assertExchangeCredential as assertExchangeCredentialPresent, DATA_SOURCES_SETTINGS_URL, exchangeCallSchema, exchangeCallsSchema, isTermsWrite, postSearchWithFallback, relayExchangeError, relayTermsRequired, removeEmptyTopLevel, SEARCH_CATEGORIES_CONFLICT_MESSAGE, SEARCH_DOMAINS_CONFLICT_MESSAGE, SEARCH_RESULT_SOURCES, annotateResultPositions, searchCategoriesAreCompatible, searchDomainsAreExclusive, searchToolBaseFields, termsWriteError, } from './tool-helpers.js'; import { httpUrl, normalizeToolArguments } from './tool-arguments.js'; /** * Library entry point: `import { createFirecrawlMcpServer } from 'firecrawl-mcp/server'`. * * Unstable: this API, its options and the types below may change in any * release, including minor and patch releases. Embedders use the types * exported here rather than the MCP SDK's own. * @packageDocumentation */ /** * An error whose message is shown to the agent as the tool result. `extras` * becomes the result's structured content. */ export const UserError: new ( message: string, extras?: Record ) => Error & { extras?: Record } = RuntimeUserError; /** What a tool's `execute` or `beforeValidate` may return. */ export type ToolResult = { content: Array< | { type: 'text'; text: string } | { type: 'image' | 'audio'; data: string; mimeType: string } | { type: 'resource'; resource: { uri: string; mimeType?: string; text?: string; blob?: string }; } | { type: 'resource_link'; uri: string; name: string; title?: string; description?: string; mimeType?: string; size?: number; } >; structuredContent?: Record; isError?: boolean; }; const require = createRequire(import.meta.url); const { version: packageVersion } = require('../package.json') as { version: string; }; /** * Per-connection state produced by `authenticate` and handed to every tool * call. Embedders may add their own fields. */ export interface SessionData { /** * FC API key (`fc-...`) or OAuth access token (`fco_...`) sent as * `Authorization: Bearer ...` to the Firecrawl API. */ firecrawlApiKey?: string; /** * The User-Agent of the HTTP request that opened the session, one of the * client signals a stateless HTTP tool call carries (see src/origin.ts). */ clientUserAgent?: string; /** The `X-Firecrawl-Origin` header: the integration the connection names. */ clientOrigin?: string; /** The `Origin` or `Referer` header of that request. */ clientReferrer?: string; /** * The client an earlier initialize named, for stateless HTTP calls that do * not carry it. Set by an embedder that can recover it; read only when the * User-Agent names a runtime rather than a client. */ initializeClient?: { name?: string; version?: string }; /** * The OAuth client id the access token was issued to. A metadata URL id * names the product; read only when the User-Agent names a runtime. */ oauthClientId?: string; /** The registered name of that client; read when the id is not a URL. */ oauthClientName?: string; authType?: 'api-key' | 'oauth' | 'env' | 'keyless' | 'none'; /** Admitted, but the supplied credential cannot be used. */ credentialError?: 'CREDENTIAL_INVALID'; /** Set per tool call. */ requestId?: string; [key: string]: unknown; } /** Per-connection session state; alias of {@link SessionData}. */ export type Session = SessionData; /** Logs to the connected client. */ export type ToolLogger = { debug(message: string, data?: unknown): void; error(message: string, data?: unknown): void; info(message: string, data?: unknown): void; warn(message: string, data?: unknown): void; }; /** What a tool's `execute` receives besides its arguments. */ export type ToolContext = { session?: Session; log: ToolLogger; /** * The connected client; `version` is the clientInfo sent at initialize, or * with `perRequest` set, the clientInfo a 2026-07-28 call carries itself. */ client?: { version?: { name?: string; version?: string }; perRequest?: boolean }; /** * The protocol revision 2026-07-28 details of the call: the client * capabilities it declared, and the input responses and request state of a * retried multi round-trip call. Absent on 2025-era calls, which can only * take a complete result. */ mcp?: ModernRequest; /** * Set when the call runs as an MCP task: a tool that starts a Firecrawl job * returns as soon as the job is accepted instead of waiting for it. */ task?: boolean; [key: string]: unknown; }; export type ToolIcon = { src: string; mimeType?: string; sizes?: string[]; theme?: 'light' | 'dark'; }; /** A tool as registered on the server. */ export interface ToolDefinition { name: string; description?: string; icons?: ToolIcon[]; /** A Standard Schema (for example a zod object) for the arguments. */ parameters?: unknown; /** JSON Schema of the structured result. */ outputSchema?: unknown; annotations?: { title?: string; readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean; [key: string]: unknown; }; _meta?: Record; /** Hide the tool from a session's tools/list. */ canList?(session: Session): boolean; /** Answer the call before argument validation by returning a result. */ beforeValidate?( args: unknown, session: Session ): ToolResult | undefined | Promise; /** * Runs the call. On protocol revision 2026-07-28 (`context.mcp` is set) it * may answer `inputRequired(...)` to ask the client for input, or a task * (`resultType: "task"`) when the client declared the tasks extension; a * {@link TaskHandler} added with `ExtraToolsContext.addTaskHandler` then * serves the task. */ execute( args: any, context: ToolContext ): Promise; } /** The runtime shape of a tool, used inside the core. */ type RegisteredTool = Tool; /** Registers a tool onto an instance. */ export type ToolRegistrar = { addTool(tool: ToolDefinition): void }; export type ResourceContent = ({ text: string } | { blob: string }) & { uri?: string; mimeType?: string; _meta?: Record; }; export interface ResourceDefinition { uri: string; name: string; description?: string; mimeType?: string; load(session?: Session): Promise; } export type ResourceRegistrar = { addResource(resource: ResourceDefinition): void; }; /** The HTTP request `authenticate` receives; absent for stdio. */ export type AuthenticationRequest = { headers: IncomingHttpHeaders; url?: string; }; /** The HTTP application routes can be added to (Hono). */ export type HttpApp = Hono; /** Reported around each tool call; `started` precedes the outcome. */ export type ToolCallEvent = { tool: string; session: SessionData; requestId: string; } & ( | { status: 'started' } | { status: 'success'; result: unknown } | { status: 'error'; error: unknown } ); /** A completed MCP task's result, for {@link FirecrawlMcpServerHooks.taskResult}. */ export type TaskResultEvent = { /** The tool whose call started the task. */ tool: string; /** The Firecrawl job behind the task. */ jobId: string; result: ToolResult; session: SessionData; /** The client, from the request's `clientInfo` metadata. */ client: { version?: { name?: string; version?: string }; perRequest?: boolean }; }; /** Per-request credential and extra headers for calls to the Firecrawl API. */ export type OutboundRequest = { /** Replaces the session's API key as the bearer credential. */ credential?: string; headers?: Record; }; /** OAuth 2.0 protected-resource metadata (RFC 9728) served by the instance. */ export type ProtectedResourceMetadata = { authorizationServers: string[]; bearerMethodsSupported?: string[]; resource: string; resourceName?: string; scopesSupported?: string[]; }; export type ExtraToolsContext = { /** Firecrawl client for a session, honouring `outboundRequest`. */ getClient(session?: SessionData): FirecrawlApp; /** Whether a session will send a credential to the Firecrawl API. */ hasCredential(session?: SessionData): boolean; /** A built-in tool definition, including one `toolFilter` excluded. */ /** Built-in tools always answer a complete result. */ builtInTool(name: string): ToolDefinition | undefined; /** * Serves the tasks the embedder's tools return with `resultType: "task"` * (protocol revision 2026-07-28, with `modernProtocol` on). */ addTaskHandler(handler: TaskHandler): void; }; export type InstructionDefaults = { /** Instructions for sessions with a credential. */ authenticated: string; /** Instructions for the keyless tool surface. */ keyless: string; }; /** * Extension points for embedding the server. Unstable: names and signatures * may change in any release. */ export interface FirecrawlMcpServerHooks { /** Replaces credential resolution. Throw a `Response` to reject the request. */ authenticate?: ( request: AuthenticationRequest | undefined ) => Promise; /** Decorates every registered tool, including tools from `registerExtraTools`. */ wrapTool?: (tool: ToolDefinition) => ToolDefinition; /** * Observes each tool call, and each built-in MCP task method as * `tasks/get:` or `tasks/cancel:`. A `tasks/get` is reported * only when it fails or returns a finished task, so polling a running task * adds no events; a task whose job failed is reported as an error * (`TaskJobFailed`). Must not throw. */ onToolResult?: (event: ToolCallEvent) => void; /** * Decorates the result of a completed `firecrawl_agent` or * `firecrawl_crawl` task each time `tasks/get` returns it. Never called * for a task that is still running. */ taskResult?: (event: TaskResultEvent) => Promise; /** Server instructions, or a function choosing them from the defaults. */ instructions?: string | ((defaults: InstructionDefaults) => string); /** Adds tools after the built-in ones. They bypass `toolFilter`. */ registerExtraTools?: ( registrar: ToolRegistrar, context: ExtraToolsContext ) => void; registerResources?: (registrar: ResourceRegistrar) => void; /** Credential and headers for each Firecrawl API request of a session. */ outboundRequest?: ( session: SessionData | undefined ) => OutboundRequest | undefined; /** Runs before a keyless request; throw to refuse it. */ beforeKeylessRequest?: ( session: SessionData | undefined, origin: string ) => Promise; /** Built-in tools whose name fails the filter are not registered. */ toolFilter?: (name: string) => boolean; /** Adds HTTP routes. Runs before the built-in routes, so its routes win. */ configureHttp?: (app: HttpApp) => void; /** Serve OAuth protected-resource metadata for this instance. */ oauth?: { protectedResource: ProtectedResourceMetadata }; } export interface FirecrawlMcpServerOptions { /** Firecrawl API base URL. Unset targets the Firecrawl cloud. */ apiUrl?: string; /** Credential used when a request does not carry one. */ apiKey?: string; /** Transport the instance is started with. Defaults to `stdio`. */ transport?: 'stdio' | 'httpStream'; /** Write server logs to the console. Defaults to true for HTTP. */ logging?: boolean; /** Restrict browser actions and webhooks, and mark scrape read-only. */ safeMode?: boolean; /** `local` (default) reads files from this machine; `upload` uses a two-phase upload flow. */ fileAccess?: 'local' | 'upload'; /** Refuse Firecrawl API calls from sessions without a credential. */ requireCredential?: boolean; /** Register firecrawl_search_feedback. Defaults to true. */ searchFeedback?: boolean; /** Register firecrawl_feedback. Defaults to true. */ endpointFeedback?: boolean; /** Tag sent as the `integration` field of API requests that accept one, such as `_zed`. */ integration?: string; /** * Also serve protocol revision 2026-07-28 over stdio and Streamable HTTP: * `server/discover`, per-request capabilities, multi round-trip requests * and MCP Tasks (`firecrawl_agent` and `firecrawl_crawl` run as tasks for * clients that declare the extension). Every 2025-era request is served as * before. Defaults to false. */ modernProtocol?: boolean; /** Extension hooks. Unstable: may change in any release. */ unstable_hooks?: FirecrawlMcpServerHooks; } export type FirecrawlMcpServerStartArgs = | { transportType: 'stdio' } | { transportType: 'httpStream'; httpStream: { port: number; /** * Address to listen on. Defaults to `FASTMCP_HOST`, then `localhost`, * which Node binds to a single address (often `::1` only), so pass * `127.0.0.1`, `::` or `0.0.0.0` to choose explicitly. */ host?: string; /** Defaults to `FASTMCP_ENDPOINT`, then `/mcp`. */ endpoint?: `/${string}`; stateless?: boolean; /** Largest request body accepted, in bytes. Defaults to 1 MiB; larger bodies get 413. */ maxBodySize?: number; /** * Host names the server answers. When set, a request whose `Host` * header, or `Origin` header when present, names another host gets * `403` (`GET` and `HEAD` on the health path excepted), and CORS * headers echo the request's origin instead of `*`. * Entries are host names or IP addresses (`localhost`, `127.0.0.1`, * `[::1]`); ports are ignored, and a URL entry counts as its host name. * `['*']` turns the check off. Unset, every host is answered and CORS * allows any origin. */ allowedHosts?: string[]; }; }; export interface FirecrawlMcpServer { start(args: FirecrawlMcpServerStartArgs): Promise; stop(): Promise; } /* * Helpers the built-in tools use, exported so embedders can build tools and * hooks that behave like the built-in ones. Unstable, like the rest of this * entry point. */ export { normalizeHeader, withoutTrailingSlash } from './headers.js'; export { acceptedContent, inputRequired, inputResponse } from '@modelcontextprotocol/server'; export type { DeferredResult, ModernRequest } from './mcp/runtime.js'; export type { TaskCaller, TaskHandler, TaskState } from './tasks.js'; export { TASKS_EXTENSION } from './tasks.js'; export { escapeWWWAuthenticateValue } from './mcp/www-authenticate.js'; export { DECLARED_ORIGIN_HEADER, initializeClientSlug, isRuntimeUserAgent, originHeaders, requestOrigin, } from './origin.js'; export { invalidApiKeyRecoveryPayload, invalidOAuthRecoveryPayload, keylessQuotaReason, keylessSignupUrlFrom, recoveryPayload, } from './recovery.js'; export { ALEXANDRIA_CATALOGUE_VERTICALS, ALEXANDRIA_SEARCH_INSTRUCTIONS, defaultDomainTools, hasAlexandria, normalizeSearchSources, withAlexandriaSource, enterpriseAllowsAlexandria, searchQueryIsValid, } from './alexandria.js'; export { searchOutputSchema } from './tool-output.js'; export { assertExchangeCredential, postSearchWithFallback, relayExchangeError, relayTermsRequired, removeEmptyTopLevel, SEARCH_CATEGORIES_CONFLICT_MESSAGE, SEARCH_DOMAINS_CONFLICT_MESSAGE, searchCategoriesAreCompatible, searchDomainsAreExclusive, searchToolBaseFields, SEARCH_RESULT_SOURCES, annotateResultPositions, } from './tool-helpers.js'; /** * Tools a keyless session can call. A copy, so changing it cannot change * which tools the server itself treats as keyless. */ const keylessToolNames: ReadonlySet = new Set(KEYLESS_TOOL_NAMES); export { keylessToolNames as KEYLESS_TOOL_NAMES }; /** Compact JSON text of `data`, with `data` as structured content when it is an object. */ const structuredCompactResult: (data: unknown) => ToolResult = structuredCompact; export { structuredCompactResult as structuredCompact }; const authResultByRequest = Symbol('firecrawlMcpAuthResult'); const outputSchemaValidator = new AjvJsonSchemaValidator(); type CachedAuthRequest = AuthenticationRequest & { [authResultByRequest]?: Promise; }; /** Placeholder SDK key for sessions whose credential is supplied per request. */ const REQUEST_SCOPED_CREDENTIAL = 'request-scoped-credential'; function headerApiKey(headers: IncomingHttpHeaders): string | undefined { const explicit = normalizeHeader( headers['x-firecrawl-api-key'] ?? headers['x-api-key'] ); if (explicit) return explicit; const authorization = normalizeHeader(headers['authorization']); if (!authorization?.toLowerCase().startsWith('bearer ')) return undefined; return authorization.slice(7).trim() || undefined; } /** * The session copy a tool call runs with. Property descriptors are copied so * non-enumerable state an `authenticate` hook attached survives the copy. */ function copySession(session: SessionData | undefined): SessionData { return Object.defineProperties( {}, Object.getOwnPropertyDescriptors(session ?? {}) ) as SessionData; } /** The `onToolResult` error for a `tasks/get` that returns a task whose Firecrawl job failed. */ class TaskJobFailedError extends Error { override name = 'TaskJobFailed'; } class ConsoleLogger implements Logger { constructor(private readonly shouldLog: boolean) {} debug(...args: unknown[]): void { if (this.shouldLog) { console.debug('[DEBUG]', new Date().toISOString(), ...args); } } error(...args: unknown[]): void { if (this.shouldLog) { console.error('[ERROR]', new Date().toISOString(), ...args); } } info(...args: unknown[]): void { if (this.shouldLog) { console.log('[INFO]', new Date().toISOString(), ...args); } } log(...args: unknown[]): void { if (this.shouldLog) { console.log('[LOG]', new Date().toISOString(), ...args); } } warn(...args: unknown[]): void { if (this.shouldLog) { console.warn('[WARN]', new Date().toISOString(), ...args); } } } // Leads both profiles so clients that cut server instructions short keep it. const OUT_OF_CREDITS_INSTRUCTION = 'If Firecrawl reports that credits ran out or the daily limit without an account was reached, end your final answer with the line it provides, link included.'; const FULL_PROFILE_INSTRUCTIONS = `${OUT_OF_CREDITS_INSTRUCTION} Firecrawl provides web search, page retrieval, site URL discovery, multi-page collection, structured page data, monitoring, and multi-source research that returns structured data. Match the requested operation to the tool boundary: firecrawl_scrape retrieves one supplied page and can return JSON matching a supplied schema, firecrawl_map enumerates URLs under a site without retrieving their content, and firecrawl_agent runs multi-source research and returns structured data when the URLs are not known or the answer spans several sites (an entity plus its fields, a list, a dataset); its result is read with firecrawl_agent_status. Authenticated firecrawl_search returns web results together with matching Alexandria providers in data.tools. ${ALEXANDRIA_CATALOGUE_SENTENCE} A matching provider can return the same fields across several entities, provenance, exact figures or timestamps, or a large set of records through firecrawl_scrape with its published contract. If web results already answer the question, use them. For the same fields across multiple pages, firecrawl_find_tools offers free provider discovery. Use firecrawl_find_tools to read a contract that was not returned in full or to browse the catalogue by category. ${ALEXANDRIA_SOURCES_ALWAYS_ON} If no provider fits, continue with web search or firecrawl_agent. For biomedical, life-science, clinical, or arXiv literature, the firecrawl_research_* tools search a paper index of abstracts and full text; firecrawl_search with categories: ["research"] is a website filter over ordinary web results and reaches different sources. For a programming question (code behaviour, a library or framework, an API contract, an error message, or a known bug), firecrawl_developer_search (or firecrawl_search with categories: ["developer"]) searches an index of public repositories, GitHub issues, merged pull requests, READMEs, and code documentation. For a legal or regulatory question, firecrawl_gov_search searches primary law and regulatory material from US federal, state, and local government sources: statutes, regulations, codes, court opinions, and other government publications; firecrawl_search with categories: ["gov"] reaches the same sources and cannot be combined with other categories. firecrawl_search with sources: [{type: "alexandria"}] returns compact tool summaries in data.tools; toolDetail: "full" includes contracts, firecrawl_find_tools starts with categories, lists providers, then compact tools, and expands the selected full contract, and firecrawl_scrape with alexandria: [{provider, capability, options}] executes up to ten capabilities and returns their results. Provide only the required inputs and account for stated network or external side effects.`; const KEYLESS_PROFILE_INSTRUCTIONS = `${OUT_OF_CREDITS_INSTRUCTION} Hosted keyless sessions expose firecrawl_search, firecrawl_scrape, and firecrawl_parse with usage limits. Alexandria provider calls require a Firecrawl API key and are not included in the hosted keyless tier. firecrawl_search searches the web. For programming questions, firecrawl_search with categories: ["developer"] searches indexed public repositories, GitHub issues, merged pull requests, repository READMEs, and code documentation. For legal or regulatory questions, firecrawl_search with categories: ["gov"] searches US government legal and regulatory sources and cannot be combined with other categories. For biomedical, life-science, clinical, or arXiv literature, firecrawl_search with categories: ["research"] filters ordinary web results to research-affiliated websites. firecrawl_scrape retrieves one supplied page and can return JSON matching a supplied schema. firecrawl_parse processes supported local files through its two-phase upload flow. An Authorization bearer API key can provide higher usage limits and expose additional tools, subject to plan, deployment, and team policy, including firecrawl_map for site URL discovery, firecrawl_agent and firecrawl_agent_status for multi-source research that returns structured data when the URLs are not known, firecrawl_research_* for paper-index research, firecrawl_developer_search for Developer Index searches of public repositories, GitHub issues, merged pull requests, repository READMEs, and code documentation, and firecrawl_find_tools as the progressive Alexandria catalogue lookup alongside the Alexandria options of firecrawl_search and firecrawl_scrape for catalogued data providers.`; function deprecatedExtractPayload() { return { code: 'DEPRECATED_TOOL', message: 'firecrawl_extract is deprecated and unavailable through MCP. For structured data from a known page, call firecrawl_scrape once per URL with formats: ["json"] and jsonOptions containing the prompt and schema. When the URLs are not known or the data spans several sites, use firecrawl_agent for multi-source research.', replacement: { name: 'firecrawl_scrape', instructions: 'Call once per known URL. Set formats to ["json"] and pass the extraction prompt and JSON schema in jsonOptions.', example_arguments: { url: 'https://example.com/page', formats: ['json'], jsonOptions: { prompt: 'Extract the requested fields from this page.', schema: { type: 'object', properties: {}, }, }, }, }, docs_url: 'https://docs.firecrawl.dev/developer-guides/usage-guides/choosing-the-data-extractor', }; } export function createFirecrawlMcpServer( options: FirecrawlMcpServerOptions = {} ): FirecrawlMcpServer { const hooks = options.unstable_hooks ?? {}; const config = { apiUrl: options.apiUrl, apiKey: options.apiKey, transport: options.transport ?? 'stdio', safeMode: options.safeMode ?? false, fileAccess: options.fileAccess ?? 'local', requireCredential: options.requireCredential ?? false, searchFeedback: options.searchFeedback ?? true, endpointFeedback: options.endpointFeedback ?? true, integration: integrationFrom(options.integration), }; const logging = options.logging ?? config.transport === 'httpStream'; const instructionDefaults: InstructionDefaults = { authenticated: FULL_PROFILE_INSTRUCTIONS, keyless: KEYLESS_PROFILE_INSTRUCTIONS, }; const instructions = typeof hooks.instructions === 'function' ? hooks.instructions(instructionDefaults) : (hooks.instructions ?? (config.apiKey ? instructionDefaults.authenticated : instructionDefaults.keyless)); async function defaultAuthenticate( request: AuthenticationRequest | undefined ): Promise { // Header credentials apply only to Firecrawl API keys; anything else is // ignored in favour of the configured credential. const supplied = request?.headers ? headerApiKey(request.headers) : undefined; const headerCredential = supplied?.startsWith('fc-') ? supplied : undefined; const credential = headerCredential ?? config.apiKey; if (config.transport === 'httpStream' && !credential && !config.apiUrl) { throw new Error( 'HTTP MCP transport requires an API URL or a Firecrawl API key' ); } return { authType: headerCredential ? 'api-key' : credential ? 'env' : 'none', firecrawlApiKey: credential, }; } const authenticateSession = hooks.authenticate ?? defaultAuthenticate; // One HTTP request may be authenticated more than once; resolve it once. function authenticate(request?: CachedAuthRequest): Promise { const cached = request?.[authResultByRequest]; if (cached) return cached; const authResult = authenticateSession(request).then((session) => { const headers = request?.headers; const userAgent = headers?.['user-agent']; if (typeof userAgent === 'string' && userAgent !== '') { session.clientUserAgent = userAgent; } const declaredOrigin = normalizeHeader(headers?.[DECLARED_ORIGIN_HEADER]); if (declaredOrigin) session.clientOrigin = declaredOrigin; const referrer = [ normalizeHeader(headers?.['origin']), normalizeHeader(headers?.['referer']), ].find((value) => value && value !== 'null'); if (referrer) session.clientReferrer = referrer; return session; }); if (request) request[authResultByRequest] = authResult; return authResult; } const taskHandlers: TaskHandler[] = []; const server = new McpRuntime({ name: 'firecrawl-fastmcp', version: packageVersion, instructions, logger: new ConsoleLogger(logging), ...(hooks.oauth ? { oauth: { protectedResource: hooks.oauth.protectedResource } } : {}), authenticate, modern: options.modernProtocol ?? false, extensions: [ tasksExtension( { getClient, agentResult: agentStatusResult, crawlResult: async (client, jobId, origin, body) => structuredText(await collectCrawlPages(client, jobId, origin, body as never)), resultSchema: (kind) => kind === 'agent' ? (servedTools.get('firecrawl_agent_status')?.outputSchema ?? agentStatusOutputSchema) : (servedTools.get('firecrawl_crawl')?.outputSchema ?? crawlOutputSchema), report: async (tool, session, run) => { const callSession = copySession(session); callSession.requestId = randomUUID(); const call = { tool, session: callSession, requestId: callSession.requestId as string }; let result: Awaited>; try { result = await run(callSession); } catch (error) { hooks.onToolResult?.({ ...call, status: 'started' }); hooks.onToolResult?.({ ...call, status: 'error', error }); throw error; } const task = result as | { status?: string; result?: { isError?: boolean; content?: Array<{ text?: string }> } } | undefined; if (task?.status === 'working' || task?.status === 'input_required') return result; hooks.onToolResult?.({ ...call, status: 'started' }); if (jobFailed(task)) { hooks.onToolResult?.({ ...call, status: 'error', error: new TaskJobFailedError(task?.result?.content?.[0]?.text ?? 'The job failed.'), }); } else { hooks.onToolResult?.({ ...call, status: 'success', result }); } return result; }, completed: async (call, result) => hooks.taskResult ? ((await hooks.taskResult({ ...call, result } as TaskResultEvent)) as ContentResult) : result, }, taskHandlers ), ], // Lightweight health endpoint for LB checks health: { message: 'ok', path: '/health', status: 200, }, }); hooks.configureHttp?.(server.getApp()); function resolveOutbound(session?: SessionData): { credential?: string; headers: Record; } { const outbound = hooks.outboundRequest?.(session); return { credential: outbound?.credential ?? session?.firecrawlApiKey, headers: outbound?.headers ?? {}, }; } function hasCredential(session?: SessionData): boolean { return Boolean( session?.firecrawlApiKey || hooks.outboundRequest?.(session)?.credential ); } function assertExchangeCredential(session?: SessionData): void { assertExchangeCredentialPresent(hasCredential(session)); } // A stdio client without a cloud credential can use only the keyless tools. // Do this at registration time so unsupported feedback tools are not // advertised. Credentials an authenticate or outboundRequest hook supplies // are only known per session, so those instances register the feedback // tools and hide them from keyless sessions instead. function isLocalKeylessStartup(): boolean { return ( config.transport !== 'httpStream' && !config.apiKey && !normalizeHeader(config.apiUrl) && !hooks.authenticate && !hooks.outboundRequest ); } /** * Every tool runs inside this pipeline: argument normalization before * validation, a per-call session copy carrying a request ID, then * `wrapTool`'s decoration, then call reporting and credential recovery * around the tool itself. */ function prepareTool(tool: RegisteredTool): RegisteredTool { const execute = tool.execute; const instrumented: RegisteredTool = { ...tool, execute: async (args, context) => { const session = context.session as SessionData; const requestId = session.requestId as string; return reported(tool.name, session, () => runWithCredentialRecovery(() => execute(args, context), requestId, session) ); }, }; const wrapped = hooks.wrapTool ? (hooks.wrapTool(instrumented as unknown as ToolDefinition) as unknown as RegisteredTool) : instrumented; let advertisedOutput: | Promise<{ keys?: Set; validate: JsonSchemaValidator }> | undefined; // MCP clients validate structuredContent against the advertised outputSchema // even on an error, so an error keeps only the keys the schema declares, and // nothing when those do not validate. async function deliverError(result: ContentResult): Promise { const { structuredContent, ...rest } = result; if (structuredContent === undefined || !wrapped.outputSchema) return result; advertisedOutput ??= toJsonSchema(wrapped.outputSchema).then((schema) => { const advertised = strictJsonSchema(schema) as JsonSchemaType; return { keys: advertised.additionalProperties === false ? new Set(Object.keys(advertised.properties ?? {})) : undefined, validate: outputSchemaValidator.getValidator(advertised), }; }); const { keys, validate } = await advertisedOutput; const declared = keys ? Object.fromEntries(Object.entries(structuredContent).filter(([key]) => keys.has(key))) : structuredContent; return Object.keys(declared).length > 0 && validate(declared).valid ? { ...rest, structuredContent: declared } : rest; } const isErrorResult = (value: unknown): value is ContentResult => typeof value === 'object' && value !== null && 'content' in value && (value as ContentResult).isError === true; async function delivered(run: () => T | Promise): Promise { try { const result = await run(); return isErrorResult(result) ? deliverError(result) : result; } catch (error) { if (error instanceof RuntimeUserError) { return deliverError({ content: [{ type: 'text', text: error.message }], isError: true, ...(error.extras ? { structuredContent: error.extras } : {}), }); } // A failed API request keeps the text the MCP framework would write // and adds its code and status, so a caller can branch on them. const apiError = apiErrorDetails(error); if (!apiError) throw error; return deliverError({ content: [{ type: 'text', text: `Tool '${wrapped.name}' execution failed: ${apiError.message}` }], isError: true, structuredContent: { success: false, error: apiError }, }); } } return { ...wrapped, beforeValidate: (args, session) => { normalizeToolArguments(wrapped.parameters, args); return delivered(() => wrapped.beforeValidate?.(args, session)); }, execute: (args, context) => { const session = copySession(context.session); session.requestId = randomUUID(); return delivered(() => wrapped.execute(args, { ...context, session })); }, }; } /** Reports a call on a session that carries its request ID to `onToolResult`. */ async function reported(tool: string, session: SessionData, run: () => Promise): Promise { const call = { tool, session, requestId: session.requestId as string }; hooks.onToolResult?.({ ...call, status: 'started' }); try { const result = await run(); hooks.onToolResult?.({ ...call, status: 'success', result }); return result; } catch (error) { hooks.onToolResult?.({ ...call, status: 'error', error }); throw error; } } const builtInTools = new Map(); const servedTools = new Map(); const addTool = server.addTool.bind(server); server.addTool = ((tool: RegisteredTool) => { builtInTools.set(tool.name, tool); if (hooks.toolFilter && !hooks.toolFilter(tool.name)) return; const prepared = prepareTool(tool); servedTools.set(tool.name, prepared); addTool(prepared); }) as typeof server.addTool; function createClient(apiKey?: string): FirecrawlApp { const clientConfig: any = { ...(config.apiUrl && { apiUrl: config.apiUrl, }), }; // Only add apiKey if it's provided (required for cloud, optional for self-hosted) if (apiKey) { clientConfig.apiKey = apiKey; } const client = new FirecrawlApp(clientConfig); if (config.integration) { (client as any).http?.instance?.interceptors?.request?.use((request: any) => { const isPost = String(request.method ?? '').toLowerCase() === 'post'; const isPlainObject = request.data != null && typeof request.data === 'object' && !Array.isArray(request.data) && !(request.data instanceof FormData); if (isPost && isPlainObject) { request.data = withIntegration( request.url ?? '', request.data, config.integration ); } return request; }); } return client; } // Safe mode restricts browser actions and webhooks for deployments that // must not drive interactive page changes. const SAFE_MODE = config.safeMode; function getClient(session?: SessionData): FirecrawlApp { if (config.requireCredential && !hasCredential(session)) { throw new Error('Unauthorized'); } if (!config.apiUrl && !hasCredential(session)) { throw new Error( 'Unauthorized: API key is required when not using a self-hosted instance' ); } const outboundRequest = hooks.outboundRequest; if (!outboundRequest) { return markCredentialRejections( createClient(session?.firecrawlApiKey), session ); } // The hook may mint a short-lived credential, so it runs on every request // the client makes rather than once here. const perRequestCredential = Boolean(outboundRequest(session)?.credential); const client = createClient( session?.firecrawlApiKey ?? (perRequestCredential ? REQUEST_SCOPED_CREDENTIAL : undefined) ); const axiosInstance = (client as any).http?.instance; if (!axiosInstance?.interceptors?.request?.use) { if (perRequestCredential) { throw new Error( 'Firecrawl client cannot apply a per-request credential' ); } return client; } axiosInstance.interceptors.request.use((request: any) => { const outbound = outboundRequest(session); const headers: Record = { ...(outbound?.headers ?? {}), ...(outbound?.credential ? { Authorization: `Bearer ${outbound.credential}` } : {}), }; for (const [name, value] of Object.entries(headers)) { if (typeof request.headers?.set === 'function') { request.headers.set(name, value); } else { request.headers = { ...(request.headers ?? {}), [name]: value }; } } return request; }); return markCredentialRejections(client, session); } /** Records on the call's session that Core answered 401 to this client. */ function markCredentialRejections( client: FirecrawlApp, session: SessionData | undefined ): FirecrawlApp { const responses = (client as any).http?.instance?.interceptors?.response; if (!session || !responses?.use) return client; responses.use( (response: unknown) => response, (error: { response?: { status?: unknown } } | undefined) => { if (error?.response?.status === 401) { (session as Record)[FIRECRAWL_CREDENTIAL_REJECTED] = true; } return Promise.reject(error); } ); return client; } function asText(data: unknown): string { return JSON.stringify(data, null, 2); } // scrape tool (v2 semantics, minimal args) // Centralized scrape params (used by scrape, and referenced in search/crawl scrapeOptions) // Define safe action types const safeActionTypes = ['wait', 'screenshot', 'scroll', 'scrape'] as const; const otherActions = [ 'click', 'write', 'press', 'executeJavascript', 'generatePDF', ] as const; const allActionTypes = [...safeActionTypes, ...otherActions] as const; // Use appropriate action types based on safe mode const allowedActionTypes = SAFE_MODE ? safeActionTypes : allActionTypes; function buildFormatsArray( args: Record ): Record[] | undefined { const formats = args.formats as string[] | undefined; if (!formats || formats.length === 0) return undefined; const result: Record[] = []; for (const fmt of formats) { if (fmt === 'json') { const jsonOpts = args.jsonOptions as Record | undefined; result.push({ type: 'json', ...jsonOpts }); } else if (fmt === 'query') { const queryOpts = args.queryOptions as Record | undefined; result.push({ type: 'query', ...queryOpts }); } else if (fmt === 'screenshot' && args.screenshotOptions) { const ssOpts = args.screenshotOptions as Record; result.push({ type: 'screenshot', ...ssOpts }); } else { result.push(fmt as unknown as Record); } } return result; } // The SDK refuses a json format with neither a prompt nor a schema, and a // query format without a prompt, before sending it, with a plain error that // reaches the client as text alone. Agents that hit the json case have often // put their question in queryOptions. function assertFormatOptions(formats: unknown): void { const list = Array.isArray(formats) ? formats : []; const refuse = (message: string) => { throw new UserError(message, { success: false, error: { code: 'invalid_request', message } }); }; if (list.some((f: any) => f?.type === 'json' && !f.prompt && !f.schema)) { refuse( 'formats "json" needs jsonOptions with a prompt or a schema. To ask a question about the page, use formats ["query"] with queryOptions.prompt instead.' ); } if (list.some((f: any) => f?.type === 'query' && !(typeof f.prompt === 'string' && f.prompt.trim()))) { refuse('formats "query" needs queryOptions with a non-empty prompt.'); } } function buildParsersArray( args: Record ): Record[] | undefined { const parsers = args.parsers as string[] | undefined; if (!parsers || parsers.length === 0) return undefined; const result: Record[] = []; for (const p of parsers) { if (p === 'pdf' && args.pdfOptions) { const pdfOpts = args.pdfOptions as Record; result.push({ type: 'pdf', ...pdfOpts }); } else { result.push(p as unknown as Record); } } return result; } function buildWebhook( args: Record ): string | Record | undefined { const webhook = args.webhook as string | undefined; if (!webhook) return undefined; const headers = args.webhookHeaders as Record | undefined; if (headers && Object.keys(headers).length > 0) { return { url: webhook, headers }; } return webhook; } function transformScrapeParams( args: Record ): Record { const out = { ...args }; const formats = buildFormatsArray(out); if (formats) out.formats = formats; const parsers = buildParsersArray(out); if (parsers) out.parsers = parsers; delete out.jsonOptions; delete out.queryOptions; delete out.screenshotOptions; delete out.pdfOptions; return out; } const scrapeParamsSchema = z.object({ url: httpUrl(), formats: z .array( z.enum([ 'markdown', 'html', 'rawHtml', 'screenshot', 'links', 'images', 'summary', 'changeTracking', 'branding', 'json', 'query', 'audio', ]) ) .optional(), jsonOptions: z .object({ prompt: z.string().optional(), schema: z.record(z.string(), z.any()).optional(), }) .optional(), queryOptions: z .object({ prompt: z.string().max(10000), mode: z.enum(['directQuote', 'freeform']).default('freeform'), }) .optional(), screenshotOptions: z .object({ fullPage: z.boolean().optional(), quality: z.number().optional(), viewport: z.object({ width: z.number(), height: z.number() }).optional(), }) .optional(), parsers: z.array(z.enum(['pdf'])).optional(), pdfOptions: z .object({ maxPages: z.number().int().min(1).max(10000).optional(), }) .optional(), onlyMainContent: z.boolean().optional(), redactPII: z.boolean().optional(), includeTags: z.array(z.string()).optional(), excludeTags: z.array(z.string()).optional(), waitFor: z.number().optional(), ...(SAFE_MODE ? {} : { actions: z .array( z.object({ type: z.enum(allowedActionTypes), selector: z.string().optional(), milliseconds: z.number().optional(), text: z.string().optional(), key: z.string().optional(), direction: z.enum(['up', 'down']).optional(), script: z.string().optional(), fullPage: z.boolean().optional(), }) ) .optional(), }), mobile: z.boolean().optional(), skipTlsVerification: z.boolean().optional(), removeBase64Images: z.boolean().optional(), location: z .object({ country: z.string().optional(), languages: z.array(z.string()).optional(), }) .optional(), storeInCache: z.boolean().optional(), zeroDataRetention: z.boolean().optional(), maxAge: z.number().optional(), lockdown: z.boolean().optional(), proxy: z.enum(['basic', 'stealth', 'enhanced', 'auto']).optional(), profile: z .object({ name: z.string(), saveChanges: z.boolean().optional(), }) .optional(), }); // In safe mode firecrawl_scrape and firecrawl_search are read-only, so a named // profile they open loads saved browser state without writing it back. The API // saves profile changes unless told otherwise, so saveChanges: false is sent // explicitly. firecrawl_interact (url with scrapeOptions.profile) saves them. const readOnlyProfileSchema = z .object({ name: z.string() }) .describe('Loads a saved browser profile without saving changes to it.'); function withReadOnlyProfile( options: Record ): Record { const profile = options.profile as { name: string } | undefined; return SAFE_MODE && profile ? { ...options, profile: { name: profile.name, saveChanges: false } } : options; } // firecrawl_scrape accepts either a page URL or an Exchange batch. The base // schema stays url-required because search, crawl, and monitor reuse it for // nested scrapeOptions, where `alexandria` has no meaning. const ALEXANDRIA_IGNORED_SCRAPE_OPTIONS = new Set(['toolDetail', 'domainTools']); const scrapeToolParamsSchema = scrapeParamsSchema .extend({ ...(SAFE_MODE ? { profile: readOnlyProfileSchema.optional() } : {}), url: httpUrl().optional(), timeout: z.number().int().positive().optional().describe("Execution timeout in milliseconds."), requestId: z .string() .regex(/^[A-Za-z0-9._:-]{1,128}$/) .optional() .describe( 'Idempotency key bound to one Alexandria execution payload. Generated when omitted and returned with the result.' ), alexandria: z.union([exchangeCallSchema, exchangeCallsSchema]) .optional() .describe( 'Catalogued Alexandria capability invocation, mutually exclusive with url. One {provider, capability, options} object or an array of 1-10, with contracts available through firecrawl_search or firecrawl_find_tools. Each call may include version to pin a published workflow; omitting it uses latest. Only requestId and timeout are supported alongside alexandria. ' + ALEXANDRIA_CONTRACT_GUIDANCE + ' Returns per-capability results in data.alexandria with data, records, or an error with a code; individual capabilities can fail even when the outer response succeeds. ' + ALEXANDRIA_TERMS_GUIDANCE ), toolDetail: z.enum(['compact', 'summary', 'full']).optional().describe('URL mode only: domain discovery detail, summary by default; compact returns provider/capability/description, full includes contracts. Ignored with alexandria.'), domainTools: z .boolean() .optional() .describe( 'URL mode only: include domain-matched Alexandria tools for the page in tools on the returned document. Ignored with alexandria.' ), }) .refine( (args) => Boolean(args.url) !== Boolean(args.alexandria), 'Provide exactly one of url or alexandria' ) .refine( (args) => !args.alexandria || Object.entries(args).every( ([key, value]) => key === 'alexandria' || key === 'requestId' || key === 'timeout' || // URL-mode discovery options that mean nothing when executing a // provider. Agents carry toolDetail over from firecrawl_search (where // it selects contract detail), so accept and ignore them rather than // failing the call: Codex sent toolDetail on 18 of 315 Alexandria // executions across the AX EXP-058 runs, each one a wasted round trip. ALEXANDRIA_IGNORED_SCRAPE_OPTIONS.has(key) || value === undefined ), 'alexandria cannot be combined with url or other scrape options' ) .refine( (args) => !args.requestId || !!args.alexandria, 'requestId applies to Alexandria execution only' ); const parseOptionParamsSchema = z.object({ formats: z .array( z.enum([ 'markdown', 'html', 'rawHtml', 'links', 'summary', 'json', 'query', ]) ) .optional(), jsonOptions: z .object({ prompt: z.string().optional(), schema: z.record(z.string(), z.any()).optional(), }) .optional(), queryOptions: z .object({ prompt: z.string().max(10000), mode: z.enum(['directQuote', 'freeform']).default('freeform'), }) .optional(), parsers: z.array(z.enum(['pdf'])).optional(), pdfOptions: z .object({ maxPages: z.number().int().min(1).max(10000).optional(), }) .optional(), onlyMainContent: z.boolean().optional(), redactPII: z.boolean().optional(), includeTags: z.array(z.string()).optional(), excludeTags: z.array(z.string()).optional(), removeBase64Images: z.boolean().optional(), skipTlsVerification: z.boolean().optional(), storeInCache: z.boolean().optional(), zeroDataRetention: z.boolean().optional(), maxAge: z .number() .optional() .describe('Ignored: parse never reuses or stores indexed content.'), proxy: z.enum(['basic', 'auto']).optional(), }); const localParseParamsSchema = parseOptionParamsSchema.extend({ filePath: z .string() .min(1) .describe( 'Absolute or relative path to a local file to parse. Supported: .html, .htm, .pdf, .docx, .doc, .odt, .rtf, .xlsx, .xls' ), contentType: z .string() .optional() .describe( 'Optional MIME type override. If omitted, the server infers the file kind from the extension.' ), }); const hostedParseParamsSchema = parseOptionParamsSchema .extend({ filePath: z .string() .min(1) .optional() .describe( '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.' ), uploadRef: z .string() .min(1) .optional() .describe( 'Phase 2 only: short-lived upload reference returned by phase 1 after the local PUT upload completes.' ), contentType: z .string() .optional() .describe( 'Phase 1 MIME type override. If omitted, the server infers it from the file extension without reading the file.' ), declaredSizeBytes: z .number() .int() .positive() .optional() .describe( 'Optional phase 1 size declaration. Hosted MCP does not stat the file; provide this only if the caller already knows it.' ), }) .superRefine((value, ctx) => { const hasFilePath = typeof value.filePath === 'string' && value.filePath.length > 0; const hasUploadRef = typeof value.uploadRef === 'string' && value.uploadRef.length > 0; if (hasFilePath === hasUploadRef) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Hosted firecrawl_parse requires exactly one of filePath (phase 1) or uploadRef (phase 2).', path: hasFilePath && hasUploadRef ? ['uploadRef'] : ['filePath'], }); } }); const parseParamsSchema = config.fileAccess === 'upload' ? hostedParseParamsSchema : localParseParamsSchema; const EXTENSION_CONTENT_TYPES: Record = { '.html': 'text/html', '.htm': 'text/html', '.xhtml': 'application/xhtml+xml', '.pdf': 'application/pdf', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.doc': 'application/msword', '.odt': 'application/vnd.oasis.opendocument.text', '.rtf': 'application/rtf', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.xls': 'application/vnd.ms-excel', }; function inferContentType(filename: string): string { const ext = path.extname(filename).toLowerCase(); return EXTENSION_CONTENT_TYPES[ext] ?? 'application/octet-stream'; } type ParseToolArgs = { filePath?: string; uploadRef?: string; contentType?: string; declaredSizeBytes?: number; } & Record; function extractParseOptions(args: ParseToolArgs): Record { const options = { ...args }; delete options.filePath; delete options.uploadRef; delete options.contentType; delete options.declaredSizeBytes; return options; } function buildParseOptionsPayload( options: Record, origin: string ): Record { const transformed = transformScrapeParams(options); const cleaned = removeEmptyTopLevel(transformed) as Record; return withIntegration('/v2/parse', { origin, ...cleaned }, config.integration); } function buildContinuationArguments( uploadRef: string, options: Record ): Record { return { uploadRef, ...(removeEmptyTopLevel(options) as Record), }; } function shellQuote(value: string): string { if (value.length === 0) return "''"; return "'" + value.replace(/'/g, "'\\''") + "'"; } type ParseUploadUrlData = { uploadUrl: string; uploadRef: string; method?: string; headers?: Record; fields?: Record; expiresAt?: string; maxSizeBytes?: number; }; function parseApiData(json: any): any { return json && typeof json === 'object' && 'data' in json ? json.data : json; } async function apiPostJson( pathName: string, body: Record, apiKey: string, origin?: string, extraHeaders: Record = {} ): Promise { const response = await fetch(`${resolveApiBaseUrl()}${pathName}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...extraHeaders, Authorization: `Bearer ${apiKey}`, ...(origin ? originHeaders(origin) : {}), }, body: JSON.stringify(withIntegration(pathName, body, config.integration)), }); const responseText = await response.text(); let parsed: any; try { parsed = responseText ? JSON.parse(responseText) : {}; } catch { parsed = { raw: responseText }; } if (!response.ok || parsed?.success === false) { throw new CoreHttpError( parsed?.error || parsed?.message || `Firecrawl request failed (HTTP ${response.status})`, response.status, parsed?.code ); } return parsed; } async function apiPostJsonForSession( pathName: string, body: Record, session: SessionData | undefined, origin: string ): Promise { const { credential, headers } = resolveOutbound(session); if (credential) { return apiPostJson(pathName, body, credential, origin, headers); } if (isKeylessMode(session)) { return keylessPost(pathName, body, session, origin); } throw new Error( 'Firecrawl credentials or keyless eligibility required for hosted parse.' ); } function buildCurlUploadCommand( filePath: string, upload: ParseUploadUrlData ): string { const method = upload.method ?? 'PUT'; const headerArgs = Object.entries(upload.headers ?? {}) .sort(([a], [b]) => a.localeCompare(b)) .map(([key, value]) => `-H ${shellQuote(`${key}: ${value}`)}`); if (method.toUpperCase() === 'POST' && upload.fields) { const fieldArgs = Object.entries(upload.fields) .sort(([a], [b]) => a.localeCompare(b)) .flatMap(([key, value]) => ['-F', shellQuote(`${key}=${value}`)]); return [ 'curl', '-X', shellQuote('POST'), ...headerArgs, ...fieldArgs, '-F', shellQuote(`file=@${filePath}`), shellQuote(upload.uploadUrl), ].join(' '); } return [ 'curl', '-X', shellQuote(method), ...headerArgs, '--upload-file', shellQuote(filePath), shellQuote(upload.uploadUrl), ].join(' '); } async function executeHostedParse( args: ParseToolArgs, session: SessionData | undefined, log: ToolLogger, origin: string ): Promise { const hasFilePath = typeof args.filePath === 'string' && args.filePath.length > 0; const hasUploadRef = typeof args.uploadRef === 'string' && args.uploadRef.length > 0; if (hasFilePath === hasUploadRef) { throw new Error( 'Hosted firecrawl_parse requires exactly one of filePath or uploadRef.' ); } if (!hasCredential(session) && !isKeylessMode(session)) { return asText({ success: false, mode: 'hosted-upload-ref-auth-required', message: 'Hosted firecrawl_parse requires an authenticated Firecrawl session or keyless eligibility before a local file upload URL can be minted. Connect a Firecrawl account, provide an API key, or use keyless hosted MCP while eligible, then call firecrawl_parse again.', }); } if ( session?.authType === 'keyless' && !session.firecrawlApiKey && args.zeroDataRetention === true ) { const payload = { ...recoveryPayload('KEYLESS_OPTION_NOT_AVAILABLE', session?.requestId), option: 'zeroDataRetention', message: 'Zero Data Retention is not available in anonymous keyless mode. Omit zeroDataRetention to parse with keyless access, or configure an API key for a team where Zero Data Retention is enabled, then retry.', }; throw new UserError(String(payload.message), payload); } const options = extractParseOptions(args); if (hasFilePath && args.filePath) { const filename = path.basename(args.filePath); const contentType = typeof args.contentType === 'string' && args.contentType.length > 0 ? args.contentType : inferContentType(filename); const uploadRequest = removeEmptyTopLevel({ filename, contentType, declaredSizeBytes: args.declaredSizeBytes, }) as Record; log.info('Creating hosted parse upload URL', { filename, contentType }); const uploadJson = await apiPostJsonForSession( '/v2/parse/upload-url', uploadRequest, session, origin ); const upload = parseApiData(uploadJson) as ParseUploadUrlData; if (!upload?.uploadUrl || !upload?.uploadRef) { throw new Error( 'Firecrawl upload-url response did not include uploadUrl and uploadRef' ); } const uploadHeaders = upload.headers && Object.keys(upload.headers).length > 0 ? upload.headers : (upload.method ?? 'PUT').toUpperCase() === 'POST' ? {} : { 'Content-Type': contentType }; const uploadForCommand = { ...upload, headers: uploadHeaders }; return asText({ success: true, mode: 'hosted-upload-ref-awaiting-upload', message: 'Hosted MCP cannot read local files. Run the local upload command, then call firecrawl_parse again with uploadRef. No Firecrawl API key is included in this command.', upload: { command: buildCurlUploadCommand(args.filePath, uploadForCommand), method: upload.method ?? 'PUT', headers: uploadHeaders, fields: upload.fields, uploadUrl: upload.uploadUrl, uploadRef: upload.uploadRef, expiresAt: upload.expiresAt, maxSizeBytes: upload.maxSizeBytes, }, nextToolCall: { name: 'firecrawl_parse', arguments: buildContinuationArguments(upload.uploadRef, options), }, notes: [ 'Run the curl command on the machine that can read filePath.', 'After the PUT succeeds, use nextToolCall as the second MCP tool call.', 'Clients without a local upload mechanism cannot complete hosted parse for local files.', ], }); } const parsePayload = { uploadRef: args.uploadRef as string, ...buildParseOptionsPayload(options, origin), }; log.info('Parsing hosted upload reference'); const parseJson = await apiPostJsonForSession( '/v2/parse', parsePayload, session, origin ); return asText(parseJson); } const scrapeTool: RegisteredTool = { name: 'firecrawl_scrape', annotations: { title: 'Firecrawl scrape', // Hosted scrape omits browser actions, loads profiles without saving, // and refuses provider terms writes before execution. readOnlyHint: SAFE_MODE, openWorldHint: true, // Accepts any user-supplied URL on the public web. destructiveHint: false, // Does not modify, delete, or write to external websites. }, description: ` Scrape one URL and return its content: markdown by default, or HTML, links, screenshots, branding data, a targeted answer, or JSON matching a supplied schema. Use it when the request identifies a page and needs its content or defined fields: the user gives a URL or names a page or PDF and wants it read or summarized, or wants prices, specs, contact details or a table from it. Use \`firecrawl_search\` when additional web sources are needed; on an authenticated session, \`firecrawl_map\` lists a site's URLs and \`firecrawl_crawl\` collects a set of pages. \`url\` may omit the scheme (\`example.com/page\`); it is read as https. Firecrawl may serve recently indexed content; set \`maxAge: 0\` for a live fetch or a smaller \`maxAge\` to bound staleness. A successful response does not by itself confirm the page is still current. ${SAFE_MODE ? 'A named browser profile loads saved session data without saving changes to it.' : 'Browser actions can change the live page when interactive actions are enabled.'} Authenticated responses can include a \`metadata.scrapeId\` for optional scrape feedback. On an authenticated session, \`firecrawl_search\` and \`firecrawl_find_tools\` can discover providers for the same fields across several pages; a matching provider returns typed records in one call. With \`alexandria\` in place of \`url\`, the tool runs catalogued provider capabilities instead of fetching a page. ${ALEXANDRIA_TERMS_GUIDANCE} `, outputSchema: scrapeOutputSchema, parameters: scrapeToolParamsSchema, execute: async (args: unknown, { session, sessionId, log, client: mcpClient }): Promise => { const origin = requestOrigin(mcpClient, session); const { url, alexandria, requestId: suppliedRequestId, ...options } = args as { requestId?: string; url?: string; alexandria?: z.infer | z.infer; } & Record; if (alexandria) { assertExchangeCredential(session); if ((Array.isArray(alexandria) ? alexandria : [alexandria]).some(isTermsWrite)) { throw termsWriteError(); } log.info('Executing Alexandria capabilities', { count: Array.isArray(alexandria) ? alexandria.length : 1, }); return structuredJsonText( await executeExchangeCalls(session, alexandria, suppliedRequestId, options.timeout as number | undefined, origin, false, feedbackStoreFor(session, sessionId)) ); } const transformed = transformScrapeParams( options as Record ); const cleaned = withReadOnlyProfile(removeEmptyTopLevel(transformed)); if (cleaned.lockdown) { log.info('Scraping URL (lockdown)'); } else { log.info('Scraping URL', { url: String(url) }); } if (isKeylessMode(session)) { const json = await keylessPost( '/v2/scrape', { url: String(url), ...cleaned, origin, }, session ); return structuredText(withoutDiscoveredToolPrices(json?.data ?? json)); } assertFormatOptions(cleaned.formats); const client = getClient(session); const res = await relayTermsRequired( () => client.scrape(String(url), { ...cleaned, origin, } as any), { tool: 'firecrawl_scrape' } ); return structuredText(withoutDiscoveredToolPrices(res)); }, }; server.addTool({ ...scrapeTool, _meta: { 'anthropic/alwaysLoad': true }, }); server.addTool({ name: 'firecrawl_map', annotations: { title: 'Firecrawl website map', readOnlyHint: true, // Discovers and returns indexed URLs; does not modify the target site. openWorldHint: true, // Operates against arbitrary user-supplied web domains. destructiveHint: false, // Read-only discovery; no deletion or destructive updates. }, description: ` Enumerate URLs indexed under one website through Firecrawl without fetching each page's content. Use this when the request asks for a site's URL inventory, when several relevant pages must be located, or when the desired page URL is unknown. An optional \`search\` term narrows the URL list, while sitemap, subdomain, query-parameter, and result-limit options control coverage. \`url\` may omit the scheme (\`example.com\`); it is read as https. Returns matching URLs rather than page bodies. Retrieve one page with \`firecrawl_scrape\`; collect content across multiple pages with \`firecrawl_crawl\`. Authenticated responses can include an \`id\` for optional map feedback. `, outputSchema: mapOutputSchema, parameters: z.object({ url: httpUrl(), search: z.string().optional(), sitemap: z.enum(['include', 'skip', 'only']).optional(), includeSubdomains: z.boolean().optional(), limit: z.number().optional(), ignoreQueryParameters: z.boolean().optional(), }), execute: async ( args: unknown, { session, log, client: mcpClient } ): Promise => { const { url, ...options } = args as { url: string } & Record< string, unknown >; const client = getClient(session); const cleaned = removeEmptyTopLevel(options as Record); log.info('Mapping URL', { url: String(url) }); const res = await client.map(String(url), { ...cleaned, origin: requestOrigin(mcpClient, session), } as any); return structuredText(res); }, }); const searchTool = { name: 'firecrawl_search', _meta: { 'anthropic/alwaysLoad': true }, annotations: { title: 'Firecrawl web search', readOnlyHint: true, // Runs a web search and returns results; does not modify external sites. openWorldHint: true, // Searches the open web across arbitrary domains and sources. destructiveHint: false, // Query-only; no destructive side effects on external entities. }, description: ` Use this when you need current or specific information from the web: recent events, latest releases and versions, prices, people and companies, statistics, or any fact that may have changed since your training data or that you are not sure of. Returns ranked results with query-relevant highlights; each web result is a title, URL, and description. 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 \`research\`, \`pdf\`, \`developer\`, or \`gov\`. For a programming question, add \`categories: ["developer"]\`. It searches an index of public repositories, GitHub issues, merged pull requests, repository READMEs, and code documentation, and returns the results in \`data.web\` with \`category: "developer"\`. For a legal or regulatory question, \`categories: ["gov"]\` returns results in \`data.web\` with \`category: "gov"\` and cannot be combined with other categories; authenticated sessions also have \`firecrawl_gov_search\` as the dedicated tool. Verify gov results' issuer, jurisdiction, status, and version before citing current law. ${ALEXANDRIA_SEARCH_INSTRUCTIONS} Returns result groups in \`data\` and an operation \`id\`. `, outputSchema: searchOutputSchema, parameters: z .object({ ...searchToolBaseFields, scrapeOptions: scrapeParamsSchema .omit({ url: true }) .extend(SAFE_MODE ? { profile: readOnlyProfileSchema } : {}) .partial() .optional() .describe('Fetches the full page of every web result in the same call, and each fetched result is billed as a scrape. Leave it unset unless the task needs the full text of a few results: highlights usually answer, and firecrawl_scrape can fetch only the URLs you need. These fetches ignore maxAge, so use firecrawl_scrape for a live fetch. scrapeOptions fetches web pages, never Alexandria provider tools.'), }) .refine(searchDomainsAreExclusive, SEARCH_DOMAINS_CONFLICT_MESSAGE) .refine(searchCategoriesAreCompatible, SEARCH_CATEGORIES_CONFLICT_MESSAGE) .refine( searchQueryIsValid, 'A query is required. Use Find Tools for catalogue lookup.' ), execute: async (args: unknown, { session, log, client: mcpClient }: ToolContext, tool = 'firecrawl_search'): Promise => { const { query, ...opts } = args as Record; const requestedSources = normalizeSearchSources( Array.isArray(opts.sources) && opts.sources.length ? opts.sources : ['web'] ) as unknown[]; const addsAlexandria = hasCredential(session) && enterpriseAllowsAlexandria(opts.enterprise) && !hasAlexandria(requestedSources); const searchOpts = { ...opts, sources: addsAlexandria ? withAlexandriaSource(requestedSources) : requestedSources, } as Record; searchOpts.domainTools ??= defaultDomainTools(searchOpts.sources); searchOpts.toolDetail ??= 'compact'; if (searchOpts.scrapeOptions) { searchOpts.scrapeOptions = withReadOnlyProfile( transformScrapeParams(searchOpts.scrapeOptions as Record) ); } const cleaned = removeEmptyTopLevel(searchOpts); const searchQuery = (query as string | undefined) ?? ''; log.info('Searching', { query: searchQuery }); const searchBody = { query: searchQuery, ...(cleaned as any), origin: requestOrigin(mcpClient, session), }; const byteBudget = searchByteBudget(session?.clientUserAgent); const searchResult = (response: unknown) => { const shown = annotateResultPositions(withoutDiscoveredToolPrices(response)); return structuredCompact(byteBudget ? fitSearchResponse(shown, byteBudget) : shown); }; const exchangeSource = hasAlexandria(searchBody.sources); if (exchangeSource || searchBody.domainTools) assertExchangeCredential(session); if (isKeylessMode(session)) { const json = await keylessPost('/v2/search', searchBody, session); // Search feedback requires an authenticated account. Do not expose its // identifier to keyless clients, where it would invite an unusable call. // Positions are still stamped: they identify a result regardless of auth. const keylessResponse = { ...(json ?? {}) }; delete keylessResponse.id; return searchResult(keylessResponse); } // Call /v2/search through the SDK's HTTP layer (auth + retries) instead // of `client.search()` so we preserve the full response envelope. The // high-level `search()` helper strips `id` and `creditsUsed`, which // supports the optional authenticated `firecrawl_search_feedback` workflow. const client = getClient(session); const postSearch = () => postSearchWithFallback( client, searchBody, addsAlexandria && opts.domainTools !== true, requestedSources ); const httpRes = await relayExchangeError(postSearch, { tool }); return searchResult(httpRes?.data ?? {}); }, } satisfies RegisteredTool; server.addTool(searchTool); const feedbackTokens = new FeedbackTokenRegistry(); function feedbackStoreFor(session: SessionData | undefined, sessionId: unknown): FeedbackTokenStore { return feedbackTokens.forSession(sessionId, resolveOutbound(session).credential); } async function executeExchangeCalls( session: SessionData | undefined, alexandria: | z.infer | z.infer, suppliedRequestId?: string, timeout?: number, origin = requestOrigin(undefined, session), includeCosts = false, feedbackStore: FeedbackTokenStore = new FeedbackTokenStore() ): Promise { assertExchangeCredential(session); const client = getClient(session); const requestId = suppliedRequestId ?? randomUUID(); try { const response = await relayExchangeError(() => (client as any).http.post( '/v2/scrape', { alexandria, origin, ...(timeout !== undefined ? { timeout } : {}), }, { headers: { 'x-request-id': requestId }, ...(timeout !== undefined ? { timeoutMs: timeout + 5000 } : {}), } ), { tool: 'firecrawl_scrape', requestId, providers: (Array.isArray(alexandria) ? alexandria : [alexandria]).map(call => call.provider) } ); const calls = Array.isArray(alexandria) ? alexandria : [alexandria]; return alexandriaOutput( { ...withFeedbackRefs( includeCosts ? (response?.data ?? {}) : withoutResultPrices(response?.data ?? {}), feedbackStore ), requestId, }, calls, async (body, id) => { const result = await (client as any).http.post( '/v2/scrape', { ...(body as object), origin }, { headers: { 'x-request-id': id }, timeoutMs: 15_000 } ); return result?.data; }, randomUUID(), alexandriaFeedbackAvailable() && alexandriaCallsWarrantFeedback(calls) ? { feedbackTool: ALEXANDRIA_FEEDBACK_HINT } : {} ); } catch (error) { if ((error as any)?.response?.status === 401) throw error; throw new UserError( `${error instanceof Error ? error.message : String(error)} Request ID: ${requestId}. Reuse this ID only with the identical payload.`, { ...(error instanceof UserError ? error.extras : {}), requestId } ); } } const findToolsTool: RegisteredTool = { name: 'firecrawl_find_tools', annotations: { title: 'Find Tools', readOnlyHint: true, openWorldHint: false, destructiveHint: false, idempotentHint: true, }, description: 'Browse Alexandria data providers and workflows or read a selected contract. Alexandria covers ' + ALEXANDRIA_CATALOGUE_VERTICALS + ': typed, sourced records through published contracts. Prefer normal firecrawl_search for a data task; it already returns matching providers. Use this tool when the contract you need was not returned in full, to browse a category when search found nothing, or before scraping the same fields from several pages. Discovery is free. Use query for semantic discovery or urls to find providers for a website. With no arguments, browse categories, then providers and tools. Contracts describe the inputs and outputs for execution through firecrawl_scrape. nextTool identifies further discovery or pagination. Discovery does not execute providers. Use firecrawl_search when you also need web results.', outputSchema: findToolsOutputSchema, parameters: findToolsSchema, execute: async (args, { session, client: mcpClient }) => { const origin = requestOrigin(mcpClient, session); let options; try { options = findToolsOptions(args as Parameters[0]); } catch (error) { throw new UserError(error instanceof Error ? error.message : String(error), { code: 'invalid_input' }); } const carry: { includeCosts?: true } = (args as { includeCosts?: boolean }).includeCosts ? { includeCosts: true } : {}; if (options.level === 'categories') { assertExchangeCredential(session); const response = await relayExchangeError( () => (getClient(session) as any).http.get('/exchange/discover', originHeaders(origin)), { tool: 'firecrawl_find_tools', requestId: session?.requestId } ); const rows = response?.data?.cohorts; if (!Array.isArray(rows) || rows.some((row: any) => typeof row?.cohort !== 'string' || typeof row?.about !== 'string')) { throw new UserError('Category discovery returned an invalid category index.'); } const offset = options.offset ?? 0; const items = rows.slice(offset, offset + options.limit).map((row: any) => ({ id: row.cohort, description: row.about, next: { provider: 'firecrawl', capability: 'find-tools', options: { categories: [row.cohort], level: 'providers', limit: options.limit } }, })); const page: any = { level: 'categories', items, total: rows.length }; if (offset + options.limit < rows.length) page.nextTool = { name: 'firecrawl_find_tools', arguments: { level: 'categories', limit: options.limit, offset: offset + options.limit, ...carry } }; return structuredCompact(withAlexandriaFeedbackHint(withFindToolsNavigation({ success: true, data: { alexandria: [{ provider: 'firecrawl', capability: 'find-tools', data: page }] } }, carry), alexandriaFeedbackAvailable())); } const result = await executeExchangeCalls(session, { provider: 'firecrawl', capability: 'find-tools', options }, undefined, undefined, origin, carry.includeCosts); const envelope = JSON.parse(result); return structuredCompact(withFindToolsNavigation(carry.includeCosts ? envelope : withoutFindToolsPrices(envelope), carry)); }, }; server.addTool(findToolsTool); const DEFAULT_CLOUD_API_URL = 'https://api.firecrawl.dev'; function resolveApiBaseUrl(): string { return (config.apiUrl || DEFAULT_CLOUD_API_URL).replace( /\/$/, '' ); } // Keyless free tier: when no credential is configured and we're targeting the // Firecrawl cloud (not a self-hosted API URL), scrape and search are free, // rate-limited per IP. The cloud only grants this when NO Authorization // header is sent, so we bypass the SDK — which always attaches a Bearer // header — and post directly. A deployment that requires credentials admits // keyless use only for sessions its `authenticate` marked as keyless. function isKeylessMode(session?: SessionData): boolean { if (hasCredential(session) || session?.credentialError) return false; if (session?.authType === 'keyless') return true; // Local/stdio against the cloud (not a self-hosted FIRECRAWL_API_URL). return !config.requireCredential && !config.apiUrl; } async function keylessPost( path: string, body: Record, session?: SessionData, originOverride?: string ): Promise { // The body already names the client's origin (every caller stamps it); the // headers of this request and the eligibility probe carry the same value. const origin = originOverride ?? (typeof body.origin === 'string' && body.origin.length > 0 ? body.origin : requestOrigin(undefined, session)); await hooks.beforeKeylessRequest?.(session, origin); const headers: Record = { ...originHeaders(origin), 'Content-Type': 'application/json', ...resolveOutbound(session).headers, }; const response = await fetch(`${resolveApiBaseUrl()}${path}`, { method: 'POST', headers, body: JSON.stringify(withIntegration(path, body, config.integration)), }); const json: any = await response.json().catch(() => ({})); if (!response.ok || json?.success === false) { if (isKeylessMode(session) && response.status === 429) { // The API normally supplies requests|credits. Preserve a structured, // non-specific recovery payload during a skewed or legacy deployment. const code = keylessQuotaReason(json?.reason) ? 'KEYLESS_QUOTA_EXHAUSTED' : 'KEYLESS_LIMIT_REACHED'; const payload = recoveryPayload(code, session?.requestId, { retryAfterSeconds: Number.isFinite(json?.retry_after_seconds) && json.retry_after_seconds > 0 ? json.retry_after_seconds : undefined, signupUrl: keylessSignupUrlFrom(json?.signup_url), }); throw new UserError(String(payload.message), payload); } throw new CoreHttpError( json?.error || `Firecrawl request failed (HTTP ${response.status})`, response.status, json?.code ); } return json; } async function getCrawlStatusWithOrigin( client: FirecrawlApp, jobId: string, origin: string ): Promise> { const res = await (client as any).http.get( `/v2/crawl/${encodeURIComponent(jobId)}`, originHeaders(origin) ); return collectCrawlPages(client, jobId, origin, (res?.data ?? {}) as any); } /** A crawl status body with its remaining result pages appended. */ async function collectCrawlPages( client: FirecrawlApp, jobId: string, origin: string, body: any ): Promise> { const initialDocs = Array.isArray(body.data) ? body.data : []; if (!body.next) { return { id: jobId, status: body.status, completed: body.completed ?? 0, total: body.total ?? 0, creditsUsed: body.creditsUsed, expiresAt: body.expiresAt, next: body.next ?? null, data: initialDocs, }; } const docs = initialDocs.slice(); let current = body.next as string | null; while (current) { const pageRes = await (client as any).http.get( current, originHeaders(origin) ); const payload = (pageRes?.data ?? {}) as any; if (!payload.success) break; const pageData = Array.isArray(payload.data) ? payload.data : payload.data?.pages || []; docs.push(...pageData); current = payload.next ?? (Array.isArray(payload.data) ? null : payload.data?.next) ?? null; } return { id: jobId, status: body.status, completed: body.completed ?? 0, total: body.total ?? 0, creditsUsed: body.creditsUsed, expiresAt: body.expiresAt, next: null, data: docs, }; } async function waitForCrawlCompletionWithOrigin( client: FirecrawlApp, jobId: string, origin: string, pollInterval = 2, timeout?: number ): Promise> { const startedAt = Date.now(); for (;;) { let waitMs = Math.max(1000, pollInterval * 1000); try { // Poll the first page only; result pages are read once the crawl ends. const res = await (client as any).http.get( `/v2/crawl/${encodeURIComponent(jobId)}`, originHeaders(origin) ); if ( ['completed', 'failed', 'cancelled'].includes(String(res?.data?.status ?? '')) ) { return await collectCrawlPages(client, jobId, origin, res.data); } } catch (error) { const response = (error as { response?: { status?: number; headers?: any; data?: any } }) ?.response; if (response?.status !== 429) throw error; // A rate-limited status read says nothing about the crawl: wait it out. const retryAfter = String( response.headers?.['retry-after'] ?? response.data?.retry_after_seconds ?? '' ).trim(); const retryAfterMs = /^\d+$/.test(retryAfter) ? Number(retryAfter) * 1000 : Date.parse(retryAfter) - Date.now(); waitMs = Math.min( 60_000, Math.max(waitMs, Number.isFinite(retryAfterMs) ? retryAfterMs : 10_000) ); } if (timeout != null && Date.now() - startedAt > timeout * 1000) { throw new Error(`Crawl job ${jobId} did not complete within ${timeout}s`); } await new Promise((resolve) => setTimeout(resolve, waitMs)); } } const feedbackIssueSchema = z .string() .trim() .min(1) .max(80) .regex( /^[a-z0-9][a-z0-9_-]*$/, 'Issue codes must use lowercase letters, numbers, underscores, or hyphens' ); const valuableSourceSchema = z.object({ url: z.string().url(), reason: z.string().max(1000).optional(), }); const missingContentSchema = z.object({ topic: z .string() .min(1, 'topic must not be empty') .max(200, 'topic must be 200 characters or fewer'), description: z.string().max(2000).optional(), }); const SEARCH_FEEDBACK_DISABLED = !config.searchFeedback; const ENDPOINT_FEEDBACK_DISABLED = !config.endpointFeedback; if (!SEARCH_FEEDBACK_DISABLED && !isLocalKeylessStartup()) { server.addTool({ name: 'firecrawl_search_feedback', canList: (session: SessionData) => !isKeylessMode(session), annotations: { title: 'Firecrawl search feedback', readOnlyHint: false, // POSTs structured feedback to the API, creating a server-side record. openWorldHint: true, // Feedback references open-web search results and external URLs. destructiveHint: false, // Additive only; records feedback and may refund credits, does not delete data. }, description: ` Records schema-validated quality feedback for a prior \`firecrawl_search\` UUID \`searchId\`. A \`good\` rating requires a valuable result or a valuable source, \`partial\` either of those or at least one \`missingContent\` entry, and \`bad\` at least one \`missingContent\` entry or a query suggestion; caps are 50 \`valuableResults\`, 50 \`valuableSources\`, and 20 \`missingContent\` entries. 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. Mark useful returned results in \`valuableResults\` as \`{"source", "position"}\` copied off the result. Groups are numbered from 1 independently, so \`{"source":"web","position":1}\` and \`{"source":"news","position":1}\` are different results. Be exhaustive — results you do not list are treated as not useful. Reserve \`valuableSources\` for useful URLs that were NOT among the returned results; never report one result in both fields. `, outputSchema: feedbackOutputSchema, parameters: z.object({ searchId: z .string() .uuid('searchId must be the UUID returned by firecrawl_search'), rating: z.enum(['good', 'bad', 'partial']), valuableSources: z .array( z.object({ url: z.string().url(), reason: z.string().max(1000).optional(), }) ) .max(50) .optional(), valuableResults: z .array( z.object({ source: z.enum(SEARCH_RESULT_SOURCES), position: z.number().int().positive(), reason: z.string().max(1000).optional(), }) ) .max(50) .optional() .describe( 'Every result that was actually useful, copied from the `source` and `position` ' + 'stamped on each result. Each group is numbered from 1 independently, so the ' + 'source is required — `{"source":"web","position":1}` and ' + '`{"source":"news","position":1}` are different results. Be exhaustive — ' + 'unlisted results are treated as not useful. Do not also list the same results ' + 'in valuableSources.' ), missingContent: z .array( z.object({ topic: z .string() .min(1, 'topic must not be empty') .max(200, 'topic must be 200 characters or fewer'), description: z.string().max(2000).optional(), }) ) .max(20) .optional() .describe( '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`.' ), querySuggestions: z.string().max(2000).optional(), }), execute: async ( args: unknown, { session, log, client: mcpClient } ): Promise => { assertFeedbackAvailable(session); const origin = requestOrigin(mcpClient, session); const { searchId, rating, valuableSources, valuableResults, missingContent, querySuggestions, } = args as { searchId: string; rating: 'good' | 'bad' | 'partial'; valuableSources?: { url: string; reason?: string }[]; valuableResults?: { source: (typeof SEARCH_RESULT_SOURCES)[number]; position: number; reason?: string; }[]; missingContent?: { topic: string; description?: string }[]; querySuggestions?: string; }; const apiBase = resolveApiBaseUrl(); const endpoint = `${apiBase}/v2/search/${encodeURIComponent( searchId )}/feedback`; const body: Record = { rating, origin, }; if (valuableSources && valuableSources.length > 0) { body.valuableSources = valuableSources; } if (valuableResults && valuableResults.length > 0) { body.valuableResults = valuableResults; } if (missingContent && missingContent.length > 0) { body.missingContent = missingContent; } if (querySuggestions) body.querySuggestions = querySuggestions; const headers: Record = { ...originHeaders(origin), 'Content-Type': 'application/json', }; const outbound = resolveOutbound(session); Object.assign(headers, outbound.headers); const credential = outbound.credential; if (credential) { headers['Authorization'] = `Bearer ${credential}`; } else if (config.requireCredential) { throw new Error('Unauthorized: missing API key for search feedback.'); } log.info('Submitting search feedback', { searchId, rating }); const response = await fetch(endpoint, { method: 'POST', headers, body: JSON.stringify( withIntegration( `/v2/search/${encodeURIComponent(searchId)}/feedback`, body, config.integration ) ), }); const responseText = await response.text(); let parsed: any; try { parsed = JSON.parse(responseText); } catch { parsed = { raw: responseText }; } // A 401 is Core's verdict on the forwarded API key, so let the shared // credential-recovery boundary turn it into CREDENTIAL_INVALID. if (session?.authType === 'api-key' && response.status === 401) { throw new CoreHttpError( parsed?.error ?? 'Unauthorized: invalid Firecrawl API key', response.status ); } // Other 4xx responses are terminal; surface a structured payload (with // retryable=false) so agents do not retry-loop on substantive-feedback // rejections, expired windows, etc. if (!response.ok) { log.warn('Search feedback rejected', { status: response.status, feedbackErrorCode: parsed?.feedbackErrorCode, }); return structuredText({ success: false, status: response.status, feedbackErrorCode: parsed?.feedbackErrorCode, error: feedbackRejectionError(parsed, response.status), retryable: response.status >= 500, }); } return structuredText(parsed); }, }); } /** * Feedback needs an account. canList hides the feedback tools from keyless * sessions; this refuses a call that names them anyway. */ function assertFeedbackAvailable(session?: SessionData): void { if (!isKeylessMode(session)) return; const payload = recoveryPayload('KEYLESS_TOOL_NOT_AVAILABLE', session?.requestId); throw new UserError(String(payload.message), payload); } /** * The API's rejection text followed by its validation messages, so an * INVALID_BODY rejection says which rule the body broke (for example a * `good` rating with no valuable result) instead of only "Invalid request body". * Every other rejection keeps the API's text as it is. */ function feedbackRejectionError(parsed: any, status: number): string { const error = String(parsed?.error ?? `HTTP ${status}`); if (parsed?.feedbackErrorCode !== 'INVALID_BODY') return error; const details: unknown[] = Array.isArray(parsed?.details) ? parsed.details : []; const messages = [ ...new Set( details.flatMap((issue: any) => { if (typeof issue?.message !== 'string' || issue.message === '') return []; const path = Array.isArray(issue.path) ? issue.path.join('.') : ''; return [path ? `${path}: ${issue.message}` : issue.message]; }) ), ].slice(0, 5); return messages.length > 0 ? `${error}: ${messages.join('; ')}` : error; } /** Whether firecrawl_feedback is registered on this instance, so Alexandria results only point at a tool that exists. */ function alexandriaFeedbackAvailable(): boolean { // An instance whose tool filter excludes firecrawl_feedback must not // point callers at it. return ( !ENDPOINT_FEEDBACK_DISABLED && !isLocalKeylessStartup() && (hooks.toolFilter?.('firecrawl_feedback') ?? true) ); } if (alexandriaFeedbackAvailable()) { server.addTool({ name: 'firecrawl_feedback', canList: (session: SessionData) => !isKeylessMode(session), annotations: { title: 'Firecrawl feedback', readOnlyHint: false, // POSTs structured feedback for a completed job to /v2/feedback. openWorldHint: true, // Feedback is tied to jobs that processed open-web URLs. destructiveHint: false, // Additive only; submits ratings and notes and may refund credits, does not delete jobs or external content. }, description: ` Submit concise quality feedback for a completed search, scrape, parse, or map job. Provide the endpoint, job ID, rating, and relevant issue codes or small contextual fields; omit large page contents and raw outputs. For an Alexandria session, set endpoint to \`alexandria\`, omit jobId, and provide requestedWebsite (url and requestedFunctionality), objective, rationale, and rating. objective is the underlying goal behind the session: what you or your user were ultimately trying to accomplish (for example, "shortlist federal IT contracts to bid on this quarter"), not only what was needed from this website. Optional providerFeedback and capabilityFeedback describe gaps or errors. Capability issues: new_capability_request (requires requestedFunctionality), missing_capability, insufficient_functionality, incorrect_result, execution_error, other. Alexandria results carry a feedbackRef per call. The overall rating covers every call in this session; callRatings [{ref, rating}] rate individual calls by feedbackRef. Alexandria feedback must arrive within 20 minutes of the team's latest Alexandria search, discovery, or execution; later feedback is rejected. Eligible feedback can refund 1 credit; refunds are subject to daily caps per website and per team. Returns submission status, feedback ID, and accounting fields. `, outputSchema: feedbackOutputSchema, parameters: z.object({ endpoint: z.enum(['search', 'scrape', 'parse', 'map', 'alexandria']), jobId: z.string().uuid('jobId must be the UUID returned by Firecrawl').optional(), ...alexandriaFeedbackFields, rating: z.enum(['good', 'bad', 'partial']), issues: z.array(feedbackIssueSchema).max(20).optional(), tags: z.array(feedbackIssueSchema).max(20).optional(), note: z.string().max(4000).optional(), valuableSources: z.array(valuableSourceSchema).max(50).optional(), missingContent: z.array(missingContentSchema).max(50).optional(), querySuggestions: z.string().max(2000).optional(), url: z.string().url().optional(), pageNumbers: z.array(z.number().int().positive()).max(100).optional(), metadata: z.record(z.string(), z.unknown()).optional(), }).superRefine((value, ctx) => { if (value.endpoint === 'alexandria') { const parsed = alexandriaSessionFeedbackSchema.safeParse(value); if (!parsed.success) for (const issue of parsed.error.issues) ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message }); } else if (!value.jobId) { // The Alexandria-only fields are accepted here and left out of the // job feedback body (rationale stands in for a missing note), since // agents carry objective over from the search and scrape calls. ctx.addIssue({ code: 'custom', path: ['jobId'], message: 'jobId is required for job feedback' }); } }), execute: async ( args: unknown, { session, sessionId, log, client: mcpClient } ): Promise => { assertFeedbackAvailable(session); const origin = requestOrigin(mcpClient, session); const { endpoint, jobId, rating, issues, tags, note, rationale, valuableSources, missingContent, querySuggestions, url, pageNumbers, metadata, } = args as { endpoint: 'search' | 'scrape' | 'parse' | 'map' | 'alexandria'; jobId: string; rating: 'good' | 'bad' | 'partial'; issues?: string[]; tags?: string[]; note?: string; rationale?: string; valuableSources?: { url: string; reason?: string }[]; missingContent?: { topic: string; description?: string }[]; querySuggestions?: string; url?: string; pageNumbers?: number[]; metadata?: Record; }; const apiBase = resolveApiBaseUrl(); const headers: Record = { ...originHeaders(origin), 'Content-Type': 'application/json', }; const outbound = resolveOutbound(session); Object.assign(headers, outbound.headers); const credential = outbound.credential; if (credential) { headers['Authorization'] = `Bearer ${credential}`; } else if (config.requireCredential) { throw new Error('Unauthorized: missing API key for feedback.'); } const feedbackStore = feedbackStoreFor(session, sessionId); const alexandriaArgs = endpoint === 'alexandria' ? alexandriaSessionFeedbackSchema.parse(args) : undefined; const attribution = alexandriaArgs && alexandriaFeedbackTokens(alexandriaArgs.callRatings, feedbackStore); const body = alexandriaArgs && attribution ? { ...alexandriaArgs, callRatings: undefined, ...(attribution.tokens.length ? { tokens: attribution.tokens } : {}), ...(attribution.callRatings.length ? { callRatings: attribution.callRatings } : {}), origin, } : removeEmptyTopLevel({ endpoint, jobId, rating, issues, tags, note: note ?? rationale, valuableSources, missingContent, querySuggestions, url, pageNumbers, metadata, origin, }); log.info('Submitting endpoint feedback', { endpoint, jobId, rating }); const response = await fetch(`${apiBase}/v2/feedback`, { method: 'POST', headers, body: JSON.stringify(withIntegration('/v2/feedback', body, config.integration)), }); const responseText = await response.text(); let parsed: any; try { parsed = JSON.parse(responseText); } catch { parsed = { raw: responseText }; } // A 401 is Core's verdict on the forwarded API key, so let the shared // credential-recovery boundary turn it into CREDENTIAL_INVALID. if (session?.authType === 'api-key' && response.status === 401) { throw new CoreHttpError( parsed?.error ?? 'Unauthorized: invalid Firecrawl API key', response.status ); } if (!response.ok) { log.warn('Endpoint feedback rejected', { status: response.status, feedbackErrorCode: parsed?.feedbackErrorCode, }); return structuredText({ success: false, status: response.status, feedbackErrorCode: parsed?.feedbackErrorCode, error: feedbackRejectionError(parsed, response.status), retryable: response.status >= 500, }); } if (attribution) { if (parsed?.success !== false) feedbackStore.remove(attribution.sent); if (attribution.unknownRefs.length) { return structuredText({ ...parsed, note: `Ignored call ratings for unknown or expired refs: ${attribution.unknownRefs.join(', ')}.`, }); } } return structuredText(parsed); }, }); } server.addTool({ name: 'firecrawl_crawl', annotations: { title: 'Firecrawl site crawl', readOnlyHint: false, // Starts a server-side crawl job and polls until the job reaches a terminal state. openWorldHint: true, // Crawls user-specified URLs across the public web. destructiveHint: false, // Reads pages from target sites; does not delete or alter external websites. idempotentHint: false, // Each call starts a new crawl job and is billed again. }, description: ` Start a multi-page crawl at a website URL, poll it to a terminal state, and return the final status and collected data. Scope can be bounded with include/exclude paths, depth, page limit, subdomain/external-link controls, sitemap handling, delay, and scrape options. 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. `, parameters: z.object({ url: z.string(), prompt: z.string().optional(), excludePaths: z.array(z.string()).optional(), includePaths: z.array(z.string()).optional(), maxDiscoveryDepth: z.number().optional(), sitemap: z.enum(['skip', 'include', 'only']).optional(), limit: z.number().optional(), allowExternalLinks: z.boolean().optional(), allowSubdomains: z.boolean().optional(), crawlEntireDomain: z.boolean().optional(), delay: z.number().optional(), maxConcurrency: z.number().optional(), ...(SAFE_MODE ? {} : { webhook: z.string().optional(), webhookHeaders: z.record(z.string(), z.string()).optional(), }), deduplicateSimilarURLs: z.boolean().optional(), ignoreQueryParameters: z.boolean().optional(), scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional(), }), outputSchema: crawlOutputSchema, execute: async (args, { session, log, client: mcpClient, task }): Promise => { const origin = requestOrigin(mcpClient, session); const { url, ...options } = args as Record; const client = getClient(session); const opts = { ...options } as Record; if (opts.scrapeOptions) { opts.scrapeOptions = transformScrapeParams( opts.scrapeOptions as Record ); } const webhook = buildWebhook(opts); if (webhook) opts.webhook = webhook; delete opts.webhookHeaders; const cleaned = removeEmptyTopLevel(opts); const pollInterval = typeof cleaned.pollInterval === 'number' ? (cleaned.pollInterval as number) : 2; const timeout = typeof cleaned.timeout === 'number' ? (cleaned.timeout as number) : undefined; delete (cleaned as Record).pollInterval; delete (cleaned as Record).timeout; log.info('Starting crawl', { url: String(url) }); const started = await relayExchangeError( () => (client as any).http.post('/v2/crawl', { url: String(url), ...(cleaned as Record), origin, }), { tool: 'firecrawl_crawl' } ); const crawlId = started?.data?.id; if (!crawlId || task) { return structuredText(started?.data ?? {}); } const res = await waitForCrawlCompletionWithOrigin( client, crawlId, origin, pollInterval, timeout ); return structuredText(res); }, }); server.addTool({ name: 'firecrawl_check_crawl_status', annotations: { title: 'Firecrawl crawl status', readOnlyHint: true, // Retrieves status and results for an existing crawl job by ID; no mutations. openWorldHint: false, // Queries only Firecrawl job state within the authenticated account. destructiveHint: false, // Status lookup only; no deletes or updates. }, description: ` 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. `, outputSchema: crawlOutputSchema, parameters: z.object({ id: z.string() }), execute: async ( args: unknown, { session, client: mcpClient, }: { session?: SessionData; client?: McpClient } ): Promise => { const client = getClient(session); const id = (args as any).id as string; const res = await getCrawlStatusWithOrigin( client, id, requestOrigin(mcpClient, session) ); return structuredText(res); }, }); server.addTool({ name: 'firecrawl_extract', annotations: { title: 'Firecrawl extract (deprecated: use scrape JSON)', readOnlyHint: true, openWorldHint: true, destructiveHint: false, }, description: ` Deprecated compatibility entry point. Use firecrawl_scrape once per known URL with formats: ["json"] and jsonOptions containing the prompt and schema. Use firecrawl_agent for multi-source research when the URLs are not known or the data spans several sites. `, outputSchema: deprecatedToolOutputSchema, parameters: z.object({ urls: z.array(z.string()), prompt: z.string().optional(), schema: z.record(z.string(), z.any()).optional(), allowExternalLinks: z.boolean().optional(), enableWebSearch: z.boolean().optional(), includeSubdomains: z.boolean().optional(), }), canList: () => false, beforeValidate: () => { const payload = deprecatedExtractPayload(); return { content: [{ type: 'text' as const, text: payload.message }], isError: true, structuredContent: payload, }; }, execute: async (): Promise => { const payload = deprecatedExtractPayload(); throw new UserError(payload.message, payload); }, }); // Mirrors agentExchangeSchema in firecrawl/firecrawl // apps/api/src/controllers/v2/types.ts: the gateway forwards it verbatim to // the agent service, which owns every default and the per-thread inheritance. const agentExchangeSchema = z .strictObject({ enabled: z .boolean() .optional() .describe('Let the agent call Alexandria providers. On by default.'), toolkits: z .array(z.string()) .max(5) .optional() .describe('Pin up to 5 providers the agent may use, by slug. Omitted means the whole catalog.'), maxCalls: z .number() .int() .min(1) .max(30) .optional() .describe('Most provider calls the agent may make in this turn.'), requireApproval: z .boolean() .optional() .describe( 'End the turn with a paid-call pendingApproval before any paid provider call. Requires mode "chat" on the same request, even on a follow-up.' ), approve: z .strictObject({ approvalId: z.string().uuid(), callIds: z.array(z.string()).optional(), always: z.boolean().optional(), }) .optional() .describe( 'Answer yes to the pendingApproval the previous turn of this thread ended on (its id, also exchange.requiresAction.approvalId). Needs threadId. For a terms offer, send it only after an organization admin confirms acceptance in the Firecrawl dashboard; this does not accept terms. callIds and always are ignored on terms offers. For paid calls, callIds picks a subset (default all) and always stops asking for the rest of the thread.' ), decline: z .strictObject({ approvalId: z.string().uuid() }) .optional() .describe( 'Answer no to that pendingApproval. Needs threadId. A declined terms offer keeps those providers out of the rest of the thread.' ), onTermsRequired: z .enum(['skip', 'ask']) .optional() .describe( 'What to do when a provider the agent would use needs data terms the team has not accepted. Gated providers are never called. "skip" (default): answer with accepted providers and list the rest in exchange.skippedProviders. "ask": the same, plus a terms pendingApproval and exchange.requiresAction. Read terms with terms/show; an organization admin accepts them in the Firecrawl dashboard. There is no auto-accept. Omitted on a follow-up keeps the previous turn\'s value.' ), }) .describe( 'Alexandria provider settings for this turn, forwarded as the request\'s exchange object.' ); server.addTool({ name: 'firecrawl_agent', annotations: { title: 'Firecrawl agent', readOnlyHint: false, // Starts an autonomous research agent job on the Firecrawl API. openWorldHint: true, // The agent browses and searches the open web to fulfill the prompt. destructiveHint: false, // Gathers information only; does not delete external data or user resources. idempotentHint: false, // Each call starts a new research job and is billed again. }, description: ` Run web research that returns structured data when the URLs are not known or the answer spans several sites. Describe the fields you need in \`prompt\`, optionally pass a JSON \`schema\` and seed \`urls\`, and the research agent searches, navigates, reads pages, and returns JSON assembled across sources. Use it to research an entity plus its fields (founders, pricing, contact details), to build lists and datasets (companies, people, products, jobs, papers), and for pages that need navigation or interaction to reach the data. Optional \`effort\` sets the reasoning budget, \`maxCredits\` caps spend, and \`strictConstrainToURLs\` keeps the agent to the supplied \`urls\`. This call returns only a job ID, not the research result. Read the job with \`firecrawl_agent_status\` until it reaches \`completed\` or \`failed\`; a typical research run takes one to three minutes. For one known URL use \`firecrawl_scrape\` (with formats: ["json"] for structured output); for a plain lookup that a results page answers, use \`firecrawl_search\`. The job also returns a \`threadId\`. To continue that thread, pass it with a follow-up \`prompt\`; omitted \`mode\`, \`urls\`, \`schema\` and exchange settings carry over from the previous turn. The agent only calls Alexandria providers whose terms the team has accepted. \`exchange.skippedProviders\` lists gated providers. With \`exchange.onTermsRequired\` "ask", a terms \`pendingApproval\` and \`exchange.requiresAction\` carry the \`approvalId\` and provider requirements. Read terms/show through \`firecrawl_scrape\`. An organization admin must accept terms in the Firecrawl dashboard at the provider URL or ${DATA_SOURCES_SETTINGS_URL}. Ignore any terms/accept call in the API response. Only after the admin confirms acceptance, resume with the same \`threadId\` and \`exchange.approve: {approvalId}\`; this does not accept terms. To decline, use \`exchange.decline: {approvalId}\`. Never infer acceptance from a data request. `, outputSchema: agentOutputSchema, parameters: z.object({ prompt: z.string().min(1).max(10000), urls: z.array(httpUrl()).optional(), schema: z.record(z.string(), z.any()).optional(), effort: z .enum(['low', 'medium', 'high']) .optional() .describe('Reasoning budget for the agent task.'), maxCredits: z .number() .int() .positive() .optional() .describe( 'Spending limit in credits for this run. Defaults to 2500 on the API.' ), strictConstrainToURLs: z .boolean() .optional() .describe( 'If true, agent will only visit URLs provided in the urls array.' ), threadId: z .string() .uuid() .optional() .describe( 'Continue this thread: the threadId from an earlier firecrawl_agent or firecrawl_agent_status result. Omit to start a new thread.' ), mode: z .enum(['extract', 'chat']) .optional() .describe( '"extract" (default) returns the complete structured result every turn. "chat" lets a follow-up that asks no new data get a short reply in message instead of a re-run; required for exchange.requireApproval. Omitted on a follow-up keeps the previous turn\'s mode.' ), exchange: agentExchangeSchema.optional(), }) .refine( (data) => !(data.exchange?.approve && data.exchange?.decline), { message: 'Send exchange.approve or exchange.decline, not both: each answers the pending approval one way.', path: ['exchange'], } ) .refine( (data) => !(data.exchange?.approve || data.exchange?.decline) || Boolean(data.threadId), { message: 'exchange.approve and exchange.decline answer a pending approval on an existing thread: pass that thread\'s threadId.', path: ['threadId'], } ) .refine( (data) => !data.exchange?.requireApproval || data.mode === 'chat', { // The agent service checks the mode sent on this request, not the // thread's inherited one, and the gateway turns its 400 into a 500. message: 'exchange.requireApproval needs mode: "chat" on the same request, including on a follow-up.', path: ['mode'], } ), execute: async ( args: unknown, { session, log, client: mcpClient } ): Promise => { const client = getClient(session); const a = args as Record; log.info('Starting agent', { prompt: (a.prompt as string).substring(0, 100), urlCount: Array.isArray(a.urls) ? a.urls.length : 0, threadId: (a.threadId as string | undefined) ?? null, }); const agentBody = removeEmptyTopLevel({ prompt: a.prompt as string, urls: a.urls as string[] | undefined, schema: (a.schema as Record) || undefined, effort: a.effort as 'low' | 'medium' | 'high' | undefined, maxCredits: a.maxCredits as number | undefined, strictConstrainToURLs: a.strictConstrainToURLs as boolean | undefined, threadId: a.threadId as string | undefined, mode: a.mode as 'extract' | 'chat' | undefined, // Forwarded verbatim: the agent service owns the defaults and the // per-thread inheritance. exchange: a.exchange as Record | undefined, }); const res = await (client as any).startAgent({ ...agentBody, origin: requestOrigin(mcpClient, session), }); return structuredText(res); }, }); server.addTool({ name: 'firecrawl_agent_status', annotations: { title: 'Firecrawl agent status', readOnlyHint: true, // Polls an existing agent job by ID for progress and results; no mutations. openWorldHint: false, // Queries only Firecrawl job state by job ID within the user's account. destructiveHint: false, // Read-only status check. }, description: ` Retrieve progress or final results for a \`firecrawl_agent\` job ID. A \`processing\` response is non-terminal and does not contain the final research result. Check again after 15–30 seconds until the status is \`completed\` or \`failed\`; a typical research run takes one to three minutes and complex jobs can take longer. If the job cannot finish within the task's available time, use \`firecrawl_search\` and \`firecrawl_scrape\` to complete the requested output. Returns job status, progress information, and result data when completed. `, outputSchema: agentStatusOutputSchema, parameters: z.object({ id: z.string() }), execute: async ( args: unknown, { session, log, client: mcpClient } ): Promise => { const client = getClient(session); const { id } = args as { id: string }; log.info('Checking agent status', { id }); const res = await (client as any).http.get( `/v2/agent/${encodeURIComponent(id)}`, originHeaders(requestOrigin(mcpClient, session)) ); return agentStatusResult(res?.data ?? {}); }, }); /** * A run that hit its maxCredits ends `failed`, and the API may attach a * best-effort `partial`. Lead with a plain notice so the calling model does not * mistake the partial for a finished answer or miss how to continue. Any other * status passes through unchanged. */ function agentStatusResult(data: unknown): ContentResult { const run = data as Record | null; if ( !run || typeof run !== 'object' || run.status !== 'failed' || run.stopReason !== 'credit_limit_reached' ) { return structuredText(data); } const lines = [ 'This agent run stopped at its credit limit (maxCredits) before finishing.', ]; const hasPartial = run.partial !== undefined && run.partial !== null; if (hasPartial) { const validity = run.partialSchemaValid === true ? ' It matches the schema this run was given.' : run.partialSchemaValid === false ? ' It does not match the schema this run was given.' : ''; lines.push( `\`partial\` is an INCOMPLETE best-effort result, not a finished answer; tell the user it is incomplete.${validity}` ); } else { lines.push('No partial result was recovered.'); } if (typeof run.message === 'string' && run.message.trim()) { lines.push(`Agent message: ${run.message.trim()}`); } const threadId = typeof run.threadId === 'string' ? run.threadId : undefined; lines.push( threadId ? `To continue, call firecrawl_agent with threadId "${threadId}" and a prompt to finish the task (the follow-up ${hasPartial ? 'resumes from this partial' : 'keeps this thread'}), or start a new run with a higher maxCredits.` : 'To continue, start a new firecrawl_agent run with a higher maxCredits.' ); const notice = lines.join(' '); return withStructured( `${notice}\n\n${JSON.stringify(data, null, 2)}`, { ...run, notice } ); } // Interact tools (scrape-bound browser sessions) server.addTool({ name: 'firecrawl_interact', annotations: { title: 'Firecrawl interact', readOnlyHint: false, // Executes browser interactions (clicks, form input, scripts) in a live session. openWorldHint: true, // Interacts with pages on the public web via the scraped session. destructiveHint: false, // Transient page interactions only; does not delete monitors, jobs, or external sites. idempotentHint: false, // Repeating an interaction can act on the page again. }, description: ` Open or reuse a live browser session to navigate a page, click controls, fill and submit forms, or run browser code. Provide either \`url\` (the scheme may be omitted) or \`scrapeId\`, and either a natural-language \`prompt\` or executable \`code\`; code can run as Bash, Python, or Node with a bounded timeout. The session runs in an isolated Firecrawl-hosted browser that never receives the end user's own browser cookies or credentials. By default it starts with no cookies or saved sessions; an optional team-scoped \`scrapeOptions.profile\` reuses state saved by the team's earlier sessions. Actions run on the live site, so submitting a form can have effects on that site. Returns execution output, stdout/stderr, exit status, and session viewing URLs. `, outputSchema: interactOutputSchema, parameters: z .object({ scrapeId: z.string().trim().min(1).optional(), url: httpUrl().optional(), prompt: z.string().trim().min(1).optional(), code: z.string().trim().min(1).optional(), language: z.enum(['bash', 'python', 'node']).optional(), timeout: z.number().min(1).max(300).optional(), scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional(), }) .refine((data) => Boolean(data.scrapeId) !== Boolean(data.url), { message: "Provide either 'url' (interact directly) or 'scrapeId' (reuse a previous scrape), not both.", }) .refine((data) => !data.scrapeOptions || Boolean(data.url), { message: "scrapeOptions can only be used with 'url' mode.", }) .refine((data) => data.code || data.prompt, { message: "Either 'code' or 'prompt' must be provided.", }), execute: async ( args: unknown, { session, log, client: mcpClient } ): Promise => { const origin = requestOrigin(mcpClient, session); const client = getClient(session); const { scrapeId: providedScrapeId, url, prompt, code, language, timeout, scrapeOptions, } = args as { scrapeId?: string; url?: string; prompt?: string; code?: string; language?: 'bash' | 'python' | 'node'; timeout?: number; scrapeOptions?: Record; }; // No scrapeId means the caller passed a url: scrape it first to open the // session, then interact. One tool call instead of scrape + interact. let scrapeId = providedScrapeId; const openedFromUrl = !scrapeId; if (openedFromUrl) { log.info('Opening interact session from url', { url }); const cleanedScrapeOptions = removeEmptyTopLevel( transformScrapeParams(scrapeOptions ?? {}) ); assertFormatOptions(cleanedScrapeOptions.formats); const scraped = await client.scrape(String(url), { ...cleanedScrapeOptions, origin, } as any); scrapeId = (scraped as any)?.metadata?.scrapeId; if (!scrapeId) { return structuredText({ error: 'Could not open an interact session: the scrape did not return a scrapeId. Try firecrawl_scrape first, then pass its scrapeId.', url, }); } } if (!scrapeId) { return structuredText({ error: 'Could not open an interact session: missing scrapeId.', url, }); } const activeScrapeId = scrapeId; log.info('Interacting with page', { scrapeId: activeScrapeId }); const interactArgs: Record = { origin }; if (prompt) interactArgs.prompt = prompt; if (code) interactArgs.code = code; if (language) interactArgs.language = language; if (timeout != null) interactArgs.timeout = timeout; const res = await client.interact(activeScrapeId, interactArgs as any); if (openedFromUrl && res && typeof res === 'object' && !Array.isArray(res)) { return structuredText({ ...(res as unknown as Record), scrapeId: activeScrapeId, }); } if (openedFromUrl) { return structuredText({ scrapeId: activeScrapeId, result: res }); } return structuredText(res); }, }); server.addTool({ name: 'firecrawl_interact_stop', annotations: { title: 'Stop Firecrawl interact session', readOnlyHint: false, // Calls the API to stop and tear down an active interact session. openWorldHint: false, // Operates only on a known Firecrawl scrape/interact session ID. destructiveHint: true, // Terminates the live browser session; this end state cannot be resumed. idempotentHint: true, // Stopping a session that is already stopped leaves it stopped. }, description: ` Stop the live interact session associated with a \`scrapeId\` and release its resources. Returns a success confirmation. `, outputSchema: interactStopOutputSchema, parameters: z.object({ scrapeId: z.string(), }), execute: async ( args: unknown, { session, log, client: mcpClient } ): Promise => { const client = getClient(session); const { scrapeId } = args as { scrapeId: string }; log.info('Stopping interact session', { scrapeId }); const res = await (client as any).http.delete( `/v2/scrape/${encodeURIComponent(scrapeId)}/interact`, originHeaders(requestOrigin(mcpClient, session)) ); return structuredText(res?.data ?? {}); }, }); // Parse a local file directly with local file access, or orchestrate a // two-call uploadRef flow with upload file access without reading the // caller's filesystem. server.addTool({ name: 'firecrawl_parse', annotations: { title: 'Firecrawl file parsing', readOnlyHint: true, // Local mode reads a file; hosted mode only returns upload instructions or parses an uploadRef. openWorldHint: false, // Operates on a local filesystem path/upload reference, not an arbitrary web URL. destructiveHint: false, // Read-only parsing; no deletion or writes to the source file. }, description: ` Parse one supported document into markdown, HTML, links, summary, targeted answers, or JSON matching a schema. Supported inputs include common HTML, PDF, Word, RTF, OpenDocument, and spreadsheet files; PDF parsing can be bounded with \`pdfOptions.maxPages\`. Local MCP reads \`filePath\` from the server filesystem. Hosted MCP uses two calls: first provide \`filePath\` to receive upload instructions, upload locally, then call again with the returned \`uploadRef\`; do not send both fields together. Remote web URLs belong in \`firecrawl_scrape\`. Set \`redactPII\` to request redaction of personally identifiable information in the returned content. \`zeroDataRetention\` requires an eligible authenticated account; omit it for anonymous keyless use. Returns upload instructions for hosted phase one or parsed document content for the final call. Authenticated final responses can include a \`data.metadata.scrapeId\` for optional parse feedback. `, outputSchema: parseOutputSchema, parameters: parseParamsSchema, execute: async ( args: unknown, { session, log, client: mcpClient } ): Promise => { const origin = requestOrigin(mcpClient, session); if (config.fileAccess === 'upload') { return structuredJsonText( await executeHostedParse(args as ParseToolArgs, session, log, origin) ); } const apiUrl = config.apiUrl; if (!apiUrl) { throw new Error( 'firecrawl_parse requires FIRECRAWL_API_URL to be set to a self-hosted Firecrawl API instance.' ); } const { filePath, contentType: overrideContentType, ...options } = args as { filePath: string; contentType?: string; } & Record; const absPath = path.resolve(filePath); const buffer = await readFile(absPath); const filename = path.basename(absPath); const fileContentType = overrideContentType && overrideContentType.length > 0 ? overrideContentType : inferContentType(filename); const optionsPayload = buildParseOptionsPayload( options as Record, origin ); const form = new FormData(); const blob = new Blob([new Uint8Array(buffer)], { type: fileContentType, }); form.append('file', blob, filename); form.append('options', JSON.stringify(optionsPayload)); const headers: Record = { ...originHeaders(origin), }; const outbound = resolveOutbound(session); Object.assign(headers, outbound.headers); const credential = outbound.credential; if (credential) { headers['Authorization'] = `Bearer ${credential}`; } const endpoint = `${apiUrl.replace(/\/$/, '')}/v2/parse`; log.info('Parsing local file', { endpoint, filename, size: buffer.length, }); const response = await fetch(endpoint, { method: 'POST', headers, body: form, }); const responseText = await response.text(); let parsed: any; try { parsed = JSON.parse(responseText); } catch { // Preserve non-JSON responses as raw text below. } if (!response.ok || parsed?.success === false) { throw new CoreHttpError( `Parse request failed with status ${response.status}: ${responseText}`, response.status, parsed?.code ); } return parsed === undefined ? withStructured(responseText, { raw: responseText }) : structuredText(parsed); }, }); registerMonitorTools(server, { apiUrl: config.apiUrl, resolveOutbound, }); registerResearchTools(server, getClient); registerDeveloperTools(server, getClient); registerGovTools(server, getClient); if (!isLocalKeylessStartup()) { registerEnrichTools(server, getClient, assertExchangeCredential, hasCredential, { parameters: z .object({ ...searchToolBaseFields }) .refine(searchDomainsAreExclusive, SEARCH_DOMAINS_CONFLICT_MESSAGE) .refine(searchCategoriesAreCompatible, SEARCH_CATEGORIES_CONFLICT_MESSAGE) .refine(searchQueryIsValid, 'A query is required.'), run: (args, context) => searchTool.execute(args, context, 'people_company_search'), }); } registerUsageTools(server, getClient); hooks.registerExtraTools?.( { addTool: (tool) => addTool(prepareTool(tool as unknown as RegisteredTool)), }, { getClient, hasCredential, builtInTool: (name) => builtInTools.get(name) as unknown as ToolDefinition | undefined, addTaskHandler: (handler) => { taskHandlers.push(handler); }, } ); hooks.registerResources?.({ addResource: (resource) => server.addResource(resource), }); return { start: (args) => server.start( (args.transportType === 'httpStream' ? { ...args, httpStream: { ...args.httpStream, maxBodySize: args.httpStream.maxBodySize ?? 1_048_576, }, } : args) as Parameters[0] ), stop: () => server.stop(), }; }