Firecrawl MCP Server

by mcma123
Verified
MIT License
9,757
  • Apple
  • Linux
import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import FirecrawlApp from '@mendable/firecrawl-js'; import type { SearchResponse, BatchScrapeResponse, BatchScrapeStatusResponse, CrawlResponse, CrawlStatusResponse, ScrapeResponse, FirecrawlDocument, SearchParams, } from '@mendable/firecrawl-js'; import { describe, expect, jest, test, beforeEach, afterEach, } from '@jest/globals'; import { mock, MockProxy } from 'jest-mock-extended'; // Mock FirecrawlApp jest.mock('@mendable/firecrawl-js'); // Test interfaces interface RequestParams { method: string; params: { name: string; arguments?: Record<string, any>; }; } interface BatchScrapeArgs { urls: string[]; options?: { formats?: string[]; [key: string]: any; }; } interface StatusCheckArgs { id: string; } interface SearchArgs { query: string; scrapeOptions?: { formats?: string[]; onlyMainContent?: boolean; }; } interface ScrapeArgs { url: string; formats?: string[]; onlyMainContent?: boolean; } interface CrawlArgs { url: string; maxDepth?: number; limit?: number; } // Mock client interface interface MockFirecrawlClient { scrapeUrl(url: string, options?: any): Promise<ScrapeResponse>; search(query: string, params?: SearchParams): Promise<SearchResponse>; asyncBatchScrapeUrls( urls: string[], options?: any ): Promise<BatchScrapeResponse>; checkBatchScrapeStatus(id: string): Promise<BatchScrapeStatusResponse>; asyncCrawlUrl(url: string, options?: any): Promise<CrawlResponse>; checkCrawlStatus(id: string): Promise<CrawlStatusResponse>; mapUrl(url: string, options?: any): Promise<{ links: string[] }>; } describe('FireCrawl Tool Tests', () => { let mockClient: MockProxy<MockFirecrawlClient>; let requestHandler: (request: RequestParams) => Promise<any>; beforeEach(() => { jest.clearAllMocks(); mockClient = mock<MockFirecrawlClient>(); // Set up mock implementations const mockInstance = new FirecrawlApp({ apiKey: 'test' }); Object.assign(mockInstance, mockClient); // Create request handler requestHandler = async (request: RequestParams) => { const { name, arguments: args } = request.params; if (!args) { throw new Error('No arguments provided'); } return handleRequest(name, args, mockClient); }; }); afterEach(() => { jest.clearAllMocks(); }); // Test scrape functionality test('should handle scrape request', async () => { const url = 'https://example.com'; const options = { formats: ['markdown'] }; const mockResponse: ScrapeResponse = { success: true, markdown: '# Test Content', html: undefined, rawHtml: undefined, url: 'https://example.com', actions: undefined as never, }; mockClient.scrapeUrl.mockResolvedValueOnce(mockResponse); const response = await requestHandler({ method: 'call_tool', params: { name: 'firecrawl_scrape', arguments: { url, ...options }, }, }); expect(response).toEqual({ content: [{ type: 'text', text: '# Test Content' }], isError: false, }); expect(mockClient.scrapeUrl).toHaveBeenCalledWith(url, { formats: ['markdown'], url, }); }); // Test batch scrape functionality test('should handle batch scrape request', async () => { const urls = ['https://example.com']; const options = { formats: ['markdown'] }; mockClient.asyncBatchScrapeUrls.mockResolvedValueOnce({ success: true, id: 'test-batch-id', }); const response = await requestHandler({ method: 'call_tool', params: { name: 'firecrawl_batch_scrape', arguments: { urls, options }, }, }); expect(response.content[0].text).toContain( 'Batch operation queued with ID: batch_' ); expect(mockClient.asyncBatchScrapeUrls).toHaveBeenCalledWith(urls, options); }); // Test search functionality test('should handle search request', async () => { const query = 'test query'; const scrapeOptions = { formats: ['markdown'] }; const mockSearchResponse: SearchResponse = { success: true, data: [ { url: 'https://example.com', title: 'Test Page', description: 'Test Description', markdown: '# Test Content', actions: undefined as never, }, ], }; mockClient.search.mockResolvedValueOnce(mockSearchResponse); const response = await requestHandler({ method: 'call_tool', params: { name: 'firecrawl_search', arguments: { query, scrapeOptions }, }, }); expect(response.isError).toBe(false); expect(response.content[0].text).toContain('Test Page'); expect(mockClient.search).toHaveBeenCalledWith(query, scrapeOptions); }); // Test crawl functionality test('should handle crawl request', async () => { const url = 'https://example.com'; const options = { maxDepth: 2 }; mockClient.asyncCrawlUrl.mockResolvedValueOnce({ success: true, id: 'test-crawl-id', }); const response = await requestHandler({ method: 'call_tool', params: { name: 'firecrawl_crawl', arguments: { url, ...options }, }, }); expect(response.isError).toBe(false); expect(response.content[0].text).toContain('test-crawl-id'); expect(mockClient.asyncCrawlUrl).toHaveBeenCalledWith(url, { maxDepth: 2, url, }); }); // Test error handling test('should handle API errors', async () => { const url = 'https://example.com'; mockClient.scrapeUrl.mockRejectedValueOnce(new Error('API Error')); const response = await requestHandler({ method: 'call_tool', params: { name: 'firecrawl_scrape', arguments: { url }, }, }); expect(response.isError).toBe(true); expect(response.content[0].text).toContain('API Error'); }); // Test rate limiting test('should handle rate limits', async () => { const url = 'https://example.com'; // Mock rate limit error mockClient.scrapeUrl.mockRejectedValueOnce( new Error('rate limit exceeded') ); const response = await requestHandler({ method: 'call_tool', params: { name: 'firecrawl_scrape', arguments: { url }, }, }); expect(response.isError).toBe(true); expect(response.content[0].text).toContain('rate limit exceeded'); }); }); // Helper function to simulate request handling async function handleRequest( name: string, args: any, client: MockFirecrawlClient ) { try { switch (name) { case 'firecrawl_scrape': { const response = await client.scrapeUrl(args.url, args); if (!response.success) { throw new Error(response.error || 'Scraping failed'); } return { content: [ { type: 'text', text: response.markdown || 'No content available' }, ], isError: false, }; } case 'firecrawl_batch_scrape': { const response = await client.asyncBatchScrapeUrls( args.urls, args.options ); return { content: [ { type: 'text', text: `Batch operation queued with ID: batch_1. Use firecrawl_check_batch_status to check progress.`, }, ], isError: false, }; } case 'firecrawl_search': { const response = await client.search(args.query, args.scrapeOptions); if (!response.success) { throw new Error(response.error || 'Search failed'); } const results = response.data .map( (result) => `URL: ${result.url}\nTitle: ${ result.title || 'No title' }\nDescription: ${result.description || 'No description'}\n${ result.markdown ? `\nContent:\n${result.markdown}` : '' }` ) .join('\n\n'); return { content: [{ type: 'text', text: results }], isError: false, }; } case 'firecrawl_crawl': { const response = await client.asyncCrawlUrl(args.url, args); if (!response.success) { throw new Error(response.error); } return { content: [ { type: 'text', text: `Started crawl for ${args.url} with job ID: ${response.id}`, }, ], isError: false, }; } default: throw new Error(`Unknown tool: ${name}`); } } catch (error) { return { content: [ { type: 'text', text: error instanceof Error ? error.message : String(error), }, ], isError: true, }; } }