firecrawl-mcp 3.6.1 ā 3.7.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/LICENSE +0 -0
- package/dist/index.js +114 -0
- package/package.json +2 -2
- package/dist/index-v1.js +0 -1313
- package/dist/index.test.js +0 -255
- package/dist/jest.setup.js +0 -58
- package/dist/server-v1.js +0 -1154
- package/dist/server-v2.js +0 -1067
- package/dist/src/index.js +0 -1053
- package/dist/src/index.test.js +0 -225
- package/dist/versioned-server.js +0 -203
package/dist/src/index.test.js
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
import FirecrawlApp from '@mendable/firecrawl-js';
|
|
2
|
-
import { describe, expect, jest, test, beforeEach, afterEach, } from '@jest/globals';
|
|
3
|
-
import { mock } from 'jest-mock-extended';
|
|
4
|
-
// Mock FirecrawlApp
|
|
5
|
-
jest.mock('@mendable/firecrawl-js');
|
|
6
|
-
describe('FireCrawl Tool Tests', () => {
|
|
7
|
-
let mockClient;
|
|
8
|
-
let requestHandler;
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
jest.clearAllMocks();
|
|
11
|
-
mockClient = mock();
|
|
12
|
-
// Set up mock implementations
|
|
13
|
-
const mockInstance = new FirecrawlApp({ apiKey: 'test' });
|
|
14
|
-
Object.assign(mockInstance, mockClient);
|
|
15
|
-
// Create request handler
|
|
16
|
-
requestHandler = async (request) => {
|
|
17
|
-
const { name, arguments: args } = request.params;
|
|
18
|
-
if (!args) {
|
|
19
|
-
throw new Error('No arguments provided');
|
|
20
|
-
}
|
|
21
|
-
return handleRequest(name, args, mockClient);
|
|
22
|
-
};
|
|
23
|
-
});
|
|
24
|
-
afterEach(() => {
|
|
25
|
-
jest.clearAllMocks();
|
|
26
|
-
});
|
|
27
|
-
// Test scrape functionality
|
|
28
|
-
test('should handle scrape request', async () => {
|
|
29
|
-
const url = 'https://example.com';
|
|
30
|
-
const options = { formats: ['markdown'] };
|
|
31
|
-
const mockResponse = {
|
|
32
|
-
success: true,
|
|
33
|
-
markdown: '# Test Content',
|
|
34
|
-
html: undefined,
|
|
35
|
-
rawHtml: undefined,
|
|
36
|
-
url: 'https://example.com',
|
|
37
|
-
actions: undefined,
|
|
38
|
-
};
|
|
39
|
-
mockClient.scrapeUrl.mockResolvedValueOnce(mockResponse);
|
|
40
|
-
const response = await requestHandler({
|
|
41
|
-
method: 'call_tool',
|
|
42
|
-
params: {
|
|
43
|
-
name: 'firecrawl_scrape',
|
|
44
|
-
arguments: { url, ...options },
|
|
45
|
-
},
|
|
46
|
-
});
|
|
47
|
-
expect(response).toEqual({
|
|
48
|
-
content: [{ type: 'text', text: '# Test Content' }],
|
|
49
|
-
isError: false,
|
|
50
|
-
});
|
|
51
|
-
expect(mockClient.scrapeUrl).toHaveBeenCalledWith(url, {
|
|
52
|
-
formats: ['markdown'],
|
|
53
|
-
url,
|
|
54
|
-
});
|
|
55
|
-
});
|
|
56
|
-
// Test batch scrape functionality
|
|
57
|
-
test('should handle batch scrape request', async () => {
|
|
58
|
-
const urls = ['https://example.com'];
|
|
59
|
-
const options = { formats: ['markdown'] };
|
|
60
|
-
mockClient.asyncBatchScrapeUrls.mockResolvedValueOnce({
|
|
61
|
-
success: true,
|
|
62
|
-
id: 'test-batch-id',
|
|
63
|
-
});
|
|
64
|
-
const response = await requestHandler({
|
|
65
|
-
method: 'call_tool',
|
|
66
|
-
params: {
|
|
67
|
-
name: 'firecrawl_batch_scrape',
|
|
68
|
-
arguments: { urls, options },
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
expect(response.content[0].text).toContain('Batch operation queued with ID: batch_');
|
|
72
|
-
expect(mockClient.asyncBatchScrapeUrls).toHaveBeenCalledWith(urls, options);
|
|
73
|
-
});
|
|
74
|
-
// Test search functionality
|
|
75
|
-
test('should handle search request', async () => {
|
|
76
|
-
const query = 'test query';
|
|
77
|
-
const scrapeOptions = { formats: ['markdown'] };
|
|
78
|
-
const mockSearchResponse = {
|
|
79
|
-
success: true,
|
|
80
|
-
data: [
|
|
81
|
-
{
|
|
82
|
-
url: 'https://example.com',
|
|
83
|
-
title: 'Test Page',
|
|
84
|
-
description: 'Test Description',
|
|
85
|
-
markdown: '# Test Content',
|
|
86
|
-
actions: undefined,
|
|
87
|
-
},
|
|
88
|
-
],
|
|
89
|
-
};
|
|
90
|
-
mockClient.search.mockResolvedValueOnce(mockSearchResponse);
|
|
91
|
-
const response = await requestHandler({
|
|
92
|
-
method: 'call_tool',
|
|
93
|
-
params: {
|
|
94
|
-
name: 'firecrawl_search',
|
|
95
|
-
arguments: { query, scrapeOptions },
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
expect(response.isError).toBe(false);
|
|
99
|
-
expect(response.content[0].text).toContain('Test Page');
|
|
100
|
-
expect(mockClient.search).toHaveBeenCalledWith(query, scrapeOptions);
|
|
101
|
-
});
|
|
102
|
-
// Test crawl functionality
|
|
103
|
-
test('should handle crawl request', async () => {
|
|
104
|
-
const url = 'https://example.com';
|
|
105
|
-
const options = { maxDepth: 2 };
|
|
106
|
-
mockClient.asyncCrawlUrl.mockResolvedValueOnce({
|
|
107
|
-
success: true,
|
|
108
|
-
id: 'test-crawl-id',
|
|
109
|
-
});
|
|
110
|
-
const response = await requestHandler({
|
|
111
|
-
method: 'call_tool',
|
|
112
|
-
params: {
|
|
113
|
-
name: 'firecrawl_crawl',
|
|
114
|
-
arguments: { url, ...options },
|
|
115
|
-
},
|
|
116
|
-
});
|
|
117
|
-
expect(response.isError).toBe(false);
|
|
118
|
-
expect(response.content[0].text).toContain('test-crawl-id');
|
|
119
|
-
expect(mockClient.asyncCrawlUrl).toHaveBeenCalledWith(url, {
|
|
120
|
-
maxDepth: 2,
|
|
121
|
-
url,
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
// Test error handling
|
|
125
|
-
test('should handle API errors', async () => {
|
|
126
|
-
const url = 'https://example.com';
|
|
127
|
-
mockClient.scrapeUrl.mockRejectedValueOnce(new Error('API Error'));
|
|
128
|
-
const response = await requestHandler({
|
|
129
|
-
method: 'call_tool',
|
|
130
|
-
params: {
|
|
131
|
-
name: 'firecrawl_scrape',
|
|
132
|
-
arguments: { url },
|
|
133
|
-
},
|
|
134
|
-
});
|
|
135
|
-
expect(response.isError).toBe(true);
|
|
136
|
-
expect(response.content[0].text).toContain('API Error');
|
|
137
|
-
});
|
|
138
|
-
// Test rate limiting
|
|
139
|
-
test('should handle rate limits', async () => {
|
|
140
|
-
const url = 'https://example.com';
|
|
141
|
-
// Mock rate limit error
|
|
142
|
-
mockClient.scrapeUrl.mockRejectedValueOnce(new Error('rate limit exceeded'));
|
|
143
|
-
const response = await requestHandler({
|
|
144
|
-
method: 'call_tool',
|
|
145
|
-
params: {
|
|
146
|
-
name: 'firecrawl_scrape',
|
|
147
|
-
arguments: { url },
|
|
148
|
-
},
|
|
149
|
-
});
|
|
150
|
-
expect(response.isError).toBe(true);
|
|
151
|
-
expect(response.content[0].text).toContain('rate limit exceeded');
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
// Helper function to simulate request handling
|
|
155
|
-
async function handleRequest(name, args, client) {
|
|
156
|
-
try {
|
|
157
|
-
switch (name) {
|
|
158
|
-
case 'firecrawl_scrape': {
|
|
159
|
-
const response = await client.scrapeUrl(args.url, args);
|
|
160
|
-
if (!response.success) {
|
|
161
|
-
throw new Error(response.error || 'Scraping failed');
|
|
162
|
-
}
|
|
163
|
-
return {
|
|
164
|
-
content: [
|
|
165
|
-
{ type: 'text', text: response.markdown || 'No content available' },
|
|
166
|
-
],
|
|
167
|
-
isError: false,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
case 'firecrawl_batch_scrape': {
|
|
171
|
-
const response = await client.asyncBatchScrapeUrls(args.urls, args.options);
|
|
172
|
-
return {
|
|
173
|
-
content: [
|
|
174
|
-
{
|
|
175
|
-
type: 'text',
|
|
176
|
-
text: `Batch operation queued with ID: batch_1. Use firecrawl_check_batch_status to check progress.`,
|
|
177
|
-
},
|
|
178
|
-
],
|
|
179
|
-
isError: false,
|
|
180
|
-
};
|
|
181
|
-
}
|
|
182
|
-
case 'firecrawl_search': {
|
|
183
|
-
const response = await client.search(args.query, args.scrapeOptions);
|
|
184
|
-
if (!response.success) {
|
|
185
|
-
throw new Error(response.error || 'Search failed');
|
|
186
|
-
}
|
|
187
|
-
const results = response.data
|
|
188
|
-
.map((result) => `URL: ${result.url}\nTitle: ${result.title || 'No title'}\nDescription: ${result.description || 'No description'}\n${result.markdown ? `\nContent:\n${result.markdown}` : ''}`)
|
|
189
|
-
.join('\n\n');
|
|
190
|
-
return {
|
|
191
|
-
content: [{ type: 'text', text: results }],
|
|
192
|
-
isError: false,
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
case 'firecrawl_crawl': {
|
|
196
|
-
const response = await client.asyncCrawlUrl(args.url, args);
|
|
197
|
-
if (!response.success) {
|
|
198
|
-
throw new Error(response.error);
|
|
199
|
-
}
|
|
200
|
-
return {
|
|
201
|
-
content: [
|
|
202
|
-
{
|
|
203
|
-
type: 'text',
|
|
204
|
-
text: `Started crawl for ${args.url} with job ID: ${response.id}`,
|
|
205
|
-
},
|
|
206
|
-
],
|
|
207
|
-
isError: false,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
default:
|
|
211
|
-
throw new Error(`Unknown tool: ${name}`);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
catch (error) {
|
|
215
|
-
return {
|
|
216
|
-
content: [
|
|
217
|
-
{
|
|
218
|
-
type: 'text',
|
|
219
|
-
text: error instanceof Error ? error.message : String(error),
|
|
220
|
-
},
|
|
221
|
-
],
|
|
222
|
-
isError: true,
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
}
|
package/dist/versioned-server.js
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
|
|
3
|
-
import express from 'express';
|
|
4
|
-
import dotenv from 'dotenv';
|
|
5
|
-
import { createV1Server } from './server-v1.js';
|
|
6
|
-
import { createV2Server } from './server-v2.js';
|
|
7
|
-
dotenv.config();
|
|
8
|
-
export async function runVersionedSSECloudServer() {
|
|
9
|
-
const transports = {};
|
|
10
|
-
const app = express();
|
|
11
|
-
// Health check endpoint
|
|
12
|
-
app.get('/health', (req, res) => {
|
|
13
|
-
res.status(200).json({
|
|
14
|
-
status: 'OK',
|
|
15
|
-
versions: ['v1', 'v2'],
|
|
16
|
-
endpoints: {
|
|
17
|
-
v1: {
|
|
18
|
-
sse: '/{apiKey}/sse',
|
|
19
|
-
messages: '/{apiKey}/messages'
|
|
20
|
-
},
|
|
21
|
-
v2: {
|
|
22
|
-
sse: '/{apiKey}/v2/sse',
|
|
23
|
-
messages: '/{apiKey}/v2/messages'
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
});
|
|
28
|
-
// Create server instances
|
|
29
|
-
const v1Server = createV1Server();
|
|
30
|
-
const v2Server = createV2Server();
|
|
31
|
-
// V1 SSE endpoint (legacy)
|
|
32
|
-
app.get('/:apiKey/sse', async (req, res) => {
|
|
33
|
-
const apiKey = req.params.apiKey;
|
|
34
|
-
const transport = new SSEServerTransport(`/${apiKey}/messages`, res);
|
|
35
|
-
console.log(`[V1] New SSE connection for API key: ${apiKey}`);
|
|
36
|
-
const compositeKey = `${apiKey}-${transport.sessionId}`;
|
|
37
|
-
transports[compositeKey] = { transport, version: 'v1' };
|
|
38
|
-
res.on('close', () => {
|
|
39
|
-
console.log(`[V1] SSE connection closed for: ${compositeKey}`);
|
|
40
|
-
delete transports[compositeKey];
|
|
41
|
-
});
|
|
42
|
-
await v1Server.connect(transport);
|
|
43
|
-
});
|
|
44
|
-
// V1 SSE HEAD for quick availability checks
|
|
45
|
-
app.head('/:apiKey/sse', (req, res) => {
|
|
46
|
-
res.status(200).end();
|
|
47
|
-
});
|
|
48
|
-
// V2 SSE endpoint (new)
|
|
49
|
-
app.get('/:apiKey/v2/sse', async (req, res) => {
|
|
50
|
-
const apiKey = req.params.apiKey;
|
|
51
|
-
const transport = new SSEServerTransport(`/${apiKey}/v2/messages`, res);
|
|
52
|
-
console.log(`[V2] New SSE connection for API key: ${apiKey}`);
|
|
53
|
-
const compositeKey = `${apiKey}-${transport.sessionId}`;
|
|
54
|
-
transports[compositeKey] = { transport, version: 'v2' };
|
|
55
|
-
res.on('close', () => {
|
|
56
|
-
console.log(`[V2] SSE connection closed for: ${compositeKey}`);
|
|
57
|
-
delete transports[compositeKey];
|
|
58
|
-
});
|
|
59
|
-
await v2Server.connect(transport);
|
|
60
|
-
});
|
|
61
|
-
// V2 SSE HEAD for quick availability checks
|
|
62
|
-
app.head('/:apiKey/v2/sse', (req, res) => {
|
|
63
|
-
res.status(200).end();
|
|
64
|
-
});
|
|
65
|
-
// V1 message endpoint (legacy)
|
|
66
|
-
app.post('/:apiKey/messages', express.json(), async (req, res) => {
|
|
67
|
-
const apiKey = req.params.apiKey;
|
|
68
|
-
const body = req.body;
|
|
69
|
-
// Enrich the body with API key metadata
|
|
70
|
-
const enrichedBody = {
|
|
71
|
-
...body,
|
|
72
|
-
};
|
|
73
|
-
if (enrichedBody && enrichedBody.params && !enrichedBody.params._meta) {
|
|
74
|
-
enrichedBody.params._meta = { apiKey };
|
|
75
|
-
}
|
|
76
|
-
else if (enrichedBody &&
|
|
77
|
-
enrichedBody.params &&
|
|
78
|
-
enrichedBody.params._meta) {
|
|
79
|
-
enrichedBody.params._meta.apiKey = apiKey;
|
|
80
|
-
}
|
|
81
|
-
console.log(`[V1] Message received for API key: ${apiKey}`);
|
|
82
|
-
// Prefer explicit sessionId from query, then common header names
|
|
83
|
-
const rawSessionId = req.query.sessionId ||
|
|
84
|
-
req.headers['mcp-session-id'] ||
|
|
85
|
-
req.headers['x-mcp-session-id'] ||
|
|
86
|
-
'';
|
|
87
|
-
let compositeKey = `${apiKey}-${rawSessionId}`;
|
|
88
|
-
let versionedTransport = transports[compositeKey];
|
|
89
|
-
// Fallback: if not found, and there is exactly one active V1 transport for this apiKey, use it
|
|
90
|
-
if (!versionedTransport) {
|
|
91
|
-
const candidates = Object.entries(transports).filter(([key, vt]) => vt.version === 'v1' && key.startsWith(`${apiKey}-`));
|
|
92
|
-
if (candidates.length === 1) {
|
|
93
|
-
const [fallbackKey, vt] = candidates[0];
|
|
94
|
-
console.warn(`[V1] sessionId not provided or not found. Falling back to single active transport: ${fallbackKey}`);
|
|
95
|
-
compositeKey = fallbackKey;
|
|
96
|
-
versionedTransport = vt;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (versionedTransport && versionedTransport.version === 'v1') {
|
|
100
|
-
await versionedTransport.transport.handlePostMessage(req, res, enrichedBody);
|
|
101
|
-
}
|
|
102
|
-
else {
|
|
103
|
-
console.error(`[V1] No transport found for sessionId: ${compositeKey}`);
|
|
104
|
-
res.status(400).json({
|
|
105
|
-
error: 'No V1 transport found for sessionId',
|
|
106
|
-
sessionId: compositeKey,
|
|
107
|
-
availableTransports: Object.keys(transports)
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
// V2 message endpoint (new)
|
|
112
|
-
app.post('/:apiKey/v2/messages', express.json(), async (req, res) => {
|
|
113
|
-
const apiKey = req.params.apiKey;
|
|
114
|
-
const body = req.body;
|
|
115
|
-
// Enrich the body with API key metadata
|
|
116
|
-
const enrichedBody = {
|
|
117
|
-
...body,
|
|
118
|
-
};
|
|
119
|
-
if (enrichedBody && enrichedBody.params && !enrichedBody.params._meta) {
|
|
120
|
-
enrichedBody.params._meta = { apiKey };
|
|
121
|
-
}
|
|
122
|
-
else if (enrichedBody &&
|
|
123
|
-
enrichedBody.params &&
|
|
124
|
-
enrichedBody.params._meta) {
|
|
125
|
-
enrichedBody.params._meta.apiKey = apiKey;
|
|
126
|
-
}
|
|
127
|
-
console.log(`[V2] Message received for API key: ${apiKey}`);
|
|
128
|
-
const sessionId = req.query.sessionId;
|
|
129
|
-
const compositeKey = `${apiKey}-${sessionId}`;
|
|
130
|
-
const versionedTransport = transports[compositeKey];
|
|
131
|
-
if (versionedTransport && versionedTransport.version === 'v2') {
|
|
132
|
-
await versionedTransport.transport.handlePostMessage(req, res, enrichedBody);
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
console.error(`[V2] No transport found for sessionId: ${compositeKey}`);
|
|
136
|
-
res.status(400).json({
|
|
137
|
-
error: 'No V2 transport found for sessionId',
|
|
138
|
-
sessionId: compositeKey,
|
|
139
|
-
availableTransports: Object.keys(transports)
|
|
140
|
-
});
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
// Catch-all for unsupported endpoints
|
|
144
|
-
app.use((req, res) => {
|
|
145
|
-
res.status(404).json({
|
|
146
|
-
error: 'Endpoint not found',
|
|
147
|
-
supportedEndpoints: {
|
|
148
|
-
health: '/health',
|
|
149
|
-
v1: {
|
|
150
|
-
sse: '/:apiKey/sse',
|
|
151
|
-
messages: '/:apiKey/messages'
|
|
152
|
-
},
|
|
153
|
-
v2: {
|
|
154
|
-
sse: '/:apiKey/v2/sse',
|
|
155
|
-
messages: '/:apiKey/v2/messages'
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
const PORT = process.env.PORT || 3000;
|
|
161
|
-
const server = app.listen(PORT, () => {
|
|
162
|
-
console.log(`š Versioned MCP SSE Server listening on http://localhost:${PORT}`);
|
|
163
|
-
console.log('š Available endpoints:');
|
|
164
|
-
console.log(` Health: http://localhost:${PORT}/health`);
|
|
165
|
-
console.log(` V1 SSE: http://localhost:${PORT}/{apiKey}/sse`);
|
|
166
|
-
console.log(` V1 Messages: http://localhost:${PORT}/{apiKey}/messages`);
|
|
167
|
-
console.log(` V2 SSE: http://localhost:${PORT}/{apiKey}/v2/sse`);
|
|
168
|
-
console.log(` V2 Messages: http://localhost:${PORT}/{apiKey}/v2/messages`);
|
|
169
|
-
console.log('');
|
|
170
|
-
console.log('š§ Versions:');
|
|
171
|
-
console.log(' V1: Firecrawl JS 1.29.3 (legacy tools + deep research + llms.txt)');
|
|
172
|
-
console.log(' V2: Firecrawl JS 3.1.0 (modern API + JSON extraction)');
|
|
173
|
-
});
|
|
174
|
-
server.on('error', (error) => {
|
|
175
|
-
console.error('ā Server error:', error);
|
|
176
|
-
if (error.code === 'EADDRINUSE') {
|
|
177
|
-
console.error(`ā Port ${PORT} is already in use. Please use a different port.`);
|
|
178
|
-
}
|
|
179
|
-
process.exit(1);
|
|
180
|
-
});
|
|
181
|
-
// Graceful shutdown
|
|
182
|
-
process.on('SIGINT', () => {
|
|
183
|
-
console.log('\nš Shutting down server...');
|
|
184
|
-
console.log(`š Active connections: ${Object.keys(transports).length}`);
|
|
185
|
-
// Close all transports
|
|
186
|
-
for (const [key, versionedTransport] of Object.entries(transports)) {
|
|
187
|
-
try {
|
|
188
|
-
console.log(`š Closing transport: ${key} (${versionedTransport.version})`);
|
|
189
|
-
// Note: SSEServerTransport doesn't have a close method, connections will close naturally
|
|
190
|
-
delete transports[key];
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
console.error(`ā Error closing transport ${key}:`, error);
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
console.log('ā
Server shutdown complete');
|
|
197
|
-
process.exit(0);
|
|
198
|
-
});
|
|
199
|
-
}
|
|
200
|
-
// Start the server if this file is run directly
|
|
201
|
-
// if (import.meta.url === `file://${process.argv[1]}`) {
|
|
202
|
-
// runVersionedSSECloudServer().catch(console.error);
|
|
203
|
-
// }
|