@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/README.md +362 -181
- package/dist/browser.js +145 -0
- package/dist/config.js +67 -0
- package/dist/firecrawlApi.js +86 -0
- package/dist/htmlToMarkdown.js +263 -0
- package/dist/index.js +362 -359
- package/dist/scraper.js +138 -0
- package/dist/utils.js +124 -0
- package/package.json +13 -7
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
|
-
|
|
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
|
-
|
|
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: '
|
|
122
|
+
description: 'Capture full page height',
|
|
131
123
|
default: false
|
|
132
124
|
},
|
|
133
125
|
},
|
|
134
|
-
required: ['
|
|
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
|
|
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
|
-
//
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
//
|
|
233
|
+
// Parse JSON from the response, tolerating markdown code fences and prose.
|
|
393
234
|
try {
|
|
394
|
-
const extractedData =
|
|
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
|
|
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
|
|
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
|
-
|
|
416
|
-
|
|
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 (
|
|
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:
|
|
336
|
+
version: PACKAGE_VERSION,
|
|
460
337
|
}, {
|
|
461
338
|
capabilities: {
|
|
462
339
|
tools: {},
|
|
463
340
|
},
|
|
464
341
|
});
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
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
|
|
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
|
|
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 (!
|
|
631
|
-
throw new Error('Invalid arguments for extract_with_schema: urls array
|
|
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
|
-
//
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
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', (
|
|
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:
|
|
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
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
transport
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 -
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
+
}
|