firecrawl-mcp 3.21.0 → 3.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,453 +1,2503 @@
1
1
  #!/usr/bin/env node
2
- import FirecrawlApp from '@mendable/firecrawl-js';
3
- import dotenv from 'dotenv';
4
- import { FastMCP } from 'firecrawl-fastmcp';
5
- import { readFile } from 'node:fs/promises';
6
- import path from 'node:path';
7
- import { z } from 'zod';
8
- import { registerMonitorTools } from './monitor.js';
9
- import { registerResearchTools } from './research.js';
2
+
3
+ // src/index.ts
4
+ import FirecrawlApp from "@mendable/firecrawl-js";
5
+ import dotenv from "dotenv";
6
+ import { readFile } from "fs/promises";
7
+ import { createRequire } from "module";
8
+ import path from "path";
9
+ import { z as z4 } from "zod";
10
+
11
+ // src/fastmcp/FastMCP.ts
12
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
13
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
14
+ import {
15
+ CallToolRequestSchema,
16
+ CompleteRequestSchema,
17
+ ErrorCode,
18
+ GetPromptRequestSchema,
19
+ ListPromptsRequestSchema,
20
+ ListResourcesRequestSchema,
21
+ ListResourceTemplatesRequestSchema,
22
+ ListToolsRequestSchema,
23
+ McpError,
24
+ ReadResourceRequestSchema,
25
+ RootsListChangedNotificationSchema,
26
+ SetLevelRequestSchema
27
+ } from "@modelcontextprotocol/sdk/types.js";
28
+ import { EventEmitter } from "events";
29
+ import Fuse from "fuse.js";
30
+ import { startHTTPServer } from "mcp-proxy";
31
+ import { setTimeout as delay } from "timers/promises";
32
+ import parseURITemplate from "uri-templates";
33
+ import { toJsonSchema } from "xsschema";
34
+ import { z } from "zod";
35
+ var FastMCPError = class extends Error {
36
+ constructor(message) {
37
+ super(message);
38
+ this.name = new.target.name;
39
+ }
40
+ };
41
+ var UnexpectedStateError = class extends FastMCPError {
42
+ extras;
43
+ constructor(message, extras) {
44
+ super(message);
45
+ this.name = new.target.name;
46
+ this.extras = extras;
47
+ }
48
+ };
49
+ var UserError = class extends UnexpectedStateError {
50
+ };
51
+ var TextContentZodSchema = z.object({
52
+ /**
53
+ * The text content of the message.
54
+ */
55
+ text: z.string(),
56
+ type: z.literal("text")
57
+ }).strict();
58
+ var ImageContentZodSchema = z.object({
59
+ /**
60
+ * The base64-encoded image data.
61
+ */
62
+ data: z.string().base64(),
63
+ /**
64
+ * The MIME type of the image. Different providers may support different image types.
65
+ */
66
+ mimeType: z.string(),
67
+ type: z.literal("image")
68
+ }).strict();
69
+ var AudioContentZodSchema = z.object({
70
+ /**
71
+ * The base64-encoded audio data.
72
+ */
73
+ data: z.string().base64(),
74
+ mimeType: z.string(),
75
+ type: z.literal("audio")
76
+ }).strict();
77
+ var ResourceContentZodSchema = z.object({
78
+ resource: z.object({
79
+ blob: z.string().optional(),
80
+ mimeType: z.string().optional(),
81
+ text: z.string().optional(),
82
+ uri: z.string()
83
+ }),
84
+ type: z.literal("resource")
85
+ }).strict();
86
+ var ResourceLinkZodSchema = z.object({
87
+ description: z.string().optional(),
88
+ mimeType: z.string().optional(),
89
+ name: z.string(),
90
+ title: z.string().optional(),
91
+ type: z.literal("resource_link"),
92
+ uri: z.string()
93
+ });
94
+ var ContentZodSchema = z.discriminatedUnion("type", [
95
+ TextContentZodSchema,
96
+ ImageContentZodSchema,
97
+ AudioContentZodSchema,
98
+ ResourceContentZodSchema,
99
+ ResourceLinkZodSchema
100
+ ]);
101
+ var ContentResultZodSchema = z.object({
102
+ content: ContentZodSchema.array(),
103
+ isError: z.boolean().optional()
104
+ }).strict();
105
+ var CompletionZodSchema = z.object({
106
+ /**
107
+ * Indicates whether there are additional completion options beyond those provided in the current response, even if the exact total is unknown.
108
+ */
109
+ hasMore: z.optional(z.boolean()),
110
+ /**
111
+ * The total number of completion options available. This can exceed the number of values actually sent in the response.
112
+ */
113
+ total: z.optional(z.number().int()),
114
+ /**
115
+ * An array of completion values. Must not exceed 100 items.
116
+ */
117
+ values: z.array(z.string()).max(100)
118
+ });
119
+ var FastMCPSessionEventEmitterBase = EventEmitter;
120
+ var escapeWWWAuthenticateValue = (value) => {
121
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
122
+ };
123
+ var FastMCPSessionEventEmitter = class extends FastMCPSessionEventEmitterBase {
124
+ };
125
+ var FastMCPSession = class extends FastMCPSessionEventEmitter {
126
+ get clientCapabilities() {
127
+ return this.#clientCapabilities ?? null;
128
+ }
129
+ get isReady() {
130
+ return this.#connectionState === "ready";
131
+ }
132
+ get loggingLevel() {
133
+ return this.#loggingLevel;
134
+ }
135
+ get roots() {
136
+ return this.#roots;
137
+ }
138
+ get server() {
139
+ return this.#server;
140
+ }
141
+ #auth;
142
+ #capabilities = {};
143
+ #clientCapabilities;
144
+ #connectionState = "connecting";
145
+ #logger;
146
+ #loggingLevel = "info";
147
+ #needsEventLoopFlush = false;
148
+ #pingConfig;
149
+ #pingInterval = null;
150
+ #prompts = [];
151
+ #resources = [];
152
+ #resourceTemplates = [];
153
+ #roots = [];
154
+ #rootsConfig;
155
+ #server;
156
+ #utils;
157
+ constructor({
158
+ auth,
159
+ instructions,
160
+ logger,
161
+ name,
162
+ ping,
163
+ prompts,
164
+ resources,
165
+ resourcesTemplates,
166
+ roots,
167
+ tools,
168
+ transportType,
169
+ utils,
170
+ version
171
+ }) {
172
+ super();
173
+ this.#auth = auth;
174
+ this.#logger = logger;
175
+ this.#pingConfig = ping;
176
+ this.#rootsConfig = roots;
177
+ this.#needsEventLoopFlush = transportType === "httpStream";
178
+ if (tools.length) {
179
+ this.#capabilities.tools = {};
180
+ }
181
+ if (resources.length || resourcesTemplates.length) {
182
+ this.#capabilities.resources = {};
183
+ }
184
+ if (prompts.length) {
185
+ for (const prompt of prompts) {
186
+ this.addPrompt(prompt);
187
+ }
188
+ this.#capabilities.prompts = {};
189
+ }
190
+ this.#capabilities.logging = {};
191
+ this.#server = new Server(
192
+ { name, version },
193
+ { capabilities: this.#capabilities, instructions }
194
+ );
195
+ this.#utils = utils;
196
+ this.setupErrorHandling();
197
+ this.setupLoggingHandlers();
198
+ this.setupRootsHandlers();
199
+ this.setupCompleteHandlers();
200
+ if (tools.length) {
201
+ this.setupToolHandlers(tools);
202
+ }
203
+ if (resources.length || resourcesTemplates.length) {
204
+ for (const resource of resources) {
205
+ this.addResource(resource);
206
+ }
207
+ this.setupResourceHandlers(resources);
208
+ if (resourcesTemplates.length) {
209
+ for (const resourceTemplate of resourcesTemplates) {
210
+ this.addResourceTemplate(resourceTemplate);
211
+ }
212
+ this.setupResourceTemplateHandlers(resourcesTemplates);
213
+ }
214
+ }
215
+ if (prompts.length) {
216
+ this.setupPromptHandlers(prompts);
217
+ }
218
+ }
219
+ async close() {
220
+ this.#connectionState = "closed";
221
+ if (this.#pingInterval) {
222
+ clearInterval(this.#pingInterval);
223
+ }
224
+ try {
225
+ await this.#server.close();
226
+ } catch (error) {
227
+ this.#logger.error("[FastMCP error]", "could not close server", error);
228
+ }
229
+ }
230
+ async connect(transport) {
231
+ if (this.#server.transport) {
232
+ throw new UnexpectedStateError("Server is already connected");
233
+ }
234
+ this.#connectionState = "connecting";
235
+ try {
236
+ await this.#server.connect(transport);
237
+ let attempt = 0;
238
+ const maxAttempts = 10;
239
+ const retryDelay = 100;
240
+ while (attempt++ < maxAttempts) {
241
+ const capabilities = this.#server.getClientCapabilities();
242
+ if (capabilities) {
243
+ this.#clientCapabilities = capabilities;
244
+ break;
245
+ }
246
+ await delay(retryDelay);
247
+ }
248
+ if (!this.#clientCapabilities) {
249
+ this.#logger.warn(
250
+ `[FastMCP warning] could not infer client capabilities after ${maxAttempts} attempts. Connection may be unstable.`
251
+ );
252
+ }
253
+ if (this.#clientCapabilities?.roots?.listChanged && typeof this.#server.listRoots === "function") {
254
+ try {
255
+ const roots = await this.#server.listRoots();
256
+ this.#roots = roots?.roots || [];
257
+ } catch (e) {
258
+ if (e instanceof McpError && e.code === ErrorCode.MethodNotFound) {
259
+ this.#logger.debug(
260
+ "[FastMCP debug] listRoots method not supported by client"
261
+ );
262
+ } else {
263
+ this.#logger.error(
264
+ `[FastMCP error] received error listing roots.
265
+
266
+ ${e instanceof Error ? e.stack : JSON.stringify(e)}`
267
+ );
268
+ }
269
+ }
270
+ }
271
+ if (this.#clientCapabilities) {
272
+ const pingConfig = this.#getPingConfig(transport);
273
+ if (pingConfig.enabled) {
274
+ this.#pingInterval = setInterval(async () => {
275
+ try {
276
+ await this.#server.ping();
277
+ } catch {
278
+ const logLevel = pingConfig.logLevel;
279
+ if (logLevel === "debug") {
280
+ this.#logger.debug("[FastMCP debug] server ping failed");
281
+ } else if (logLevel === "warning") {
282
+ this.#logger.warn(
283
+ "[FastMCP warning] server is not responding to ping"
284
+ );
285
+ } else if (logLevel === "error") {
286
+ this.#logger.error(
287
+ "[FastMCP error] server is not responding to ping"
288
+ );
289
+ } else {
290
+ this.#logger.info("[FastMCP info] server ping failed");
291
+ }
292
+ }
293
+ }, pingConfig.intervalMs);
294
+ }
295
+ }
296
+ this.#connectionState = "ready";
297
+ this.emit("ready");
298
+ } catch (error) {
299
+ this.#connectionState = "error";
300
+ const errorEvent = {
301
+ error: error instanceof Error ? error : new Error(String(error))
302
+ };
303
+ this.emit("error", errorEvent);
304
+ throw error;
305
+ }
306
+ }
307
+ async requestSampling(message, options) {
308
+ return this.#server.createMessage(message, options);
309
+ }
310
+ waitForReady() {
311
+ if (this.isReady) {
312
+ return Promise.resolve();
313
+ }
314
+ if (this.#connectionState === "error" || this.#connectionState === "closed") {
315
+ return Promise.reject(
316
+ new Error(`Connection is in ${this.#connectionState} state`)
317
+ );
318
+ }
319
+ return new Promise((resolve, reject) => {
320
+ const timeout = setTimeout(() => {
321
+ reject(
322
+ new Error(
323
+ "Connection timeout: Session failed to become ready within 5 seconds"
324
+ )
325
+ );
326
+ }, 5e3);
327
+ this.once("ready", () => {
328
+ clearTimeout(timeout);
329
+ resolve();
330
+ });
331
+ this.once("error", (event) => {
332
+ clearTimeout(timeout);
333
+ reject(event.error);
334
+ });
335
+ });
336
+ }
337
+ #getPingConfig(transport) {
338
+ const pingConfig = this.#pingConfig || {};
339
+ let defaultEnabled = false;
340
+ if ("type" in transport) {
341
+ if (transport.type === "httpStream") {
342
+ defaultEnabled = true;
343
+ }
344
+ }
345
+ return {
346
+ enabled: pingConfig.enabled !== void 0 ? pingConfig.enabled : defaultEnabled,
347
+ intervalMs: pingConfig.intervalMs || 5e3,
348
+ logLevel: pingConfig.logLevel || "debug"
349
+ };
350
+ }
351
+ addPrompt(inputPrompt) {
352
+ const completers = {};
353
+ const enums = {};
354
+ const fuseInstances = {};
355
+ for (const argument of inputPrompt.arguments ?? []) {
356
+ if (argument.complete) {
357
+ completers[argument.name] = argument.complete;
358
+ }
359
+ if (argument.enum) {
360
+ enums[argument.name] = argument.enum;
361
+ fuseInstances[argument.name] = new Fuse(argument.enum, {
362
+ includeScore: true,
363
+ threshold: 0.3
364
+ // More flexible matching!
365
+ });
366
+ }
367
+ }
368
+ const prompt = {
369
+ ...inputPrompt,
370
+ complete: async (name, value, auth) => {
371
+ if (completers[name]) {
372
+ return await completers[name](value, auth);
373
+ }
374
+ if (fuseInstances[name]) {
375
+ const result = fuseInstances[name].search(value);
376
+ return {
377
+ total: result.length,
378
+ values: result.map((item) => item.item)
379
+ };
380
+ }
381
+ return {
382
+ values: []
383
+ };
384
+ }
385
+ };
386
+ this.#prompts.push(prompt);
387
+ }
388
+ addResource(inputResource) {
389
+ this.#resources.push(inputResource);
390
+ }
391
+ addResourceTemplate(inputResourceTemplate) {
392
+ const completers = {};
393
+ for (const argument of inputResourceTemplate.arguments ?? []) {
394
+ if (argument.complete) {
395
+ completers[argument.name] = argument.complete;
396
+ }
397
+ }
398
+ const resourceTemplate = {
399
+ ...inputResourceTemplate,
400
+ complete: async (name, value, auth) => {
401
+ if (completers[name]) {
402
+ return await completers[name](value, auth);
403
+ }
404
+ return {
405
+ values: []
406
+ };
407
+ }
408
+ };
409
+ this.#resourceTemplates.push(resourceTemplate);
410
+ }
411
+ setupCompleteHandlers() {
412
+ this.#server.setRequestHandler(CompleteRequestSchema, async (request) => {
413
+ if (request.params.ref.type === "ref/prompt") {
414
+ const prompt = this.#prompts.find(
415
+ (prompt2) => prompt2.name === request.params.ref.name
416
+ );
417
+ if (!prompt) {
418
+ throw new UnexpectedStateError("Unknown prompt", {
419
+ request
420
+ });
421
+ }
422
+ if (!prompt.complete) {
423
+ throw new UnexpectedStateError("Prompt does not support completion", {
424
+ request
425
+ });
426
+ }
427
+ const completion = CompletionZodSchema.parse(
428
+ await prompt.complete(
429
+ request.params.argument.name,
430
+ request.params.argument.value,
431
+ this.#auth
432
+ )
433
+ );
434
+ return {
435
+ completion
436
+ };
437
+ }
438
+ if (request.params.ref.type === "ref/resource") {
439
+ const resource = this.#resourceTemplates.find(
440
+ (resource2) => resource2.uriTemplate === request.params.ref.uri
441
+ );
442
+ if (!resource) {
443
+ throw new UnexpectedStateError("Unknown resource", {
444
+ request
445
+ });
446
+ }
447
+ if (!("uriTemplate" in resource)) {
448
+ throw new UnexpectedStateError("Unexpected resource");
449
+ }
450
+ if (!resource.complete) {
451
+ throw new UnexpectedStateError(
452
+ "Resource does not support completion",
453
+ {
454
+ request
455
+ }
456
+ );
457
+ }
458
+ const completion = CompletionZodSchema.parse(
459
+ await resource.complete(
460
+ request.params.argument.name,
461
+ request.params.argument.value,
462
+ this.#auth
463
+ )
464
+ );
465
+ return {
466
+ completion
467
+ };
468
+ }
469
+ throw new UnexpectedStateError("Unexpected completion request", {
470
+ request
471
+ });
472
+ });
473
+ }
474
+ setupErrorHandling() {
475
+ this.#server.onerror = (error) => {
476
+ this.#logger.error("[FastMCP error]", error);
477
+ };
478
+ }
479
+ setupLoggingHandlers() {
480
+ this.#server.setRequestHandler(SetLevelRequestSchema, (request) => {
481
+ this.#loggingLevel = request.params.level;
482
+ return {};
483
+ });
484
+ }
485
+ setupPromptHandlers(prompts) {
486
+ this.#server.setRequestHandler(ListPromptsRequestSchema, async () => {
487
+ return {
488
+ prompts: prompts.map((prompt) => {
489
+ return {
490
+ arguments: prompt.arguments,
491
+ complete: prompt.complete,
492
+ description: prompt.description,
493
+ name: prompt.name
494
+ };
495
+ })
496
+ };
497
+ });
498
+ this.#server.setRequestHandler(GetPromptRequestSchema, async (request) => {
499
+ const prompt = prompts.find(
500
+ (prompt2) => prompt2.name === request.params.name
501
+ );
502
+ if (!prompt) {
503
+ throw new McpError(
504
+ ErrorCode.MethodNotFound,
505
+ `Unknown prompt: ${request.params.name}`
506
+ );
507
+ }
508
+ const args2 = request.params.arguments;
509
+ for (const arg of prompt.arguments ?? []) {
510
+ if (arg.required && !(args2 && arg.name in args2)) {
511
+ throw new McpError(
512
+ ErrorCode.InvalidRequest,
513
+ `Prompt '${request.params.name}' requires argument '${arg.name}': ${arg.description || "No description provided"}`
514
+ );
515
+ }
516
+ }
517
+ let result;
518
+ try {
519
+ result = await prompt.load(
520
+ args2,
521
+ this.#auth
522
+ );
523
+ } catch (error) {
524
+ const errorMessage = error instanceof Error ? error.message : String(error);
525
+ throw new McpError(
526
+ ErrorCode.InternalError,
527
+ `Failed to load prompt '${request.params.name}': ${errorMessage}`
528
+ );
529
+ }
530
+ if (typeof result === "string") {
531
+ return {
532
+ description: prompt.description,
533
+ messages: [
534
+ {
535
+ content: { text: result, type: "text" },
536
+ role: "user"
537
+ }
538
+ ]
539
+ };
540
+ } else {
541
+ return {
542
+ description: prompt.description,
543
+ messages: result.messages
544
+ };
545
+ }
546
+ });
547
+ }
548
+ setupResourceHandlers(resources) {
549
+ this.#server.setRequestHandler(ListResourcesRequestSchema, async () => {
550
+ return {
551
+ resources: resources.map((resource) => ({
552
+ description: resource.description,
553
+ mimeType: resource.mimeType,
554
+ name: resource.name,
555
+ uri: resource.uri
556
+ }))
557
+ };
558
+ });
559
+ this.#server.setRequestHandler(
560
+ ReadResourceRequestSchema,
561
+ async (request) => {
562
+ if ("uri" in request.params) {
563
+ const resource = resources.find(
564
+ (resource2) => "uri" in resource2 && resource2.uri === request.params.uri
565
+ );
566
+ if (!resource) {
567
+ for (const resourceTemplate of this.#resourceTemplates) {
568
+ const uriTemplate = parseURITemplate(
569
+ resourceTemplate.uriTemplate
570
+ );
571
+ const match = uriTemplate.fromUri(request.params.uri);
572
+ if (!match) {
573
+ continue;
574
+ }
575
+ const uri = uriTemplate.fill(match);
576
+ const result = await resourceTemplate.load(match, this.#auth);
577
+ const resources2 = Array.isArray(result) ? result : [result];
578
+ return {
579
+ contents: resources2.map((resource2) => ({
580
+ ...resource2,
581
+ description: resourceTemplate.description,
582
+ mimeType: resource2.mimeType ?? resourceTemplate.mimeType,
583
+ name: resourceTemplate.name,
584
+ uri: resource2.uri ?? uri
585
+ }))
586
+ };
587
+ }
588
+ throw new McpError(
589
+ ErrorCode.MethodNotFound,
590
+ `Resource not found: '${request.params.uri}'. Available resources: ${resources.map((r) => r.uri).join(", ") || "none"}`
591
+ );
592
+ }
593
+ if (!("uri" in resource)) {
594
+ throw new UnexpectedStateError("Resource does not support reading");
595
+ }
596
+ let maybeArrayResult;
597
+ try {
598
+ maybeArrayResult = await resource.load(this.#auth);
599
+ } catch (error) {
600
+ const errorMessage = error instanceof Error ? error.message : String(error);
601
+ throw new McpError(
602
+ ErrorCode.InternalError,
603
+ `Failed to load resource '${resource.name}' (${resource.uri}): ${errorMessage}`,
604
+ {
605
+ uri: resource.uri
606
+ }
607
+ );
608
+ }
609
+ const resourceResults = Array.isArray(maybeArrayResult) ? maybeArrayResult : [maybeArrayResult];
610
+ return {
611
+ contents: resourceResults.map((result) => ({
612
+ ...result,
613
+ mimeType: result.mimeType ?? resource.mimeType,
614
+ name: resource.name,
615
+ uri: result.uri ?? resource.uri
616
+ }))
617
+ };
618
+ }
619
+ throw new UnexpectedStateError("Unknown resource request", {
620
+ request
621
+ });
622
+ }
623
+ );
624
+ }
625
+ setupResourceTemplateHandlers(resourceTemplates) {
626
+ this.#server.setRequestHandler(
627
+ ListResourceTemplatesRequestSchema,
628
+ async () => {
629
+ return {
630
+ resourceTemplates: resourceTemplates.map((resourceTemplate) => ({
631
+ description: resourceTemplate.description,
632
+ mimeType: resourceTemplate.mimeType,
633
+ name: resourceTemplate.name,
634
+ uriTemplate: resourceTemplate.uriTemplate
635
+ }))
636
+ };
637
+ }
638
+ );
639
+ }
640
+ setupRootsHandlers() {
641
+ if (this.#rootsConfig?.enabled === false) {
642
+ this.#logger.debug(
643
+ "[FastMCP debug] roots capability explicitly disabled via config"
644
+ );
645
+ return;
646
+ }
647
+ if (typeof this.#server.listRoots === "function") {
648
+ this.#server.setNotificationHandler(
649
+ RootsListChangedNotificationSchema,
650
+ () => {
651
+ this.#server.listRoots().then((roots) => {
652
+ this.#roots = roots.roots;
653
+ this.emit("rootsChanged", {
654
+ roots: roots.roots
655
+ });
656
+ }).catch((error) => {
657
+ if (error instanceof McpError && error.code === ErrorCode.MethodNotFound) {
658
+ this.#logger.debug(
659
+ "[FastMCP debug] listRoots method not supported by client"
660
+ );
661
+ } else {
662
+ this.#logger.error(
663
+ `[FastMCP error] received error listing roots.
664
+
665
+ ${error instanceof Error ? error.stack : JSON.stringify(error)}`
666
+ );
667
+ }
668
+ });
669
+ }
670
+ );
671
+ } else {
672
+ this.#logger.debug(
673
+ "[FastMCP debug] roots capability not available, not setting up notification handler"
674
+ );
675
+ }
676
+ }
677
+ setupToolHandlers(tools) {
678
+ this.#server.setRequestHandler(ListToolsRequestSchema, async () => {
679
+ return {
680
+ tools: await Promise.all(
681
+ tools.map(async (tool) => {
682
+ return {
683
+ annotations: tool.annotations,
684
+ description: tool.description,
685
+ inputSchema: tool.parameters ? await toJsonSchema(tool.parameters) : {
686
+ additionalProperties: false,
687
+ properties: {},
688
+ type: "object"
689
+ },
690
+ // More complete schema for Cursor compatibility
691
+ name: tool.name
692
+ };
693
+ })
694
+ )
695
+ };
696
+ });
697
+ this.#server.setRequestHandler(CallToolRequestSchema, async (request) => {
698
+ const tool = tools.find((tool2) => tool2.name === request.params.name);
699
+ if (!tool) {
700
+ throw new McpError(
701
+ ErrorCode.MethodNotFound,
702
+ `Unknown tool: ${request.params.name}`
703
+ );
704
+ }
705
+ let args2 = void 0;
706
+ if (tool.parameters) {
707
+ const parsed = await tool.parameters["~standard"].validate(
708
+ request.params.arguments
709
+ );
710
+ if (parsed.issues) {
711
+ const friendlyErrors = this.#utils?.formatInvalidParamsErrorMessage ? this.#utils.formatInvalidParamsErrorMessage(parsed.issues) : parsed.issues.map((issue) => {
712
+ const path2 = issue.path?.join(".") || "root";
713
+ return `${path2}: ${issue.message}`;
714
+ }).join(", ");
715
+ throw new McpError(
716
+ ErrorCode.InvalidParams,
717
+ `Tool '${request.params.name}' parameter validation failed: ${friendlyErrors}. Please check the parameter types and values according to the tool's schema.`
718
+ );
719
+ }
720
+ args2 = parsed.value;
721
+ }
722
+ const progressToken = request.params?._meta?.progressToken;
723
+ let result;
724
+ try {
725
+ const reportProgress = async (progress) => {
726
+ try {
727
+ await this.#server.notification({
728
+ method: "notifications/progress",
729
+ params: {
730
+ ...progress,
731
+ progressToken
732
+ }
733
+ });
734
+ if (this.#needsEventLoopFlush) {
735
+ await new Promise((resolve) => setImmediate(resolve));
736
+ }
737
+ } catch (progressError) {
738
+ this.#logger.warn(
739
+ `[FastMCP warning] Failed to report progress for tool '${request.params.name}':`,
740
+ progressError instanceof Error ? progressError.message : String(progressError)
741
+ );
742
+ }
743
+ };
744
+ const log = {
745
+ debug: (message, context) => {
746
+ this.#server.sendLoggingMessage({
747
+ data: {
748
+ context,
749
+ message
750
+ },
751
+ level: "debug"
752
+ });
753
+ },
754
+ error: (message, context) => {
755
+ this.#server.sendLoggingMessage({
756
+ data: {
757
+ context,
758
+ message
759
+ },
760
+ level: "error"
761
+ });
762
+ },
763
+ info: (message, context) => {
764
+ this.#server.sendLoggingMessage({
765
+ data: {
766
+ context,
767
+ message
768
+ },
769
+ level: "info"
770
+ });
771
+ },
772
+ warn: (message, context) => {
773
+ this.#server.sendLoggingMessage({
774
+ data: {
775
+ context,
776
+ message
777
+ },
778
+ level: "warning"
779
+ });
780
+ }
781
+ };
782
+ const streamContent = async (content) => {
783
+ const contentArray = Array.isArray(content) ? content : [content];
784
+ try {
785
+ await this.#server.notification({
786
+ method: "notifications/tool/streamContent",
787
+ params: {
788
+ content: contentArray,
789
+ toolName: request.params.name
790
+ }
791
+ });
792
+ if (this.#needsEventLoopFlush) {
793
+ await new Promise((resolve) => setImmediate(resolve));
794
+ }
795
+ } catch (streamError) {
796
+ this.#logger.warn(
797
+ `[FastMCP warning] Failed to stream content for tool '${request.params.name}':`,
798
+ streamError instanceof Error ? streamError.message : String(streamError)
799
+ );
800
+ }
801
+ };
802
+ const executeToolPromise = tool.execute(args2, {
803
+ client: {
804
+ version: this.#server.getClientVersion()
805
+ },
806
+ log,
807
+ reportProgress,
808
+ session: this.#auth,
809
+ streamContent
810
+ });
811
+ const maybeStringResult = await (tool.timeoutMs ? Promise.race([
812
+ executeToolPromise,
813
+ new Promise((_, reject) => {
814
+ const timeoutId = setTimeout(() => {
815
+ reject(
816
+ new UserError(
817
+ `Tool '${request.params.name}' timed out after ${tool.timeoutMs}ms. Consider increasing timeoutMs or optimizing the tool implementation.`
818
+ )
819
+ );
820
+ }, tool.timeoutMs);
821
+ executeToolPromise.finally(() => clearTimeout(timeoutId));
822
+ })
823
+ ]) : executeToolPromise);
824
+ await delay(1);
825
+ if (maybeStringResult === void 0 || maybeStringResult === null) {
826
+ result = ContentResultZodSchema.parse({
827
+ content: []
828
+ });
829
+ } else if (typeof maybeStringResult === "string") {
830
+ result = ContentResultZodSchema.parse({
831
+ content: [{ text: maybeStringResult, type: "text" }]
832
+ });
833
+ } else if ("type" in maybeStringResult) {
834
+ result = ContentResultZodSchema.parse({
835
+ content: [maybeStringResult]
836
+ });
837
+ } else {
838
+ result = ContentResultZodSchema.parse(maybeStringResult);
839
+ }
840
+ } catch (error) {
841
+ if (error instanceof UserError) {
842
+ return {
843
+ content: [{ text: error.message, type: "text" }],
844
+ isError: true
845
+ };
846
+ }
847
+ const errorMessage = error instanceof Error ? error.message : String(error);
848
+ return {
849
+ content: [
850
+ {
851
+ text: `Tool '${request.params.name}' execution failed: ${errorMessage}`,
852
+ type: "text"
853
+ }
854
+ ],
855
+ isError: true
856
+ };
857
+ }
858
+ return result;
859
+ });
860
+ }
861
+ };
862
+ function camelToSnakeCase(str) {
863
+ return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
864
+ }
865
+ function convertObjectToSnakeCase(obj) {
866
+ const result = {};
867
+ for (const [key, value] of Object.entries(obj)) {
868
+ const snakeKey = camelToSnakeCase(key);
869
+ result[snakeKey] = value;
870
+ }
871
+ return result;
872
+ }
873
+ var FastMCPEventEmitterBase = EventEmitter;
874
+ var FastMCPEventEmitter = class extends FastMCPEventEmitterBase {
875
+ };
876
+ var FastMCP = class extends FastMCPEventEmitter {
877
+ constructor(options) {
878
+ super();
879
+ this.options = options;
880
+ this.#options = options;
881
+ this.#authenticate = options.authenticate;
882
+ this.#logger = options.logger || console;
883
+ }
884
+ options;
885
+ get sessions() {
886
+ return this.#sessions;
887
+ }
888
+ #authenticate;
889
+ #httpStreamServer = null;
890
+ #logger;
891
+ #options;
892
+ #prompts = [];
893
+ #resources = [];
894
+ #resourcesTemplates = [];
895
+ #sessions = [];
896
+ #tools = [];
897
+ /**
898
+ * Adds a prompt to the server.
899
+ */
900
+ addPrompt(prompt) {
901
+ this.#prompts.push(prompt);
902
+ }
903
+ /**
904
+ * Adds a resource to the server.
905
+ */
906
+ addResource(resource) {
907
+ this.#resources.push(resource);
908
+ }
909
+ /**
910
+ * Adds a resource template to the server.
911
+ */
912
+ addResourceTemplate(resource) {
913
+ this.#resourcesTemplates.push(resource);
914
+ }
915
+ /**
916
+ * Adds a tool to the server.
917
+ */
918
+ addTool(tool) {
919
+ this.#tools.push(tool);
920
+ }
921
+ /**
922
+ * Embeds a resource by URI, making it easy to include resources in tool responses.
923
+ *
924
+ * @param uri - The URI of the resource to embed
925
+ * @returns Promise<ResourceContent> - The embedded resource content
926
+ */
927
+ async embedded(uri) {
928
+ const directResource = this.#resources.find(
929
+ (resource) => resource.uri === uri
930
+ );
931
+ if (directResource) {
932
+ const result = await directResource.load();
933
+ const results = Array.isArray(result) ? result : [result];
934
+ const firstResult = results[0];
935
+ const resourceData = {
936
+ mimeType: directResource.mimeType,
937
+ uri
938
+ };
939
+ if ("text" in firstResult) {
940
+ resourceData.text = firstResult.text;
941
+ }
942
+ if ("blob" in firstResult) {
943
+ resourceData.blob = firstResult.blob;
944
+ }
945
+ return resourceData;
946
+ }
947
+ for (const template of this.#resourcesTemplates) {
948
+ const templateBase = template.uriTemplate.split("{")[0];
949
+ if (uri.startsWith(templateBase)) {
950
+ const params = {};
951
+ const templateParts = template.uriTemplate.split("/");
952
+ const uriParts = uri.split("/");
953
+ for (let i = 0; i < templateParts.length; i++) {
954
+ const templatePart = templateParts[i];
955
+ if (templatePart?.startsWith("{") && templatePart.endsWith("}")) {
956
+ const paramName = templatePart.slice(1, -1);
957
+ const paramValue = uriParts[i];
958
+ if (paramValue) {
959
+ params[paramName] = paramValue;
960
+ }
961
+ }
962
+ }
963
+ const result = await template.load(
964
+ params
965
+ );
966
+ const resourceData = {
967
+ mimeType: template.mimeType,
968
+ uri
969
+ };
970
+ if ("text" in result) {
971
+ resourceData.text = result.text;
972
+ }
973
+ if ("blob" in result) {
974
+ resourceData.blob = result.blob;
975
+ }
976
+ return resourceData;
977
+ }
978
+ }
979
+ throw new UnexpectedStateError(`Resource not found: ${uri}`, { uri });
980
+ }
981
+ /**
982
+ * Starts the server.
983
+ */
984
+ async start(options) {
985
+ const config = this.#parseRuntimeConfig(options);
986
+ if (config.transportType === "stdio") {
987
+ const transport = new StdioServerTransport();
988
+ let auth;
989
+ if (this.#authenticate) {
990
+ try {
991
+ auth = await this.#authenticate(
992
+ void 0
993
+ );
994
+ } catch (error) {
995
+ this.#logger.error(
996
+ "[FastMCP error] Authentication failed for stdio transport:",
997
+ error instanceof Error ? error.message : String(error)
998
+ );
999
+ }
1000
+ }
1001
+ const session = new FastMCPSession({
1002
+ auth,
1003
+ instructions: this.#options.instructions,
1004
+ logger: this.#logger,
1005
+ name: this.#options.name,
1006
+ ping: this.#options.ping,
1007
+ prompts: this.#prompts,
1008
+ resources: this.#resources,
1009
+ resourcesTemplates: this.#resourcesTemplates,
1010
+ roots: this.#options.roots,
1011
+ tools: this.#tools,
1012
+ transportType: "stdio",
1013
+ utils: this.#options.utils,
1014
+ version: this.#options.version
1015
+ });
1016
+ await session.connect(transport);
1017
+ this.#sessions.push(session);
1018
+ session.once("error", () => {
1019
+ this.#removeSession(session);
1020
+ });
1021
+ if (transport.onclose) {
1022
+ const originalOnClose = transport.onclose;
1023
+ transport.onclose = () => {
1024
+ this.#removeSession(session);
1025
+ if (originalOnClose) {
1026
+ originalOnClose();
1027
+ }
1028
+ };
1029
+ } else {
1030
+ transport.onclose = () => {
1031
+ this.#removeSession(session);
1032
+ };
1033
+ }
1034
+ this.emit("connect", {
1035
+ session
1036
+ });
1037
+ } else if (config.transportType === "httpStream") {
1038
+ const httpConfig = config.httpStream;
1039
+ if (httpConfig.stateless) {
1040
+ this.#logger.info(
1041
+ `[FastMCP info] Starting server in stateless mode on HTTP Stream at http://${httpConfig.host}:${httpConfig.port}${httpConfig.endpoint}`
1042
+ );
1043
+ this.#httpStreamServer = await startHTTPServer({
1044
+ createServer: async (request) => {
1045
+ const auth = await this.#authenticateRequest(request);
1046
+ return this.#createSession(auth);
1047
+ },
1048
+ enableJsonResponse: httpConfig.enableJsonResponse,
1049
+ eventStore: httpConfig.eventStore,
1050
+ host: httpConfig.host,
1051
+ oauth: this.#options.oauth,
1052
+ // In stateless mode, we don't track sessions
1053
+ onClose: async () => {
1054
+ },
1055
+ onConnect: async () => {
1056
+ this.#logger.debug(
1057
+ `[FastMCP debug] Stateless HTTP Stream request handled`
1058
+ );
1059
+ },
1060
+ onUnhandledRequest: async (req, res) => {
1061
+ await this.#handleUnhandledRequest(req, res, true, httpConfig.host);
1062
+ },
1063
+ port: httpConfig.port,
1064
+ sseEndpoint: "/sse",
1065
+ // Server-Sent Events endpoint for streaming events
1066
+ stateless: true,
1067
+ streamEndpoint: httpConfig.endpoint
1068
+ });
1069
+ } else {
1070
+ this.#httpStreamServer = await startHTTPServer({
1071
+ createServer: async (request) => {
1072
+ const auth = await this.#authenticateRequest(request);
1073
+ return this.#createSession(auth);
1074
+ },
1075
+ enableJsonResponse: httpConfig.enableJsonResponse,
1076
+ eventStore: httpConfig.eventStore,
1077
+ host: httpConfig.host,
1078
+ oauth: this.#options.oauth,
1079
+ onClose: async (session) => {
1080
+ const sessionIndex = this.#sessions.indexOf(session);
1081
+ if (sessionIndex !== -1) this.#sessions.splice(sessionIndex, 1);
1082
+ this.emit("disconnect", {
1083
+ session
1084
+ });
1085
+ },
1086
+ onConnect: async (session) => {
1087
+ this.#sessions.push(session);
1088
+ this.#logger.info(`[FastMCP info] HTTP Stream session established`);
1089
+ this.emit("connect", {
1090
+ session
1091
+ });
1092
+ },
1093
+ onUnhandledRequest: async (req, res) => {
1094
+ await this.#handleUnhandledRequest(
1095
+ req,
1096
+ res,
1097
+ false,
1098
+ httpConfig.host
1099
+ );
1100
+ },
1101
+ port: httpConfig.port,
1102
+ sseEndpoint: "/sse",
1103
+ streamEndpoint: httpConfig.endpoint
1104
+ });
1105
+ this.#logger.info(
1106
+ `[FastMCP info] server is running on HTTP Stream at http://${httpConfig.host}:${httpConfig.port}${httpConfig.endpoint}`
1107
+ );
1108
+ this.#logger.info(
1109
+ `[FastMCP info] Transport type: httpStream (Streamable HTTP, not SSE)`
1110
+ );
1111
+ }
1112
+ } else {
1113
+ throw new Error("Invalid transport type");
1114
+ }
1115
+ }
1116
+ /**
1117
+ * Stops the server.
1118
+ */
1119
+ async stop() {
1120
+ if (this.#httpStreamServer) {
1121
+ await this.#httpStreamServer.close();
1122
+ }
1123
+ }
1124
+ async #authenticateRequest(request) {
1125
+ if (!this.#authenticate) {
1126
+ return void 0;
1127
+ }
1128
+ try {
1129
+ return await this.#authenticate(request);
1130
+ } catch (error) {
1131
+ const response = this.#createOAuthChallengeResponse(error);
1132
+ if (response) {
1133
+ throw response;
1134
+ }
1135
+ throw error;
1136
+ }
1137
+ }
1138
+ #createOAuthChallengeResponse(error) {
1139
+ if (error instanceof Response) {
1140
+ return void 0;
1141
+ }
1142
+ const resourceMetadataUrl = this.#options.oauth?.protectedResourceMetadataUrl;
1143
+ if (!this.#options.oauth?.enabled || !resourceMetadataUrl) {
1144
+ return void 0;
1145
+ }
1146
+ const errorMessage = error instanceof Error ? error.message : String(error || "Unauthorized");
1147
+ const wwwAuthenticate = [
1148
+ `resource_metadata="${escapeWWWAuthenticateValue(resourceMetadataUrl)}"`,
1149
+ `error="invalid_token"`,
1150
+ `error_description="${escapeWWWAuthenticateValue(errorMessage)}"`
1151
+ ].join(", ");
1152
+ return new Response(
1153
+ JSON.stringify({
1154
+ error: "invalid_token",
1155
+ error_description: errorMessage
1156
+ }),
1157
+ {
1158
+ headers: {
1159
+ "Content-Type": "application/json",
1160
+ "WWW-Authenticate": `Bearer ${wwwAuthenticate}`
1161
+ },
1162
+ status: 401
1163
+ }
1164
+ );
1165
+ }
1166
+ /**
1167
+ * Creates a new FastMCPSession instance with the current configuration.
1168
+ * Used both for regular sessions and stateless requests.
1169
+ */
1170
+ #createSession(auth) {
1171
+ const allowedTools = auth ? this.#tools.filter(
1172
+ (tool) => tool.canAccess ? tool.canAccess(auth) : true
1173
+ ) : this.#tools;
1174
+ return new FastMCPSession({
1175
+ auth,
1176
+ instructions: this.#options.instructions,
1177
+ logger: this.#logger,
1178
+ name: this.#options.name,
1179
+ ping: this.#options.ping,
1180
+ prompts: this.#prompts,
1181
+ resources: this.#resources,
1182
+ resourcesTemplates: this.#resourcesTemplates,
1183
+ roots: this.#options.roots,
1184
+ tools: allowedTools,
1185
+ transportType: "httpStream",
1186
+ utils: this.#options.utils,
1187
+ version: this.#options.version
1188
+ });
1189
+ }
1190
+ /**
1191
+ * Handles unhandled HTTP requests with health, readiness, and OAuth endpoints
1192
+ */
1193
+ #handleUnhandledRequest = async (req, res, isStateless = false, host) => {
1194
+ const url = new URL(req.url || "", `http://${host}`);
1195
+ if (url.pathname === "/messages") {
1196
+ return;
1197
+ }
1198
+ const healthConfig = this.#options.health ?? {};
1199
+ const enabled = healthConfig.enabled === void 0 ? true : healthConfig.enabled;
1200
+ if (enabled) {
1201
+ const path2 = healthConfig.path ?? "/health";
1202
+ try {
1203
+ if (req.method === "GET" && url.pathname === path2) {
1204
+ res.writeHead(healthConfig.status ?? 200, {
1205
+ "Content-Type": "text/plain"
1206
+ }).end(healthConfig.message ?? "\u2713 Ok");
1207
+ return;
1208
+ }
1209
+ if (req.method === "GET" && url.pathname === "/ready") {
1210
+ if (isStateless) {
1211
+ const response = {
1212
+ mode: "stateless",
1213
+ ready: 1,
1214
+ status: "ready",
1215
+ total: 1
1216
+ };
1217
+ res.writeHead(200, {
1218
+ "Content-Type": "application/json"
1219
+ }).end(JSON.stringify(response));
1220
+ } else {
1221
+ const readySessions = this.#sessions.filter(
1222
+ (s) => s.isReady
1223
+ ).length;
1224
+ const totalSessions = this.#sessions.length;
1225
+ const allReady = readySessions === totalSessions && totalSessions > 0;
1226
+ const response = {
1227
+ ready: readySessions,
1228
+ status: allReady ? "ready" : totalSessions === 0 ? "no_sessions" : "initializing",
1229
+ total: totalSessions
1230
+ };
1231
+ res.writeHead(allReady ? 200 : 503, {
1232
+ "Content-Type": "application/json"
1233
+ }).end(JSON.stringify(response));
1234
+ }
1235
+ return;
1236
+ }
1237
+ } catch (error) {
1238
+ this.#logger.error("[FastMCP error] health endpoint error", error);
1239
+ }
1240
+ }
1241
+ const openaiAppsChallengeConfig = this.#options.openaiAppsChallenge;
1242
+ const openaiAppsChallengeToken = openaiAppsChallengeConfig?.token?.trim();
1243
+ const openaiAppsChallengeEnabled = openaiAppsChallengeConfig?.enabled !== false && !!openaiAppsChallengeToken;
1244
+ if (openaiAppsChallengeEnabled && req.method === "GET") {
1245
+ const path2 = openaiAppsChallengeConfig?.path ?? "/.well-known/openai-apps-challenge";
1246
+ if (url.pathname === path2) {
1247
+ res.writeHead(200, {
1248
+ "Content-Type": "text/plain"
1249
+ }).end(openaiAppsChallengeToken);
1250
+ return;
1251
+ }
1252
+ }
1253
+ const oauthConfig = this.#options.oauth;
1254
+ if (oauthConfig?.enabled && req.method === "GET") {
1255
+ if (url.pathname === "/.well-known/oauth-authorization-server" && oauthConfig.authorizationServer) {
1256
+ const metadata = convertObjectToSnakeCase(
1257
+ oauthConfig.authorizationServer
1258
+ );
1259
+ res.writeHead(200, {
1260
+ "Content-Type": "application/json"
1261
+ }).end(JSON.stringify(metadata));
1262
+ return;
1263
+ }
1264
+ if (url.pathname === "/.well-known/oauth-protected-resource" && oauthConfig.protectedResource) {
1265
+ const metadata = convertObjectToSnakeCase(
1266
+ oauthConfig.protectedResource
1267
+ );
1268
+ res.writeHead(200, {
1269
+ "Content-Type": "application/json"
1270
+ }).end(JSON.stringify(metadata));
1271
+ return;
1272
+ }
1273
+ }
1274
+ res.writeHead(404).end();
1275
+ };
1276
+ #parseRuntimeConfig(overrides) {
1277
+ const args2 = process.argv.slice(2);
1278
+ const getArg = (name) => {
1279
+ const index = args2.findIndex((arg) => arg === `--${name}`);
1280
+ return index !== -1 && index + 1 < args2.length ? args2[index + 1] : void 0;
1281
+ };
1282
+ const transportArg = getArg("transport");
1283
+ const portArg = getArg("port");
1284
+ const endpointArg = getArg("endpoint");
1285
+ const statelessArg = getArg("stateless");
1286
+ const hostArg = getArg("host");
1287
+ const envTransport = process.env.FASTMCP_TRANSPORT;
1288
+ const envPort = process.env.FASTMCP_PORT;
1289
+ const envEndpoint = process.env.FASTMCP_ENDPOINT;
1290
+ const envStateless = process.env.FASTMCP_STATELESS;
1291
+ const envHost = process.env.FASTMCP_HOST;
1292
+ const transportType = overrides?.transportType || (transportArg === "http-stream" ? "httpStream" : transportArg) || envTransport || "stdio";
1293
+ if (transportType === "httpStream") {
1294
+ const port = parseInt(
1295
+ overrides?.httpStream?.port?.toString() || portArg || envPort || "8080"
1296
+ );
1297
+ const host = overrides?.httpStream?.host || hostArg || envHost || "localhost";
1298
+ const endpoint = overrides?.httpStream?.endpoint || endpointArg || envEndpoint || "/mcp";
1299
+ const enableJsonResponse = overrides?.httpStream?.enableJsonResponse || false;
1300
+ const stateless = overrides?.httpStream?.stateless || statelessArg === "true" || envStateless === "true" || false;
1301
+ return {
1302
+ httpStream: {
1303
+ enableJsonResponse,
1304
+ endpoint,
1305
+ host,
1306
+ port,
1307
+ stateless
1308
+ },
1309
+ transportType: "httpStream"
1310
+ };
1311
+ }
1312
+ return { transportType: "stdio" };
1313
+ }
1314
+ #removeSession(session) {
1315
+ const sessionIndex = this.#sessions.indexOf(session);
1316
+ if (sessionIndex !== -1) {
1317
+ this.#sessions.splice(sessionIndex, 1);
1318
+ this.emit("disconnect", {
1319
+ session
1320
+ });
1321
+ }
1322
+ }
1323
+ };
1324
+
1325
+ // src/monitor.ts
1326
+ import { z as z2 } from "zod";
1327
+ var DEFAULT_API_URL = "https://api.firecrawl.dev";
1328
+ function resolveAuth(session) {
1329
+ const apiKey = session?.firecrawlApiKey ?? process.env.FIRECRAWL_API_KEY;
1330
+ const baseUrl = (process.env.FIRECRAWL_API_URL ?? DEFAULT_API_URL).replace(
1331
+ /\/$/,
1332
+ ""
1333
+ );
1334
+ return { apiKey, baseUrl };
1335
+ }
1336
+ async function monitorRequest(session, path2, init = {}) {
1337
+ const { apiKey, baseUrl } = resolveAuth(session);
1338
+ if (!apiKey && !process.env.FIRECRAWL_API_URL) {
1339
+ throw new Error("Unauthorized: API key is required for monitor requests");
1340
+ }
1341
+ let url = `${baseUrl}/v2${path2}`;
1342
+ if (init.query) {
1343
+ const qs = new URLSearchParams();
1344
+ for (const [k, v] of Object.entries(init.query)) {
1345
+ if (v !== void 0 && v !== null && v !== "") qs.set(k, String(v));
1346
+ }
1347
+ const s = qs.toString();
1348
+ if (s) url += `?${s}`;
1349
+ }
1350
+ const headers = { "X-Origin": "mcp" };
1351
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
1352
+ if (init.body !== void 0) headers["Content-Type"] = "application/json";
1353
+ const response = await fetch(url, {
1354
+ method: init.method ?? "GET",
1355
+ headers,
1356
+ body: init.body !== void 0 ? JSON.stringify(init.body) : void 0
1357
+ });
1358
+ const payload = await response.json().catch(() => ({}));
1359
+ if (!response.ok || payload?.success === false) {
1360
+ const message = payload?.error || `HTTP ${response.status}: ${response.statusText || "Request failed"}`;
1361
+ throw new Error(message);
1362
+ }
1363
+ return payload;
1364
+ }
1365
+ function asText(data) {
1366
+ return JSON.stringify(data, null, 2);
1367
+ }
1368
+ var pageStatusSchema = z2.enum(["same", "new", "changed", "removed", "error"]);
1369
+ var checkStatusSchema = z2.enum([
1370
+ "queued",
1371
+ "running",
1372
+ "completed",
1373
+ "failed",
1374
+ "partial",
1375
+ "skipped_overlap"
1376
+ ]);
1377
+ function splitPages(page, pages) {
1378
+ return [page, ...pages ?? []].filter((url) => typeof url === "string").map((url) => url.trim()).filter(Boolean);
1379
+ }
1380
+ function buildMonitorCreateBody(args2) {
1381
+ if (args2.body && typeof args2.body === "object" && !Array.isArray(args2.body)) {
1382
+ return args2.body;
1383
+ }
1384
+ const urls = splitPages(
1385
+ args2.page,
1386
+ args2.pages
1387
+ );
1388
+ if (urls.length === 0) {
1389
+ throw new Error(
1390
+ "firecrawl_monitor_create requires either `body`, `page`, or `pages`."
1391
+ );
1392
+ }
1393
+ const goal = typeof args2.goal === "string" ? args2.goal.trim() : "";
1394
+ if (!goal) {
1395
+ throw new Error(
1396
+ "firecrawl_monitor_create shorthand requires `goal`. Use `body` for advanced requests without a goal."
1397
+ );
1398
+ }
1399
+ const webhookUrl = typeof args2.webhookUrl === "string" ? args2.webhookUrl.trim() : "";
1400
+ const email = typeof args2.email === "string" && args2.email.trim() ? {
1401
+ email: {
1402
+ enabled: true,
1403
+ recipients: [args2.email.trim()],
1404
+ includeDiffs: Boolean(args2.includeDiffs)
1405
+ }
1406
+ } : void 0;
1407
+ return {
1408
+ name: typeof args2.name === "string" && args2.name.trim() ? args2.name.trim() : `Monitor ${urls[0]}`,
1409
+ schedule: {
1410
+ text: typeof args2.scheduleText === "string" && args2.scheduleText.trim() ? args2.scheduleText.trim() : "every 30 minutes",
1411
+ timezone: typeof args2.timezone === "string" && args2.timezone.trim() ? args2.timezone.trim() : "UTC"
1412
+ },
1413
+ goal,
1414
+ targets: [{ type: "scrape", urls }],
1415
+ ...email ? { notification: email } : {},
1416
+ ...webhookUrl ? {
1417
+ webhook: {
1418
+ url: webhookUrl,
1419
+ events: ["monitor.page", "monitor.check.completed"]
1420
+ }
1421
+ } : {}
1422
+ };
1423
+ }
1424
+ function registerMonitorTools(server2) {
1425
+ server2.addTool({
1426
+ name: "firecrawl_monitor_create",
1427
+ annotations: {
1428
+ title: "Create monitor",
1429
+ readOnlyHint: false,
1430
+ // Creates a new recurring monitor configuration on the Firecrawl API.
1431
+ openWorldHint: true,
1432
+ // Monitors user-specified URLs on the public web on a recurring schedule.
1433
+ destructiveHint: false
1434
+ // Additive; creates a new monitor without deleting existing monitors or external content.
1435
+ },
1436
+ description: `
1437
+ Create a Firecrawl monitor \u2014 a recurring scrape or crawl that diffs each result against the last retained snapshot.
1438
+
1439
+ Prefer the simple path: pass \`page\` or \`pages\` plus \`goal\`. The tool will create a scrape monitor with a 30-minute schedule and meaningful-change judging enabled by the API. Use \`body\` only for advanced requests such as crawl targets, JSON change tracking, custom retention, or manual \`judgeEnabled\` control.
1440
+
1441
+ Meaningful-change judge: set \`goal\` to a plain-language description of what the user actually cares about. \`judgeEnabled\` defaults to true when \`goal\` is set, so providing \`goal\` is enough. Page webhooks expose \`isMeaningful\` and \`judgment\` on \`monitor.page\` events.
1442
+
1443
+ Simple fields:
1444
+ - \`page\`: one page URL to monitor.
1445
+ - \`pages\`: multiple page URLs to monitor.
1446
+ - \`goal\`: plain-English instruction for what changes matter. Required for the simple path.
1447
+ - \`scheduleText\`: optional natural-language schedule, default \`every 30 minutes\`.
1448
+ - \`email\`: optional email recipient for summaries.
1449
+ - \`webhookUrl\`: optional webhook URL. Configures \`monitor.page\` and \`monitor.check.completed\`.
1450
+
1451
+ Goal guidance:
1452
+ - Expand the user's one-line monitoring intent into a concise 2-3 sentence monitor goal.
1453
+ - State what should trigger an alert, restate any scope the user gave, and include intent-specific exclusions only when obvious from the user's request.
1454
+ - Generic noise such as whitespace, formatting-only changes, request IDs, tracking params, generic metadata, and unrelated page chrome is already handled by the judge; do not repeat it in every goal.
1455
+ - If the user is vague, keep the goal broad rather than guessing exclusions. If the user asks for broad monitoring or "any change", preserve that and do not add exclusions that hide changes.
1456
+ - If the user says they do not care about something, include that explicitly. It is okay to ask whether they want to ignore specific noise when it is likely to matter.
1457
+ - Do not invent page-specific sections, thresholds, entities, or business rules unless the user mentioned them.
1458
+
1459
+ Full \`body\` requests require: \`name\`, \`schedule\` (with \`cron\` or \`text\`), and \`targets\` (one or more \`{ type: 'scrape', urls: [...] }\` or \`{ type: 'crawl', url: '...' }\`). Optional: \`goal\`, \`judgeEnabled\`, \`webhook\`, \`notification\`, \`retentionDays\`.
1460
+
1461
+ **Markdown-mode (default):** Each check produces a unified text diff of the page's markdown. No extra configuration needed.
1462
+
1463
+ \`\`\`json
1464
+ {
1465
+ "name": "firecrawl_monitor_create",
1466
+ "arguments": {
1467
+ "page": "https://example.com/blog",
1468
+ "goal": "Alert when a new blog post is published or an existing headline changes.",
1469
+ "email": "alerts@example.com"
1470
+ }
1471
+ }
1472
+ \`\`\`
1473
+
1474
+ **Multiple pages:**
1475
+
1476
+ \`\`\`json
1477
+ {
1478
+ "name": "firecrawl_monitor_create",
1479
+ "arguments": {
1480
+ "pages": ["https://example.com/pricing", "https://example.com/changelog"],
1481
+ "goal": "Alert when pricing, packaging, or launch messaging changes.",
1482
+ "webhookUrl": "https://example.com/webhooks/firecrawl"
1483
+ }
1484
+ }
1485
+ \`\`\`
1486
+
1487
+ **JSON-mode change tracking:** To detect changes in **specific structured fields** (price, headline, in-stock flag, list items) instead of the whole page, add a \`changeTracking\` format with \`modes: ["json"]\` and a JSON schema to the target's \`scrapeOptions.formats\`. The check response will then carry a per-field diff (keyed by JSON path, e.g. \`plans[0].price\`) and a \`snapshot.json\` with the full current extraction. See \`firecrawl_monitor_check\` for the response shape.
1488
+
1489
+ \`\`\`json
1490
+ {
1491
+ "name": "firecrawl_monitor_create",
1492
+ "arguments": {
1493
+ "body": {
1494
+ "name": "Pricing watch",
1495
+ "schedule": { "text": "hourly", "timezone": "UTC" },
1496
+ "goal": "Alert when a pricing tier, price, billing period, limit, or headline feature changes. Ignore unrelated marketing copy unless it changes the pricing offer.",
1497
+ "targets": [{
1498
+ "type": "scrape",
1499
+ "urls": ["https://example.com/pricing"],
1500
+ "scrapeOptions": {
1501
+ "formats": [{
1502
+ "type": "changeTracking",
1503
+ "modes": ["json"],
1504
+ "prompt": "Extract pricing tiers and headline features for each plan.",
1505
+ "schema": {
1506
+ "type": "object",
1507
+ "properties": {
1508
+ "plans": {
1509
+ "type": "array",
1510
+ "items": {
1511
+ "type": "object",
1512
+ "properties": {
1513
+ "name": { "type": "string" },
1514
+ "price": { "type": "string" },
1515
+ "features": { "type": "array", "items": { "type": "string" } }
1516
+ }
1517
+ }
1518
+ }
1519
+ }
1520
+ }
1521
+ }]
1522
+ }
1523
+ }]
1524
+ }
1525
+ }
1526
+ }
1527
+ \`\`\`
1528
+
1529
+ **Mixed mode (JSON + git-diff):** Use \`modes: ["json", "git-diff"]\` to get both per-field diffs and a markdown sidecar. The page is marked \`changed\` whenever either surface changed.
1530
+ `,
1531
+ parameters: z2.object({
1532
+ body: z2.record(z2.string(), z2.any()).optional(),
1533
+ page: z2.string().optional(),
1534
+ pages: z2.array(z2.string()).optional(),
1535
+ goal: z2.string().optional(),
1536
+ name: z2.string().optional(),
1537
+ scheduleText: z2.string().optional(),
1538
+ timezone: z2.string().optional(),
1539
+ email: z2.string().optional(),
1540
+ includeDiffs: z2.boolean().optional(),
1541
+ webhookUrl: z2.string().optional()
1542
+ }),
1543
+ execute: async (args2, { session, log }) => {
1544
+ const body = buildMonitorCreateBody(args2);
1545
+ log.info("Creating monitor", { name: String(body.name) });
1546
+ const res = await monitorRequest(session, "/monitor", {
1547
+ method: "POST",
1548
+ body
1549
+ });
1550
+ return asText(res);
1551
+ }
1552
+ });
1553
+ server2.addTool({
1554
+ name: "firecrawl_monitor_list",
1555
+ annotations: {
1556
+ title: "List monitors",
1557
+ readOnlyHint: true,
1558
+ // Lists monitors for the authenticated account; no mutations.
1559
+ openWorldHint: false,
1560
+ // Returns only the user's Firecrawl monitor records, not arbitrary web content.
1561
+ destructiveHint: false
1562
+ // Read-only listing.
1563
+ },
1564
+ description: `
1565
+ List all Firecrawl monitors for the authenticated account.
1566
+
1567
+ **Usage Example:**
1568
+ \`\`\`json
1569
+ { "name": "firecrawl_monitor_list", "arguments": { "limit": 20 } }
1570
+ \`\`\`
1571
+ `,
1572
+ parameters: z2.object({
1573
+ limit: z2.number().int().positive().optional(),
1574
+ offset: z2.number().int().nonnegative().optional()
1575
+ }),
1576
+ execute: async (args2, { session, log }) => {
1577
+ const { limit, offset } = args2;
1578
+ const res = await monitorRequest(session, "/monitor", {
1579
+ query: { limit, offset }
1580
+ });
1581
+ return asText(res);
1582
+ }
1583
+ });
1584
+ server2.addTool({
1585
+ name: "firecrawl_monitor_get",
1586
+ annotations: {
1587
+ title: "Get monitor",
1588
+ readOnlyHint: true,
1589
+ // Fetches a single monitor by ID; no mutations.
1590
+ openWorldHint: false,
1591
+ // Reads a specific monitor resource in the user's Firecrawl account.
1592
+ destructiveHint: false
1593
+ // Read-only retrieval.
1594
+ },
1595
+ description: `
1596
+ Get a single monitor by ID.
1597
+
1598
+ **Usage Example:**
1599
+ \`\`\`json
1600
+ { "name": "firecrawl_monitor_get", "arguments": { "id": "mon_abc123" } }
1601
+ \`\`\`
1602
+ `,
1603
+ parameters: z2.object({ id: z2.string() }),
1604
+ execute: async (args2, { session, log }) => {
1605
+ const { id } = args2;
1606
+ const res = await monitorRequest(
1607
+ session,
1608
+ `/monitor/${encodeURIComponent(id)}`
1609
+ );
1610
+ return asText(res);
1611
+ }
1612
+ });
1613
+ server2.addTool({
1614
+ name: "firecrawl_monitor_update",
1615
+ annotations: {
1616
+ title: "Update monitor",
1617
+ readOnlyHint: false,
1618
+ // PATCHes an existing monitor (status, schedule, targets, webhooks, etc.).
1619
+ openWorldHint: true,
1620
+ // Can change which external URLs are monitored and how recurring scrapes run.
1621
+ destructiveHint: true
1622
+ // Can pause, replace, or remove monitor configuration; changes overwrite prior settings.
1623
+ },
1624
+ description: `
1625
+ Update a monitor. Pass any subset of fields to patch: \`name\`, \`status\` ("active" | "paused"), \`schedule\`, \`targets\`, \`goal\`, \`judgeEnabled\`, \`webhook\`, \`notification\`, \`retentionDays\`.
1626
+
1627
+ **Usage Example:**
1628
+ \`\`\`json
1629
+ {
1630
+ "name": "firecrawl_monitor_update",
1631
+ "arguments": {
1632
+ "id": "mon_abc123",
1633
+ "body": { "status": "paused" }
1634
+ }
1635
+ }
1636
+ \`\`\`
1637
+ `,
1638
+ parameters: z2.object({
1639
+ id: z2.string(),
1640
+ body: z2.record(z2.string(), z2.any())
1641
+ }),
1642
+ execute: async (args2, { session, log }) => {
1643
+ const { id, body } = args2;
1644
+ const res = await monitorRequest(
1645
+ session,
1646
+ `/monitor/${encodeURIComponent(id)}`,
1647
+ { method: "PATCH", body }
1648
+ );
1649
+ return asText(res);
1650
+ }
1651
+ });
1652
+ server2.addTool({
1653
+ name: "firecrawl_monitor_delete",
1654
+ annotations: {
1655
+ title: "Delete monitor",
1656
+ readOnlyHint: false,
1657
+ // Permanently deletes a monitor via DELETE on the API.
1658
+ openWorldHint: true,
1659
+ // Deletes a monitor that tracked open-web URLs.
1660
+ destructiveHint: true
1661
+ // Irreversibly removes the monitor and stops its schedule.
1662
+ },
1663
+ description: `
1664
+ Permanently delete a monitor and stop its schedule. This cannot be undone.
1665
+
1666
+ **Usage Example:**
1667
+ \`\`\`json
1668
+ { "name": "firecrawl_monitor_delete", "arguments": { "id": "mon_abc123" } }
1669
+ \`\`\`
1670
+ `,
1671
+ parameters: z2.object({ id: z2.string() }),
1672
+ execute: async (args2, { session, log }) => {
1673
+ const { id } = args2;
1674
+ log.info("Deleting monitor", { id });
1675
+ const res = await monitorRequest(
1676
+ session,
1677
+ `/monitor/${encodeURIComponent(id)}`,
1678
+ { method: "DELETE" }
1679
+ );
1680
+ return asText(res);
1681
+ }
1682
+ });
1683
+ server2.addTool({
1684
+ name: "firecrawl_monitor_run",
1685
+ annotations: {
1686
+ title: "Run monitor now",
1687
+ readOnlyHint: false,
1688
+ // Triggers an immediate monitor check, queueing a new scrape/diff run.
1689
+ openWorldHint: true,
1690
+ // The triggered check scrapes external URLs configured on the monitor.
1691
+ destructiveHint: false
1692
+ // Starts a read-only check job; does not delete the monitor or external sites.
1693
+ },
1694
+ description: `
1695
+ Trigger a monitor check immediately, outside its normal schedule. Returns the queued check.
1696
+
1697
+ **Usage Example:**
1698
+ \`\`\`json
1699
+ { "name": "firecrawl_monitor_run", "arguments": { "id": "mon_abc123" } }
1700
+ \`\`\`
1701
+ `,
1702
+ parameters: z2.object({ id: z2.string() }),
1703
+ execute: async (args2, { session, log }) => {
1704
+ const { id } = args2;
1705
+ const res = await monitorRequest(
1706
+ session,
1707
+ `/monitor/${encodeURIComponent(id)}/run`,
1708
+ { method: "POST" }
1709
+ );
1710
+ return asText(res);
1711
+ }
1712
+ });
1713
+ server2.addTool({
1714
+ name: "firecrawl_monitor_checks",
1715
+ annotations: {
1716
+ title: "List monitor checks",
1717
+ readOnlyHint: true,
1718
+ // Lists historical check runs for a monitor; no mutations.
1719
+ openWorldHint: false,
1720
+ // Returns check history for a known monitor ID within the user's account.
1721
+ destructiveHint: false
1722
+ // Read-only listing.
1723
+ },
1724
+ description: `
1725
+ List historical checks for a monitor.
1726
+
1727
+ **Usage Example:**
1728
+ \`\`\`json
1729
+ { "name": "firecrawl_monitor_checks", "arguments": { "id": "mon_abc123", "limit": 10, "status": "completed" } }
1730
+ \`\`\`
1731
+ `,
1732
+ parameters: z2.object({
1733
+ id: z2.string(),
1734
+ limit: z2.number().int().positive().optional(),
1735
+ offset: z2.number().int().nonnegative().optional(),
1736
+ status: checkStatusSchema.optional()
1737
+ }),
1738
+ execute: async (args2, { session, log }) => {
1739
+ const { id, limit, offset, status } = args2;
1740
+ const res = await monitorRequest(
1741
+ session,
1742
+ `/monitor/${encodeURIComponent(id)}/checks`,
1743
+ { query: { limit, offset, status } }
1744
+ );
1745
+ return asText(res);
1746
+ }
1747
+ });
1748
+ server2.addTool({
1749
+ name: "firecrawl_monitor_check",
1750
+ annotations: {
1751
+ title: "Get monitor check",
1752
+ readOnlyHint: true,
1753
+ // Retrieves a single check run with page-level diff results; no mutations.
1754
+ openWorldHint: false,
1755
+ // Reads stored check results for a known monitor/check ID in the user's account.
1756
+ destructiveHint: false
1757
+ // Read-only retrieval of diff snapshots and judgments.
1758
+ },
1759
+ description: `
1760
+ Get a single check with page-level diff results. Filter \`pageStatus\` to surface only the pages that changed (or were new, removed, etc.).
1761
+
1762
+ Each entry in \`data.pages[]\` has \`url\`, \`status\` (\`same\` | \`new\` | \`changed\` | \`removed\` | \`error\`), optional \`judgment\` when goal-based judging ran, and \u2014 when changed \u2014 a \`diff\` and possibly a \`snapshot\`. The shape of \`diff\` depends on the monitor's \`formats\` configuration:
1763
+
1764
+ - **Markdown mode (default).** \`diff.text\` is the unified markdown diff; \`diff.json\` is a parse-diff AST (\`{ files: [...] }\`). No \`snapshot\`.
1765
+ - **JSON mode** (\`changeTracking\` with \`modes: ["json"]\`). \`diff.json\` is a per-field map keyed by JSON path into the extraction, e.g. \`plans[0].price\`, with each value being \`{ previous, current }\`. \`snapshot.json\` is the full current extraction. No \`diff.text\`.
1766
+ - **Mixed mode** (\`modes: ["json", "git-diff"]\`). Both \`diff.text\` (markdown sidecar) AND \`diff.json\` (per-field map) are present, plus \`snapshot.json\`.
1767
+
1768
+ **Example JSON-mode response \`pages[]\` entry:**
1769
+
1770
+ \`\`\`json
1771
+ {
1772
+ "url": "https://example.com/pricing",
1773
+ "status": "changed",
1774
+ "diff": {
1775
+ "json": {
1776
+ "plans[0].price": { "previous": "$19/mo", "current": "$24/mo" },
1777
+ "plans[1].features[2]": { "previous": "10 GB storage", "current": "25 GB storage" }
1778
+ }
1779
+ },
1780
+ "snapshot": { "json": { "plans": [/* current full extraction matching the monitor's schema */] } },
1781
+ "judgment": {
1782
+ "meaningful": true,
1783
+ "confidence": "high",
1784
+ "reason": "The pricing changed, which matches the monitor goal.",
1785
+ "meaningfulChanges": [
1786
+ {
1787
+ "type": "changed",
1788
+ "before": "$19/mo",
1789
+ "after": "$24/mo",
1790
+ "reason": "The tracked plan price changed."
1791
+ }
1792
+ ]
1793
+ }
1794
+ }
1795
+ \`\`\`
1796
+
1797
+ When summarizing a check for the user, prefer \`diff.json\` paths (e.g. "plans[0].price changed from $19/mo to $24/mo") over re-printing the markdown diff \u2014 it's more concise and grounded in the schema fields they asked for.
1798
+
1799
+ When \`judgment\` is present, use it to decide what to surface. \`judgment.meaningful: false\` means the change was classified as noise for the monitor's goal. When \`judgment.meaningfulChanges\` is present, prefer those goal-relevant changes over raw diff hunks; each item includes \`type\`, \`before\`, \`after\`, and \`reason\`.
1800
+
1801
+ The endpoint paginates via a top-level \`next\` URL; this tool returns one page at a time. Increase \`limit\` (max 100) to fetch fewer pages.
1802
+
1803
+ **Usage Example:**
1804
+ \`\`\`json
1805
+ {
1806
+ "name": "firecrawl_monitor_check",
1807
+ "arguments": {
1808
+ "id": "mon_abc123",
1809
+ "checkId": "chk_xyz",
1810
+ "pageStatus": "changed"
1811
+ }
1812
+ }
1813
+ \`\`\`
1814
+ `,
1815
+ parameters: z2.object({
1816
+ id: z2.string(),
1817
+ checkId: z2.string(),
1818
+ limit: z2.number().int().positive().optional(),
1819
+ skip: z2.number().int().nonnegative().optional(),
1820
+ pageStatus: pageStatusSchema.optional()
1821
+ }),
1822
+ execute: async (args2, { session, log }) => {
1823
+ const { id, checkId, limit, skip, pageStatus } = args2;
1824
+ const res = await monitorRequest(
1825
+ session,
1826
+ `/monitor/${encodeURIComponent(id)}/checks/${encodeURIComponent(checkId)}`,
1827
+ { query: { limit, skip, status: pageStatus } }
1828
+ );
1829
+ return asText(res);
1830
+ }
1831
+ });
1832
+ }
1833
+
1834
+ // src/research.ts
1835
+ import { z as z3 } from "zod";
1836
+ var BASE = "/v2/search/research";
1837
+ var ORIGIN_HEADERS = { "X-Origin": "mcp-fastmcp" };
1838
+ function appendParam(params, key, value) {
1839
+ if (value == null) return;
1840
+ if (Array.isArray(value)) {
1841
+ for (const v of value) {
1842
+ if (v != null && String(v).length > 0) params.append(key, String(v));
1843
+ }
1844
+ } else {
1845
+ params.append(key, String(value));
1846
+ }
1847
+ }
1848
+ function withQuery(path2, params) {
1849
+ const qs = params.toString();
1850
+ return qs ? `${path2}?${qs}` : path2;
1851
+ }
1852
+ var MAX_AUTHORS = 15;
1853
+ var MAX_ABSTRACT_CHARS = 600;
1854
+ var MAX_AFFIL_CHARS = 60;
1855
+ var MAX_AUTHORS_LINE_CHARS = 400;
1856
+ function displayId(p) {
1857
+ return p.primaryId ?? "missing-primary-id";
1858
+ }
1859
+ function fmtAuthors(authors) {
1860
+ if (!authors) return null;
1861
+ let shown;
1862
+ let total;
1863
+ if (typeof authors === "string") {
1864
+ const names = authors.split(",").map((s) => s.trim()).filter(Boolean);
1865
+ if (names.length === 0) return null;
1866
+ total = names.length;
1867
+ shown = names.slice(0, MAX_AUTHORS);
1868
+ } else {
1869
+ if (authors.length === 0) return null;
1870
+ total = authors.length;
1871
+ shown = authors.slice(0, MAX_AUTHORS).map((a) => {
1872
+ const aff = a.affiliation?.trim();
1873
+ return aff ? `${a.name} (${aff.slice(0, MAX_AFFIL_CHARS)})` : a.name;
1874
+ });
1875
+ }
1876
+ const extra = total > MAX_AUTHORS ? `; +${total - MAX_AUTHORS} more` : "";
1877
+ return ("Authors: " + shown.join("; ") + extra).slice(
1878
+ 0,
1879
+ MAX_AUTHORS_LINE_CHARS
1880
+ );
1881
+ }
1882
+ function fmtHits(results) {
1883
+ if (!results || results.length === 0) return "(no results)";
1884
+ return results.map((r) => {
1885
+ const lines = [`## [${displayId(r)}] ${r.title ?? "(untitled)"}`];
1886
+ const authors = fmtAuthors(r.authors);
1887
+ if (authors) lines.push(authors);
1888
+ lines.push(
1889
+ (r.abstract || "(no abstract)").replace(/\s+/g, " ").slice(0, MAX_ABSTRACT_CHARS)
1890
+ );
1891
+ return lines.join("\n");
1892
+ }).join("\n\n");
1893
+ }
1894
+ function fmtPaperMetadata(paper) {
1895
+ if (!paper) return "(paper not found)";
1896
+ const lines = [`# ${paper.title ?? "(untitled)"}`];
1897
+ lines.push("");
1898
+ lines.push(`Paper ID: ${paper.paperId ?? "?"}`);
1899
+ const ids = Object.entries(paper.ids ?? {}).flatMap(
1900
+ ([namespace, values]) => values.map((value) => `${namespace}:${value}`)
1901
+ ).join(", ");
1902
+ if (ids) lines.push(`IDs: ${ids}`);
1903
+ const authors = fmtAuthors(paper.authors);
1904
+ if (authors) lines.push(authors);
1905
+ if (paper.categories?.length) {
1906
+ lines.push(`Categories: ${paper.categories.join(", ")}`);
1907
+ }
1908
+ const dates = [
1909
+ paper.createdDate ? `created ${paper.createdDate}` : "",
1910
+ paper.updateDate ? `updated ${paper.updateDate}` : ""
1911
+ ].filter(Boolean).join("; ");
1912
+ if (dates) lines.push(`Dates: ${dates}`);
1913
+ lines.push("");
1914
+ lines.push("## Abstract");
1915
+ lines.push((paper.abstract || "(no abstract)").replace(/\s+/g, " "));
1916
+ return lines.join("\n");
1917
+ }
1918
+ var MAX_GITHUB_CONTENT_CHARS = 1200;
1919
+ function fmtGithub(results) {
1920
+ if (!results || results.length === 0) return "(no results)";
1921
+ return results.map((r) => {
1922
+ const lines = [];
1923
+ if (r.resultType === "repo_readme") {
1924
+ lines.push(`[${r.repo ?? "?"}] README`);
1925
+ } else {
1926
+ const ref = r.number != null ? `#${r.number}` : "";
1927
+ const meta = [
1928
+ r.pageType,
1929
+ r.segmentCount ? `${r.segmentCount} segments` : ""
1930
+ ].filter(Boolean).join(", ");
1931
+ lines.push(`[${r.repo ?? "?"}${ref}]${meta ? ` (${meta})` : ""}`);
1932
+ }
1933
+ const url = r.readmeUrl ?? r.url;
1934
+ if (url) lines.push(url);
1935
+ const body = (r.contentMd || r.snippet || "").trim();
1936
+ lines.push(
1937
+ body ? body.slice(0, MAX_GITHUB_CONTENT_CHARS) : "(no content)"
1938
+ );
1939
+ return lines.join("\n");
1940
+ }).join("\n\n");
1941
+ }
1942
+ function registerResearchTools(server2, getClient2) {
1943
+ server2.addTool({
1944
+ name: "firecrawl_research_search_papers",
1945
+ annotations: {
1946
+ title: "Search arXiv papers",
1947
+ readOnlyHint: true,
1948
+ // Semantic search over indexed arXiv metadata; returns ranked results only.
1949
+ openWorldHint: true,
1950
+ // Searches the public arXiv research corpus.
1951
+ destructiveHint: false
1952
+ // Query-only; no writes to arXiv or the research index.
1953
+ },
1954
+ description: "Primary entry point for finding arXiv papers by topic. Semantic (HyDE) search over arXiv abstracts; returns ranked papers with arXiv id, title, and abstract. The query should be a natural-language description of what you want. Run SEVERAL distinct framings of the question (sibling domains, rival methods, dataset/benchmark names) rather than one query \u2014 recall improves markedly with diverse framings. Returns up to `k` results (default 40).",
1955
+ parameters: z3.object({
1956
+ query: z3.string().min(1),
1957
+ k: z3.number().int().min(1).max(500).optional(),
1958
+ authors: z3.array(z3.string()).optional().describe(
1959
+ "Author substring filter(s); ALL must match (case-insensitive)."
1960
+ ),
1961
+ categories: z3.array(z3.string()).optional().describe("arXiv category filter(s) (e.g. `cs.LG`); ALL must match."),
1962
+ from: z3.string().optional().describe(
1963
+ "Inclusive lower bound on created/updated date (`YYYY-MM-DD`)."
1964
+ ),
1965
+ to: z3.string().optional().describe(
1966
+ "Inclusive upper bound on created/updated date (`YYYY-MM-DD`)."
1967
+ )
1968
+ }),
1969
+ execute: async (args2, { session }) => {
1970
+ const { query, k, authors, categories, from, to } = args2;
1971
+ const params = new URLSearchParams();
1972
+ appendParam(params, "query", query);
1973
+ appendParam(params, "k", k);
1974
+ appendParam(params, "authors", authors);
1975
+ appendParam(params, "categories", categories);
1976
+ appendParam(params, "from", from);
1977
+ appendParam(params, "to", to);
1978
+ const client = getClient2(session);
1979
+ const res = await client.http.get(
1980
+ withQuery(`${BASE}/papers`, params),
1981
+ ORIGIN_HEADERS
1982
+ );
1983
+ return fmtHits(res.data?.results);
1984
+ }
1985
+ });
1986
+ server2.addTool({
1987
+ name: "firecrawl_research_inspect_paper",
1988
+ annotations: {
1989
+ title: "Inspect a paper",
1990
+ readOnlyHint: true,
1991
+ // Fetches canonical metadata (title, abstract, authors) for one paper by ID.
1992
+ openWorldHint: true,
1993
+ // Retrieves metadata for papers in public indexes (arXiv, PMC, DOI, etc.).
1994
+ destructiveHint: false
1995
+ // Read-only metadata lookup.
1996
+ },
1997
+ description: "Fetch canonical metadata for one paper by primaryId or canonical paperId. Use this after search/related results when you need the full title, abstract, authors, categories, source ids, and dates rendered as markdown.",
1998
+ parameters: z3.object({
1999
+ paperId: z3.string().min(1).describe(
2000
+ "Canonical paperId or primaryId such as `arxiv:1706.03762`, `pmcid:PMC12530322`, `pmid:40953549`, or `doi:10.1016/j.neunet.2025.108095`."
2001
+ )
2002
+ }),
2003
+ execute: async (args2, { session }) => {
2004
+ const { paperId } = args2;
2005
+ const client = getClient2(session);
2006
+ const res = await client.http.get(
2007
+ `${BASE}/papers/${encodeURIComponent(paperId)}`,
2008
+ ORIGIN_HEADERS
2009
+ );
2010
+ return fmtPaperMetadata(res.data?.paper);
2011
+ }
2012
+ });
2013
+ server2.addTool({
2014
+ name: "firecrawl_research_related_papers",
2015
+ annotations: {
2016
+ title: "Find related arXiv papers",
2017
+ readOnlyHint: true,
2018
+ // Finds related papers via citation graph expansion; returns candidates only.
2019
+ openWorldHint: true,
2020
+ // Traverses relationships across the public research paper corpus.
2021
+ destructiveHint: false
2022
+ // Read-only graph query; no modifications.
2023
+ },
2024
+ description: "Expand from anchor papers you have already found, via the citation graph, ranked and filtered to a natural-language `intent`. Pass arXiv ids of your strongest hits as `seed_ids`. Modes: `similar` (cocitation/coupling \u2014 papers in the same niche; the default), `citers` (papers that cite the anchors), `references` (papers the anchors cite). This reaches relevant papers that plain search misses, so use it on your best hits before finishing. A `similar` call already runs a DEEP multi-round expansion internally (re-seeding from each round\u2019s best finds), so one call reaches the wider neighborhood \u2014 no need to chain many. Returns the candidates plus the pool size.",
2025
+ parameters: z3.object({
2026
+ seed_ids: z3.array(z3.string()).min(1).max(10),
2027
+ intent: z3.string().min(1),
2028
+ mode: z3.enum(["similar", "citers", "references"]).optional(),
2029
+ k: z3.number().int().min(1).max(500).optional(),
2030
+ rerank: z3.boolean().optional().describe("Apply an additional rerank over the fused candidates.")
2031
+ }),
2032
+ execute: async (args2, { session }) => {
2033
+ const { seed_ids, intent, mode, k, rerank } = args2;
2034
+ const [primary, ...anchors] = seed_ids;
2035
+ const params = new URLSearchParams();
2036
+ appendParam(params, "intent", intent);
2037
+ appendParam(params, "mode", mode);
2038
+ appendParam(params, "k", k);
2039
+ if (rerank != null) appendParam(params, "rerank", rerank);
2040
+ appendParam(params, "anchor", anchors);
2041
+ const client = getClient2(session);
2042
+ const res = await client.http.get(
2043
+ withQuery(
2044
+ `${BASE}/papers/${encodeURIComponent(primary)}/similar`,
2045
+ params
2046
+ ),
2047
+ ORIGIN_HEADERS
2048
+ );
2049
+ const note = res.data?.note ? `
2050
+ note: ${res.data.note}` : "";
2051
+ return `${fmtHits(res.data?.results)}
2052
+ (poolSize=${res.data?.poolSize ?? 0})${note}`;
2053
+ }
2054
+ });
2055
+ server2.addTool({
2056
+ name: "firecrawl_research_read_paper",
2057
+ annotations: {
2058
+ title: "Read a paper",
2059
+ readOnlyHint: true,
2060
+ // Retrieves relevant full-text passages from a paper; does not modify the paper.
2061
+ openWorldHint: true,
2062
+ // Reads from publicly indexed paper full text when available.
2063
+ destructiveHint: false
2064
+ // Read-only passage retrieval.
2065
+ },
2066
+ description: "Read the most relevant in-body (full-text) passages of ONE specific paper for a question. Use this to VERIFY whether a candidate actually satisfies a constraint before you include or reject it (e.g. 'does this paper actually use technique X / report a score on benchmark Y'). Returns the best-matching passages, or a notice if the paper's full text is unavailable.",
2067
+ parameters: z3.object({
2068
+ paperId: z3.string().min(1).describe(
2069
+ "Canonical paperId or primaryId such as `arxiv:1706.03762`, `pmcid:PMC12530322`, `pmid:40953549`, or `doi:10.1016/j.neunet.2025.108095`."
2070
+ ),
2071
+ question: z3.string().min(1),
2072
+ k: z3.number().int().min(1).max(50).optional().describe("Number of passages to return (default 4).")
2073
+ }),
2074
+ execute: async (args2, { session }) => {
2075
+ const { paperId, question, k } = args2;
2076
+ const params = new URLSearchParams();
2077
+ appendParam(params, "query", question);
2078
+ appendParam(params, "k", k);
2079
+ const client = getClient2(session);
2080
+ const res = await client.http.get(
2081
+ withQuery(`${BASE}/papers/${encodeURIComponent(paperId)}`, params),
2082
+ ORIGIN_HEADERS
2083
+ );
2084
+ const passages = res.data?.passages ?? [];
2085
+ return passages.length ? passages.map((p) => p.text).join("\n---\n") : "(no full-text passages available for this paper)";
2086
+ }
2087
+ });
2088
+ server2.addTool({
2089
+ name: "firecrawl_research_search_github",
2090
+ annotations: {
2091
+ title: "Search GitHub history",
2092
+ readOnlyHint: true,
2093
+ // Searches indexed GitHub issue/PR history and READMEs; returns matches only.
2094
+ openWorldHint: true,
2095
+ // Searches public GitHub content.
2096
+ destructiveHint: false
2097
+ // Query-only; does not create issues, PRs, or modify repositories.
2098
+ },
2099
+ description: "Search GitHub issue/PR history and repository readmes. Returns ranked matches with repo, url, a short snippet, and (when available) the full matched content in markdown.",
2100
+ parameters: z3.object({
2101
+ query: z3.string().min(1),
2102
+ k: z3.number().int().min(1).max(100).optional()
2103
+ }),
2104
+ execute: async (args2, { session }) => {
2105
+ const { query, k } = args2;
2106
+ const params = new URLSearchParams();
2107
+ appendParam(params, "query", query);
2108
+ appendParam(params, "k", k);
2109
+ const client = getClient2(session);
2110
+ const res = await client.http.get(
2111
+ withQuery(`${BASE}/github`, params),
2112
+ ORIGIN_HEADERS
2113
+ );
2114
+ return fmtGithub(res.data?.results);
2115
+ }
2116
+ });
2117
+ }
2118
+
2119
+ // src/index.ts
10
2120
  dotenv.config({ debug: false, quiet: true });
2121
+ var require2 = createRequire(import.meta.url);
2122
+ var { version: packageVersion } = require2("../package.json");
11
2123
  function normalizeHeader(value) {
12
- if (value == null)
13
- return undefined;
14
- const v = Array.isArray(value) ? value[0] : value;
15
- const trimmed = typeof v === 'string' ? v.trim() : '';
16
- return trimmed || undefined;
2124
+ if (value == null) return void 0;
2125
+ const v = Array.isArray(value) ? value[0] : value;
2126
+ const trimmed = typeof v === "string" ? v.trim() : "";
2127
+ return trimmed || void 0;
17
2128
  }
18
2129
  function extractBearerToken(headers) {
19
- const headerAuth = normalizeHeader(headers['authorization']);
20
- if (!headerAuth?.toLowerCase().startsWith('bearer '))
21
- return undefined;
22
- const raw = headerAuth.slice(7).trim();
23
- return raw || undefined;
2130
+ const headerAuth = normalizeHeader(headers["authorization"]);
2131
+ if (!headerAuth?.toLowerCase().startsWith("bearer ")) return void 0;
2132
+ const raw = headerAuth.slice(7).trim();
2133
+ return raw || void 0;
24
2134
  }
25
- /** OAuth access tokens minted by Firecrawl (Authorization Server). */
26
2135
  function isFirecrawlOAuthAccessToken(token) {
27
- return token.startsWith('fco_');
2136
+ return token.startsWith("fco_");
28
2137
  }
29
2138
  function resolveCredentialFromEnv() {
30
- return (normalizeHeader(process.env.FIRECRAWL_OAUTH_TOKEN) ??
31
- normalizeHeader(process.env.FIRECRAWL_API_KEY));
2139
+ return normalizeHeader(process.env.FIRECRAWL_OAUTH_TOKEN) ?? normalizeHeader(process.env.FIRECRAWL_API_KEY);
32
2140
  }
33
2141
  function isHttpStreamingTransport() {
34
- return (process.env.HTTP_STREAMABLE_SERVER === 'true' ||
35
- process.env.SSE_LOCAL === 'true');
2142
+ return process.env.HTTP_STREAMABLE_SERVER === "true" || process.env.SSE_LOCAL === "true";
36
2143
  }
37
- const DEFAULT_OAUTH_ISSUER = 'https://www.firecrawl.dev';
38
- const DEFAULT_MCP_RESOURCE_URL = 'https://mcp.firecrawl.dev/v2/mcp';
2144
+ var DEFAULT_OAUTH_ISSUER = "https://www.firecrawl.dev";
2145
+ var DEFAULT_MCP_RESOURCE_URL = "https://mcp.firecrawl.dev/v2/mcp";
39
2146
  function withoutTrailingSlash(value) {
40
- return value.replace(/\/+$/, '');
2147
+ return value.replace(/\/+$/, "");
41
2148
  }
42
2149
  function getOAuthIssuer() {
43
- return withoutTrailingSlash(normalizeHeader(process.env.FIRECRAWL_OAUTH_ISSUER) ?? DEFAULT_OAUTH_ISSUER);
2150
+ return withoutTrailingSlash(
2151
+ normalizeHeader(process.env.FIRECRAWL_OAUTH_ISSUER) ?? DEFAULT_OAUTH_ISSUER
2152
+ );
44
2153
  }
45
2154
  function getMcpResourceUrl() {
46
- return (normalizeHeader(process.env.FIRECRAWL_MCP_RESOURCE_URL) ??
47
- DEFAULT_MCP_RESOURCE_URL);
2155
+ return normalizeHeader(process.env.FIRECRAWL_MCP_RESOURCE_URL) ?? DEFAULT_MCP_RESOURCE_URL;
48
2156
  }
49
- // PRM lives at the MCP origin per RFC 9728 (one PRM per resource). firecrawl-fastmcp
50
- // auto-serves it at the standard /.well-known/oauth-protected-resource path from the
51
- // protectedResource config, so the URL is fully derived from the MCP resource.
52
2157
  function getOAuthProtectedResourceMetadataUrl() {
53
- return `${new URL(getMcpResourceUrl()).origin}/.well-known/oauth-protected-resource`;
2158
+ return `${new URL(getMcpResourceUrl()).origin}/.well-known/oauth-protected-resource`;
54
2159
  }
55
2160
  function getOAuthIntrospectionEndpoint() {
56
- return `${getOAuthIssuer()}/api/oauth/introspect`;
2161
+ return `${getOAuthIssuer()}/api/oauth/introspect`;
57
2162
  }
58
2163
  function getOAuthIntrospectionSecret() {
59
- return normalizeHeader(process.env.FIRECRAWL_OAUTH_INTROSPECT_SECRET);
2164
+ return normalizeHeader(process.env.FIRECRAWL_OAUTH_INTROSPECT_SECRET);
60
2165
  }
61
2166
  function isMcpOAuthEnabled() {
62
- return process.env.CLOUD_SERVICE === 'true';
2167
+ return process.env.CLOUD_SERVICE === "true";
63
2168
  }
64
2169
  async function introspectOAuthAccessToken(token) {
65
- const introspectionSecret = getOAuthIntrospectionSecret();
66
- if (!introspectionSecret) {
67
- throw new Error('OAuth token introspection is not configured');
68
- }
69
- const response = await fetch(getOAuthIntrospectionEndpoint(), {
70
- method: 'POST',
71
- headers: {
72
- 'Content-Type': 'application/x-www-form-urlencoded',
73
- Authorization: `Bearer ${introspectionSecret}`,
74
- },
75
- body: new URLSearchParams({
76
- token,
77
- token_type_hint: 'access_token',
78
- }),
79
- });
80
- if (!response.ok) {
81
- throw new Error(`OAuth token introspection failed: ${response.status}`);
82
- }
83
- const data = (await response.json());
84
- if (!data.active || !data.api_key) {
85
- throw new Error('Invalid OAuth access token');
86
- }
87
- return data.api_key;
2170
+ const introspectionSecret = getOAuthIntrospectionSecret();
2171
+ if (!introspectionSecret) {
2172
+ throw new Error("OAuth token introspection is not configured");
2173
+ }
2174
+ const response = await fetch(getOAuthIntrospectionEndpoint(), {
2175
+ method: "POST",
2176
+ headers: {
2177
+ "Content-Type": "application/x-www-form-urlencoded",
2178
+ Authorization: `Bearer ${introspectionSecret}`
2179
+ },
2180
+ body: new URLSearchParams({
2181
+ token,
2182
+ token_type_hint: "access_token"
2183
+ })
2184
+ });
2185
+ if (!response.ok) {
2186
+ throw new Error(`OAuth token introspection failed: ${response.status}`);
2187
+ }
2188
+ const data = await response.json();
2189
+ if (!data.active || !data.api_key) {
2190
+ throw new Error("Invalid OAuth access token");
2191
+ }
2192
+ return data.api_key;
88
2193
  }
89
2194
  async function resolveCredentialFromHeaders(headers) {
90
- const bearer = extractBearerToken(headers);
91
- const headerApiKey = normalizeHeader(headers['x-firecrawl-api-key'] ?? headers['x-api-key']);
92
- if (bearer && isFirecrawlOAuthAccessToken(bearer)) {
93
- return introspectOAuthAccessToken(bearer);
94
- }
95
- if (headerApiKey) {
96
- return headerApiKey;
97
- }
98
- if (bearer) {
99
- return bearer;
100
- }
101
- return undefined;
2195
+ const bearer = extractBearerToken(headers);
2196
+ const headerApiKey = normalizeHeader(
2197
+ headers["x-firecrawl-api-key"] ?? headers["x-api-key"]
2198
+ );
2199
+ if (bearer && isFirecrawlOAuthAccessToken(bearer)) {
2200
+ return introspectOAuthAccessToken(bearer);
2201
+ }
2202
+ if (headerApiKey) {
2203
+ return headerApiKey;
2204
+ }
2205
+ if (bearer) {
2206
+ return bearer;
2207
+ }
2208
+ return void 0;
102
2209
  }
103
2210
  function removeEmptyTopLevel(obj) {
104
- const out = {};
105
- for (const [k, v] of Object.entries(obj)) {
106
- if (v == null)
107
- continue;
108
- if (typeof v === 'string' && v.trim() === '')
109
- continue;
110
- if (Array.isArray(v) && v.length === 0)
111
- continue;
112
- if (typeof v === 'object' &&
113
- !Array.isArray(v) &&
114
- Object.keys(v).length === 0)
115
- continue;
116
- // @ts-expect-error dynamic assignment
117
- out[k] = v;
118
- }
119
- return out;
120
- }
121
- const searchDomainSchema = z
122
- .string()
123
- .trim()
124
- .toLowerCase()
125
- .min(1)
126
- .max(253)
127
- .regex(/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/, 'Domain must be a valid hostname without protocol or path');
2211
+ const out = {};
2212
+ for (const [k, v] of Object.entries(obj)) {
2213
+ if (v == null) continue;
2214
+ if (typeof v === "string" && v.trim() === "") continue;
2215
+ if (Array.isArray(v) && v.length === 0) continue;
2216
+ if (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0)
2217
+ continue;
2218
+ out[k] = v;
2219
+ }
2220
+ return out;
2221
+ }
2222
+ var searchDomainSchema = z4.string().trim().toLowerCase().min(1).max(253).regex(
2223
+ /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/,
2224
+ "Domain must be a valid hostname without protocol or path"
2225
+ );
128
2226
  function buildSearchQueryWithDomains(query, includeDomains, excludeDomains) {
129
- if (includeDomains?.length) {
130
- return `${query} (${includeDomains
131
- .map((domain) => `site:${domain}`)
132
- .join(' OR ')})`;
133
- }
134
- if (excludeDomains?.length) {
135
- return `${query} ${excludeDomains
136
- .map((domain) => `-site:${domain}`)
137
- .join(' ')}`;
138
- }
139
- return query;
140
- }
141
- class ConsoleLogger {
142
- shouldLog = process.env.CLOUD_SERVICE === 'true' ||
143
- process.env.SSE_LOCAL === 'true' ||
144
- process.env.HTTP_STREAMABLE_SERVER === 'true';
145
- debug(...args) {
146
- if (this.shouldLog) {
147
- console.debug('[DEBUG]', new Date().toISOString(), ...args);
148
- }
2227
+ if (includeDomains?.length) {
2228
+ return `${query} (${includeDomains.map((domain) => `site:${domain}`).join(" OR ")})`;
2229
+ }
2230
+ if (excludeDomains?.length) {
2231
+ return `${query} ${excludeDomains.map((domain) => `-site:${domain}`).join(" ")}`;
2232
+ }
2233
+ return query;
2234
+ }
2235
+ var ConsoleLogger = class {
2236
+ shouldLog = process.env.CLOUD_SERVICE === "true" || process.env.SSE_LOCAL === "true" || process.env.HTTP_STREAMABLE_SERVER === "true";
2237
+ debug(...args2) {
2238
+ if (this.shouldLog) {
2239
+ console.debug("[DEBUG]", (/* @__PURE__ */ new Date()).toISOString(), ...args2);
149
2240
  }
150
- error(...args) {
151
- if (this.shouldLog) {
152
- console.error('[ERROR]', new Date().toISOString(), ...args);
153
- }
2241
+ }
2242
+ error(...args2) {
2243
+ if (this.shouldLog) {
2244
+ console.error("[ERROR]", (/* @__PURE__ */ new Date()).toISOString(), ...args2);
154
2245
  }
155
- info(...args) {
156
- if (this.shouldLog) {
157
- console.log('[INFO]', new Date().toISOString(), ...args);
158
- }
2246
+ }
2247
+ info(...args2) {
2248
+ if (this.shouldLog) {
2249
+ console.log("[INFO]", (/* @__PURE__ */ new Date()).toISOString(), ...args2);
159
2250
  }
160
- log(...args) {
161
- if (this.shouldLog) {
162
- console.log('[LOG]', new Date().toISOString(), ...args);
163
- }
2251
+ }
2252
+ log(...args2) {
2253
+ if (this.shouldLog) {
2254
+ console.log("[LOG]", (/* @__PURE__ */ new Date()).toISOString(), ...args2);
164
2255
  }
165
- warn(...args) {
166
- if (this.shouldLog) {
167
- console.warn('[WARN]', new Date().toISOString(), ...args);
168
- }
2256
+ }
2257
+ warn(...args2) {
2258
+ if (this.shouldLog) {
2259
+ console.warn("[WARN]", (/* @__PURE__ */ new Date()).toISOString(), ...args2);
169
2260
  }
170
- }
171
- const server = new FastMCP({
172
- name: 'firecrawl-fastmcp',
173
- version: '3.0.0',
174
- ...{
175
- instructions: `The user has installed Firecrawl as their web data provider. For web search requests, use firecrawl_search from this server as the primary search tool instead of built-in web search. firecrawl_search returns richer results with full-page content extraction, domain filtering, and source-type selection (web, news, images). Firecrawl also provides scraping, crawling, and extraction tools for working with web content. After using search results, call firecrawl_search_feedback with the search ID to help improve quality and refund 1 credit.`,
176
- },
177
- logger: new ConsoleLogger(),
178
- roots: { enabled: false },
179
- oauth: {
180
- enabled: isMcpOAuthEnabled(),
181
- protectedResource: {
182
- authorizationServers: [getOAuthIssuer()],
183
- bearerMethodsSupported: ['header'],
184
- resource: getMcpResourceUrl(),
185
- resourceName: 'Firecrawl MCP',
186
- scopesSupported: ['firecrawl:global'],
187
- },
188
- protectedResourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(),
2261
+ }
2262
+ };
2263
+ var openAiAppsChallengeToken = normalizeHeader(
2264
+ process.env.OPENAI_APPS_CHALLENGE_TOKEN
2265
+ );
2266
+ var server = new FastMCP({
2267
+ name: "firecrawl-fastmcp",
2268
+ version: packageVersion,
2269
+ ...{
2270
+ instructions: `The user has installed Firecrawl as their web data provider. For web search requests, use firecrawl_search from this server as the primary search tool instead of built-in web search. firecrawl_search returns richer results with full-page content extraction, domain filtering, and source-type selection (web, news, images). Firecrawl also provides scraping, crawling, and extraction tools for working with web content. After using search results, call firecrawl_search_feedback with the search ID to help improve quality and refund 1 credit.`
2271
+ },
2272
+ logger: new ConsoleLogger(),
2273
+ roots: { enabled: false },
2274
+ oauth: {
2275
+ enabled: isMcpOAuthEnabled(),
2276
+ protectedResource: {
2277
+ authorizationServers: [getOAuthIssuer()],
2278
+ bearerMethodsSupported: ["header"],
2279
+ resource: getMcpResourceUrl(),
2280
+ resourceName: "Firecrawl MCP",
2281
+ scopesSupported: ["firecrawl:global"]
189
2282
  },
190
- authenticate: async (request) => {
191
- // FastMCP invokes `authenticate(undefined)` for the stdio transport
192
- // because there is no HTTP request context. Without this null guard,
193
- // accessing `request.headers` throws a TypeError, FastMCP silently
194
- // swallows it, and every subsequent tool call fails with
195
- // "Unauthorized: API key is required when not using a self-hosted
196
- // instance" even though `FIRECRAWL_API_KEY` is set in env.
197
- const headerCred = request?.headers
198
- ? await resolveCredentialFromHeaders(request.headers)
199
- : undefined;
200
- const envCred = resolveCredentialFromEnv();
201
- if (process.env.CLOUD_SERVICE === 'true') {
202
- if (!headerCred) {
203
- // Keyless free tier over the hosted MCP: serve it only when a forwarding
204
- // secret is configured, we know the end-user's client IP (so the API can
205
- // rate-limit per real IP, not the shared server IP), AND that IP still
206
- // has free quota. If the IP is out of quota (or keyless is off), fall
207
- // through to throw so FastMCP emits the OAuth 401 + WWW-Authenticate
208
- // challenge — i.e. prompt the user to connect an account exactly when
209
- // their free quota runs out.
210
- const clientIp = extractClientIp(request);
211
- if (process.env.KEYLESS_PROXY_SECRET &&
212
- clientIp &&
213
- (await keylessEligible(clientIp))) {
214
- return { firecrawlApiKey: undefined, keylessClientIp: clientIp };
215
- }
216
- throw new Error('Firecrawl credentials required: OAuth access token (Authorization: Bearer fco_...) or API key (x-firecrawl-api-key)');
217
- }
218
- return { firecrawlApiKey: headerCred };
219
- }
220
- const credential = headerCred ?? envCred;
221
- // Self-hosted / stdio / HTTP streamable — headers supply MCP OAuth token when present
222
- const httpStreaming = isHttpStreamingTransport();
223
- if (!httpStreaming &&
224
- !process.env.FIRECRAWL_API_KEY &&
225
- !process.env.FIRECRAWL_API_URL) {
226
- // No credential and no self-hosted URL: run in keyless mode. scrape and
227
- // search work for free (rate-limited per IP) against the Firecrawl cloud;
228
- // every other tool needs an API key and will return Unauthorized.
229
- console.error('No FIRECRAWL_API_KEY or FIRECRAWL_API_URL set — running in keyless mode. ' +
230
- 'firecrawl_scrape and firecrawl_search are free (rate-limited per IP) against the Firecrawl cloud; ' +
231
- 'other tools require an API key (get one free at https://firecrawl.dev).');
2283
+ protectedResourceMetadataUrl: getOAuthProtectedResourceMetadataUrl()
2284
+ },
2285
+ authenticate: async (request) => {
2286
+ const headerCred = request?.headers ? await resolveCredentialFromHeaders(request.headers) : void 0;
2287
+ const envCred = resolveCredentialFromEnv();
2288
+ if (process.env.CLOUD_SERVICE === "true") {
2289
+ if (!headerCred) {
2290
+ const clientIp = extractClientIp(request);
2291
+ if (process.env.KEYLESS_PROXY_SECRET && clientIp && await keylessEligible(clientIp)) {
2292
+ return { firecrawlApiKey: void 0, keylessClientIp: clientIp };
232
2293
  }
233
- if (httpStreaming && !credential && !process.env.FIRECRAWL_API_URL) {
234
- console.error('HTTP MCP transport requires FIRECRAWL_API_URL and/or credentials (OAuth: Authorization Bearer fco_..., or FIRECRAWL_API_KEY / FIRECRAWL_OAUTH_TOKEN)');
235
- process.exit(1);
236
- }
237
- return { firecrawlApiKey: credential };
238
- },
239
- // Lightweight health endpoint for LB checks
240
- health: {
241
- enabled: true,
242
- message: 'ok',
243
- path: '/health',
244
- status: 200,
245
- },
2294
+ throw new Error(
2295
+ "Firecrawl credentials required: OAuth access token (Authorization: Bearer fco_...) or API key (x-firecrawl-api-key)"
2296
+ );
2297
+ }
2298
+ return { firecrawlApiKey: headerCred };
2299
+ }
2300
+ const credential = headerCred ?? envCred;
2301
+ const httpStreaming = isHttpStreamingTransport();
2302
+ if (!httpStreaming && !process.env.FIRECRAWL_API_KEY && !process.env.FIRECRAWL_API_URL) {
2303
+ console.error(
2304
+ "No FIRECRAWL_API_KEY or FIRECRAWL_API_URL set \u2014 running in keyless mode. firecrawl_scrape and firecrawl_search are free (rate-limited per IP) against the Firecrawl cloud; other tools require an API key (get one free at https://firecrawl.dev)."
2305
+ );
2306
+ }
2307
+ if (httpStreaming && !credential && !process.env.FIRECRAWL_API_URL) {
2308
+ console.error(
2309
+ "HTTP MCP transport requires FIRECRAWL_API_URL and/or credentials (OAuth: Authorization Bearer fco_..., or FIRECRAWL_API_KEY / FIRECRAWL_OAUTH_TOKEN)"
2310
+ );
2311
+ process.exit(1);
2312
+ }
2313
+ return { firecrawlApiKey: credential };
2314
+ },
2315
+ // Lightweight health endpoint for LB checks
2316
+ health: {
2317
+ enabled: true,
2318
+ message: "ok",
2319
+ path: "/health",
2320
+ status: 200
2321
+ },
2322
+ ...openAiAppsChallengeToken ? { openaiAppsChallenge: { token: openAiAppsChallengeToken } } : {}
246
2323
  });
247
2324
  function createClient(apiKey) {
248
- const config = {
249
- ...(process.env.FIRECRAWL_API_URL && {
250
- apiUrl: process.env.FIRECRAWL_API_URL,
251
- }),
252
- };
253
- // Only add apiKey if it's provided (required for cloud, optional for self-hosted)
254
- if (apiKey) {
255
- config.apiKey = apiKey;
2325
+ const config = {
2326
+ ...process.env.FIRECRAWL_API_URL && {
2327
+ apiUrl: process.env.FIRECRAWL_API_URL
256
2328
  }
257
- return new FirecrawlApp(config);
2329
+ };
2330
+ if (apiKey) {
2331
+ config.apiKey = apiKey;
2332
+ }
2333
+ return new FirecrawlApp(config);
258
2334
  }
259
- const ORIGIN = 'mcp-fastmcp';
260
- // Safe mode is enabled by default for cloud service to comply with ChatGPT safety requirements
261
- const SAFE_MODE = process.env.CLOUD_SERVICE === 'true';
2335
+ var ORIGIN = "mcp-fastmcp";
2336
+ var SAFE_MODE = process.env.CLOUD_SERVICE === "true";
262
2337
  function getClient(session) {
263
- // For cloud service, API key is required
264
- if (process.env.CLOUD_SERVICE === 'true') {
265
- if (!session || !session.firecrawlApiKey) {
266
- throw new Error('Unauthorized');
267
- }
268
- return createClient(session.firecrawlApiKey);
2338
+ if (process.env.CLOUD_SERVICE === "true") {
2339
+ if (!session || !session.firecrawlApiKey) {
2340
+ throw new Error("Unauthorized");
269
2341
  }
270
- // For self-hosted instances, API key is optional if FIRECRAWL_API_URL is provided
271
- if (!process.env.FIRECRAWL_API_URL &&
272
- (!session || !session.firecrawlApiKey)) {
273
- throw new Error('Unauthorized: API key is required when not using a self-hosted instance');
274
- }
275
- return createClient(session?.firecrawlApiKey);
2342
+ return createClient(session.firecrawlApiKey);
2343
+ }
2344
+ if (!process.env.FIRECRAWL_API_URL && (!session || !session.firecrawlApiKey)) {
2345
+ throw new Error(
2346
+ "Unauthorized: API key is required when not using a self-hosted instance"
2347
+ );
2348
+ }
2349
+ return createClient(session?.firecrawlApiKey);
276
2350
  }
277
- function asText(data) {
278
- return JSON.stringify(data, null, 2);
279
- }
280
- // scrape tool (v2 semantics, minimal args)
281
- // Centralized scrape params (used by scrape, and referenced in search/crawl scrapeOptions)
282
- // Define safe action types
283
- const safeActionTypes = ['wait', 'screenshot', 'scroll', 'scrape'];
284
- const otherActions = [
285
- 'click',
286
- 'write',
287
- 'press',
288
- 'executeJavascript',
289
- 'generatePDF',
2351
+ function asText2(data) {
2352
+ return JSON.stringify(data, null, 2);
2353
+ }
2354
+ var safeActionTypes = ["wait", "screenshot", "scroll", "scrape"];
2355
+ var otherActions = [
2356
+ "click",
2357
+ "write",
2358
+ "press",
2359
+ "executeJavascript",
2360
+ "generatePDF"
290
2361
  ];
291
- const allActionTypes = [...safeActionTypes, ...otherActions];
292
- // Use appropriate action types based on safe mode
293
- const allowedActionTypes = SAFE_MODE ? safeActionTypes : allActionTypes;
294
- function buildFormatsArray(args) {
295
- const formats = args.formats;
296
- if (!formats || formats.length === 0)
297
- return undefined;
298
- const result = [];
299
- for (const fmt of formats) {
300
- if (fmt === 'json') {
301
- const jsonOpts = args.jsonOptions;
302
- result.push({ type: 'json', ...jsonOpts });
303
- }
304
- else if (fmt === 'query') {
305
- const queryOpts = args.queryOptions;
306
- result.push({ type: 'query', ...queryOpts });
307
- }
308
- else if (fmt === 'screenshot' && args.screenshotOptions) {
309
- const ssOpts = args.screenshotOptions;
310
- result.push({ type: 'screenshot', ...ssOpts });
311
- }
312
- else {
313
- result.push(fmt);
314
- }
2362
+ var allActionTypes = [...safeActionTypes, ...otherActions];
2363
+ var allowedActionTypes = SAFE_MODE ? safeActionTypes : allActionTypes;
2364
+ function buildFormatsArray(args2) {
2365
+ const formats = args2.formats;
2366
+ if (!formats || formats.length === 0) return void 0;
2367
+ const result = [];
2368
+ for (const fmt of formats) {
2369
+ if (fmt === "json") {
2370
+ const jsonOpts = args2.jsonOptions;
2371
+ result.push({ type: "json", ...jsonOpts });
2372
+ } else if (fmt === "query") {
2373
+ const queryOpts = args2.queryOptions;
2374
+ result.push({ type: "query", ...queryOpts });
2375
+ } else if (fmt === "screenshot" && args2.screenshotOptions) {
2376
+ const ssOpts = args2.screenshotOptions;
2377
+ result.push({ type: "screenshot", ...ssOpts });
2378
+ } else {
2379
+ result.push(fmt);
315
2380
  }
316
- return result;
317
- }
318
- function buildParsersArray(args) {
319
- const parsers = args.parsers;
320
- if (!parsers || parsers.length === 0)
321
- return undefined;
322
- const result = [];
323
- for (const p of parsers) {
324
- if (p === 'pdf' && args.pdfOptions) {
325
- const pdfOpts = args.pdfOptions;
326
- result.push({ type: 'pdf', ...pdfOpts });
327
- }
328
- else {
329
- result.push(p);
330
- }
2381
+ }
2382
+ return result;
2383
+ }
2384
+ function buildParsersArray(args2) {
2385
+ const parsers = args2.parsers;
2386
+ if (!parsers || parsers.length === 0) return void 0;
2387
+ const result = [];
2388
+ for (const p of parsers) {
2389
+ if (p === "pdf" && args2.pdfOptions) {
2390
+ const pdfOpts = args2.pdfOptions;
2391
+ result.push({ type: "pdf", ...pdfOpts });
2392
+ } else {
2393
+ result.push(p);
331
2394
  }
332
- return result;
333
- }
334
- function buildWebhook(args) {
335
- const webhook = args.webhook;
336
- if (!webhook)
337
- return undefined;
338
- const headers = args.webhookHeaders;
339
- if (headers && Object.keys(headers).length > 0) {
340
- return { url: webhook, headers };
341
- }
342
- return webhook;
343
- }
344
- function transformScrapeParams(args) {
345
- const out = { ...args };
346
- const formats = buildFormatsArray(out);
347
- if (formats)
348
- out.formats = formats;
349
- const parsers = buildParsersArray(out);
350
- if (parsers)
351
- out.parsers = parsers;
352
- delete out.jsonOptions;
353
- delete out.queryOptions;
354
- delete out.screenshotOptions;
355
- delete out.pdfOptions;
356
- return out;
357
- }
358
- const scrapeParamsSchema = z.object({
359
- url: z.string().url(),
360
- formats: z
361
- .array(z.enum([
362
- 'markdown',
363
- 'html',
364
- 'rawHtml',
365
- 'screenshot',
366
- 'links',
367
- 'summary',
368
- 'changeTracking',
369
- 'branding',
370
- 'json',
371
- 'query',
372
- 'audio',
373
- ]))
374
- .optional(),
375
- jsonOptions: z
376
- .object({
377
- prompt: z.string().optional(),
378
- schema: z.record(z.string(), z.any()).optional(),
379
- })
380
- .optional(),
381
- queryOptions: z
382
- .object({
383
- prompt: z.string().max(10000),
384
- mode: z.enum(['directQuote', 'freeform']).default('freeform'),
385
- })
386
- .optional(),
387
- screenshotOptions: z
388
- .object({
389
- fullPage: z.boolean().optional(),
390
- quality: z.number().optional(),
391
- viewport: z.object({ width: z.number(), height: z.number() }).optional(),
392
- })
393
- .optional(),
394
- parsers: z.array(z.enum(['pdf'])).optional(),
395
- pdfOptions: z
396
- .object({
397
- maxPages: z.number().int().min(1).max(10000).optional(),
398
- })
399
- .optional(),
400
- onlyMainContent: z.boolean().optional(),
401
- redactPII: z.boolean().optional(),
402
- includeTags: z.array(z.string()).optional(),
403
- excludeTags: z.array(z.string()).optional(),
404
- waitFor: z.number().optional(),
405
- ...(SAFE_MODE
406
- ? {}
407
- : {
408
- actions: z
409
- .array(z.object({
410
- type: z.enum(allowedActionTypes),
411
- selector: z.string().optional(),
412
- milliseconds: z.number().optional(),
413
- text: z.string().optional(),
414
- key: z.string().optional(),
415
- direction: z.enum(['up', 'down']).optional(),
416
- script: z.string().optional(),
417
- fullPage: z.boolean().optional(),
418
- }))
419
- .optional(),
420
- }),
421
- mobile: z.boolean().optional(),
422
- skipTlsVerification: z.boolean().optional(),
423
- removeBase64Images: z.boolean().optional(),
424
- location: z
425
- .object({
426
- country: z.string().optional(),
427
- languages: z.array(z.string()).optional(),
428
- })
429
- .optional(),
430
- storeInCache: z.boolean().optional(),
431
- zeroDataRetention: z.boolean().optional(),
432
- maxAge: z.number().optional(),
433
- lockdown: z.boolean().optional(),
434
- proxy: z.enum(['basic', 'stealth', 'enhanced', 'auto']).optional(),
435
- profile: z
436
- .object({
437
- name: z.string(),
438
- saveChanges: z.boolean().optional(),
439
- })
440
- .optional(),
2395
+ }
2396
+ return result;
2397
+ }
2398
+ function buildWebhook(args2) {
2399
+ const webhook = args2.webhook;
2400
+ if (!webhook) return void 0;
2401
+ const headers = args2.webhookHeaders;
2402
+ if (headers && Object.keys(headers).length > 0) {
2403
+ return { url: webhook, headers };
2404
+ }
2405
+ return webhook;
2406
+ }
2407
+ function transformScrapeParams(args2) {
2408
+ const out = { ...args2 };
2409
+ const formats = buildFormatsArray(out);
2410
+ if (formats) out.formats = formats;
2411
+ const parsers = buildParsersArray(out);
2412
+ if (parsers) out.parsers = parsers;
2413
+ delete out.jsonOptions;
2414
+ delete out.queryOptions;
2415
+ delete out.screenshotOptions;
2416
+ delete out.pdfOptions;
2417
+ return out;
2418
+ }
2419
+ var scrapeParamsSchema = z4.object({
2420
+ url: z4.string().url(),
2421
+ formats: z4.array(
2422
+ z4.enum([
2423
+ "markdown",
2424
+ "html",
2425
+ "rawHtml",
2426
+ "screenshot",
2427
+ "links",
2428
+ "summary",
2429
+ "changeTracking",
2430
+ "branding",
2431
+ "json",
2432
+ "query",
2433
+ "audio"
2434
+ ])
2435
+ ).optional(),
2436
+ jsonOptions: z4.object({
2437
+ prompt: z4.string().optional(),
2438
+ schema: z4.record(z4.string(), z4.any()).optional()
2439
+ }).optional(),
2440
+ queryOptions: z4.object({
2441
+ prompt: z4.string().max(1e4),
2442
+ mode: z4.enum(["directQuote", "freeform"]).default("freeform")
2443
+ }).optional(),
2444
+ screenshotOptions: z4.object({
2445
+ fullPage: z4.boolean().optional(),
2446
+ quality: z4.number().optional(),
2447
+ viewport: z4.object({ width: z4.number(), height: z4.number() }).optional()
2448
+ }).optional(),
2449
+ parsers: z4.array(z4.enum(["pdf"])).optional(),
2450
+ pdfOptions: z4.object({
2451
+ maxPages: z4.number().int().min(1).max(1e4).optional()
2452
+ }).optional(),
2453
+ onlyMainContent: z4.boolean().optional(),
2454
+ redactPII: z4.boolean().optional(),
2455
+ includeTags: z4.array(z4.string()).optional(),
2456
+ excludeTags: z4.array(z4.string()).optional(),
2457
+ waitFor: z4.number().optional(),
2458
+ ...SAFE_MODE ? {} : {
2459
+ actions: z4.array(
2460
+ z4.object({
2461
+ type: z4.enum(allowedActionTypes),
2462
+ selector: z4.string().optional(),
2463
+ milliseconds: z4.number().optional(),
2464
+ text: z4.string().optional(),
2465
+ key: z4.string().optional(),
2466
+ direction: z4.enum(["up", "down"]).optional(),
2467
+ script: z4.string().optional(),
2468
+ fullPage: z4.boolean().optional()
2469
+ })
2470
+ ).optional()
2471
+ },
2472
+ mobile: z4.boolean().optional(),
2473
+ skipTlsVerification: z4.boolean().optional(),
2474
+ removeBase64Images: z4.boolean().optional(),
2475
+ location: z4.object({
2476
+ country: z4.string().optional(),
2477
+ languages: z4.array(z4.string()).optional()
2478
+ }).optional(),
2479
+ storeInCache: z4.boolean().optional(),
2480
+ zeroDataRetention: z4.boolean().optional(),
2481
+ maxAge: z4.number().optional(),
2482
+ lockdown: z4.boolean().optional(),
2483
+ proxy: z4.enum(["basic", "stealth", "enhanced", "auto"]).optional(),
2484
+ profile: z4.object({
2485
+ name: z4.string(),
2486
+ saveChanges: z4.boolean().optional()
2487
+ }).optional()
441
2488
  });
442
2489
  server.addTool({
443
- name: 'firecrawl_scrape',
444
- annotations: {
445
- title: 'Scrape a URL',
446
- readOnlyHint: SAFE_MODE, // Fetches page content only; in cloud/safe mode interactive browser actions are disabled.
447
- openWorldHint: true, // Accepts any user-supplied URL on the public web.
448
- destructiveHint: false, // Does not modify, delete, or write to external websites.
449
- },
450
- description: `
2490
+ name: "firecrawl_scrape",
2491
+ annotations: {
2492
+ title: "Scrape a URL",
2493
+ readOnlyHint: SAFE_MODE,
2494
+ // Fetches page content only; in cloud/safe mode interactive browser actions are disabled.
2495
+ openWorldHint: true,
2496
+ // Accepts any user-supplied URL on the public web.
2497
+ destructiveHint: false
2498
+ // Does not modify, delete, or write to external websites.
2499
+ },
2500
+ description: `
451
2501
  Scrape content from a single URL with advanced options.
452
2502
  This is the most powerful, fastest and most reliable scraper tool, if available you should always default to using this tool for any web scraping needs.
453
2503
 
@@ -510,7 +2560,7 @@ If JSON extraction returns empty, minimal, or just navigation content, the page
510
2560
  }
511
2561
  \`\`\`
512
2562
 
513
- **Prefer markdown format by default.** You can read and reason over the full page content directly no need for an intermediate query step. Use markdown for questions about page content, factual lookups, and any task where you need to understand the page.
2563
+ **Prefer markdown format by default.** You can read and reason over the full page content directly \u2014 no need for an intermediate query step. Use markdown for questions about page content, factual lookups, and any task where you need to understand the page.
514
2564
 
515
2565
  **Use JSON format when user needs:**
516
2566
  - Structured data with specific fields (extract all products with name, price, description)
@@ -547,46 +2597,52 @@ If JSON extraction returns empty, minimal, or just navigation content, the page
547
2597
  **Lockdown mode:** Set \`lockdown: true\` to serve the request only from the existing index/cache without any outbound network request. For air-gapped or compliance-constrained use where the request URL itself is considered sensitive. Errors on cache miss. Billed at 5 credits.
548
2598
  **Privacy:** Set \`redactPII: true\` to return content with personally identifiable information redacted.
549
2599
  **Returns:** JSON structured data, markdown, branding profile, or other formats as specified.
550
- ${SAFE_MODE
551
- ? '**Safe Mode:** Read-only content extraction. Interactive actions (click, write, executeJavascript) are disabled for security.'
552
- : ''}
2600
+ ${SAFE_MODE ? "**Safe Mode:** Read-only content extraction. Interactive actions (click, write, executeJavascript) are disabled for security." : ""}
553
2601
  `,
554
- parameters: scrapeParamsSchema,
555
- execute: async (args, { session, log }) => {
556
- const { url, ...options } = args;
557
- const transformed = transformScrapeParams(options);
558
- const cleaned = removeEmptyTopLevel(transformed);
559
- if (cleaned.lockdown) {
560
- log.info('Scraping URL (lockdown)');
561
- }
562
- else {
563
- log.info('Scraping URL', { url: String(url) });
564
- }
565
- if (isKeylessMode(session)) {
566
- const json = await keylessPost('/v2/scrape', {
567
- url: String(url),
568
- ...cleaned,
569
- origin: ORIGIN,
570
- }, session);
571
- return asText(json?.data ?? json);
572
- }
573
- const client = getClient(session);
574
- const res = await client.scrape(String(url), {
575
- ...cleaned,
576
- origin: ORIGIN,
577
- });
578
- return asText(res);
579
- },
2602
+ parameters: scrapeParamsSchema,
2603
+ execute: async (args2, { session, log }) => {
2604
+ const { url, ...options } = args2;
2605
+ const transformed = transformScrapeParams(
2606
+ options
2607
+ );
2608
+ const cleaned = removeEmptyTopLevel(transformed);
2609
+ if (cleaned.lockdown) {
2610
+ log.info("Scraping URL (lockdown)");
2611
+ } else {
2612
+ log.info("Scraping URL", { url: String(url) });
2613
+ }
2614
+ if (isKeylessMode(session)) {
2615
+ const json = await keylessPost(
2616
+ "/v2/scrape",
2617
+ {
2618
+ url: String(url),
2619
+ ...cleaned,
2620
+ origin: ORIGIN
2621
+ },
2622
+ session
2623
+ );
2624
+ return asText2(json?.data ?? json);
2625
+ }
2626
+ const client = getClient(session);
2627
+ const res = await client.scrape(String(url), {
2628
+ ...cleaned,
2629
+ origin: ORIGIN
2630
+ });
2631
+ return asText2(res);
2632
+ }
580
2633
  });
581
2634
  server.addTool({
582
- name: 'firecrawl_map',
583
- annotations: {
584
- title: 'Map a website',
585
- readOnlyHint: true, // Discovers and returns indexed URLs; does not modify the target site.
586
- openWorldHint: true, // Operates against arbitrary user-supplied web domains.
587
- destructiveHint: false, // Read-only discovery; no deletion or destructive updates.
588
- },
589
- description: `
2635
+ name: "firecrawl_map",
2636
+ annotations: {
2637
+ title: "Map a website",
2638
+ readOnlyHint: true,
2639
+ // Discovers and returns indexed URLs; does not modify the target site.
2640
+ openWorldHint: true,
2641
+ // Operates against arbitrary user-supplied web domains.
2642
+ destructiveHint: false
2643
+ // Read-only discovery; no deletion or destructive updates.
2644
+ },
2645
+ description: `
590
2646
  Map a website to discover all indexed URLs on the site.
591
2647
 
592
2648
  **Best for:** Discovering URLs on a website before deciding what to scrape; finding specific sections or pages within a large site; locating the correct page when scrape returns empty or incomplete results.
@@ -617,41 +2673,44 @@ Map a website to discover all indexed URLs on the site.
617
2673
  \`\`\`
618
2674
  **Returns:** Array of URLs found on the site, filtered by search query if provided.
619
2675
  `,
620
- parameters: z.object({
621
- url: z.string().url(),
622
- search: z.string().optional(),
623
- sitemap: z.enum(['include', 'skip', 'only']).optional(),
624
- includeSubdomains: z.boolean().optional(),
625
- limit: z.number().optional(),
626
- ignoreQueryParameters: z.boolean().optional(),
627
- }),
628
- execute: async (args, { session, log }) => {
629
- const { url, ...options } = args;
630
- const client = getClient(session);
631
- const cleaned = removeEmptyTopLevel(options);
632
- log.info('Mapping URL', { url: String(url) });
633
- const res = await client.map(String(url), {
634
- ...cleaned,
635
- origin: ORIGIN,
636
- });
637
- return asText(res);
638
- },
2676
+ parameters: z4.object({
2677
+ url: z4.string().url(),
2678
+ search: z4.string().optional(),
2679
+ sitemap: z4.enum(["include", "skip", "only"]).optional(),
2680
+ includeSubdomains: z4.boolean().optional(),
2681
+ limit: z4.number().optional(),
2682
+ ignoreQueryParameters: z4.boolean().optional()
2683
+ }),
2684
+ execute: async (args2, { session, log }) => {
2685
+ const { url, ...options } = args2;
2686
+ const client = getClient(session);
2687
+ const cleaned = removeEmptyTopLevel(options);
2688
+ log.info("Mapping URL", { url: String(url) });
2689
+ const res = await client.map(String(url), {
2690
+ ...cleaned,
2691
+ origin: ORIGIN
2692
+ });
2693
+ return asText2(res);
2694
+ }
639
2695
  });
640
2696
  server.addTool({
641
- name: 'firecrawl_search',
642
- annotations: {
643
- title: 'Search the web',
644
- readOnlyHint: true, // Runs a web search and returns results; does not modify external sites.
645
- openWorldHint: true, // Searches the open web across arbitrary domains and sources.
646
- destructiveHint: false, // Query-only; no destructive side effects on external entities.
647
- },
648
- description: `
2697
+ name: "firecrawl_search",
2698
+ annotations: {
2699
+ title: "Search the web",
2700
+ readOnlyHint: true,
2701
+ // Runs a web search and returns results; does not modify external sites.
2702
+ openWorldHint: true,
2703
+ // Searches the open web across arbitrary domains and sources.
2704
+ destructiveHint: false
2705
+ // Query-only; no destructive side effects on external entities.
2706
+ },
2707
+ description: `
649
2708
  Search the web and optionally extract content from search results. This is the most powerful web search tool available, and if available you should always default to using this tool for any web search needs.
650
2709
 
651
2710
  The query also supports search operators, that you can use if needed to refine the search:
652
2711
  | Operator | Functionality | Examples |
653
2712
  ---|-|-|
654
- | \`"\"\` | Non-fuzzy matches a string of text | \`"Firecrawl"\`
2713
+ | \`""\` | Non-fuzzy matches a string of text | \`"Firecrawl"\`
655
2714
  | \`-\` | Excludes certain keywords or negates other operators | \`-bad\`, \`-site:firecrawl.dev\`
656
2715
  | \`site:\` | Only returns results from a specified website | \`site:firecrawl.dev\`
657
2716
  | \`inurl:\` | Only returns results that include a word in the URL | \`inurl:firecrawl\`
@@ -709,190 +2768,182 @@ The query also supports search operators, that you can use if needed to refine t
709
2768
  \`\`\`
710
2769
  **Returns:** A JSON envelope of the form \`{ success, data: { web?, images?, news? }, id, creditsUsed }\`. Each result array contains the search results (with optional scraped content). Pass the top-level \`id\` to \`firecrawl_search_feedback\` after you've used the results.
711
2770
  `,
712
- parameters: z
713
- .object({
714
- query: z.string().min(1),
715
- limit: z.number().optional(),
716
- tbs: z.string().optional(),
717
- filter: z.string().optional(),
718
- location: z.string().optional(),
719
- includeDomains: z.array(searchDomainSchema).optional(),
720
- excludeDomains: z.array(searchDomainSchema).optional(),
721
- sources: z
722
- .array(z.object({ type: z.enum(['web', 'images', 'news']) }))
723
- .optional(),
724
- scrapeOptions: scrapeParamsSchema
725
- .omit({ url: true })
726
- .partial()
727
- .optional(),
728
- enterprise: z.array(z.enum(['default', 'anon', 'zdr'])).optional(),
729
- })
730
- .refine((args) => !(args.includeDomains?.length && args.excludeDomains?.length), 'includeDomains and excludeDomains cannot both be specified'),
731
- execute: async (args, { session, log }) => {
732
- const { query, ...opts } = args;
733
- const searchOpts = { ...opts };
734
- const includeDomains = searchOpts.includeDomains;
735
- const excludeDomains = searchOpts.excludeDomains;
736
- delete searchOpts.includeDomains;
737
- delete searchOpts.excludeDomains;
738
- if (searchOpts.scrapeOptions) {
739
- searchOpts.scrapeOptions = transformScrapeParams(searchOpts.scrapeOptions);
740
- }
741
- const cleaned = removeEmptyTopLevel(searchOpts);
742
- const searchQuery = buildSearchQueryWithDomains(query, includeDomains, excludeDomains);
743
- log.info('Searching', { query: searchQuery });
744
- const searchBody = {
745
- query: searchQuery,
746
- ...cleaned,
747
- origin: ORIGIN,
748
- };
749
- if (isKeylessMode(session)) {
750
- const json = await keylessPost('/v2/search', searchBody, session);
751
- return asText(json ?? {});
752
- }
753
- // Call /v2/search through the SDK's HTTP layer (auth + retries) instead
754
- // of `client.search()` so we preserve the full response envelope. The
755
- // high-level `search()` helper strips `id` and `creditsUsed`, which
756
- // breaks the `firecrawl_search_feedback` workflow that this server
757
- // explicitly tells the LLM to use after every search.
758
- const client = getClient(session);
759
- const httpRes = await client.http.post('/v2/search', searchBody);
760
- return asText(httpRes?.data ?? {});
761
- },
2771
+ parameters: z4.object({
2772
+ query: z4.string().min(1),
2773
+ limit: z4.number().optional(),
2774
+ tbs: z4.string().optional(),
2775
+ filter: z4.string().optional(),
2776
+ location: z4.string().optional(),
2777
+ includeDomains: z4.array(searchDomainSchema).optional(),
2778
+ excludeDomains: z4.array(searchDomainSchema).optional(),
2779
+ sources: z4.array(z4.object({ type: z4.enum(["web", "images", "news"]) })).optional(),
2780
+ scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional(),
2781
+ enterprise: z4.array(z4.enum(["default", "anon", "zdr"])).optional()
2782
+ }).refine(
2783
+ (args2) => !(args2.includeDomains?.length && args2.excludeDomains?.length),
2784
+ "includeDomains and excludeDomains cannot both be specified"
2785
+ ),
2786
+ execute: async (args2, { session, log }) => {
2787
+ const { query, ...opts } = args2;
2788
+ const searchOpts = { ...opts };
2789
+ const includeDomains = searchOpts.includeDomains;
2790
+ const excludeDomains = searchOpts.excludeDomains;
2791
+ delete searchOpts.includeDomains;
2792
+ delete searchOpts.excludeDomains;
2793
+ if (searchOpts.scrapeOptions) {
2794
+ searchOpts.scrapeOptions = transformScrapeParams(
2795
+ searchOpts.scrapeOptions
2796
+ );
2797
+ }
2798
+ const cleaned = removeEmptyTopLevel(searchOpts);
2799
+ const searchQuery = buildSearchQueryWithDomains(
2800
+ query,
2801
+ includeDomains,
2802
+ excludeDomains
2803
+ );
2804
+ log.info("Searching", { query: searchQuery });
2805
+ const searchBody = {
2806
+ query: searchQuery,
2807
+ ...cleaned,
2808
+ origin: ORIGIN
2809
+ };
2810
+ if (isKeylessMode(session)) {
2811
+ const json = await keylessPost("/v2/search", searchBody, session);
2812
+ return asText2(json ?? {});
2813
+ }
2814
+ const client = getClient(session);
2815
+ const httpRes = await client.http.post("/v2/search", searchBody);
2816
+ return asText2(httpRes?.data ?? {});
2817
+ }
762
2818
  });
763
- const DEFAULT_CLOUD_API_URL = 'https://api.firecrawl.dev';
2819
+ var DEFAULT_CLOUD_API_URL = "https://api.firecrawl.dev";
764
2820
  function resolveApiBaseUrl() {
765
- return (process.env.FIRECRAWL_API_URL || DEFAULT_CLOUD_API_URL).replace(/\/$/, '');
766
- }
767
- // Keyless free tier: when no credential is configured and we're targeting the
768
- // Firecrawl cloud (not self-hosted via FIRECRAWL_API_URL, not the multi-tenant
769
- // CLOUD_SERVICE deployment), scrape and search are free, rate-limited per IP.
770
- // The cloud only grants this when NO Authorization header is sent, so we bypass
771
- // the SDK — which always attaches a Bearer header — and post directly.
772
- /** Best-effort end-user client IP from the incoming MCP request headers. */
2821
+ return (process.env.FIRECRAWL_API_URL || DEFAULT_CLOUD_API_URL).replace(
2822
+ /\/$/,
2823
+ ""
2824
+ );
2825
+ }
773
2826
  function extractClientIp(request) {
774
- const xff = request?.headers?.['x-forwarded-for'];
775
- const raw = Array.isArray(xff) ? xff[0] : xff;
776
- const first = typeof raw === 'string' ? raw.split(',')[0].trim() : undefined;
777
- return first || undefined;
778
- }
779
- /**
780
- * Read-only check (no quota consumed) of whether a client IP can still use the
781
- * keyless free tier, via the API's secret-gated eligibility endpoint. Fails
782
- * closed: anything other than a clear "eligible: true" means fall through to the
783
- * OAuth challenge rather than silently granting keyless.
784
- */
2827
+ const xff = request?.headers?.["x-forwarded-for"];
2828
+ const raw = Array.isArray(xff) ? xff[0] : xff;
2829
+ const first = typeof raw === "string" ? raw.split(",")[0].trim() : void 0;
2830
+ return first || void 0;
2831
+ }
785
2832
  async function keylessEligible(clientIp) {
786
- const secret = process.env.KEYLESS_PROXY_SECRET;
787
- if (!secret)
788
- return false;
789
- try {
790
- const response = await fetch(`${resolveApiBaseUrl()}/v2/keyless/eligibility`, {
791
- headers: {
792
- 'x-firecrawl-keyless-ip': clientIp,
793
- 'x-firecrawl-keyless-secret': secret,
794
- },
795
- });
796
- if (!response.ok)
797
- return false;
798
- const json = await response.json().catch(() => ({}));
799
- return json?.eligible === true;
800
- }
801
- catch {
802
- return false;
803
- }
2833
+ const secret = process.env.KEYLESS_PROXY_SECRET;
2834
+ if (!secret) return false;
2835
+ try {
2836
+ const response = await fetch(
2837
+ `${resolveApiBaseUrl()}/v2/keyless/eligibility`,
2838
+ {
2839
+ headers: {
2840
+ "x-firecrawl-keyless-ip": clientIp,
2841
+ "x-firecrawl-keyless-secret": secret
2842
+ }
2843
+ }
2844
+ );
2845
+ if (!response.ok) return false;
2846
+ const json = await response.json().catch(() => ({}));
2847
+ return json?.eligible === true;
2848
+ } catch {
2849
+ return false;
2850
+ }
804
2851
  }
805
2852
  function isKeylessMode(session) {
806
- if (session?.firecrawlApiKey)
807
- return false;
808
- if (process.env.CLOUD_SERVICE === 'true') {
809
- // Hosted: keyless only for secret-gated sessions carrying the forwarded
810
- // client IP (so the per-IP cap is meaningful, not the shared server IP).
811
- return !!session?.keylessClientIp;
812
- }
813
- // Local/stdio against the cloud (not a self-hosted FIRECRAWL_API_URL).
814
- return !process.env.FIRECRAWL_API_URL;
815
- }
816
- async function keylessPost(path, body, session) {
817
- const headers = {
818
- 'Content-Type': 'application/json',
819
- };
820
- // Forward the real client IP (secret-authenticated) when proxying keyless
821
- // requests through the hosted MCP, so the API rate-limits per real IP.
822
- if (session?.keylessClientIp && process.env.KEYLESS_PROXY_SECRET) {
823
- headers['x-firecrawl-keyless-ip'] = session.keylessClientIp;
824
- headers['x-firecrawl-keyless-secret'] = process.env.KEYLESS_PROXY_SECRET;
825
- }
826
- const response = await fetch(`${resolveApiBaseUrl()}${path}`, {
827
- method: 'POST',
828
- headers,
829
- body: JSON.stringify(body),
830
- });
831
- const json = await response.json().catch(() => ({}));
832
- if (!response.ok) {
833
- throw new Error(json?.error || `Firecrawl request failed (HTTP ${response.status})`);
834
- }
835
- return json;
836
- }
837
- const feedbackIssueSchema = z
838
- .string()
839
- .trim()
840
- .min(1)
841
- .max(80)
842
- .regex(/^[a-z0-9][a-z0-9_-]*$/, 'Issue codes must use lowercase letters, numbers, underscores, or hyphens');
843
- const valuableSourceSchema = z.object({
844
- url: z.string().url(),
845
- reason: z.string().max(1000).optional(),
2853
+ if (session?.firecrawlApiKey) return false;
2854
+ if (process.env.CLOUD_SERVICE === "true") {
2855
+ return !!session?.keylessClientIp;
2856
+ }
2857
+ return !process.env.FIRECRAWL_API_URL;
2858
+ }
2859
+ async function keylessPost(path2, body, session) {
2860
+ const headers = {
2861
+ "Content-Type": "application/json"
2862
+ };
2863
+ if (session?.keylessClientIp && process.env.KEYLESS_PROXY_SECRET) {
2864
+ headers["x-firecrawl-keyless-ip"] = session.keylessClientIp;
2865
+ headers["x-firecrawl-keyless-secret"] = process.env.KEYLESS_PROXY_SECRET;
2866
+ }
2867
+ const response = await fetch(`${resolveApiBaseUrl()}${path2}`, {
2868
+ method: "POST",
2869
+ headers,
2870
+ body: JSON.stringify(body)
2871
+ });
2872
+ const json = await response.json().catch(() => ({}));
2873
+ if (!response.ok) {
2874
+ throw new Error(
2875
+ json?.error || `Firecrawl request failed (HTTP ${response.status})`
2876
+ );
2877
+ }
2878
+ return json;
2879
+ }
2880
+ var feedbackIssueSchema = z4.string().trim().min(1).max(80).regex(
2881
+ /^[a-z0-9][a-z0-9_-]*$/,
2882
+ "Issue codes must use lowercase letters, numbers, underscores, or hyphens"
2883
+ );
2884
+ var valuableSourceSchema = z4.object({
2885
+ url: z4.string().url(),
2886
+ reason: z4.string().max(1e3).optional()
846
2887
  });
847
- const missingContentSchema = z.object({
848
- topic: z
849
- .string()
850
- .min(1, 'topic must not be empty')
851
- .max(200, 'topic must be 200 characters or fewer'),
852
- description: z.string().max(2000).optional(),
2888
+ var missingContentSchema = z4.object({
2889
+ topic: z4.string().min(1, "topic must not be empty").max(200, "topic must be 200 characters or fewer"),
2890
+ description: z4.string().max(2e3).optional()
853
2891
  });
854
- const FEEDBACK_DISABLED_VALUES = new Set(['1', 'true', 'yes', 'on']);
2892
+ var FEEDBACK_DISABLED_VALUES = /* @__PURE__ */ new Set(["1", "true", "yes", "on"]);
855
2893
  function feedbackEnvEnabled(...keys) {
856
- return keys.some((key) => FEEDBACK_DISABLED_VALUES.has((process.env[key] || '').trim().toLowerCase()));
2894
+ return keys.some(
2895
+ (key) => FEEDBACK_DISABLED_VALUES.has((process.env[key] || "").trim().toLowerCase())
2896
+ );
857
2897
  }
858
- const SEARCH_FEEDBACK_DISABLED = feedbackEnvEnabled('FIRECRAWL_NO_SEARCH_FEEDBACK', 'FIRECRAWL_DISABLE_SEARCH_FEEDBACK');
859
- const ENDPOINT_FEEDBACK_DISABLED = feedbackEnvEnabled('FIRECRAWL_NO_ENDPOINT_FEEDBACK', 'FIRECRAWL_DISABLE_ENDPOINT_FEEDBACK');
2898
+ var SEARCH_FEEDBACK_DISABLED = feedbackEnvEnabled(
2899
+ "FIRECRAWL_NO_SEARCH_FEEDBACK",
2900
+ "FIRECRAWL_DISABLE_SEARCH_FEEDBACK"
2901
+ );
2902
+ var ENDPOINT_FEEDBACK_DISABLED = feedbackEnvEnabled(
2903
+ "FIRECRAWL_NO_ENDPOINT_FEEDBACK",
2904
+ "FIRECRAWL_DISABLE_ENDPOINT_FEEDBACK"
2905
+ );
860
2906
  if (SEARCH_FEEDBACK_DISABLED) {
861
- console.error('[firecrawl-mcp] Search feedback tool disabled by FIRECRAWL_NO_SEARCH_FEEDBACK; firecrawl_search_feedback will not be registered.');
2907
+ console.error(
2908
+ "[firecrawl-mcp] Search feedback tool disabled by FIRECRAWL_NO_SEARCH_FEEDBACK; firecrawl_search_feedback will not be registered."
2909
+ );
862
2910
  }
863
2911
  if (!SEARCH_FEEDBACK_DISABLED) {
864
- server.addTool({
865
- name: 'firecrawl_search_feedback',
866
- annotations: {
867
- title: 'Send feedback on a search result',
868
- readOnlyHint: false, // POSTs structured feedback to the API, creating a server-side record.
869
- openWorldHint: true, // Feedback references open-web search results and external URLs.
870
- destructiveHint: false, // Additive only; records feedback and may refund credits, does not delete data.
871
- },
872
- description: `
2912
+ server.addTool({
2913
+ name: "firecrawl_search_feedback",
2914
+ annotations: {
2915
+ title: "Send feedback on a search result",
2916
+ readOnlyHint: false,
2917
+ // POSTs structured feedback to the API, creating a server-side record.
2918
+ openWorldHint: true,
2919
+ // Feedback references open-web search results and external URLs.
2920
+ destructiveHint: false
2921
+ // Additive only; records feedback and may refund credits, does not delete data.
2922
+ },
2923
+ description: `
873
2924
  Send structured feedback on a previous \`firecrawl_search\` result. **Call this immediately after a search where you used the results** so we can improve search quality and refund 1 credit (search costs 2).
874
2925
 
875
2926
  Pass the \`searchId\` returned by \`firecrawl_search\` (the \`id\` field on the response) and tell us:
876
2927
 
877
- - **rating** overall result quality: \`good\`, \`partial\`, or \`bad\`.
878
- - **valuableSources** which result URLs were actually useful, and a short reason why.
879
- - **missingContent** **the most important field.** An ARRAY of specific pieces of content you expected to find but didn't. One entry per missing piece, each with a short \`topic\` and an optional longer \`description\`. Examples: \`{"topic":"enterprise pricing","description":"no pricing tier table for the Enterprise plan was returned"}\`, \`{"topic":"API rate limits"}\`, \`{"topic":"comparison vs competitors"}\`. **Be specific** these aggregate across teams and tell us what to index next. Do not pack multiple topics into one entry.
880
- - **querySuggestions** how the query or response shape could be improved (e.g. "would have liked official docs first", "should boost github.com").
2928
+ - **rating** \u2014 overall result quality: \`good\`, \`partial\`, or \`bad\`.
2929
+ - **valuableSources** \u2014 which result URLs were actually useful, and a short reason why.
2930
+ - **missingContent** \u2014 **the most important field.** An ARRAY of specific pieces of content you expected to find but didn't. One entry per missing piece, each with a short \`topic\` and an optional longer \`description\`. Examples: \`{"topic":"enterprise pricing","description":"no pricing tier table for the Enterprise plan was returned"}\`, \`{"topic":"API rate limits"}\`, \`{"topic":"comparison vs competitors"}\`. **Be specific** \u2014 these aggregate across teams and tell us what to index next. Do not pack multiple topics into one entry.
2931
+ - **querySuggestions** \u2014 how the query or response shape could be improved (e.g. "would have liked official docs first", "should boost github.com").
881
2932
 
882
2933
  **Substantive-feedback requirement** (zero-effort feedback is rejected with HTTP 400):
883
- - \`good\` must include at least one \`valuableSources\` entry
884
- - \`partial\` must include \`valuableSources\` or at least one \`missingContent\` entry
885
- - \`bad\` must include at least one \`missingContent\` entry or \`querySuggestions\`
2934
+ - \`good\` \u2014 must include at least one \`valuableSources\` entry
2935
+ - \`partial\` \u2014 must include \`valuableSources\` or at least one \`missingContent\` entry
2936
+ - \`bad\` \u2014 must include at least one \`missingContent\` entry or \`querySuggestions\`
886
2937
 
887
- **Time window:** Feedback must be submitted within ~2 minutes of the search. Beyond that, the call returns HTTP 409 with \`feedbackErrorCode: "FEEDBACK_WINDOW_EXPIRED"\` do not retry, just move on. Same goes for any 4xx response: do not retry-loop.
2938
+ **Time window:** Feedback must be submitted within ~2 minutes of the search. Beyond that, the call returns HTTP 409 with \`feedbackErrorCode: "FEEDBACK_WINDOW_EXPIRED"\` \u2014 do not retry, just move on. Same goes for any 4xx response: do not retry-loop.
888
2939
 
889
2940
  **Behaviors:**
890
2941
  - Idempotent per \`searchId\`. Re-submitting for the same id returns \`alreadySubmitted: true\` with \`creditsRefunded: 0\`.
891
2942
  - Refund only applies to billable searches; preview teams are blocked.
892
2943
  - Failed searches cannot receive feedback (the search itself already returned an error you can act on).
893
- - **Daily refund cap (per team, per UTC day, default 100 credits).** Once a team's \`creditsRefundedToday\` reaches \`dailyRefundCap\`, the response returns \`dailyCapReached: true\` with \`creditsRefunded: 0\`. The feedback is still recorded for search-quality improvement only the credit refund is gated. **Stop calling this tool for the rest of the UTC day** when you see \`dailyCapReached: true\`.
2944
+ - **Daily refund cap (per team, per UTC day, default 100 credits).** Once a team's \`creditsRefundedToday\` reaches \`dailyRefundCap\`, the response returns \`dailyCapReached: true\` with \`creditsRefunded: 0\`. The feedback is still recorded for search-quality improvement \u2014 only the credit refund is gated. **Stop calling this tool for the rest of the UTC day** when you see \`dailyCapReached: true\`.
894
2945
 
895
- **When to call:** Right after processing a search result. If the result didn't help, send rating \`bad\` with a clear \`missingContent\` that is just as valuable as a \`good\` rating.
2946
+ **When to call:** Right after processing a search result. If the result didn't help, send rating \`bad\` with a clear \`missingContent\` \u2014 that is just as valuable as a \`good\` rating.
896
2947
 
897
2948
  **Usage Example (good rating with valuable sources + missing content):**
898
2949
  \`\`\`json
@@ -930,204 +2981,217 @@ Pass the \`searchId\` returned by \`firecrawl_search\` (the \`id\` field on the
930
2981
 
931
2982
  **Returns:** \`{ success, feedbackId, creditsRefunded, creditsRefundedToday, dailyRefundCap, dailyCapReached?, alreadySubmitted?, warning? }\` JSON.
932
2983
  `,
933
- parameters: z.object({
934
- searchId: z
935
- .string()
936
- .uuid('searchId must be the UUID returned by firecrawl_search'),
937
- rating: z.enum(['good', 'bad', 'partial']),
938
- valuableSources: z
939
- .array(z.object({
940
- url: z.string().url(),
941
- reason: z.string().max(1000).optional(),
942
- }))
943
- .max(50)
944
- .optional(),
945
- missingContent: z
946
- .array(z.object({
947
- topic: z
948
- .string()
949
- .min(1, 'topic must not be empty')
950
- .max(200, 'topic must be 200 characters or fewer'),
951
- description: z.string().max(2000).optional(),
952
- }))
953
- .max(20)
954
- .optional()
955
- .describe('Array of specific pieces of content the agent expected to find but did not. ' +
956
- 'One entry per distinct topic. Each entry has a short `topic` and optional ' +
957
- 'longer `description`.'),
958
- querySuggestions: z.string().max(2000).optional(),
959
- }),
960
- execute: async (args, { session, log }) => {
961
- const { searchId, rating, valuableSources, missingContent, querySuggestions, } = args;
962
- const apiBase = resolveApiBaseUrl();
963
- const endpoint = `${apiBase}/v2/search/${encodeURIComponent(searchId)}/feedback`;
964
- const body = {
965
- rating,
966
- origin: ORIGIN,
967
- };
968
- if (valuableSources && valuableSources.length > 0) {
969
- body.valuableSources = valuableSources;
970
- }
971
- if (missingContent && missingContent.length > 0) {
972
- body.missingContent = missingContent;
973
- }
974
- if (querySuggestions)
975
- body.querySuggestions = querySuggestions;
976
- const headers = {
977
- 'Content-Type': 'application/json',
978
- };
979
- const apiKey = session?.firecrawlApiKey;
980
- if (apiKey) {
981
- headers['Authorization'] = `Bearer ${apiKey}`;
982
- }
983
- else if (process.env.CLOUD_SERVICE === 'true') {
984
- throw new Error('Unauthorized: missing API key for search feedback.');
985
- }
986
- log.info('Submitting search feedback', { searchId, rating });
987
- const response = await fetch(endpoint, {
988
- method: 'POST',
989
- headers,
990
- body: JSON.stringify(body),
991
- });
992
- const responseText = await response.text();
993
- let parsed;
994
- try {
995
- parsed = JSON.parse(responseText);
996
- }
997
- catch {
998
- parsed = { raw: responseText };
999
- }
1000
- // 4xx is terminal; surface a structured payload (with retryable=false)
1001
- // so agents do not retry-loop on substantive-feedback rejections,
1002
- // expired windows, etc.
1003
- if (!response.ok) {
1004
- log.warn('Search feedback rejected', {
1005
- status: response.status,
1006
- feedbackErrorCode: parsed?.feedbackErrorCode,
1007
- });
1008
- return asText({
1009
- success: false,
1010
- status: response.status,
1011
- feedbackErrorCode: parsed?.feedbackErrorCode,
1012
- error: parsed?.error ?? `HTTP ${response.status}`,
1013
- retryable: response.status >= 500,
1014
- });
1015
- }
1016
- return asText(parsed);
1017
- },
1018
- });
2984
+ parameters: z4.object({
2985
+ searchId: z4.string().uuid("searchId must be the UUID returned by firecrawl_search"),
2986
+ rating: z4.enum(["good", "bad", "partial"]),
2987
+ valuableSources: z4.array(
2988
+ z4.object({
2989
+ url: z4.string().url(),
2990
+ reason: z4.string().max(1e3).optional()
2991
+ })
2992
+ ).max(50).optional(),
2993
+ missingContent: z4.array(
2994
+ z4.object({
2995
+ topic: z4.string().min(1, "topic must not be empty").max(200, "topic must be 200 characters or fewer"),
2996
+ description: z4.string().max(2e3).optional()
2997
+ })
2998
+ ).max(20).optional().describe(
2999
+ "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`."
3000
+ ),
3001
+ querySuggestions: z4.string().max(2e3).optional()
3002
+ }),
3003
+ execute: async (args2, { session, log }) => {
3004
+ const {
3005
+ searchId,
3006
+ rating,
3007
+ valuableSources,
3008
+ missingContent,
3009
+ querySuggestions
3010
+ } = args2;
3011
+ const apiBase = resolveApiBaseUrl();
3012
+ const endpoint = `${apiBase}/v2/search/${encodeURIComponent(
3013
+ searchId
3014
+ )}/feedback`;
3015
+ const body = {
3016
+ rating,
3017
+ origin: ORIGIN
3018
+ };
3019
+ if (valuableSources && valuableSources.length > 0) {
3020
+ body.valuableSources = valuableSources;
3021
+ }
3022
+ if (missingContent && missingContent.length > 0) {
3023
+ body.missingContent = missingContent;
3024
+ }
3025
+ if (querySuggestions) body.querySuggestions = querySuggestions;
3026
+ const headers = {
3027
+ "Content-Type": "application/json"
3028
+ };
3029
+ const apiKey = session?.firecrawlApiKey;
3030
+ if (apiKey) {
3031
+ headers["Authorization"] = `Bearer ${apiKey}`;
3032
+ } else if (process.env.CLOUD_SERVICE === "true") {
3033
+ throw new Error("Unauthorized: missing API key for search feedback.");
3034
+ }
3035
+ log.info("Submitting search feedback", { searchId, rating });
3036
+ const response = await fetch(endpoint, {
3037
+ method: "POST",
3038
+ headers,
3039
+ body: JSON.stringify(body)
3040
+ });
3041
+ const responseText = await response.text();
3042
+ let parsed;
3043
+ try {
3044
+ parsed = JSON.parse(responseText);
3045
+ } catch {
3046
+ parsed = { raw: responseText };
3047
+ }
3048
+ if (!response.ok) {
3049
+ log.warn("Search feedback rejected", {
3050
+ status: response.status,
3051
+ feedbackErrorCode: parsed?.feedbackErrorCode
3052
+ });
3053
+ return asText2({
3054
+ success: false,
3055
+ status: response.status,
3056
+ feedbackErrorCode: parsed?.feedbackErrorCode,
3057
+ error: parsed?.error ?? `HTTP ${response.status}`,
3058
+ retryable: response.status >= 500
3059
+ });
3060
+ }
3061
+ return asText2(parsed);
3062
+ }
3063
+ });
1019
3064
  }
1020
3065
  if (ENDPOINT_FEEDBACK_DISABLED) {
1021
- console.error('[firecrawl-mcp] Endpoint feedback tool disabled by FIRECRAWL_NO_ENDPOINT_FEEDBACK; firecrawl_feedback will not be registered.');
3066
+ console.error(
3067
+ "[firecrawl-mcp] Endpoint feedback tool disabled by FIRECRAWL_NO_ENDPOINT_FEEDBACK; firecrawl_feedback will not be registered."
3068
+ );
1022
3069
  }
1023
3070
  if (!ENDPOINT_FEEDBACK_DISABLED) {
1024
- server.addTool({
1025
- name: 'firecrawl_feedback',
1026
- annotations: {
1027
- title: 'Send feedback on a Firecrawl job',
1028
- readOnlyHint: false, // POSTs structured feedback for a completed job to /v2/feedback.
1029
- openWorldHint: true, // Feedback is tied to jobs that processed open-web URLs.
1030
- destructiveHint: false, // Additive only; submits ratings and notes, does not delete jobs or external content.
1031
- },
1032
- description: `
3071
+ server.addTool({
3072
+ name: "firecrawl_feedback",
3073
+ annotations: {
3074
+ title: "Send feedback on a Firecrawl job",
3075
+ readOnlyHint: false,
3076
+ // POSTs structured feedback for a completed job to /v2/feedback.
3077
+ openWorldHint: true,
3078
+ // Feedback is tied to jobs that processed open-web URLs.
3079
+ destructiveHint: false
3080
+ // Additive only; submits ratings and notes, does not delete jobs or external content.
3081
+ },
3082
+ description: `
1033
3083
  Send structured feedback for a completed Firecrawl v2 job. Use this for endpoint-level feedback on \`scrape\`, \`parse\`, \`map\`, or \`search\` jobs when the job result was useful, partially useful, or failed to meet expectations.
1034
3084
 
1035
3085
  For search-result quality specifically, prefer \`firecrawl_search_feedback\` when available because it has search-focused guidance. This generic tool posts to \`/v2/feedback\` and accepts endpoint-wide signals:
1036
3086
 
1037
- - **endpoint** one of \`search\`, \`scrape\`, \`parse\`, or \`map\`.
1038
- - **jobId** the id returned by that endpoint.
1039
- - **rating** overall result quality: \`good\`, \`partial\`, or \`bad\`.
1040
- - **issues** stable lowercase issue codes such as \`missing_markdown\`, \`bad_pdf_parse\`, or \`wrong_links\`.
1041
- - **tags** optional lowercase tags for grouping feedback.
1042
- - **note** short human-readable context. Do not include huge page contents or raw scrape results.
1043
- - **url**, **pageNumbers**, and **metadata** small contextual fields that identify what the feedback refers to.
3087
+ - **endpoint** \u2014 one of \`search\`, \`scrape\`, \`parse\`, or \`map\`.
3088
+ - **jobId** \u2014 the id returned by that endpoint.
3089
+ - **rating** \u2014 overall result quality: \`good\`, \`partial\`, or \`bad\`.
3090
+ - **issues** \u2014 stable lowercase issue codes such as \`missing_markdown\`, \`bad_pdf_parse\`, or \`wrong_links\`.
3091
+ - **tags** \u2014 optional lowercase tags for grouping feedback.
3092
+ - **note** \u2014 short human-readable context. Do not include huge page contents or raw scrape results.
3093
+ - **url**, **pageNumbers**, and **metadata** \u2014 small contextual fields that identify what the feedback refers to.
1044
3094
 
1045
3095
  Do not store multi-MB outputs in feedback. Use concise notes, issue codes, URLs, and page numbers.
1046
3096
 
1047
3097
  **Returns:** \`{ success, feedbackId, creditsRefunded, creditsRefundedToday?, dailyRefundCap?, dailyCapReached?, alreadySubmitted?, warning? }\` JSON.
1048
3098
  `,
1049
- parameters: z.object({
1050
- endpoint: z.enum(['search', 'scrape', 'parse', 'map']),
1051
- jobId: z.string().uuid('jobId must be the UUID returned by Firecrawl'),
1052
- rating: z.enum(['good', 'bad', 'partial']),
1053
- issues: z.array(feedbackIssueSchema).max(20).optional(),
1054
- tags: z.array(feedbackIssueSchema).max(20).optional(),
1055
- note: z.string().max(4000).optional(),
1056
- valuableSources: z.array(valuableSourceSchema).max(50).optional(),
1057
- missingContent: z.array(missingContentSchema).max(50).optional(),
1058
- querySuggestions: z.string().max(2000).optional(),
1059
- url: z.string().url().optional(),
1060
- pageNumbers: z.array(z.number().int().positive()).max(100).optional(),
1061
- metadata: z.record(z.string(), z.unknown()).optional(),
1062
- }),
1063
- execute: async (args, { session, log }) => {
1064
- const { endpoint, jobId, rating, issues, tags, note, valuableSources, missingContent, querySuggestions, url, pageNumbers, metadata, } = args;
1065
- const apiBase = resolveApiBaseUrl();
1066
- const headers = {
1067
- 'Content-Type': 'application/json',
1068
- };
1069
- const apiKey = session?.firecrawlApiKey;
1070
- if (apiKey) {
1071
- headers['Authorization'] = `Bearer ${apiKey}`;
1072
- }
1073
- else if (process.env.CLOUD_SERVICE === 'true') {
1074
- throw new Error('Unauthorized: missing API key for feedback.');
1075
- }
1076
- const body = removeEmptyTopLevel({
1077
- endpoint,
1078
- jobId,
1079
- rating,
1080
- issues,
1081
- tags,
1082
- note,
1083
- valuableSources,
1084
- missingContent,
1085
- querySuggestions,
1086
- url,
1087
- pageNumbers,
1088
- metadata,
1089
- origin: ORIGIN,
1090
- });
1091
- log.info('Submitting endpoint feedback', { endpoint, jobId, rating });
1092
- const response = await fetch(`${apiBase}/v2/feedback`, {
1093
- method: 'POST',
1094
- headers,
1095
- body: JSON.stringify(body),
1096
- });
1097
- const responseText = await response.text();
1098
- let parsed;
1099
- try {
1100
- parsed = JSON.parse(responseText);
1101
- }
1102
- catch {
1103
- parsed = { raw: responseText };
1104
- }
1105
- if (!response.ok) {
1106
- log.warn('Endpoint feedback rejected', {
1107
- status: response.status,
1108
- feedbackErrorCode: parsed?.feedbackErrorCode,
1109
- });
1110
- return asText({
1111
- success: false,
1112
- status: response.status,
1113
- feedbackErrorCode: parsed?.feedbackErrorCode,
1114
- error: parsed?.error ?? `HTTP ${response.status}`,
1115
- retryable: response.status >= 500,
1116
- });
1117
- }
1118
- return asText(parsed);
1119
- },
1120
- });
3099
+ parameters: z4.object({
3100
+ endpoint: z4.enum(["search", "scrape", "parse", "map"]),
3101
+ jobId: z4.string().uuid("jobId must be the UUID returned by Firecrawl"),
3102
+ rating: z4.enum(["good", "bad", "partial"]),
3103
+ issues: z4.array(feedbackIssueSchema).max(20).optional(),
3104
+ tags: z4.array(feedbackIssueSchema).max(20).optional(),
3105
+ note: z4.string().max(4e3).optional(),
3106
+ valuableSources: z4.array(valuableSourceSchema).max(50).optional(),
3107
+ missingContent: z4.array(missingContentSchema).max(50).optional(),
3108
+ querySuggestions: z4.string().max(2e3).optional(),
3109
+ url: z4.string().url().optional(),
3110
+ pageNumbers: z4.array(z4.number().int().positive()).max(100).optional(),
3111
+ metadata: z4.record(z4.string(), z4.unknown()).optional()
3112
+ }),
3113
+ execute: async (args2, { session, log }) => {
3114
+ const {
3115
+ endpoint,
3116
+ jobId,
3117
+ rating,
3118
+ issues,
3119
+ tags,
3120
+ note,
3121
+ valuableSources,
3122
+ missingContent,
3123
+ querySuggestions,
3124
+ url,
3125
+ pageNumbers,
3126
+ metadata
3127
+ } = args2;
3128
+ const apiBase = resolveApiBaseUrl();
3129
+ const headers = {
3130
+ "Content-Type": "application/json"
3131
+ };
3132
+ const apiKey = session?.firecrawlApiKey;
3133
+ if (apiKey) {
3134
+ headers["Authorization"] = `Bearer ${apiKey}`;
3135
+ } else if (process.env.CLOUD_SERVICE === "true") {
3136
+ throw new Error("Unauthorized: missing API key for feedback.");
3137
+ }
3138
+ const body = removeEmptyTopLevel({
3139
+ endpoint,
3140
+ jobId,
3141
+ rating,
3142
+ issues,
3143
+ tags,
3144
+ note,
3145
+ valuableSources,
3146
+ missingContent,
3147
+ querySuggestions,
3148
+ url,
3149
+ pageNumbers,
3150
+ metadata,
3151
+ origin: ORIGIN
3152
+ });
3153
+ log.info("Submitting endpoint feedback", { endpoint, jobId, rating });
3154
+ const response = await fetch(`${apiBase}/v2/feedback`, {
3155
+ method: "POST",
3156
+ headers,
3157
+ body: JSON.stringify(body)
3158
+ });
3159
+ const responseText = await response.text();
3160
+ let parsed;
3161
+ try {
3162
+ parsed = JSON.parse(responseText);
3163
+ } catch {
3164
+ parsed = { raw: responseText };
3165
+ }
3166
+ if (!response.ok) {
3167
+ log.warn("Endpoint feedback rejected", {
3168
+ status: response.status,
3169
+ feedbackErrorCode: parsed?.feedbackErrorCode
3170
+ });
3171
+ return asText2({
3172
+ success: false,
3173
+ status: response.status,
3174
+ feedbackErrorCode: parsed?.feedbackErrorCode,
3175
+ error: parsed?.error ?? `HTTP ${response.status}`,
3176
+ retryable: response.status >= 500
3177
+ });
3178
+ }
3179
+ return asText2(parsed);
3180
+ }
3181
+ });
1121
3182
  }
1122
3183
  server.addTool({
1123
- name: 'firecrawl_crawl',
1124
- annotations: {
1125
- title: 'Start a site crawl',
1126
- readOnlyHint: false, // Starts an asynchronous crawl job, creating a persistent server-side job.
1127
- openWorldHint: true, // Crawls user-specified URLs across the public web.
1128
- destructiveHint: false, // Reads pages from target sites; does not delete or alter external websites.
1129
- },
1130
- description: `
3184
+ name: "firecrawl_crawl",
3185
+ annotations: {
3186
+ title: "Start a site crawl",
3187
+ readOnlyHint: false,
3188
+ // Starts an asynchronous crawl job, creating a persistent server-side job.
3189
+ openWorldHint: true,
3190
+ // Crawls user-specified URLs across the public web.
3191
+ destructiveHint: false
3192
+ // Reads pages from target sites; does not delete or alter external websites.
3193
+ },
3194
+ description: `
1131
3195
  Starts a crawl job on a website and extracts content from all pages.
1132
3196
 
1133
3197
  **Best for:** Extracting content from multiple related pages, when you need comprehensive coverage.
@@ -1150,62 +3214,62 @@ server.addTool({
1150
3214
  }
1151
3215
  \`\`\`
1152
3216
  **Returns:** Operation ID for status checking; use firecrawl_check_crawl_status to check progress.
1153
- ${SAFE_MODE
1154
- ? '**Safe Mode:** Read-only crawling. Webhooks and interactive actions are disabled for security.'
1155
- : ''}
3217
+ ${SAFE_MODE ? "**Safe Mode:** Read-only crawling. Webhooks and interactive actions are disabled for security." : ""}
1156
3218
  `,
1157
- parameters: z.object({
1158
- url: z.string(),
1159
- prompt: z.string().optional(),
1160
- excludePaths: z.array(z.string()).optional(),
1161
- includePaths: z.array(z.string()).optional(),
1162
- maxDiscoveryDepth: z.number().optional(),
1163
- sitemap: z.enum(['skip', 'include', 'only']).optional(),
1164
- limit: z.number().optional(),
1165
- allowExternalLinks: z.boolean().optional(),
1166
- allowSubdomains: z.boolean().optional(),
1167
- crawlEntireDomain: z.boolean().optional(),
1168
- delay: z.number().optional(),
1169
- maxConcurrency: z.number().optional(),
1170
- ...(SAFE_MODE
1171
- ? {}
1172
- : {
1173
- webhook: z.string().optional(),
1174
- webhookHeaders: z.record(z.string(), z.string()).optional(),
1175
- }),
1176
- deduplicateSimilarURLs: z.boolean().optional(),
1177
- ignoreQueryParameters: z.boolean().optional(),
1178
- scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional(),
1179
- }),
1180
- execute: async (args, { session, log }) => {
1181
- const { url, ...options } = args;
1182
- const client = getClient(session);
1183
- const opts = { ...options };
1184
- if (opts.scrapeOptions) {
1185
- opts.scrapeOptions = transformScrapeParams(opts.scrapeOptions);
1186
- }
1187
- const webhook = buildWebhook(opts);
1188
- if (webhook)
1189
- opts.webhook = webhook;
1190
- delete opts.webhookHeaders;
1191
- const cleaned = removeEmptyTopLevel(opts);
1192
- log.info('Starting crawl', { url: String(url) });
1193
- const res = await client.crawl(String(url), {
1194
- ...cleaned,
1195
- origin: ORIGIN,
1196
- });
1197
- return asText(res);
3219
+ parameters: z4.object({
3220
+ url: z4.string(),
3221
+ prompt: z4.string().optional(),
3222
+ excludePaths: z4.array(z4.string()).optional(),
3223
+ includePaths: z4.array(z4.string()).optional(),
3224
+ maxDiscoveryDepth: z4.number().optional(),
3225
+ sitemap: z4.enum(["skip", "include", "only"]).optional(),
3226
+ limit: z4.number().optional(),
3227
+ allowExternalLinks: z4.boolean().optional(),
3228
+ allowSubdomains: z4.boolean().optional(),
3229
+ crawlEntireDomain: z4.boolean().optional(),
3230
+ delay: z4.number().optional(),
3231
+ maxConcurrency: z4.number().optional(),
3232
+ ...SAFE_MODE ? {} : {
3233
+ webhook: z4.string().optional(),
3234
+ webhookHeaders: z4.record(z4.string(), z4.string()).optional()
1198
3235
  },
3236
+ deduplicateSimilarURLs: z4.boolean().optional(),
3237
+ ignoreQueryParameters: z4.boolean().optional(),
3238
+ scrapeOptions: scrapeParamsSchema.omit({ url: true }).partial().optional()
3239
+ }),
3240
+ execute: async (args2, { session, log }) => {
3241
+ const { url, ...options } = args2;
3242
+ const client = getClient(session);
3243
+ const opts = { ...options };
3244
+ if (opts.scrapeOptions) {
3245
+ opts.scrapeOptions = transformScrapeParams(
3246
+ opts.scrapeOptions
3247
+ );
3248
+ }
3249
+ const webhook = buildWebhook(opts);
3250
+ if (webhook) opts.webhook = webhook;
3251
+ delete opts.webhookHeaders;
3252
+ const cleaned = removeEmptyTopLevel(opts);
3253
+ log.info("Starting crawl", { url: String(url) });
3254
+ const res = await client.crawl(String(url), {
3255
+ ...cleaned,
3256
+ origin: ORIGIN
3257
+ });
3258
+ return asText2(res);
3259
+ }
1199
3260
  });
1200
3261
  server.addTool({
1201
- name: 'firecrawl_check_crawl_status',
1202
- annotations: {
1203
- title: 'Get crawl status',
1204
- readOnlyHint: true, // Retrieves status and results for an existing crawl job by ID; no mutations.
1205
- openWorldHint: false, // Queries only Firecrawl job state within the authenticated account.
1206
- destructiveHint: false, // Status lookup only; no deletes or updates.
1207
- },
1208
- description: `
3262
+ name: "firecrawl_check_crawl_status",
3263
+ annotations: {
3264
+ title: "Get crawl status",
3265
+ readOnlyHint: true,
3266
+ // Retrieves status and results for an existing crawl job by ID; no mutations.
3267
+ openWorldHint: false,
3268
+ // Queries only Firecrawl job state within the authenticated account.
3269
+ destructiveHint: false
3270
+ // Status lookup only; no deletes or updates.
3271
+ },
3272
+ description: `
1209
3273
  Check the status of a crawl job.
1210
3274
 
1211
3275
  **Usage Example:**
@@ -1219,22 +3283,25 @@ Check the status of a crawl job.
1219
3283
  \`\`\`
1220
3284
  **Returns:** Status and progress of the crawl job, including results if available.
1221
3285
  `,
1222
- parameters: z.object({ id: z.string() }),
1223
- execute: async (args, { session }) => {
1224
- const client = getClient(session);
1225
- const res = await client.getCrawlStatus(args.id);
1226
- return asText(res);
1227
- },
3286
+ parameters: z4.object({ id: z4.string() }),
3287
+ execute: async (args2, { session }) => {
3288
+ const client = getClient(session);
3289
+ const res = await client.getCrawlStatus(args2.id);
3290
+ return asText2(res);
3291
+ }
1228
3292
  });
1229
3293
  server.addTool({
1230
- name: 'firecrawl_extract',
1231
- annotations: {
1232
- title: 'Extract structured data',
1233
- readOnlyHint: true, // Uses LLM extraction to pull structured data from URLs without modifying those sites.
1234
- openWorldHint: true, // Accepts arbitrary user-supplied URLs on the public web.
1235
- destructiveHint: false, // Read-only extraction; no destructive changes to external content.
1236
- },
1237
- description: `
3294
+ name: "firecrawl_extract",
3295
+ annotations: {
3296
+ title: "Extract structured data",
3297
+ readOnlyHint: true,
3298
+ // Uses LLM extraction to pull structured data from URLs without modifying those sites.
3299
+ openWorldHint: true,
3300
+ // Accepts arbitrary user-supplied URLs on the public web.
3301
+ destructiveHint: false
3302
+ // Read-only extraction; no destructive changes to external content.
3303
+ },
3304
+ description: `
1238
3305
  Extract structured information from web pages using LLM capabilities. Supports both cloud AI and self-hosted LLM extraction.
1239
3306
 
1240
3307
  **Best for:** Extracting specific structured data like prices, names, details from web pages.
@@ -1271,48 +3338,51 @@ Extract structured information from web pages using LLM capabilities. Supports b
1271
3338
  \`\`\`
1272
3339
  **Returns:** Extracted structured data as defined by your schema.
1273
3340
  `,
1274
- parameters: z.object({
1275
- urls: z.array(z.string()),
1276
- prompt: z.string().optional(),
1277
- schema: z.record(z.string(), z.any()).optional(),
1278
- allowExternalLinks: z.boolean().optional(),
1279
- enableWebSearch: z.boolean().optional(),
1280
- includeSubdomains: z.boolean().optional(),
1281
- }),
1282
- execute: async (args, { session, log }) => {
1283
- const client = getClient(session);
1284
- const a = args;
1285
- log.info('Extracting from URLs', {
1286
- count: Array.isArray(a.urls) ? a.urls.length : 0,
1287
- });
1288
- const extractBody = removeEmptyTopLevel({
1289
- urls: a.urls,
1290
- prompt: a.prompt,
1291
- schema: a.schema || undefined,
1292
- allowExternalLinks: a.allowExternalLinks,
1293
- enableWebSearch: a.enableWebSearch,
1294
- includeSubdomains: a.includeSubdomains,
1295
- origin: ORIGIN,
1296
- });
1297
- const res = await client.extract(extractBody);
1298
- return asText(res);
1299
- },
3341
+ parameters: z4.object({
3342
+ urls: z4.array(z4.string()),
3343
+ prompt: z4.string().optional(),
3344
+ schema: z4.record(z4.string(), z4.any()).optional(),
3345
+ allowExternalLinks: z4.boolean().optional(),
3346
+ enableWebSearch: z4.boolean().optional(),
3347
+ includeSubdomains: z4.boolean().optional()
3348
+ }),
3349
+ execute: async (args2, { session, log }) => {
3350
+ const client = getClient(session);
3351
+ const a = args2;
3352
+ log.info("Extracting from URLs", {
3353
+ count: Array.isArray(a.urls) ? a.urls.length : 0
3354
+ });
3355
+ const extractBody = removeEmptyTopLevel({
3356
+ urls: a.urls,
3357
+ prompt: a.prompt,
3358
+ schema: a.schema || void 0,
3359
+ allowExternalLinks: a.allowExternalLinks,
3360
+ enableWebSearch: a.enableWebSearch,
3361
+ includeSubdomains: a.includeSubdomains,
3362
+ origin: ORIGIN
3363
+ });
3364
+ const res = await client.extract(extractBody);
3365
+ return asText2(res);
3366
+ }
1300
3367
  });
1301
3368
  server.addTool({
1302
- name: 'firecrawl_agent',
1303
- annotations: {
1304
- title: 'Start a research agent',
1305
- readOnlyHint: false, // Starts an autonomous research agent job on the Firecrawl API.
1306
- openWorldHint: true, // The agent browses and searches the open web to fulfill the prompt.
1307
- destructiveHint: false, // Gathers information only; does not delete external data or user resources.
1308
- },
1309
- description: `
3369
+ name: "firecrawl_agent",
3370
+ annotations: {
3371
+ title: "Start a research agent",
3372
+ readOnlyHint: false,
3373
+ // Starts an autonomous research agent job on the Firecrawl API.
3374
+ openWorldHint: true,
3375
+ // The agent browses and searches the open web to fulfill the prompt.
3376
+ destructiveHint: false
3377
+ // Gathers information only; does not delete external data or user resources.
3378
+ },
3379
+ description: `
1310
3380
  Autonomous web research agent. This is a separate AI agent layer that independently browses the internet, searches for information, navigates through pages, and extracts structured data based on your query. You describe what you need, and the agent figures out where to find it.
1311
3381
 
1312
3382
  **How it works:** The agent performs web searches, follows links, reads pages, and gathers data autonomously. This runs **asynchronously** - it returns a job ID immediately, and you poll \`firecrawl_agent_status\` to check when complete and retrieve results.
1313
3383
 
1314
3384
  **IMPORTANT - Async workflow with patient polling:**
1315
- 1. Call \`firecrawl_agent\` with your prompt/schema returns job ID immediately
3385
+ 1. Call \`firecrawl_agent\` with your prompt/schema \u2192 returns job ID immediately
1316
3386
  2. Poll \`firecrawl_agent_status\` with the job ID to check progress
1317
3387
  3. **Keep polling for at least 2-3 minutes** - agent research typically takes 1-5 minutes for complex queries
1318
3388
  4. Poll every 15-30 seconds until status is "completed" or "failed"
@@ -1375,39 +3445,42 @@ Then poll with \`firecrawl_agent_status\` every 15-30 seconds for at least 2-3 m
1375
3445
  \`\`\`
1376
3446
  **Returns:** Job ID for status checking. Use \`firecrawl_agent_status\` to poll for results.
1377
3447
  `,
1378
- parameters: z.object({
1379
- prompt: z.string().min(1).max(10000),
1380
- urls: z.array(z.string().url()).optional(),
1381
- schema: z.record(z.string(), z.any()).optional(),
1382
- }),
1383
- execute: async (args, { session, log }) => {
1384
- const client = getClient(session);
1385
- const a = args;
1386
- log.info('Starting agent', {
1387
- prompt: a.prompt.substring(0, 100),
1388
- urlCount: Array.isArray(a.urls) ? a.urls.length : 0,
1389
- });
1390
- const agentBody = removeEmptyTopLevel({
1391
- prompt: a.prompt,
1392
- urls: a.urls,
1393
- schema: a.schema || undefined,
1394
- });
1395
- const res = await client.startAgent({
1396
- ...agentBody,
1397
- origin: ORIGIN,
1398
- });
1399
- return asText(res);
1400
- },
3448
+ parameters: z4.object({
3449
+ prompt: z4.string().min(1).max(1e4),
3450
+ urls: z4.array(z4.string().url()).optional(),
3451
+ schema: z4.record(z4.string(), z4.any()).optional()
3452
+ }),
3453
+ execute: async (args2, { session, log }) => {
3454
+ const client = getClient(session);
3455
+ const a = args2;
3456
+ log.info("Starting agent", {
3457
+ prompt: a.prompt.substring(0, 100),
3458
+ urlCount: Array.isArray(a.urls) ? a.urls.length : 0
3459
+ });
3460
+ const agentBody = removeEmptyTopLevel({
3461
+ prompt: a.prompt,
3462
+ urls: a.urls,
3463
+ schema: a.schema || void 0
3464
+ });
3465
+ const res = await client.startAgent({
3466
+ ...agentBody,
3467
+ origin: ORIGIN
3468
+ });
3469
+ return asText2(res);
3470
+ }
1401
3471
  });
1402
3472
  server.addTool({
1403
- name: 'firecrawl_agent_status',
1404
- annotations: {
1405
- title: 'Get agent job status',
1406
- readOnlyHint: true, // Polls an existing agent job by ID for progress and results; no mutations.
1407
- openWorldHint: false, // Queries only Firecrawl job state by job ID within the user's account.
1408
- destructiveHint: false, // Read-only status check.
1409
- },
1410
- description: `
3473
+ name: "firecrawl_agent_status",
3474
+ annotations: {
3475
+ title: "Get agent job status",
3476
+ readOnlyHint: true,
3477
+ // Polls an existing agent job by ID for progress and results; no mutations.
3478
+ openWorldHint: false,
3479
+ // Queries only Firecrawl job state by job ID within the user's account.
3480
+ destructiveHint: false
3481
+ // Read-only status check.
3482
+ },
3483
+ description: `
1411
3484
  Check the status of an agent job and retrieve results when complete. Use this to poll for results after starting an agent with \`firecrawl_agent\`.
1412
3485
 
1413
3486
  **IMPORTANT - Be patient with polling:**
@@ -1432,28 +3505,30 @@ Check the status of an agent job and retrieve results when complete. Use this to
1432
3505
 
1433
3506
  **Returns:** Status, progress, and results (if completed) of the agent job.
1434
3507
  `,
1435
- parameters: z.object({ id: z.string() }),
1436
- execute: async (args, { session, log }) => {
1437
- const client = getClient(session);
1438
- const { id } = args;
1439
- log.info('Checking agent status', { id });
1440
- const res = await client.getAgentStatus(id);
1441
- return asText(res);
1442
- },
3508
+ parameters: z4.object({ id: z4.string() }),
3509
+ execute: async (args2, { session, log }) => {
3510
+ const client = getClient(session);
3511
+ const { id } = args2;
3512
+ log.info("Checking agent status", { id });
3513
+ const res = await client.getAgentStatus(id);
3514
+ return asText2(res);
3515
+ }
1443
3516
  });
1444
- // Interact tools (scrape-bound browser sessions)
1445
3517
  server.addTool({
1446
- name: 'firecrawl_interact',
1447
- annotations: {
1448
- title: 'Interact with a scraped page',
1449
- readOnlyHint: false, // Executes browser interactions (clicks, form input, scripts) in a live session.
1450
- openWorldHint: true, // Interacts with pages on the public web via the scraped session.
1451
- destructiveHint: false, // Transient page interactions only; does not delete monitors, jobs, or external sites.
1452
- },
1453
- description: `
3518
+ name: "firecrawl_interact",
3519
+ annotations: {
3520
+ title: "Interact with a scraped page",
3521
+ readOnlyHint: false,
3522
+ // Executes browser interactions (clicks, form input, scripts) in a live session.
3523
+ openWorldHint: true,
3524
+ // Interacts with pages on the public web via the scraped session.
3525
+ destructiveHint: false
3526
+ // Transient page interactions only; does not delete monitors, jobs, or external sites.
3527
+ },
3528
+ description: `
1454
3529
  Interact with a previously scraped page in a live browser session. Scrape a page first with firecrawl_scrape, then use the returned scrapeId to click buttons, fill forms, extract dynamic content, or navigate deeper.
1455
3530
 
1456
- **Best for:** Multi-step workflows on a single page searching a site, clicking through results, filling forms, extracting data that requires interaction.
3531
+ **Best for:** Multi-step workflows on a single page \u2014 searching a site, clicking through results, filling forms, extracting data that requires interaction.
1457
3532
  **Requires:** A scrapeId from a previous firecrawl_scrape call (found in the metadata of the scrape response).
1458
3533
 
1459
3534
  **Arguments:**
@@ -1487,43 +3562,40 @@ Interact with a previously scraped page in a live browser session. Scrape a page
1487
3562
  \`\`\`
1488
3563
  **Returns:** Execution result including output, stdout, stderr, exit code, and live view URLs.
1489
3564
  `,
1490
- parameters: z
1491
- .object({
1492
- scrapeId: z.string(),
1493
- prompt: z.string().optional(),
1494
- code: z.string().optional(),
1495
- language: z.enum(['bash', 'python', 'node']).optional(),
1496
- timeout: z.number().min(1).max(300).optional(),
1497
- })
1498
- .refine((data) => data.code || data.prompt, {
1499
- message: "Either 'code' or 'prompt' must be provided.",
1500
- }),
1501
- execute: async (args, { session, log }) => {
1502
- const client = getClient(session);
1503
- const { scrapeId, prompt, code, language, timeout } = args;
1504
- log.info('Interacting with scraped page', { scrapeId });
1505
- const interactArgs = { origin: ORIGIN };
1506
- if (prompt)
1507
- interactArgs.prompt = prompt;
1508
- if (code)
1509
- interactArgs.code = code;
1510
- if (language)
1511
- interactArgs.language = language;
1512
- if (timeout != null)
1513
- interactArgs.timeout = timeout;
1514
- const res = await client.interact(scrapeId, interactArgs);
1515
- return asText(res);
1516
- },
3565
+ parameters: z4.object({
3566
+ scrapeId: z4.string(),
3567
+ prompt: z4.string().optional(),
3568
+ code: z4.string().optional(),
3569
+ language: z4.enum(["bash", "python", "node"]).optional(),
3570
+ timeout: z4.number().min(1).max(300).optional()
3571
+ }).refine((data) => data.code || data.prompt, {
3572
+ message: "Either 'code' or 'prompt' must be provided."
3573
+ }),
3574
+ execute: async (args2, { session, log }) => {
3575
+ const client = getClient(session);
3576
+ const { scrapeId, prompt, code, language, timeout } = args2;
3577
+ log.info("Interacting with scraped page", { scrapeId });
3578
+ const interactArgs = { origin: ORIGIN };
3579
+ if (prompt) interactArgs.prompt = prompt;
3580
+ if (code) interactArgs.code = code;
3581
+ if (language) interactArgs.language = language;
3582
+ if (timeout != null) interactArgs.timeout = timeout;
3583
+ const res = await client.interact(scrapeId, interactArgs);
3584
+ return asText2(res);
3585
+ }
1517
3586
  });
1518
3587
  server.addTool({
1519
- name: 'firecrawl_interact_stop',
1520
- annotations: {
1521
- title: 'Stop interact session',
1522
- readOnlyHint: false, // Calls the API to stop and tear down an active interact session.
1523
- openWorldHint: false, // Operates only on a known Firecrawl scrape/interact session ID.
1524
- destructiveHint: true, // Terminates the live browser session; this end state cannot be resumed.
1525
- },
1526
- description: `
3588
+ name: "firecrawl_interact_stop",
3589
+ annotations: {
3590
+ title: "Stop interact session",
3591
+ readOnlyHint: false,
3592
+ // Calls the API to stop and tear down an active interact session.
3593
+ openWorldHint: false,
3594
+ // Operates only on a known Firecrawl scrape/interact session ID.
3595
+ destructiveHint: true
3596
+ // Terminates the live browser session; this end state cannot be resumed.
3597
+ },
3598
+ description: `
1527
3599
  Stop an interact session for a scraped page. Call this when you are done interacting to free resources.
1528
3600
 
1529
3601
  **Usage Example:**
@@ -1537,100 +3609,93 @@ Stop an interact session for a scraped page. Call this when you are done interac
1537
3609
  \`\`\`
1538
3610
  **Returns:** Success confirmation.
1539
3611
  `,
1540
- parameters: z.object({
1541
- scrapeId: z.string(),
1542
- }),
1543
- execute: async (args, { session, log }) => {
1544
- const client = getClient(session);
1545
- const { scrapeId } = args;
1546
- log.info('Stopping interact session', { scrapeId });
1547
- const res = await client.stopInteraction(scrapeId);
1548
- return asText(res);
1549
- },
3612
+ parameters: z4.object({
3613
+ scrapeId: z4.string()
3614
+ }),
3615
+ execute: async (args2, { session, log }) => {
3616
+ const client = getClient(session);
3617
+ const { scrapeId } = args2;
3618
+ log.info("Stopping interact session", { scrapeId });
3619
+ const res = await client.stopInteraction(scrapeId);
3620
+ return asText2(res);
3621
+ }
1550
3622
  });
1551
- // Local-only: parse a local file via the self-hosted Firecrawl /v2/parse endpoint.
1552
- // The parse endpoint is only exposed on self-hosted/local Firecrawl API deployments,
1553
- // so this tool is registered only when the MCP is NOT running in cloud mode.
1554
- if (process.env.CLOUD_SERVICE !== 'true') {
1555
- const parseParamsSchema = z.object({
1556
- filePath: z
1557
- .string()
1558
- .min(1)
1559
- .describe('Absolute or relative path to a local file to parse. Supported: .html, .htm, .pdf, .docx, .doc, .odt, .rtf, .xlsx, .xls'),
1560
- contentType: z
1561
- .string()
1562
- .optional()
1563
- .describe('Optional MIME type override. If omitted, the server infers the file kind from the extension.'),
1564
- formats: z
1565
- .array(z.enum([
1566
- 'markdown',
1567
- 'html',
1568
- 'rawHtml',
1569
- 'links',
1570
- 'summary',
1571
- 'json',
1572
- 'query',
1573
- ]))
1574
- .optional(),
1575
- jsonOptions: z
1576
- .object({
1577
- prompt: z.string().optional(),
1578
- schema: z.record(z.string(), z.any()).optional(),
1579
- })
1580
- .optional(),
1581
- queryOptions: z
1582
- .object({
1583
- prompt: z.string().max(10000),
1584
- mode: z.enum(['directQuote', 'freeform']).default('freeform'),
1585
- })
1586
- .optional(),
1587
- parsers: z.array(z.enum(['pdf'])).optional(),
1588
- pdfOptions: z
1589
- .object({
1590
- maxPages: z.number().int().min(1).max(10000).optional(),
1591
- })
1592
- .optional(),
1593
- onlyMainContent: z.boolean().optional(),
1594
- redactPII: z.boolean().optional(),
1595
- includeTags: z.array(z.string()).optional(),
1596
- excludeTags: z.array(z.string()).optional(),
1597
- removeBase64Images: z.boolean().optional(),
1598
- skipTlsVerification: z.boolean().optional(),
1599
- storeInCache: z.boolean().optional(),
1600
- zeroDataRetention: z.boolean().optional(),
1601
- maxAge: z.number().optional(),
1602
- proxy: z.enum(['basic', 'auto']).optional(),
1603
- });
1604
- const EXTENSION_CONTENT_TYPES = {
1605
- '.html': 'text/html',
1606
- '.htm': 'text/html',
1607
- '.pdf': 'application/pdf',
1608
- '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
1609
- '.doc': 'application/msword',
1610
- '.odt': 'application/vnd.oasis.opendocument.text',
1611
- '.rtf': 'application/rtf',
1612
- '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1613
- '.xls': 'application/vnd.ms-excel',
1614
- };
1615
- function inferContentType(filename) {
1616
- const ext = path.extname(filename).toLowerCase();
1617
- return EXTENSION_CONTENT_TYPES[ext] ?? 'application/octet-stream';
1618
- }
1619
- server.addTool({
1620
- name: 'firecrawl_parse',
1621
- annotations: {
1622
- title: 'Parse a local file',
1623
- readOnlyHint: true, // Reads and parses a local file; does not modify the file on disk.
1624
- openWorldHint: false, // Operates on a local filesystem path, not the open web.
1625
- destructiveHint: false, // Read-only parsing; no deletion or writes to the source file.
1626
- },
1627
- description: `
3623
+ if (process.env.CLOUD_SERVICE !== "true") {
3624
+ let inferContentType = function(filename) {
3625
+ const ext = path.extname(filename).toLowerCase();
3626
+ return EXTENSION_CONTENT_TYPES[ext] ?? "application/octet-stream";
3627
+ };
3628
+ inferContentType2 = inferContentType;
3629
+ const parseParamsSchema = z4.object({
3630
+ filePath: z4.string().min(1).describe(
3631
+ "Absolute or relative path to a local file to parse. Supported: .html, .htm, .pdf, .docx, .doc, .odt, .rtf, .xlsx, .xls"
3632
+ ),
3633
+ contentType: z4.string().optional().describe(
3634
+ "Optional MIME type override. If omitted, the server infers the file kind from the extension."
3635
+ ),
3636
+ formats: z4.array(
3637
+ z4.enum([
3638
+ "markdown",
3639
+ "html",
3640
+ "rawHtml",
3641
+ "links",
3642
+ "summary",
3643
+ "json",
3644
+ "query"
3645
+ ])
3646
+ ).optional(),
3647
+ jsonOptions: z4.object({
3648
+ prompt: z4.string().optional(),
3649
+ schema: z4.record(z4.string(), z4.any()).optional()
3650
+ }).optional(),
3651
+ queryOptions: z4.object({
3652
+ prompt: z4.string().max(1e4),
3653
+ mode: z4.enum(["directQuote", "freeform"]).default("freeform")
3654
+ }).optional(),
3655
+ parsers: z4.array(z4.enum(["pdf"])).optional(),
3656
+ pdfOptions: z4.object({
3657
+ maxPages: z4.number().int().min(1).max(1e4).optional()
3658
+ }).optional(),
3659
+ onlyMainContent: z4.boolean().optional(),
3660
+ redactPII: z4.boolean().optional(),
3661
+ includeTags: z4.array(z4.string()).optional(),
3662
+ excludeTags: z4.array(z4.string()).optional(),
3663
+ removeBase64Images: z4.boolean().optional(),
3664
+ skipTlsVerification: z4.boolean().optional(),
3665
+ storeInCache: z4.boolean().optional(),
3666
+ zeroDataRetention: z4.boolean().optional(),
3667
+ maxAge: z4.number().optional(),
3668
+ proxy: z4.enum(["basic", "auto"]).optional()
3669
+ });
3670
+ const EXTENSION_CONTENT_TYPES = {
3671
+ ".html": "text/html",
3672
+ ".htm": "text/html",
3673
+ ".pdf": "application/pdf",
3674
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
3675
+ ".doc": "application/msword",
3676
+ ".odt": "application/vnd.oasis.opendocument.text",
3677
+ ".rtf": "application/rtf",
3678
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
3679
+ ".xls": "application/vnd.ms-excel"
3680
+ };
3681
+ server.addTool({
3682
+ name: "firecrawl_parse",
3683
+ annotations: {
3684
+ title: "Parse a local file",
3685
+ readOnlyHint: true,
3686
+ // Reads and parses a local file; does not modify the file on disk.
3687
+ openWorldHint: false,
3688
+ // Operates on a local filesystem path, not the open web.
3689
+ destructiveHint: false
3690
+ // Read-only parsing; no deletion or writes to the source file.
3691
+ },
3692
+ description: `
1628
3693
  Parse a file from the local filesystem using a self-hosted Firecrawl API's /v2/parse endpoint.
1629
- This is the fastest and most reliable way to extract content from a document on disk if the file lives locally and the MCP is pointed at a self-hosted Firecrawl instance, you should always prefer this tool over uploading the file elsewhere and then scraping it.
3694
+ This is the fastest and most reliable way to extract content from a document on disk \u2014 if the file lives locally and the MCP is pointed at a self-hosted Firecrawl instance, you should always prefer this tool over uploading the file elsewhere and then scraping it.
1630
3695
 
1631
3696
  **Best for:** Extracting content from a local document (PDF, Word, Excel, HTML, etc.) when you don't want to host it on the public web first; pulling structured data out of a file with JSON format; converting binary documents into markdown for downstream reasoning.
1632
- **Not recommended for:** Remote URLs (use firecrawl_scrape); multiple files at once (call parse multiple times); documents that require interactive actions, screenshots, or change tracking those aren't supported by the parse endpoint.
1633
- **Common mistakes:** Passing a URL instead of a local file path; requesting an unsupported format (screenshot, branding, changeTracking); setting waitFor, location, mobile, or a non-basic/auto proxy parse uploads reject all of those.
3697
+ **Not recommended for:** Remote URLs (use firecrawl_scrape); multiple files at once (call parse multiple times); documents that require interactive actions, screenshots, or change tracking \u2014 those aren't supported by the parse endpoint.
3698
+ **Common mistakes:** Passing a URL instead of a local file path; requesting an unsupported format (screenshot, branding, changeTracking); setting waitFor, location, mobile, or a non-basic/auto proxy \u2014 parse uploads reject all of those.
1634
3699
 
1635
3700
  **Supported file types:** .html, .htm, .xhtml, .pdf, .docx, .doc, .odt, .rtf, .xlsx, .xls
1636
3701
  **Unsupported options:** actions, screenshot/branding/changeTracking formats, waitFor > 0, location, mobile, proxy values other than "auto" or "basic".
@@ -1696,79 +3761,81 @@ Add \`"parsers": ["pdf"]\` (optionally with \`pdfOptions.maxPages\`) when parsin
1696
3761
  \`\`\`
1697
3762
  **Returns:** A parsed document with markdown, html, links, summary, json, or query results depending on the requested formats.
1698
3763
  `,
1699
- parameters: parseParamsSchema,
1700
- execute: async (args, { session, log }) => {
1701
- const apiUrl = process.env.FIRECRAWL_API_URL;
1702
- if (!apiUrl) {
1703
- throw new Error('firecrawl_parse requires FIRECRAWL_API_URL to be set to a self-hosted Firecrawl API instance.');
1704
- }
1705
- const { filePath, contentType: overrideContentType, ...options } = args;
1706
- const absPath = path.resolve(filePath);
1707
- const buffer = await readFile(absPath);
1708
- const filename = path.basename(absPath);
1709
- const fileContentType = overrideContentType && overrideContentType.length > 0
1710
- ? overrideContentType
1711
- : inferContentType(filename);
1712
- const transformed = transformScrapeParams(options);
1713
- const cleaned = removeEmptyTopLevel(transformed);
1714
- const optionsPayload = { origin: ORIGIN, ...cleaned };
1715
- const form = new FormData();
1716
- const blob = new Blob([new Uint8Array(buffer)], {
1717
- type: fileContentType,
1718
- });
1719
- form.append('file', blob, filename);
1720
- form.append('options', JSON.stringify(optionsPayload));
1721
- const headers = {};
1722
- const apiKey = session?.firecrawlApiKey;
1723
- if (apiKey) {
1724
- headers['Authorization'] = `Bearer ${apiKey}`;
1725
- }
1726
- const endpoint = `${apiUrl.replace(/\/$/, '')}/v2/parse`;
1727
- log.info('Parsing local file', {
1728
- endpoint,
1729
- filename,
1730
- size: buffer.length,
1731
- });
1732
- const response = await fetch(endpoint, {
1733
- method: 'POST',
1734
- headers,
1735
- body: form,
1736
- });
1737
- const responseText = await response.text();
1738
- if (!response.ok) {
1739
- throw new Error(`Parse request failed with status ${response.status}: ${responseText}`);
1740
- }
1741
- try {
1742
- return asText(JSON.parse(responseText));
1743
- }
1744
- catch {
1745
- return responseText;
1746
- }
1747
- },
1748
- });
1749
- }
1750
- const PORT = Number(process.env.PORT || 3000);
1751
- const HOST = process.env.CLOUD_SERVICE === 'true'
1752
- ? '0.0.0.0'
1753
- : process.env.HOST || 'localhost';
1754
- let args;
1755
- if (process.env.CLOUD_SERVICE === 'true' ||
1756
- process.env.SSE_LOCAL === 'true' ||
1757
- process.env.HTTP_STREAMABLE_SERVER === 'true') {
1758
- args = {
1759
- transportType: 'httpStream',
1760
- httpStream: {
1761
- port: PORT,
1762
- host: HOST,
1763
- stateless: true,
1764
- },
1765
- };
3764
+ parameters: parseParamsSchema,
3765
+ execute: async (args2, { session, log }) => {
3766
+ const apiUrl = process.env.FIRECRAWL_API_URL;
3767
+ if (!apiUrl) {
3768
+ throw new Error(
3769
+ "firecrawl_parse requires FIRECRAWL_API_URL to be set to a self-hosted Firecrawl API instance."
3770
+ );
3771
+ }
3772
+ const {
3773
+ filePath,
3774
+ contentType: overrideContentType,
3775
+ ...options
3776
+ } = args2;
3777
+ const absPath = path.resolve(filePath);
3778
+ const buffer = await readFile(absPath);
3779
+ const filename = path.basename(absPath);
3780
+ const fileContentType = overrideContentType && overrideContentType.length > 0 ? overrideContentType : inferContentType(filename);
3781
+ const transformed = transformScrapeParams(
3782
+ options
3783
+ );
3784
+ const cleaned = removeEmptyTopLevel(transformed);
3785
+ const optionsPayload = { origin: ORIGIN, ...cleaned };
3786
+ const form = new FormData();
3787
+ const blob = new Blob([new Uint8Array(buffer)], {
3788
+ type: fileContentType
3789
+ });
3790
+ form.append("file", blob, filename);
3791
+ form.append("options", JSON.stringify(optionsPayload));
3792
+ const headers = {};
3793
+ const apiKey = session?.firecrawlApiKey;
3794
+ if (apiKey) {
3795
+ headers["Authorization"] = `Bearer ${apiKey}`;
3796
+ }
3797
+ const endpoint = `${apiUrl.replace(/\/$/, "")}/v2/parse`;
3798
+ log.info("Parsing local file", {
3799
+ endpoint,
3800
+ filename,
3801
+ size: buffer.length
3802
+ });
3803
+ const response = await fetch(endpoint, {
3804
+ method: "POST",
3805
+ headers,
3806
+ body: form
3807
+ });
3808
+ const responseText = await response.text();
3809
+ if (!response.ok) {
3810
+ throw new Error(
3811
+ `Parse request failed with status ${response.status}: ${responseText}`
3812
+ );
3813
+ }
3814
+ try {
3815
+ return asText2(JSON.parse(responseText));
3816
+ } catch {
3817
+ return responseText;
3818
+ }
3819
+ }
3820
+ });
1766
3821
  }
1767
- else {
1768
- // default: stdio
1769
- args = {
1770
- transportType: 'stdio',
1771
- };
3822
+ var inferContentType2;
3823
+ var PORT = Number(process.env.PORT || 3e3);
3824
+ var HOST = process.env.CLOUD_SERVICE === "true" ? "0.0.0.0" : process.env.HOST || "localhost";
3825
+ var args;
3826
+ if (process.env.CLOUD_SERVICE === "true" || process.env.SSE_LOCAL === "true" || process.env.HTTP_STREAMABLE_SERVER === "true") {
3827
+ args = {
3828
+ transportType: "httpStream",
3829
+ httpStream: {
3830
+ port: PORT,
3831
+ host: HOST,
3832
+ stateless: true
3833
+ }
3834
+ };
3835
+ } else {
3836
+ args = {
3837
+ transportType: "stdio"
3838
+ };
1772
3839
  }
1773
3840
  registerMonitorTools(server);
1774
3841
  registerResearchTools(server, getClient);