@ariangibson/firecrawl-lite-mcp-server 1.1.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/LICENSE +21 -0
- package/README.md +287 -0
- package/dist/index.js +890 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 ariangibson
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
# Firecrawl Lite MCP Server
|
|
2
|
+
|
|
3
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
|
+
|
|
5
|
+
A **privacy-first, standalone** MCP server that provides web scraping and data extraction tools using local browser automation and your own LLM API key. **No external dependencies or API keys required** - completely decoupled from Firecrawl's cloud service.
|
|
6
|
+
|
|
7
|
+
## � **What Makes Firecrawl Lite Special**
|
|
8
|
+
|
|
9
|
+
### **🔒 Privacy-First Architecture**
|
|
10
|
+
- **Local Processing** - All web scraping and data extraction happens on your machine
|
|
11
|
+
- **Your Data Stays Local** - Content is processed locally, not sent to third parties
|
|
12
|
+
- **No External Service Lock-in** - Doesn't require Firecrawl's cloud API
|
|
13
|
+
- **Complete Control** - You own your data and infrastructure
|
|
14
|
+
|
|
15
|
+
### **💰 Cost-Effective & Transparent**
|
|
16
|
+
- **Pay Only for LLM Usage** - No additional subscription or API fees
|
|
17
|
+
- **Your LLM Provider** - Compatible with OpenAI, xAI, Anthropic, Ollama, etc.
|
|
18
|
+
- **Predictable Costs** - Transparent pricing based on your chosen LLM rates
|
|
19
|
+
|
|
20
|
+
### **⚡ Performance & Simplicity**
|
|
21
|
+
- **Lightning-Fast Startup** - Lightweight design means quick initialization
|
|
22
|
+
- **Single Container** - Simple deployment with Docker support
|
|
23
|
+
- **Minimal Resource Usage** - Optimized for efficiency and low memory footprint
|
|
24
|
+
|
|
25
|
+
## 📊 **Feature Comparison**
|
|
26
|
+
|
|
27
|
+
| Feature | Firecrawl Lite ✅ | Original Firecrawl ❌ |
|
|
28
|
+
|---------|-------------------|----------------------|
|
|
29
|
+
| **🏠 Deployment** | **Standalone/Local** | Cloud Service |
|
|
30
|
+
| **🔑 API Keys Required** | **Your LLM key only** | Firecrawl API + LLM keys |
|
|
31
|
+
| **🔒 Data Privacy** | **100% local processing** | Cloud processing |
|
|
32
|
+
| **💰 Cost Model** | **LLM usage only** | Subscription + LLM costs |
|
|
33
|
+
| **⚙️ Setup Complexity** | **Single container** | Multi-service deployment |
|
|
34
|
+
| **📦 Bundle Size** | **~50MB lightweight** | Heavy multi-service |
|
|
35
|
+
| **🏠 Local LLM Support** | **✅ Ollama/Local LLMs** | Limited local options |
|
|
36
|
+
| **🎛️ Customization** | **Full control** | Limited customization |
|
|
37
|
+
| **🚀 Startup Time** | **< 5 seconds** | Variable (cloud dependent) |
|
|
38
|
+
| **🔧 Maintenance** | **Self-managed** | Managed service |
|
|
39
|
+
|
|
40
|
+
## �️ **Available Tools**
|
|
41
|
+
|
|
42
|
+
This standalone version provides local web scraping and data extraction using Puppeteer and your own LLM:
|
|
43
|
+
|
|
44
|
+
### ✅ **`scrape_page`** - Extract content from a single webpage
|
|
45
|
+
- **Implementation**: Local browser automation with Puppeteer
|
|
46
|
+
- **Use case**: Get webpage content for LLMs to read
|
|
47
|
+
- **Parameters**: `url`, `onlyMainContent`
|
|
48
|
+
- **Privacy**: All data processed locally
|
|
49
|
+
|
|
50
|
+
### ✅ **`batch_scrape`** - Scrape multiple URLs in a single request
|
|
51
|
+
- **Implementation**: Sequential local scraping with rate limiting
|
|
52
|
+
- **Use case**: Process multiple pages efficiently
|
|
53
|
+
- **Parameters**: `urls[]`, `onlyMainContent`
|
|
54
|
+
- **Privacy**: All data processed locally
|
|
55
|
+
|
|
56
|
+
### ✅ **`extract_data`** - Extract structured data using LLM
|
|
57
|
+
- **Implementation**: Local scraping + your LLM for data extraction
|
|
58
|
+
- **Use case**: Pull specific data from pages using natural language prompts
|
|
59
|
+
- **Parameters**: `urls[]`, `prompt`, `enableWebSearch`
|
|
60
|
+
- **Privacy**: Content scraped locally, sent to your LLM only
|
|
61
|
+
|
|
62
|
+
### ✅ **`extract_with_schema`** - Extract data using JSON schema
|
|
63
|
+
- **Implementation**: Local scraping + schema-guided LLM extraction
|
|
64
|
+
- **Use case**: Extract structured data with predefined schema
|
|
65
|
+
- **Parameters**: `urls[]`, `schema`, `prompt`, `enableWebSearch`
|
|
66
|
+
- **Privacy**: Content scraped locally, sent to your LLM only
|
|
67
|
+
|
|
68
|
+
## 🚀 **Quick Start**
|
|
69
|
+
|
|
70
|
+
### **1. Install the package:**
|
|
71
|
+
```bash
|
|
72
|
+
npm install -g @ariangibson/firecrawl-lite-mcp-server
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Or use npx to run without global installation (recommended).
|
|
76
|
+
|
|
77
|
+
### **2. Configure your LLM:**
|
|
78
|
+
```bash
|
|
79
|
+
# Create a .env file or set environment variables
|
|
80
|
+
LLM_API_KEY=your_llm_api_key_here
|
|
81
|
+
LLM_PROVIDER_BASE_URL=https://api.x.ai/v1
|
|
82
|
+
LLM_MODEL=grok-code-fast-1
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### **3. Configure your MCP client:**
|
|
86
|
+
|
|
87
|
+
#### **Claude Desktop**
|
|
88
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json`:
|
|
89
|
+
```json
|
|
90
|
+
{
|
|
91
|
+
"mcpServers": {
|
|
92
|
+
"firecrawl-lite": {
|
|
93
|
+
"command": "npx",
|
|
94
|
+
"args": ["-y", "@ariangibson/firecrawl-lite-mcp-server"],
|
|
95
|
+
"env": {
|
|
96
|
+
"LLM_API_KEY": "your_llm_api_key_here",
|
|
97
|
+
"LLM_PROVIDER_BASE_URL": "https://api.x.ai/v1",
|
|
98
|
+
"LLM_MODEL": "grok-code-fast-1"
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
#### **Claude Code (CLI)**
|
|
106
|
+
```bash
|
|
107
|
+
claude config mcp add firecrawl-lite \
|
|
108
|
+
--command "npx" \
|
|
109
|
+
--args "-y" --args "@ariangibson/firecrawl-lite-mcp-server" \
|
|
110
|
+
--env LLM_API_KEY=your_llm_api_key_here \
|
|
111
|
+
--env LLM_PROVIDER_BASE_URL=https://api.x.ai/v1 \
|
|
112
|
+
--env LLM_MODEL=grok-code-fast-1
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### **4. Restart your MCP client and start scraping!**
|
|
116
|
+
|
|
117
|
+
## ⚙️ **Configuration Guide**
|
|
118
|
+
|
|
119
|
+
### **Required Environment Variables**
|
|
120
|
+
```bash
|
|
121
|
+
# Your LLM API key (xAI, OpenAI, Anthropic, etc.)
|
|
122
|
+
LLM_API_KEY=your_api_key_here
|
|
123
|
+
|
|
124
|
+
# LLM provider base URL
|
|
125
|
+
LLM_PROVIDER_BASE_URL=https://api.x.ai/v1
|
|
126
|
+
|
|
127
|
+
# LLM model name
|
|
128
|
+
LLM_MODEL=grok-code-fast-1
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### **LLM Provider Examples**
|
|
132
|
+
```bash
|
|
133
|
+
# xAI (Grok)
|
|
134
|
+
LLM_PROVIDER_BASE_URL=https://api.x.ai/v1
|
|
135
|
+
LLM_API_KEY=xai-your-key-here
|
|
136
|
+
LLM_MODEL=grok-code-fast-1
|
|
137
|
+
|
|
138
|
+
# OpenAI
|
|
139
|
+
LLM_PROVIDER_BASE_URL=https://api.openai.com/v1
|
|
140
|
+
LLM_API_KEY=sk-your-key-here
|
|
141
|
+
LLM_MODEL=gpt-4o-mini
|
|
142
|
+
|
|
143
|
+
# Anthropic
|
|
144
|
+
LLM_PROVIDER_BASE_URL=https://api.anthropic.com
|
|
145
|
+
LLM_API_KEY=sk-ant-your-key-here
|
|
146
|
+
LLM_MODEL=claude-3-haiku-20240307
|
|
147
|
+
|
|
148
|
+
# Local LLM (Ollama)
|
|
149
|
+
LLM_PROVIDER_BASE_URL=http://localhost:11434/v1
|
|
150
|
+
LLM_API_KEY=your-local-key
|
|
151
|
+
LLM_MODEL=llama2
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### **Optional Configuration**
|
|
155
|
+
```bash
|
|
156
|
+
# Proxy configuration (for web scraping and LLM API calls)
|
|
157
|
+
PROXY_SERVER_URL=http://your-proxy.com:8080
|
|
158
|
+
PROXY_SERVER_USERNAME=your_proxy_username
|
|
159
|
+
PROXY_SERVER_PASSWORD=your_proxy_password
|
|
160
|
+
|
|
161
|
+
# Scraping configuration (anti-detection and rate limiting)
|
|
162
|
+
SCRAPE_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
|
|
163
|
+
SCRAPE_VIEWPORT_WIDTH=1920
|
|
164
|
+
SCRAPE_VIEWPORT_HEIGHT=1080
|
|
165
|
+
SCRAPE_DELAY_MIN=1000
|
|
166
|
+
SCRAPE_DELAY_MAX=3000
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## 🛡️ **Anti-Detection Features**
|
|
170
|
+
|
|
171
|
+
Firecrawl Lite includes sophisticated anti-detection measures to handle modern websites with bot protection:
|
|
172
|
+
|
|
173
|
+
### ✅ **Built-in Anti-Detection**
|
|
174
|
+
- **Realistic Browser Fingerprinting**: Spoofs navigator properties, plugins, and browser APIs
|
|
175
|
+
- **Random Delays**: Adds human-like delays between requests (configurable)
|
|
176
|
+
- **Modern User Agent**: Uses up-to-date Chrome user agent strings
|
|
177
|
+
- **Viewport Simulation**: Sets realistic desktop viewport sizes
|
|
178
|
+
- **Headless Optimization**: Configured for maximum stealth in headless mode
|
|
179
|
+
|
|
180
|
+
### ✅ **Configurable Settings**
|
|
181
|
+
```bash
|
|
182
|
+
# Control delays (in milliseconds)
|
|
183
|
+
SCRAPE_DELAY_MIN=1000 # Minimum delay before navigation
|
|
184
|
+
SCRAPE_DELAY_MAX=3000 # Maximum delay before navigation
|
|
185
|
+
SCRAPE_BATCH_DELAY_MIN=2000 # Minimum delay between batch requests
|
|
186
|
+
SCRAPE_BATCH_DELAY_MAX=5000 # Maximum delay between batch requests
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## � **Docker Deployment (Optional)**
|
|
190
|
+
|
|
191
|
+
If you prefer Docker deployment:
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
# Build and run with Docker
|
|
195
|
+
docker-compose up --build
|
|
196
|
+
|
|
197
|
+
# Run in background
|
|
198
|
+
docker-compose up -d --build
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The server will be available at `http://localhost:3000` with a health endpoint at `http://localhost:3000/health`.
|
|
202
|
+
|
|
203
|
+
## 📊 **Usage Examples**
|
|
204
|
+
|
|
205
|
+
### Scrape a webpage
|
|
206
|
+
```json
|
|
207
|
+
{
|
|
208
|
+
"name": "scrape_page",
|
|
209
|
+
"arguments": {
|
|
210
|
+
"url": "https://example.com"
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Batch scrape multiple URLs
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"name": "batch_scrape",
|
|
219
|
+
"arguments": {
|
|
220
|
+
"urls": ["https://example.com", "https://example.org"],
|
|
221
|
+
"onlyMainContent": true
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
### Extract data with prompt
|
|
227
|
+
```json
|
|
228
|
+
{
|
|
229
|
+
"name": "extract_data",
|
|
230
|
+
"arguments": {
|
|
231
|
+
"urls": ["https://example.com"],
|
|
232
|
+
"prompt": "Extract the main article title and summary"
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### Extract with schema
|
|
238
|
+
```json
|
|
239
|
+
{
|
|
240
|
+
"name": "extract_with_schema",
|
|
241
|
+
"arguments": {
|
|
242
|
+
"urls": ["https://example.com"],
|
|
243
|
+
"schema": {
|
|
244
|
+
"type": "object",
|
|
245
|
+
"properties": {
|
|
246
|
+
"title": {"type": "string"},
|
|
247
|
+
"description": {"type": "string"}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
## ❓ **Important Notes**
|
|
255
|
+
|
|
256
|
+
### **🌐 Internet Requirements**
|
|
257
|
+
- **Requires Internet Access** - Still needs to access target websites
|
|
258
|
+
- **LLM API Access** - Requires connection to your chosen LLM provider
|
|
259
|
+
- **No Offline Operation** - Cannot work completely offline
|
|
260
|
+
|
|
261
|
+
### **� Intentionally Excluded Features**
|
|
262
|
+
By design, this lite version excludes advanced features to maintain simplicity:
|
|
263
|
+
- Web search functionality
|
|
264
|
+
- Website URL discovery/mapping
|
|
265
|
+
- Multi-page website crawling
|
|
266
|
+
- LLMs.txt file generation
|
|
267
|
+
- Advanced research capabilities
|
|
268
|
+
- Crawl job status checking
|
|
269
|
+
|
|
270
|
+
## 🙏 **Credits & Acknowledgments**
|
|
271
|
+
|
|
272
|
+
This project is inspired by and builds upon the excellent work of the original Firecrawl projects:
|
|
273
|
+
|
|
274
|
+
### 🔥 **[Firecrawl](https://firecrawl.com)**
|
|
275
|
+
The original Firecrawl project by **Mendable.ai** - a comprehensive web scraping and crawling platform with advanced features like website mapping, multi-page crawling, and deep research capabilities.
|
|
276
|
+
|
|
277
|
+
### 🔥 **[Firecrawl MCP Server](https://github.com/firecrawl/firecrawl-mcp-server)**
|
|
278
|
+
The official MCP server implementation by the Firecrawl team, providing MCP integration for their cloud-based scraping service.
|
|
279
|
+
|
|
280
|
+
**We give huge thanks to the Firecrawl team for their pioneering work in web scraping and MCP integration!** 🚀
|
|
281
|
+
|
|
282
|
+
> **💡 Looking for a very generous free tier and dead-simple cloud-hosted solution?**
|
|
283
|
+
> Visit **[firecrawl.com](https://firecrawl.com)** and sign up for a Firecrawl account! Their cloud service offers enterprise-grade web scraping with zero setup complexity.
|
|
284
|
+
|
|
285
|
+
## �📝 **License**
|
|
286
|
+
|
|
287
|
+
MIT License - see [LICENSE](LICENSE) for details.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Core MCP SDK imports
|
|
3
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
6
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
7
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
|
|
8
|
+
// Web framework and utilities
|
|
9
|
+
import express from 'express';
|
|
10
|
+
import { randomUUID } from 'node:crypto';
|
|
11
|
+
// External dependencies
|
|
12
|
+
import dotenv from 'dotenv';
|
|
13
|
+
import axios from 'axios';
|
|
14
|
+
import puppeteer from 'puppeteer';
|
|
15
|
+
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
|
+
// Security constants
|
|
29
|
+
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
|
+
const SCRAPE_TOOL = {
|
|
50
|
+
name: 'scrape_page',
|
|
51
|
+
description: 'Extract content from a single webpage',
|
|
52
|
+
inputSchema: {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties: {
|
|
55
|
+
url: { type: 'string', description: 'Webpage URL to scrape' },
|
|
56
|
+
onlyMainContent: {
|
|
57
|
+
type: 'boolean',
|
|
58
|
+
description: 'Extract only main content',
|
|
59
|
+
default: true
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
required: ['url'],
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
const BATCH_SCRAPE_TOOL = {
|
|
66
|
+
name: 'batch_scrape',
|
|
67
|
+
description: 'Scrape multiple URLs in a single request',
|
|
68
|
+
inputSchema: {
|
|
69
|
+
type: 'object',
|
|
70
|
+
properties: {
|
|
71
|
+
urls: {
|
|
72
|
+
type: 'array',
|
|
73
|
+
items: { type: 'string' },
|
|
74
|
+
description: 'Array of URLs to scrape'
|
|
75
|
+
},
|
|
76
|
+
onlyMainContent: {
|
|
77
|
+
type: 'boolean',
|
|
78
|
+
description: 'Extract only main content',
|
|
79
|
+
default: true
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
required: ['urls'],
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
const EXTRACT_DATA_TOOL = {
|
|
86
|
+
name: 'extract_data',
|
|
87
|
+
description: 'Extract structured data from webpages using LLM',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {
|
|
91
|
+
urls: {
|
|
92
|
+
type: 'array',
|
|
93
|
+
items: { type: 'string' },
|
|
94
|
+
description: 'URLs to extract data from'
|
|
95
|
+
},
|
|
96
|
+
prompt: {
|
|
97
|
+
type: 'string',
|
|
98
|
+
description: 'Instructions for what data to extract'
|
|
99
|
+
},
|
|
100
|
+
enableWebSearch: {
|
|
101
|
+
type: 'boolean',
|
|
102
|
+
description: 'Enable web search for additional context',
|
|
103
|
+
default: false
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
required: ['urls', 'prompt'],
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
const EXTRACT_WITH_SCHEMA_TOOL = {
|
|
110
|
+
name: 'extract_with_schema',
|
|
111
|
+
description: 'Extract structured data using a JSON schema',
|
|
112
|
+
inputSchema: {
|
|
113
|
+
type: 'object',
|
|
114
|
+
properties: {
|
|
115
|
+
urls: {
|
|
116
|
+
type: 'array',
|
|
117
|
+
items: { type: 'string' },
|
|
118
|
+
description: 'URLs to extract data from'
|
|
119
|
+
},
|
|
120
|
+
schema: {
|
|
121
|
+
type: 'object',
|
|
122
|
+
description: 'JSON schema defining the data structure to extract'
|
|
123
|
+
},
|
|
124
|
+
prompt: {
|
|
125
|
+
type: 'string',
|
|
126
|
+
description: 'Optional instructions for extraction'
|
|
127
|
+
},
|
|
128
|
+
enableWebSearch: {
|
|
129
|
+
type: 'boolean',
|
|
130
|
+
description: 'Enable web search for additional context',
|
|
131
|
+
default: false
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
required: ['urls', 'schema'],
|
|
135
|
+
},
|
|
136
|
+
};
|
|
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
|
+
async function extractDataWithLLM(url, prompt, schema) {
|
|
289
|
+
// SECURITY: Validate inputs
|
|
290
|
+
if (!isValidUrl(url)) {
|
|
291
|
+
return {
|
|
292
|
+
url,
|
|
293
|
+
data: null,
|
|
294
|
+
success: false,
|
|
295
|
+
error: 'Invalid URL format. Only HTTP and HTTPS URLs are allowed.'
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (!validatePrompt(prompt)) {
|
|
299
|
+
return {
|
|
300
|
+
url,
|
|
301
|
+
data: null,
|
|
302
|
+
success: false,
|
|
303
|
+
error: 'Invalid prompt. Prompt must be between 1 and 10,000 characters.'
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
const sanitizedUrl = sanitizeUrl(url);
|
|
307
|
+
const sanitizedPrompt = prompt.trim();
|
|
308
|
+
try {
|
|
309
|
+
// First scrape the webpage
|
|
310
|
+
const scraped = await scrapeWebpage(url, true);
|
|
311
|
+
if (!scraped.success) {
|
|
312
|
+
return {
|
|
313
|
+
url,
|
|
314
|
+
data: null,
|
|
315
|
+
success: false,
|
|
316
|
+
error: scraped.error
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
// Get LLM configuration
|
|
320
|
+
const LLM_API_KEY = CONFIG.llm.apiKey;
|
|
321
|
+
const LLM_PROVIDER_BASE_URL = CONFIG.llm.providerBaseUrl;
|
|
322
|
+
const LLM_MODEL = CONFIG.llm.model;
|
|
323
|
+
if (!LLM_API_KEY || !LLM_PROVIDER_BASE_URL || !LLM_MODEL) {
|
|
324
|
+
return {
|
|
325
|
+
url,
|
|
326
|
+
data: null,
|
|
327
|
+
success: false,
|
|
328
|
+
error: 'LLM configuration not available'
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
// Prepare the extraction prompt
|
|
332
|
+
const extractionPrompt = `
|
|
333
|
+
You are a data extraction assistant. Extract information from the following webpage content based on the user's request.
|
|
334
|
+
|
|
335
|
+
Webpage URL: ${sanitizedUrl}
|
|
336
|
+
Webpage Title: ${scraped.title}
|
|
337
|
+
|
|
338
|
+
Content:
|
|
339
|
+
${scraped.content}
|
|
340
|
+
|
|
341
|
+
${schema ? `Extract data according to this JSON schema: ${JSON.stringify(schema, null, 2)}` : ''}
|
|
342
|
+
|
|
343
|
+
User Request: ${sanitizedPrompt}
|
|
344
|
+
|
|
345
|
+
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
|
+
`;
|
|
347
|
+
// Get proxy configuration for LLM API calls
|
|
348
|
+
const proxyUrl = CONFIG.proxy.url;
|
|
349
|
+
const proxyUsername = CONFIG.proxy.username;
|
|
350
|
+
const proxyPassword = CONFIG.proxy.password;
|
|
351
|
+
// Build axios configuration
|
|
352
|
+
const axiosConfig = {
|
|
353
|
+
headers: {
|
|
354
|
+
'Authorization': `Bearer ${LLM_API_KEY}`,
|
|
355
|
+
'Content-Type': 'application/json'
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
// Add proxy configuration if available
|
|
359
|
+
if (proxyUrl) {
|
|
360
|
+
const proxyConfig = {
|
|
361
|
+
host: proxyUrl.replace(/^https?:\/\//, '').split(':')[0],
|
|
362
|
+
port: parseInt(proxyUrl.split(':').pop() || '80'),
|
|
363
|
+
protocol: proxyUrl.startsWith('https') ? 'https' : 'http'
|
|
364
|
+
};
|
|
365
|
+
if (proxyUsername && proxyPassword) {
|
|
366
|
+
proxyConfig.auth = {
|
|
367
|
+
username: proxyUsername,
|
|
368
|
+
password: proxyPassword
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
axiosConfig.proxy = proxyConfig;
|
|
372
|
+
console.error(`Using proxy for LLM API: ${proxyUrl}`);
|
|
373
|
+
}
|
|
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
|
+
}, {
|
|
386
|
+
...axiosConfig,
|
|
387
|
+
timeout: 60000, // 60 second timeout for security
|
|
388
|
+
maxContentLength: 10 * 1024 * 1024, // 10MB max response size
|
|
389
|
+
maxBodyLength: 10 * 1024 * 1024
|
|
390
|
+
});
|
|
391
|
+
const llmResponse = response.data.choices[0].message.content;
|
|
392
|
+
// Try to parse JSON from the response
|
|
393
|
+
try {
|
|
394
|
+
const extractedData = JSON.parse(llmResponse);
|
|
395
|
+
return {
|
|
396
|
+
url,
|
|
397
|
+
data: extractedData,
|
|
398
|
+
success: true
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
catch (parseError) {
|
|
402
|
+
// If JSON parsing fails, return the raw response
|
|
403
|
+
return {
|
|
404
|
+
url,
|
|
405
|
+
data: { raw_response: llmResponse },
|
|
406
|
+
success: true
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
// SECURITY: Prevent information disclosure in error messages
|
|
412
|
+
const isAxiosError = axios.isAxiosError(error);
|
|
413
|
+
let safeErrorMessage = 'An error occurred while processing the request';
|
|
414
|
+
if (isAxiosError) {
|
|
415
|
+
// Only expose safe error information
|
|
416
|
+
if (error.response?.status === 401) {
|
|
417
|
+
safeErrorMessage = 'Authentication failed with LLM provider';
|
|
418
|
+
}
|
|
419
|
+
else if (error.response?.status === 429) {
|
|
420
|
+
safeErrorMessage = 'Rate limit exceeded with LLM provider';
|
|
421
|
+
}
|
|
422
|
+
else if (error.code === 'ECONNABORTED') {
|
|
423
|
+
safeErrorMessage = 'Request timeout - LLM provider took too long to respond';
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return {
|
|
427
|
+
url: sanitizedUrl,
|
|
428
|
+
data: null,
|
|
429
|
+
success: false,
|
|
430
|
+
error: safeErrorMessage
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Type guards
|
|
435
|
+
function isScrapeOptions(args) {
|
|
436
|
+
return (typeof args === 'object' &&
|
|
437
|
+
args !== null &&
|
|
438
|
+
'url' in args &&
|
|
439
|
+
typeof args.url === 'string');
|
|
440
|
+
}
|
|
441
|
+
function isBatchScrapeOptions(args) {
|
|
442
|
+
return (typeof args === 'object' &&
|
|
443
|
+
args !== null &&
|
|
444
|
+
'urls' in args &&
|
|
445
|
+
Array.isArray(args.urls));
|
|
446
|
+
}
|
|
447
|
+
function isExtractOptions(args) {
|
|
448
|
+
return (typeof args === 'object' &&
|
|
449
|
+
args !== null &&
|
|
450
|
+
'urls' in args &&
|
|
451
|
+
'prompt' in args &&
|
|
452
|
+
Array.isArray(args.urls) &&
|
|
453
|
+
typeof args.prompt === 'string');
|
|
454
|
+
}
|
|
455
|
+
// Remove all complex tools - keep only essential ones above
|
|
456
|
+
// Server implementation
|
|
457
|
+
const server = new Server({
|
|
458
|
+
name: 'firecrawl-lite-mcp-server',
|
|
459
|
+
version: '1.0.0',
|
|
460
|
+
}, {
|
|
461
|
+
capabilities: {
|
|
462
|
+
tools: {},
|
|
463
|
+
},
|
|
464
|
+
});
|
|
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
|
+
};
|
|
493
|
+
// Get LLM configuration
|
|
494
|
+
const LLM_API_KEY = CONFIG.llm.apiKey;
|
|
495
|
+
const LLM_PROVIDER_BASE_URL = CONFIG.llm.providerBaseUrl;
|
|
496
|
+
const LLM_MODEL = CONFIG.llm.model;
|
|
497
|
+
// Add utility function for delay
|
|
498
|
+
function delay(ms) {
|
|
499
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
500
|
+
}
|
|
501
|
+
let isStdioTransport = false;
|
|
502
|
+
function safeLog(level, data) {
|
|
503
|
+
try {
|
|
504
|
+
// Always log to stderr to avoid relying on MCP logging capability
|
|
505
|
+
const message = `[${level}] ${typeof data === 'object' ? JSON.stringify(data) : String(data)}`;
|
|
506
|
+
console.error(message);
|
|
507
|
+
}
|
|
508
|
+
catch (_) {
|
|
509
|
+
// ignore
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Tool handlers
|
|
513
|
+
server.setRequestHandler(ListToolsRequestSchema, async function listToolsRequestHandler() {
|
|
514
|
+
return {
|
|
515
|
+
tools: [
|
|
516
|
+
SCRAPE_TOOL,
|
|
517
|
+
BATCH_SCRAPE_TOOL,
|
|
518
|
+
EXTRACT_DATA_TOOL,
|
|
519
|
+
EXTRACT_WITH_SCHEMA_TOOL,
|
|
520
|
+
],
|
|
521
|
+
};
|
|
522
|
+
});
|
|
523
|
+
server.setRequestHandler(CallToolRequestSchema, async function callToolRequestHandler(request) {
|
|
524
|
+
const startTime = Date.now();
|
|
525
|
+
try {
|
|
526
|
+
const { name, arguments: args } = request.params;
|
|
527
|
+
// Log incoming request with timestamp
|
|
528
|
+
safeLog('info', `[${new Date().toISOString()}] Received request for tool: ${name}`);
|
|
529
|
+
if (!args) {
|
|
530
|
+
throw new Error('No arguments provided');
|
|
531
|
+
}
|
|
532
|
+
switch (name) {
|
|
533
|
+
case 'scrape_page': {
|
|
534
|
+
if (!isScrapeOptions(args)) {
|
|
535
|
+
throw new Error('Invalid arguments for scrape_page');
|
|
536
|
+
}
|
|
537
|
+
// SECURITY: Validate URL before processing
|
|
538
|
+
if (!isValidUrl(args.url)) {
|
|
539
|
+
throw new Error('Invalid URL format. Only HTTP and HTTPS URLs are allowed.');
|
|
540
|
+
}
|
|
541
|
+
const result = await scrapeWebpage(args.url, args.onlyMainContent !== false);
|
|
542
|
+
return {
|
|
543
|
+
content: [{ type: 'text', text: result.success ? result.markdown : `Error: ${result.error}` }],
|
|
544
|
+
isError: !result.success,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
case 'batch_scrape': {
|
|
548
|
+
if (!isBatchScrapeOptions(args)) {
|
|
549
|
+
throw new Error('Invalid arguments for batch_scrape: urls array required');
|
|
550
|
+
}
|
|
551
|
+
// SECURITY: Validate all URLs before processing
|
|
552
|
+
const invalidUrls = args.urls.filter(url => !isValidUrl(url));
|
|
553
|
+
if (invalidUrls.length > 0) {
|
|
554
|
+
throw new Error(`Invalid URL format detected: ${invalidUrls.join(', ')}. Only HTTP and HTTPS URLs are allowed.`);
|
|
555
|
+
}
|
|
556
|
+
// SECURITY: Limit batch size to prevent abuse
|
|
557
|
+
if (args.urls.length > 10) {
|
|
558
|
+
throw new Error('Batch size limited to 10 URLs maximum for security and performance reasons.');
|
|
559
|
+
}
|
|
560
|
+
const results = [];
|
|
561
|
+
for (const url of args.urls) {
|
|
562
|
+
try {
|
|
563
|
+
const result = await scrapeWebpage(url, args.onlyMainContent !== false);
|
|
564
|
+
results.push({
|
|
565
|
+
url,
|
|
566
|
+
success: result.success,
|
|
567
|
+
title: result.title,
|
|
568
|
+
content: result.success ? result.markdown : `Error: ${result.error}`
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
results.push({
|
|
573
|
+
url,
|
|
574
|
+
success: false,
|
|
575
|
+
error: error instanceof Error ? error.message : String(error)
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
// Add random delay between requests to avoid rate limiting
|
|
579
|
+
const batchDelay = Math.floor(Math.random() * (CONFIG.scraping.batchDelayMax - CONFIG.scraping.batchDelayMin)) + CONFIG.scraping.batchDelayMin;
|
|
580
|
+
await delay(batchDelay);
|
|
581
|
+
}
|
|
582
|
+
return {
|
|
583
|
+
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
|
|
584
|
+
isError: false,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
case 'extract_data': {
|
|
588
|
+
if (!isExtractOptions(args)) {
|
|
589
|
+
throw new Error('Invalid arguments for extract_data: urls array and prompt required');
|
|
590
|
+
}
|
|
591
|
+
// SECURITY: Validate all URLs
|
|
592
|
+
const invalidUrls = args.urls.filter(url => !isValidUrl(url));
|
|
593
|
+
if (invalidUrls.length > 0) {
|
|
594
|
+
throw new Error(`Invalid URL format detected: ${invalidUrls.join(', ')}. Only HTTP and HTTPS URLs are allowed.`);
|
|
595
|
+
}
|
|
596
|
+
// SECURITY: Validate prompt
|
|
597
|
+
if (!validatePrompt(args.prompt)) {
|
|
598
|
+
throw new Error('Invalid prompt. Prompt must be between 1 and 10,000 characters.');
|
|
599
|
+
}
|
|
600
|
+
// SECURITY: Limit batch size
|
|
601
|
+
if (args.urls.length > 5) {
|
|
602
|
+
throw new Error('Extraction limited to 5 URLs maximum for security and performance reasons.');
|
|
603
|
+
}
|
|
604
|
+
const results = [];
|
|
605
|
+
for (const url of args.urls) {
|
|
606
|
+
try {
|
|
607
|
+
const result = await extractDataWithLLM(url, args.prompt);
|
|
608
|
+
results.push({
|
|
609
|
+
url,
|
|
610
|
+
success: result.success,
|
|
611
|
+
data: result.success ? result.data : `Error: ${result.error}`
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
catch (error) {
|
|
615
|
+
results.push({
|
|
616
|
+
url,
|
|
617
|
+
success: false,
|
|
618
|
+
error: error instanceof Error ? error.message : String(error)
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
// Add delay between requests
|
|
622
|
+
await delay(1000);
|
|
623
|
+
}
|
|
624
|
+
return {
|
|
625
|
+
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
|
|
626
|
+
isError: false,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
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');
|
|
632
|
+
}
|
|
633
|
+
// Security validations
|
|
634
|
+
if (!Array.isArray(args.urls) || args.urls.length === 0) {
|
|
635
|
+
throw new Error('Invalid arguments: urls must be a non-empty array');
|
|
636
|
+
}
|
|
637
|
+
if (args.urls.length > MAX_URLS_PER_REQUEST) {
|
|
638
|
+
throw new Error(`Too many URLs: maximum ${MAX_URLS_PER_REQUEST} URLs allowed per request`);
|
|
639
|
+
}
|
|
640
|
+
// Validate and sanitize each URL
|
|
641
|
+
const sanitizedUrls = [];
|
|
642
|
+
for (const url of args.urls) {
|
|
643
|
+
if (typeof url !== 'string') {
|
|
644
|
+
throw new Error('Invalid URL format: all URLs must be strings');
|
|
645
|
+
}
|
|
646
|
+
if (!isValidUrl(url)) {
|
|
647
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
648
|
+
}
|
|
649
|
+
const sanitizedUrl = sanitizeUrl(url);
|
|
650
|
+
if (!sanitizedUrl) {
|
|
651
|
+
throw new Error(`Failed to sanitize URL: ${url}`);
|
|
652
|
+
}
|
|
653
|
+
sanitizedUrls.push(sanitizedUrl);
|
|
654
|
+
}
|
|
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');
|
|
661
|
+
}
|
|
662
|
+
const sanitizedPrompt = args.prompt.trim();
|
|
663
|
+
// Validate schema (basic validation)
|
|
664
|
+
if (typeof args.schema !== 'object' || args.schema === null) {
|
|
665
|
+
throw new Error('Invalid schema: must be a valid object');
|
|
666
|
+
}
|
|
667
|
+
const results = [];
|
|
668
|
+
for (const url of sanitizedUrls) {
|
|
669
|
+
try {
|
|
670
|
+
const result = await extractDataWithLLM(url, sanitizedPrompt, args.schema);
|
|
671
|
+
results.push({
|
|
672
|
+
url,
|
|
673
|
+
success: result.success,
|
|
674
|
+
data: result.success ? result.data : `Error: ${result.error}`
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
catch (error) {
|
|
678
|
+
// Prevent information disclosure in error messages
|
|
679
|
+
const safeErrorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
|
|
680
|
+
results.push({
|
|
681
|
+
url,
|
|
682
|
+
success: false,
|
|
683
|
+
error: safeErrorMessage.replace(/[^\w\s\-.:]/g, '') // Remove potentially sensitive characters
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
// Add delay between requests to prevent rate limiting
|
|
687
|
+
await delay(1000);
|
|
688
|
+
}
|
|
689
|
+
return {
|
|
690
|
+
content: [{ type: 'text', text: JSON.stringify(results, null, 2) }],
|
|
691
|
+
isError: false,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
default:
|
|
695
|
+
return {
|
|
696
|
+
content: [{ type: 'text', text: `Unknown tool: ${name}` }],
|
|
697
|
+
isError: true,
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
// Log detailed error information
|
|
703
|
+
safeLog('error', {
|
|
704
|
+
message: `Request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
705
|
+
tool: request.params.name,
|
|
706
|
+
arguments: request.params.arguments,
|
|
707
|
+
timestamp: new Date().toISOString(),
|
|
708
|
+
duration: Date.now() - startTime,
|
|
709
|
+
});
|
|
710
|
+
return {
|
|
711
|
+
content: [
|
|
712
|
+
{
|
|
713
|
+
type: 'text',
|
|
714
|
+
text: trimResponseText(`Error: ${error instanceof Error ? error.message : String(error)}`),
|
|
715
|
+
},
|
|
716
|
+
],
|
|
717
|
+
isError: true,
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
finally {
|
|
721
|
+
// Log request completion with performance metrics
|
|
722
|
+
safeLog('info', `Request completed in ${Date.now() - startTime}ms`);
|
|
723
|
+
}
|
|
724
|
+
});
|
|
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
|
+
// Utility function to trim trailing whitespace from text responses
|
|
736
|
+
// This prevents Claude API errors with "final assistant content cannot end with trailing whitespace"
|
|
737
|
+
function trimResponseText(text) {
|
|
738
|
+
return text.trim();
|
|
739
|
+
}
|
|
740
|
+
// Server startup
|
|
741
|
+
async function runLocalServer() {
|
|
742
|
+
try {
|
|
743
|
+
console.error('Initializing Firecrawl Lite MCP Server...');
|
|
744
|
+
const transport = new StdioServerTransport();
|
|
745
|
+
// Detect if we're using stdio transport
|
|
746
|
+
isStdioTransport = transport instanceof StdioServerTransport;
|
|
747
|
+
if (isStdioTransport) {
|
|
748
|
+
console.error('Running in stdio mode, logging will be directed to stderr');
|
|
749
|
+
}
|
|
750
|
+
await server.connect(transport);
|
|
751
|
+
// Now that we're connected, we can send logging messages
|
|
752
|
+
safeLog('info', 'Firecrawl Lite MCP Server initialized successfully');
|
|
753
|
+
safeLog('info', `LLM Configuration: ${LLM_PROVIDER_BASE_URL ? 'Configured' : 'Not configured'} (${LLM_MODEL || 'no model'})`);
|
|
754
|
+
console.error('Firecrawl Lite MCP Server running on stdio');
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
console.error('Fatal error running server:', error);
|
|
758
|
+
process.exit(1);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
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
|
+
async function runHTTPStreamableServer() {
|
|
792
|
+
const app = express();
|
|
793
|
+
app.use(express.json());
|
|
794
|
+
// Health check endpoint
|
|
795
|
+
app.get('/health', (req, res) => {
|
|
796
|
+
res.status(200).json({
|
|
797
|
+
status: 'OK',
|
|
798
|
+
server: 'Firecrawl Lite MCP Server',
|
|
799
|
+
version: '1.0.0',
|
|
800
|
+
timestamp: new Date().toISOString()
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
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];
|
|
811
|
+
}
|
|
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
|
+
},
|
|
825
|
+
});
|
|
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
|
+
await server.connect(transport);
|
|
835
|
+
await transport.handleRequest(req, res, req.body);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
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
|
+
});
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
await transport.handleRequest(req, res, req.body);
|
|
850
|
+
}
|
|
851
|
+
catch (error) {
|
|
852
|
+
if (!res.headersSent) {
|
|
853
|
+
res.status(500).json({
|
|
854
|
+
jsonrpc: '2.0',
|
|
855
|
+
error: {
|
|
856
|
+
code: -32603,
|
|
857
|
+
message: 'Internal server error',
|
|
858
|
+
},
|
|
859
|
+
id: null,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
const PORT = 3000;
|
|
865
|
+
const appServer = app.listen(PORT, () => {
|
|
866
|
+
console.log(`MCP Streamable HTTP Server listening on port ${PORT}`);
|
|
867
|
+
});
|
|
868
|
+
process.on('SIGINT', async () => {
|
|
869
|
+
console.log('Shutting down server...');
|
|
870
|
+
for (const sessionId in transports) {
|
|
871
|
+
try {
|
|
872
|
+
console.log(`Closing transport for session ${sessionId}`);
|
|
873
|
+
await transports[sessionId].close();
|
|
874
|
+
delete transports[sessionId];
|
|
875
|
+
}
|
|
876
|
+
catch (error) {
|
|
877
|
+
console.error(`Error closing transport for session ${sessionId}:`, error);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
appServer.close(() => {
|
|
881
|
+
console.log('Server shutdown complete');
|
|
882
|
+
process.exit(0);
|
|
883
|
+
});
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
// Server startup - standalone MCP server
|
|
887
|
+
runLocalServer().catch((error) => {
|
|
888
|
+
console.error('Fatal error running server:', error);
|
|
889
|
+
process.exit(1);
|
|
890
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ariangibson/firecrawl-lite-mcp-server",
|
|
3
|
+
"version": "1.1.1",
|
|
4
|
+
"description": "Privacy-first, standalone MCP server for web scraping and data extraction using local browser automation and your own LLM API key",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"firecrawl-lite-mcp-server": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "tsc",
|
|
14
|
+
"start": "node dist/index.js",
|
|
15
|
+
"dev": "tsc && node dist/index.js",
|
|
16
|
+
"lint": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@modelcontextprotocol/sdk": "^1.17.3",
|
|
21
|
+
"@types/cheerio": "^0.22.35",
|
|
22
|
+
"axios": "^1.11.0",
|
|
23
|
+
"cheerio": "^1.1.2",
|
|
24
|
+
"dotenv": "^16.4.7",
|
|
25
|
+
"express": "^5.1.0",
|
|
26
|
+
"puppeteer": "^24.17.1",
|
|
27
|
+
"ws": "^8.18.1"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/express": "^5.0.1",
|
|
31
|
+
"@types/node": "^20.10.5",
|
|
32
|
+
"typescript": "^5.9.2"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "https://github.com/ariangibson/firecrawl-lite-mcp-server.git"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://github.com/ariangibson/firecrawl-lite-mcp-server#readme",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/ariangibson/firecrawl-lite-mcp-server/issues"
|
|
44
|
+
}
|
|
45
|
+
}
|