@ariangibson/firecrawl-lite-mcp-server 1.1.2 → 1.4.0

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
@@ -11,41 +11,17 @@ import { randomUUID } from 'node:crypto';
11
11
  // External dependencies
12
12
  import dotenv from 'dotenv';
13
13
  import axios from 'axios';
14
- import puppeteer from 'puppeteer';
14
+ // Shared pure helpers (also covered by unit tests in tests/)
15
+ import { isValidUrl, sanitizeUrl, validatePrompt, buildLlmRequestBody, parseLlmJson, } from './utils.js';
16
+ import { loadConfig, createRotator } from './config.js';
17
+ import { extractPageMetadata } from './htmlToMarkdown.js';
18
+ import { createScraper } from './scraper.js';
19
+ import { parseFirecrawlScrapeRequest, buildFirecrawlDocument, isFirecrawlRequestAuthorized, } from './firecrawlApi.js';
20
+ import { createRequire } from 'node:module';
21
+ const { version: PACKAGE_VERSION } = createRequire(import.meta.url)('../package.json');
15
22
  dotenv.config();
16
- // Constants
17
- const DEFAULT_USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
18
- const DEFAULT_VIEWPORT_WIDTH = 1920;
19
- const DEFAULT_VIEWPORT_HEIGHT = 1080;
20
- const DEFAULT_SCRAPE_DELAY_MIN = 1000;
21
- const DEFAULT_SCRAPE_DELAY_MAX = 3000;
22
- const DEFAULT_BATCH_DELAY_MIN = 2000;
23
- const DEFAULT_BATCH_DELAY_MAX = 5000;
24
- const DEFAULT_RETRY_ATTEMPTS = 3;
25
- const DEFAULT_RETRY_INITIAL_DELAY = 1000;
26
- const DEFAULT_RETRY_MAX_DELAY = 10000;
27
- const DEFAULT_RETRY_BACKOFF_FACTOR = 2;
28
23
  // Security constants
29
24
  const MAX_URLS_PER_REQUEST = 10;
30
- // Input validation utilities
31
- function isValidUrl(url) {
32
- try {
33
- const parsedUrl = new URL(url);
34
- // Only allow http and https protocols
35
- return ['http:', 'https:'].includes(parsedUrl.protocol);
36
- }
37
- catch {
38
- return false;
39
- }
40
- }
41
- function sanitizeUrl(url) {
42
- // Remove any potentially dangerous characters
43
- return url.trim().replace(/[<>'"]/g, '');
44
- }
45
- function validatePrompt(prompt) {
46
- // Basic prompt validation - prevent extremely long prompts
47
- return prompt.length > 0 && prompt.length < 10000;
48
- }
49
25
  const SCRAPE_TOOL = {
50
26
  name: 'scrape_page',
51
27
  description: 'Extract content from a single webpage',
@@ -97,11 +73,6 @@ const EXTRACT_DATA_TOOL = {
97
73
  type: 'string',
98
74
  description: 'Instructions for what data to extract'
99
75
  },
100
- enableWebSearch: {
101
- type: 'boolean',
102
- description: 'Enable web search for additional context',
103
- default: false
104
- },
105
76
  },
106
77
  required: ['urls', 'prompt'],
107
78
  },
@@ -125,166 +96,36 @@ const EXTRACT_WITH_SCHEMA_TOOL = {
125
96
  type: 'string',
126
97
  description: 'Optional instructions for extraction'
127
98
  },
128
- enableWebSearch: {
99
+ },
100
+ required: ['urls', 'schema'],
101
+ },
102
+ };
103
+ const SCREENSHOT_TOOL = {
104
+ name: 'screenshot',
105
+ description: 'Take a screenshot of a webpage using stealth browser',
106
+ inputSchema: {
107
+ type: 'object',
108
+ properties: {
109
+ url: { type: 'string', description: 'Webpage URL to screenshot' },
110
+ width: {
111
+ type: 'number',
112
+ description: 'Viewport width in pixels',
113
+ default: 1920
114
+ },
115
+ height: {
116
+ type: 'number',
117
+ description: 'Viewport height in pixels',
118
+ default: 1080
119
+ },
120
+ fullPage: {
129
121
  type: 'boolean',
130
- description: 'Enable web search for additional context',
122
+ description: 'Capture full page height',
131
123
  default: false
132
124
  },
133
125
  },
134
- required: ['urls', 'schema'],
126
+ required: ['url'],
135
127
  },
136
128
  };
137
- // Lightweight tool definitions for essential Firecrawl functionality
138
- // Local web scraping functions
139
- async function scrapeWebpage(url, onlyMainContent = true) {
140
- // SECURITY: Validate and sanitize URL
141
- if (!isValidUrl(url)) {
142
- return {
143
- url,
144
- title: '',
145
- content: '',
146
- markdown: '',
147
- html: '',
148
- success: false,
149
- error: 'Invalid URL format. Only HTTP and HTTPS URLs are allowed.'
150
- };
151
- }
152
- const sanitizedUrl = sanitizeUrl(url);
153
- let browser;
154
- try {
155
- // Get proxy configuration
156
- const proxyUrl = CONFIG.proxy.url;
157
- const proxyUsername = CONFIG.proxy.username;
158
- const proxyPassword = CONFIG.proxy.password;
159
- // Get scraping configuration
160
- const customUserAgent = CONFIG.scraping.userAgent;
161
- const viewportWidth = CONFIG.scraping.viewportWidth;
162
- const viewportHeight = CONFIG.scraping.viewportHeight;
163
- const delayMin = CONFIG.scraping.delayMin;
164
- const delayMax = CONFIG.scraping.delayMax;
165
- // Build Puppeteer launch options with enhanced anti-detection
166
- // SECURITY: Removed --disable-web-security which is a major security risk
167
- const launchOptions = {
168
- headless: true,
169
- args: [
170
- '--no-sandbox',
171
- '--disable-setuid-sandbox',
172
- '--disable-dev-shm-usage',
173
- '--disable-accelerated-2d-canvas',
174
- '--no-first-run',
175
- '--no-zygote',
176
- '--disable-gpu',
177
- '--disable-features=VizDisplayCompositor',
178
- `--user-agent=${customUserAgent}`
179
- ]
180
- };
181
- // Add proxy configuration if available
182
- if (proxyUrl) {
183
- launchOptions.args.push(`--proxy-server=${proxyUrl}`);
184
- // If proxy requires authentication, we'll handle it in the page setup
185
- if (proxyUsername && proxyPassword) {
186
- console.error(`Using authenticated proxy: ${proxyUrl}`);
187
- }
188
- else {
189
- console.error(`Using proxy: ${proxyUrl}`);
190
- }
191
- }
192
- browser = await puppeteer.launch(launchOptions);
193
- const page = await browser.newPage();
194
- // Enhanced anti-detection setup
195
- await page.setUserAgent(customUserAgent);
196
- // Set viewport to common desktop size
197
- await page.setViewport({ width: viewportWidth, height: viewportHeight });
198
- // Add common browser properties to avoid detection
199
- await page.evaluateOnNewDocument(() => {
200
- // Override navigator properties
201
- Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
202
- Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
203
- Object.defineProperty(navigator, 'languages', { get: () => ['en-US', 'en'] });
204
- // Mock common browser APIs
205
- window.chrome = { runtime: {} };
206
- });
207
- // Handle proxy authentication if credentials are provided
208
- if (proxyUrl && proxyUsername && proxyPassword) {
209
- await page.authenticate({
210
- username: proxyUsername,
211
- password: proxyPassword
212
- });
213
- }
214
- // Add random delay before navigation
215
- const delay = Math.floor(Math.random() * (delayMax - delayMin)) + delayMin;
216
- await new Promise(resolve => setTimeout(resolve, delay));
217
- // SECURITY: Use sanitized URL and add timeout
218
- await page.goto(sanitizedUrl, {
219
- waitUntil: 'networkidle2',
220
- timeout: 30000
221
- });
222
- // Wait additional time for dynamic content
223
- await new Promise(resolve => setTimeout(resolve, 2000));
224
- // Extract title
225
- const title = await page.title();
226
- // Extract content based on preference
227
- let content = '';
228
- let markdown = '';
229
- if (onlyMainContent) {
230
- // Try to extract main content using common selectors
231
- const mainContent = await page.evaluate(() => {
232
- const selectors = [
233
- 'main',
234
- '[role="main"]',
235
- '.content',
236
- '.post-content',
237
- '.entry-content',
238
- 'article',
239
- '.article-content',
240
- '#content',
241
- '.main-content'
242
- ];
243
- for (const selector of selectors) {
244
- const element = document.querySelector(selector);
245
- if (element && element.textContent && element.textContent.trim().length > 100) {
246
- return element.textContent.trim();
247
- }
248
- }
249
- // Fallback to body content
250
- return document.body.textContent || '';
251
- });
252
- content = mainContent;
253
- markdown = content;
254
- }
255
- else {
256
- // Extract full page content
257
- content = await page.evaluate(() => document.body.textContent || '');
258
- markdown = content;
259
- }
260
- // Get HTML
261
- const html = await page.content();
262
- return {
263
- url,
264
- title,
265
- content,
266
- markdown,
267
- html,
268
- success: true
269
- };
270
- }
271
- catch (error) {
272
- return {
273
- url,
274
- title: '',
275
- content: '',
276
- markdown: '',
277
- html: '',
278
- success: false,
279
- error: error instanceof Error ? error.message : String(error)
280
- };
281
- }
282
- finally {
283
- if (browser) {
284
- await browser.close();
285
- }
286
- }
287
- }
288
129
  async function extractDataWithLLM(url, prompt, schema) {
289
130
  // SECURITY: Validate inputs
290
131
  if (!isValidUrl(url)) {
@@ -307,7 +148,7 @@ async function extractDataWithLLM(url, prompt, schema) {
307
148
  const sanitizedPrompt = prompt.trim();
308
149
  try {
309
150
  // First scrape the webpage
310
- const scraped = await scrapeWebpage(url, true);
151
+ const scraped = await scraper.scrape(url, true);
311
152
  if (!scraped.success) {
312
153
  return {
313
154
  url,
@@ -336,7 +177,7 @@ Webpage URL: ${sanitizedUrl}
336
177
  Webpage Title: ${scraped.title}
337
178
 
338
179
  Content:
339
- ${scraped.content}
180
+ ${scraped.markdown || scraped.content}
340
181
 
341
182
  ${schema ? `Extract data according to this JSON schema: ${JSON.stringify(schema, null, 2)}` : ''}
342
183
 
@@ -344,8 +185,9 @@ User Request: ${sanitizedPrompt}
344
185
 
345
186
  Please provide the extracted data in JSON format. ${schema ? 'Ensure the response matches the provided schema.' : 'Structure the data logically based on the content and request.'}
346
187
  `;
347
- // Get proxy configuration for LLM API calls
348
- const proxyUrl = CONFIG.proxy.url;
188
+ // LLM API calls go out directly by default. Only use the (scraping) proxy
189
+ // for the LLM call when explicitly opted in via PROXY_LLM_API=true.
190
+ const proxyUrl = CONFIG.proxy.proxyLlmApi ? getNextProxy() : undefined;
349
191
  const proxyUsername = CONFIG.proxy.username;
350
192
  const proxyPassword = CONFIG.proxy.password;
351
193
  // Build axios configuration
@@ -369,29 +211,28 @@ Please provide the extracted data in JSON format. ${schema ? 'Ensure the respons
369
211
  };
370
212
  }
371
213
  axiosConfig.proxy = proxyConfig;
372
- console.error(`Using proxy for LLM API: ${proxyUrl}`);
214
+ // SECURITY: Never log proxy URLs that might contain credentials
215
+ console.error(`Using proxy for LLM API: [REDACTED]`);
373
216
  }
374
- // Call LLM API with timeout for security
375
- const response = await axios.post(`${LLM_PROVIDER_BASE_URL}/chat/completions`, {
376
- model: LLM_MODEL,
377
- messages: [
378
- {
379
- role: 'user',
380
- content: extractionPrompt
381
- }
382
- ],
383
- temperature: 0.1,
384
- max_tokens: 2000
385
- }, {
217
+ // Call LLM API with timeout for security.
218
+ // Optional tuning params (temperature, max_tokens, top_p, reasoning_effort)
219
+ // are sourced from CONFIG.llm so they can be set via env vars.
220
+ const requestBody = buildLlmRequestBody(LLM_MODEL, [
221
+ {
222
+ role: 'user',
223
+ content: extractionPrompt
224
+ }
225
+ ], CONFIG.llm);
226
+ const response = await axios.post(`${LLM_PROVIDER_BASE_URL}/chat/completions`, requestBody, {
386
227
  ...axiosConfig,
387
228
  timeout: 60000, // 60 second timeout for security
388
229
  maxContentLength: 10 * 1024 * 1024, // 10MB max response size
389
230
  maxBodyLength: 10 * 1024 * 1024
390
231
  });
391
232
  const llmResponse = response.data.choices[0].message.content;
392
- // Try to parse JSON from the response
233
+ // Parse JSON from the response, tolerating markdown code fences and prose.
393
234
  try {
394
- const extractedData = JSON.parse(llmResponse);
235
+ const extractedData = parseLlmJson(llmResponse);
395
236
  return {
396
237
  url,
397
238
  data: extractedData,
@@ -399,7 +240,7 @@ Please provide the extracted data in JSON format. ${schema ? 'Ensure the respons
399
240
  };
400
241
  }
401
242
  catch (parseError) {
402
- // If JSON parsing fails, return the raw response
243
+ // If no JSON can be recovered, return the raw response
403
244
  return {
404
245
  url,
405
246
  data: { raw_response: llmResponse },
@@ -408,20 +249,40 @@ Please provide the extracted data in JSON format. ${schema ? 'Ensure the respons
408
249
  }
409
250
  }
410
251
  catch (error) {
411
- // SECURITY: Prevent information disclosure in error messages
252
+ // SECURITY: Prevent information disclosure in the message returned to the
253
+ // client, but log full detail to stderr so operators can actually debug.
412
254
  const isAxiosError = axios.isAxiosError(error);
413
255
  let safeErrorMessage = 'An error occurred while processing the request';
414
256
  if (isAxiosError) {
415
- // Only expose safe error information
416
- if (error.response?.status === 401) {
257
+ const status = error.response?.status;
258
+ // Server-side diagnostics (stderr only — never returned to the client).
259
+ const responseBody = typeof error.response?.data === 'string'
260
+ ? error.response.data
261
+ : JSON.stringify(error.response?.data ?? {});
262
+ console.error(`LLM extract_data request failed: status=${status ?? 'n/a'} code=${error.code ?? 'n/a'} body=${responseBody.slice(0, 800)}`);
263
+ // HTTP status codes are not sensitive, so surface them to aid debugging.
264
+ if (status === 401) {
417
265
  safeErrorMessage = 'Authentication failed with LLM provider';
418
266
  }
419
- else if (error.response?.status === 429) {
267
+ else if (status === 429) {
420
268
  safeErrorMessage = 'Rate limit exceeded with LLM provider';
421
269
  }
270
+ else if (status === 400) {
271
+ safeErrorMessage =
272
+ 'LLM provider rejected the request (HTTP 400) - check the model name and LLM_* tuning params';
273
+ }
422
274
  else if (error.code === 'ECONNABORTED') {
423
275
  safeErrorMessage = 'Request timeout - LLM provider took too long to respond';
424
276
  }
277
+ else if (status) {
278
+ safeErrorMessage = `LLM provider returned an error (HTTP ${status})`;
279
+ }
280
+ else {
281
+ safeErrorMessage = `LLM request failed (${error.code ?? 'network error'})`;
282
+ }
283
+ }
284
+ else {
285
+ console.error('extract_data unexpected error:', error);
425
286
  }
426
287
  return {
427
288
  url: sanitizedUrl,
@@ -438,6 +299,12 @@ function isScrapeOptions(args) {
438
299
  'url' in args &&
439
300
  typeof args.url === 'string');
440
301
  }
302
+ function isScreenshotOptions(args) {
303
+ return (typeof args === 'object' &&
304
+ args !== null &&
305
+ 'url' in args &&
306
+ typeof args.url === 'string');
307
+ }
441
308
  function isBatchScrapeOptions(args) {
442
309
  return (typeof args === 'object' &&
443
310
  args !== null &&
@@ -452,46 +319,32 @@ function isExtractOptions(args) {
452
319
  Array.isArray(args.urls) &&
453
320
  typeof args.prompt === 'string');
454
321
  }
322
+ // For schema-based extraction the prompt is optional (the schema drives it).
323
+ function isExtractWithSchemaOptions(args) {
324
+ return (typeof args === 'object' &&
325
+ args !== null &&
326
+ 'urls' in args &&
327
+ 'schema' in args &&
328
+ Array.isArray(args.urls) &&
329
+ args.schema != null &&
330
+ typeof args.schema === 'object');
331
+ }
455
332
  // Remove all complex tools - keep only essential ones above
456
333
  // Server implementation
457
334
  const server = new Server({
458
335
  name: 'firecrawl-lite-mcp-server',
459
- version: '1.0.0',
336
+ version: PACKAGE_VERSION,
460
337
  }, {
461
338
  capabilities: {
462
339
  tools: {},
463
340
  },
464
341
  });
465
- // Configuration for retries and monitoring
466
- const CONFIG = {
467
- scraping: {
468
- userAgent: process.env.SCRAPE_USER_AGENT || DEFAULT_USER_AGENT,
469
- viewportWidth: Number(process.env.SCRAPE_VIEWPORT_WIDTH) || DEFAULT_VIEWPORT_WIDTH,
470
- viewportHeight: Number(process.env.SCRAPE_VIEWPORT_HEIGHT) || DEFAULT_VIEWPORT_HEIGHT,
471
- delayMin: Number(process.env.SCRAPE_DELAY_MIN) || DEFAULT_SCRAPE_DELAY_MIN,
472
- delayMax: Number(process.env.SCRAPE_DELAY_MAX) || DEFAULT_SCRAPE_DELAY_MAX,
473
- batchDelayMin: Number(process.env.SCRAPE_BATCH_DELAY_MIN) || DEFAULT_BATCH_DELAY_MIN,
474
- batchDelayMax: Number(process.env.SCRAPE_BATCH_DELAY_MAX) || DEFAULT_BATCH_DELAY_MAX,
475
- },
476
- retry: {
477
- maxAttempts: Number(process.env.FIRECRAWL_RETRY_MAX_ATTEMPTS) || DEFAULT_RETRY_ATTEMPTS,
478
- initialDelay: Number(process.env.FIRECRAWL_RETRY_INITIAL_DELAY) || DEFAULT_RETRY_INITIAL_DELAY,
479
- maxDelay: Number(process.env.FIRECRAWL_RETRY_MAX_DELAY) || DEFAULT_RETRY_MAX_DELAY,
480
- backoffFactor: Number(process.env.FIRECRAWL_RETRY_BACKOFF_FACTOR) || DEFAULT_RETRY_BACKOFF_FACTOR,
481
- },
482
- llm: {
483
- apiKey: process.env.LLM_API_KEY,
484
- providerBaseUrl: process.env.LLM_PROVIDER_BASE_URL,
485
- model: process.env.LLM_MODEL,
486
- },
487
- proxy: {
488
- url: process.env.PROXY_SERVER_URL,
489
- username: process.env.PROXY_SERVER_USERNAME,
490
- password: process.env.PROXY_SERVER_PASSWORD,
491
- },
492
- };
342
+ const CONFIG = loadConfig();
343
+ // Proxy rotation for LLM API calls (only used when PROXY_LLM_API=true).
344
+ const getNextProxy = createRotator(CONFIG.proxy.urls);
345
+ // Scraper: browser sessions, rotation, retries, HTML -> Markdown.
346
+ const scraper = createScraper(CONFIG);
493
347
  // Get LLM configuration
494
- const LLM_API_KEY = CONFIG.llm.apiKey;
495
348
  const LLM_PROVIDER_BASE_URL = CONFIG.llm.providerBaseUrl;
496
349
  const LLM_MODEL = CONFIG.llm.model;
497
350
  // Add utility function for delay
@@ -517,6 +370,7 @@ server.setRequestHandler(ListToolsRequestSchema, async function listToolsRequest
517
370
  BATCH_SCRAPE_TOOL,
518
371
  EXTRACT_DATA_TOOL,
519
372
  EXTRACT_WITH_SCHEMA_TOOL,
373
+ SCREENSHOT_TOOL,
520
374
  ],
521
375
  };
522
376
  });
@@ -538,12 +392,44 @@ server.setRequestHandler(CallToolRequestSchema, async function callToolRequestHa
538
392
  if (!isValidUrl(args.url)) {
539
393
  throw new Error('Invalid URL format. Only HTTP and HTTPS URLs are allowed.');
540
394
  }
541
- const result = await scrapeWebpage(args.url, args.onlyMainContent !== false);
395
+ const result = await scraper.scrape(args.url, args.onlyMainContent !== false);
542
396
  return {
543
397
  content: [{ type: 'text', text: result.success ? result.markdown : `Error: ${result.error}` }],
544
398
  isError: !result.success,
545
399
  };
546
400
  }
401
+ case 'screenshot': {
402
+ if (!isScreenshotOptions(args)) {
403
+ throw new Error('Invalid arguments for screenshot');
404
+ }
405
+ // SECURITY: Validate URL before processing
406
+ if (!isValidUrl(args.url)) {
407
+ throw new Error('Invalid URL format. Only HTTP and HTTPS URLs are allowed.');
408
+ }
409
+ const result = await scraper.screenshot(args.url, {
410
+ width: args.width || 1920,
411
+ height: args.height || 1080,
412
+ fullPage: args.fullPage || false,
413
+ });
414
+ if (result.success && result.dataUrl) {
415
+ return {
416
+ content: [{
417
+ type: 'text',
418
+ text: `Screenshot captured successfully!\n\nMetadata:\n- Format: ${result.metadata?.format}\n- Size: ${result.metadata?.sizeKB}KB\n- Dimensions: ${result.metadata?.dimensions}\n- Full Page: ${result.metadata?.fullPage}\n- URL: ${result.metadata?.url}\n- Timestamp: ${result.metadata?.timestamp}\n\nBase64 Data URL:\n${result.dataUrl}`
419
+ }],
420
+ isError: false,
421
+ };
422
+ }
423
+ else {
424
+ return {
425
+ content: [{
426
+ type: 'text',
427
+ text: `Screenshot failed: ${result.error}`
428
+ }],
429
+ isError: true,
430
+ };
431
+ }
432
+ }
547
433
  case 'batch_scrape': {
548
434
  if (!isBatchScrapeOptions(args)) {
549
435
  throw new Error('Invalid arguments for batch_scrape: urls array required');
@@ -560,7 +446,7 @@ server.setRequestHandler(CallToolRequestSchema, async function callToolRequestHa
560
446
  const results = [];
561
447
  for (const url of args.urls) {
562
448
  try {
563
- const result = await scrapeWebpage(url, args.onlyMainContent !== false);
449
+ const result = await scraper.scrape(url, args.onlyMainContent !== false);
564
450
  results.push({
565
451
  url,
566
452
  success: result.success,
@@ -627,8 +513,8 @@ server.setRequestHandler(CallToolRequestSchema, async function callToolRequestHa
627
513
  };
628
514
  }
629
515
  case 'extract_with_schema': {
630
- if (!isExtractOptions(args) || !args.schema) {
631
- throw new Error('Invalid arguments for extract_with_schema: urls array, schema, and prompt required');
516
+ if (!isExtractWithSchemaOptions(args)) {
517
+ throw new Error('Invalid arguments for extract_with_schema: urls array and schema object are required');
632
518
  }
633
519
  // Security validations
634
520
  if (!Array.isArray(args.urls) || args.urls.length === 0) {
@@ -652,14 +538,18 @@ server.setRequestHandler(CallToolRequestSchema, async function callToolRequestHa
652
538
  }
653
539
  sanitizedUrls.push(sanitizedUrl);
654
540
  }
655
- // Validate and sanitize prompt
656
- if (typeof args.prompt !== 'string') {
657
- throw new Error('Invalid prompt: must be a string');
658
- }
659
- if (!validatePrompt(args.prompt)) {
660
- throw new Error('Invalid prompt: must be between 1 and 10,000 characters');
541
+ // Prompt is optional for schema-based extraction; the schema drives
542
+ // the extraction. Validate it only if the caller supplied one.
543
+ let sanitizedPrompt = 'Extract data matching the provided JSON schema.';
544
+ if (args.prompt !== undefined) {
545
+ if (typeof args.prompt !== 'string') {
546
+ throw new Error('Invalid prompt: must be a string');
547
+ }
548
+ if (!validatePrompt(args.prompt)) {
549
+ throw new Error('Invalid prompt: must be between 1 and 10,000 characters');
550
+ }
551
+ sanitizedPrompt = args.prompt.trim();
661
552
  }
662
- const sanitizedPrompt = args.prompt.trim();
663
553
  // Validate schema (basic validation)
664
554
  if (typeof args.schema !== 'object' || args.schema === null) {
665
555
  throw new Error('Invalid schema: must be a valid object');
@@ -722,16 +612,6 @@ server.setRequestHandler(CallToolRequestSchema, async function callToolRequestHa
722
612
  safeLog('info', `Request completed in ${Date.now() - startTime}ms`);
723
613
  }
724
614
  });
725
- // Helper function to format results
726
- function formatResults(data) {
727
- return data
728
- .map((doc) => {
729
- const content = doc.markdown || doc.content || 'No content';
730
- return `Title: ${doc.title}
731
- Content: ${content.substring(0, 100)}${content.length > 100 ? '...' : ''}`;
732
- })
733
- .join('\n\n');
734
- }
735
615
  // Utility function to trim trailing whitespace from text responses
736
616
  // This prevents Claude API errors with "final assistant content cannot end with trailing whitespace"
737
617
  function trimResponseText(text) {
@@ -758,123 +638,234 @@ async function runLocalServer() {
758
638
  process.exit(1);
759
639
  }
760
640
  }
761
- async function runSSELocalServer() {
762
- let transport = null;
763
- const app = express();
764
- app.get('/sse', async (req, res) => {
765
- transport = new SSEServerTransport(`/messages`, res);
766
- res.on('close', () => {
767
- transport = null;
768
- });
769
- await server.connect(transport);
770
- });
771
- // Endpoint for the client to POST messages
772
- // Remove express.json() middleware - let the transport handle the body
773
- app.post('/messages', (req, res) => {
774
- if (transport) {
775
- transport.handlePostMessage(req, res);
776
- }
777
- });
778
- const PORT = process.env.PORT || 3000;
779
- console.log('Starting server on port', PORT);
780
- try {
781
- app.listen(PORT, () => {
782
- console.log(`MCP SSE Server listening on http://localhost:${PORT}`);
783
- console.log(`SSE endpoint: http://localhost:${PORT}/sse`);
784
- console.log(`Message endpoint: http://localhost:${PORT}/messages`);
785
- });
786
- }
787
- catch (error) {
788
- console.error('Error starting server:', error);
789
- }
790
- }
791
641
  async function runHTTPStreamableServer() {
792
642
  const app = express();
793
643
  app.use(express.json());
794
644
  // Health check endpoint
795
- app.get('/health', (req, res) => {
645
+ app.get('/health', (_req, res) => {
796
646
  res.status(200).json({
797
647
  status: 'OK',
798
648
  server: 'Firecrawl Lite MCP Server',
799
- version: '1.0.0',
800
- timestamp: new Date().toISOString()
649
+ version: PACKAGE_VERSION,
650
+ timestamp: new Date().toISOString(),
651
+ endpoints: {
652
+ mcp: CONFIG.endpoints.enableHttpStreamableEndpoint ? 'enabled' : 'disabled',
653
+ sse: CONFIG.endpoints.enableSseEndpoint ? 'enabled' : 'disabled',
654
+ firecrawlApi: CONFIG.endpoints.enableFirecrawlApi ? 'enabled' : 'disabled'
655
+ }
801
656
  });
802
657
  });
803
658
  const transports = {};
804
- // A single endpoint handles all MCP requests.
805
- app.all('/mcp', async (req, res) => {
806
- try {
807
- const sessionId = req.headers['mcp-session-id'];
808
- let transport;
809
- if (sessionId && transports[sessionId]) {
810
- transport = transports[sessionId];
659
+ let sseTransport = null;
660
+ // MCP endpoint - only if enabled
661
+ if (CONFIG.endpoints.enableHttpStreamableEndpoint) {
662
+ app.all('/mcp', async (req, res) => {
663
+ try {
664
+ const sessionId = req.headers['mcp-session-id'];
665
+ let transport;
666
+ if (sessionId && transports[sessionId]) {
667
+ transport = transports[sessionId];
668
+ }
669
+ else if (!sessionId &&
670
+ req.method === 'POST' &&
671
+ req.body &&
672
+ typeof req.body === 'object' &&
673
+ req.body.method === 'initialize') {
674
+ transport = new StreamableHTTPServerTransport({
675
+ sessionIdGenerator: () => {
676
+ const id = randomUUID();
677
+ return id;
678
+ },
679
+ onsessioninitialized: (sid) => {
680
+ transports[sid] = transport;
681
+ },
682
+ });
683
+ transport.onclose = () => {
684
+ const sid = transport.sessionId;
685
+ if (sid && transports[sid]) {
686
+ delete transports[sid];
687
+ }
688
+ };
689
+ console.log('Creating server instance');
690
+ console.log('Connecting transport to server');
691
+ await server.connect(transport);
692
+ await transport.handleRequest(req, res, req.body);
693
+ return;
694
+ }
695
+ else {
696
+ res.status(400).json({
697
+ jsonrpc: '2.0',
698
+ error: {
699
+ code: -32000,
700
+ message: 'Invalid or missing session ID',
701
+ },
702
+ id: null,
703
+ });
704
+ return;
705
+ }
706
+ await transport.handleRequest(req, res, req.body);
707
+ }
708
+ catch (error) {
709
+ if (!res.headersSent) {
710
+ res.status(500).json({
711
+ jsonrpc: '2.0',
712
+ error: {
713
+ code: -32603,
714
+ message: 'Internal server error',
715
+ },
716
+ id: null,
717
+ });
718
+ }
811
719
  }
812
- else if (!sessionId &&
813
- req.method === 'POST' &&
814
- req.body &&
815
- typeof req.body === 'object' &&
816
- req.body.method === 'initialize') {
817
- transport = new StreamableHTTPServerTransport({
818
- sessionIdGenerator: () => {
819
- const id = randomUUID();
820
- return id;
821
- },
822
- onsessioninitialized: (sid) => {
823
- transports[sid] = transport;
824
- },
720
+ });
721
+ }
722
+ // SSE endpoint - only if enabled
723
+ if (CONFIG.endpoints.enableSseEndpoint) {
724
+ // Map to store SSE transports per session
725
+ const sseTransports = new Map();
726
+ app.get('/sse', async (req, res) => {
727
+ try {
728
+ const sessionId = req.query.sessionId || randomUUID();
729
+ console.log(`SSE connection established for session: ${sessionId}`);
730
+ const transport = new SSEServerTransport(`/messages?sessionId=${sessionId}`, res);
731
+ sseTransports.set(sessionId, transport);
732
+ res.on('close', () => {
733
+ console.log(`SSE connection closed for session: ${sessionId}`);
734
+ sseTransports.delete(sessionId);
735
+ transport.close?.();
736
+ });
737
+ res.on('error', (error) => {
738
+ console.error(`SSE error for session ${sessionId}:`, error);
739
+ sseTransports.delete(sessionId);
740
+ transport.close?.();
825
741
  });
826
- transport.onclose = () => {
827
- const sid = transport.sessionId;
828
- if (sid && transports[sid]) {
829
- delete transports[sid];
830
- }
831
- };
832
- console.log('Creating server instance');
833
- console.log('Connecting transport to server');
834
742
  await server.connect(transport);
835
- await transport.handleRequest(req, res, req.body);
836
- return;
837
743
  }
838
- else {
839
- res.status(400).json({
840
- jsonrpc: '2.0',
841
- error: {
842
- code: -32000,
843
- message: 'Invalid or missing session ID',
844
- },
845
- id: null,
846
- });
744
+ catch (error) {
745
+ console.error('SSE endpoint error:', error);
746
+ res.status(500).json({ error: 'Failed to establish SSE connection' });
747
+ }
748
+ });
749
+ app.post('/messages', async (req, res) => {
750
+ try {
751
+ const sessionId = req.query.sessionId;
752
+ // First try exact match, then fallback to any available transport
753
+ let transport = sessionId ? sseTransports.get(sessionId) : null;
754
+ if (!transport) {
755
+ transport = Array.from(sseTransports.values())[0];
756
+ }
757
+ if (transport) {
758
+ console.log(`Routing message from session ${sessionId} to transport`);
759
+ await transport.handlePostMessage(req, res, req.body);
760
+ }
761
+ else {
762
+ console.error('No SSE transport available. Active sessions:', Array.from(sseTransports.keys()));
763
+ res.status(503).json({
764
+ error: 'SSE transport not available. Connect to /sse first.',
765
+ sessionId: sessionId,
766
+ activeSessions: Array.from(sseTransports.keys())
767
+ });
768
+ }
769
+ }
770
+ catch (error) {
771
+ console.error('Messages endpoint error:', error);
772
+ res.status(500).json({ error: 'Failed to handle message' });
773
+ }
774
+ });
775
+ }
776
+ // Firecrawl-compatible REST API - only if enabled.
777
+ // Implements the subset of the Firecrawl v2 API that agent frameworks use
778
+ // for page extraction (the firecrawl SDK's `scrape`). Point FIRECRAWL_API_URL
779
+ // at this server and it behaves like a self-hosted Firecrawl instance.
780
+ if (CONFIG.endpoints.enableFirecrawlApi) {
781
+ const requireFirecrawlAuth = (req, res) => {
782
+ if (isFirecrawlRequestAuthorized(req.headers.authorization, CONFIG.endpoints.firecrawlApiKey)) {
783
+ return true;
784
+ }
785
+ res.status(401).json({ success: false, error: 'Unauthorized: invalid or missing API key' });
786
+ return false;
787
+ };
788
+ const handleFirecrawlScrape = async (req, res) => {
789
+ if (!requireFirecrawlAuth(req, res))
790
+ return;
791
+ const parsed = parseFirecrawlScrapeRequest(req.body);
792
+ if (!parsed.ok || !parsed.request) {
793
+ res.status(400).json({ success: false, error: parsed.error });
847
794
  return;
848
795
  }
849
- await transport.handleRequest(req, res, req.body);
850
- }
851
- catch (error) {
852
- if (!res.headersSent) {
796
+ const { url, formats, onlyMainContent } = parsed.request;
797
+ try {
798
+ const result = await scraper.scrape(url, onlyMainContent);
799
+ if (!result.success) {
800
+ res.status(500).json({ success: false, error: result.error || 'Scrape failed' });
801
+ return;
802
+ }
803
+ const meta = extractPageMetadata(result.html, url);
804
+ res.status(200).json({ success: true, data: buildFirecrawlDocument(result, formats, meta) });
805
+ }
806
+ catch (error) {
807
+ console.error('Firecrawl API scrape error:', error);
853
808
  res.status(500).json({
854
- jsonrpc: '2.0',
855
- error: {
856
- code: -32603,
857
- message: 'Internal server error',
858
- },
859
- id: null,
809
+ success: false,
810
+ error: error instanceof Error ? error.message : String(error),
860
811
  });
861
812
  }
862
- }
863
- });
864
- const PORT = 3000;
813
+ };
814
+ app.post('/v2/scrape', handleFirecrawlScrape);
815
+ app.post('/v1/scrape', handleFirecrawlScrape);
816
+ // Web search is intentionally out of scope: this server renders pages, it
817
+ // is not a search engine. Return a clear error so clients can fall back
818
+ // to a dedicated search backend (DuckDuckGo, SearXNG, Brave, ...).
819
+ const handleFirecrawlSearch = (req, res) => {
820
+ if (!requireFirecrawlAuth(req, res))
821
+ return;
822
+ res.status(501).json({
823
+ success: false,
824
+ error: 'Search is not supported by Firecrawl Lite. Configure a dedicated search backend ' +
825
+ '(e.g. in Hermes Agent set web.search_backend to ddgs, searxng, or brave-free) ' +
826
+ 'and use this server for extraction only.',
827
+ });
828
+ };
829
+ app.post('/v2/search', handleFirecrawlSearch);
830
+ app.post('/v1/search', handleFirecrawlSearch);
831
+ }
832
+ const PORT = CONFIG.endpoints.port;
865
833
  const appServer = app.listen(PORT, () => {
866
- console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
834
+ console.log(`🚀 Firecrawl Lite MCP Server v${PACKAGE_VERSION} listening on port ${PORT}`);
835
+ console.log(`📊 Health endpoint: http://localhost:${PORT}/health`);
836
+ if (CONFIG.endpoints.enableHttpStreamableEndpoint) {
837
+ console.log(`🔌 MCP endpoint: http://localhost:${PORT}/mcp`);
838
+ }
839
+ if (CONFIG.endpoints.enableSseEndpoint) {
840
+ console.log(`📡 SSE endpoint: http://localhost:${PORT}/sse`);
841
+ console.log(`💬 Messages endpoint: http://localhost:${PORT}/messages`);
842
+ }
843
+ if (CONFIG.endpoints.enableFirecrawlApi) {
844
+ console.log(`🔥 Firecrawl-compatible API: http://localhost:${PORT}/v2/scrape${CONFIG.endpoints.firecrawlApiKey ? ' (bearer auth enabled)' : ' (no auth)'}`);
845
+ }
867
846
  });
868
847
  process.on('SIGINT', async () => {
869
848
  console.log('Shutting down server...');
849
+ // Close MCP transports
870
850
  for (const sessionId in transports) {
871
851
  try {
872
- console.log(`Closing transport for session ${sessionId}`);
852
+ console.log(`Closing MCP transport for session ${sessionId}`);
873
853
  await transports[sessionId].close();
874
854
  delete transports[sessionId];
875
855
  }
876
856
  catch (error) {
877
- console.error(`Error closing transport for session ${sessionId}:`, error);
857
+ console.error(`Error closing MCP transport for session ${sessionId}:`, error);
858
+ }
859
+ }
860
+ // Close SSE transport if active
861
+ if (sseTransport) {
862
+ try {
863
+ console.log('Closing SSE transport');
864
+ await sseTransport.close();
865
+ sseTransport = null;
866
+ }
867
+ catch (error) {
868
+ console.error('Error closing SSE transport:', error);
878
869
  }
879
870
  }
880
871
  appServer.close(() => {
@@ -883,8 +874,20 @@ async function runHTTPStreamableServer() {
883
874
  });
884
875
  });
885
876
  }
886
- // Server startup - standalone MCP server
887
- runLocalServer().catch((error) => {
888
- console.error('Fatal error running server:', error);
889
- process.exit(1);
890
- });
877
+ // Server startup - conditional based on enabled endpoints
878
+ if (CONFIG.endpoints.enableHttpStreamableEndpoint ||
879
+ CONFIG.endpoints.enableSseEndpoint ||
880
+ CONFIG.endpoints.enableFirecrawlApi) {
881
+ console.error('Starting HTTP server...');
882
+ runHTTPStreamableServer().catch((error) => {
883
+ console.error('Fatal error running HTTP server:', error);
884
+ process.exit(1);
885
+ });
886
+ }
887
+ else {
888
+ console.error('Starting stdio MCP Server...');
889
+ runLocalServer().catch((error) => {
890
+ console.error('Fatal error running server:', error);
891
+ process.exit(1);
892
+ });
893
+ }