# Directory Structure ``` ├── .dockerignore ├── .gitignore ├── .npmignore ├── .smithery │ └── index.cjs ├── bin │ └── cli.js ├── Dockerfile ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── smithery.yaml ├── src │ ├── index.js │ ├── index.ts │ ├── tools │ │ ├── feloTool.js │ │ ├── fetchUrlTool.js │ │ ├── metadataTool.js │ │ └── searchTool.js │ └── utils │ ├── search_felo.js │ └── search.js └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- ``` node_modules npm-debug.log .dockerignore .git .gitignore .smithery dist .build .idea .vscode **/*.md **/*.log ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` .qodo # Dependency directories node_modules/ npm-debug.log yarn-debug.log yarn-error.log # Environment variables .env .env.local .env.development.local .env.test.local .env.production.local # Build directories dist/ build/ # IDE and editor files .idea/ .vscode/ *.swp *.swo .DS_Store ``` -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- ``` # Development files .git/ .github/ .vscode/ .idea/ .DS_Store # Test files test/ tests/ __tests__/ coverage/ # Configuration files .eslintrc* .prettierrc* .editorconfig tsconfig.json jest.config.js # Logs logs/ *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Misc .qodo .env .env.* node_modules/ ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown <div align="center"> <img src="https://img.shields.io/npm/v/@oevortex/ddg_search.svg" alt="npm version" /> <img src="https://img.shields.io/badge/License-Apache%202.0-blue.svg" alt="License: Apache 2.0" /> <img src="https://img.shields.io/badge/YouTube-%40OEvortex-red.svg" alt="YouTube Channel" /> <h1>DuckDuckGo & Felo AI Search MCP 🔍🧠</h1> <p>A blazing-fast, privacy-friendly Model Context Protocol (MCP) server for web search and AI-powered responses using DuckDuckGo and Felo AI.</p> <a href="https://glama.ai/mcp/servers/@OEvortex/ddg_search"> <img width="380" height="200" src="https://glama.ai/mcp/servers/@OEvortex/ddg_search/badge" alt="DuckDuckGo Search MCP server" /> </a> <a href="https://youtube.com/@OEvortex"><strong>Subscribe for updates & tutorials</strong></a> </div> --- > [!IMPORTANT] > DuckDuckGo Search MCP supports the Model Context Protocol (MCP) standard, making it compatible with various AI assistants and tools. --- ## ✨ Features <div style="display: flex; flex-wrap: wrap; gap: 1.5em; margin-bottom: 1.5em;"> <div><b>🌐 Web search</b> using DuckDuckGo HTML</div> <div><b>🧠 AI search</b> using Felo AI</div> <div><b>📄 URL content extraction</b> with smart filtering</div> <div><b>📊 URL metadata extraction</b> (title, description, images)</div> <div><b>⚡ Performance optimized</b> with caching</div> <div><b>🛡️ Security features</b> including rate limiting and rotating user agents</div> <div><b>🔌 MCP-compliant</b> server implementation</div> <div><b>🆓 No API keys required</b> - works out of the box</div> </div> > [!IMPORTANT] > Unlike many search tools, this package performs actual web scraping rather than using limited APIs, giving you more comprehensive results. --- ## 🚀 Quick Start <div style="background: #222; color: #fff; padding: 1.5em; border-radius: 8px; margin: 1.5em 0;"> <b>Run instantly with npx:</b> ```bash npx -y @oevortex/ddg_search@latest ``` </div> > [!TIP] > This will download and run the latest version of the MCP server directly without installation – perfect for quick use with AI assistants. --- ## 🛠️ Installation Options <details> <summary><b>Global Installation</b></summary> ```bash npm install -g @oevortex/ddg_search ``` Run globally: ```bash ddg-search-mcp ``` </details> <details> <summary><b>Local Installation (Development)</b></summary> ```bash git clone https://github.com/OEvortex/ddg_search.git cd ddg_search npm install npm start ``` </details> --- ## 🧑💻 Command Line Options ```bash npx -y @oevortex/ddg_search@latest --help ``` > [!TIP] > Use the <code>--version</code> flag to check which version you're running. --- ## 🤖 Using with MCP Clients > [!IMPORTANT] > The most common way to use this tool is by integrating it with MCP-compatible AI assistants. Add the server to your MCP client configuration: ```json { "mcpServers": { "ddg-search": { "command": "npx", "args": ["-y", "@oevortex/ddg_search@latest"] } } } ``` Or if installed globally: ```json { "mcpServers": { "ddg-search": { "command": "ddg-search-mcp" } } } ``` > [!TIP] > After configuring, restart your MCP client to apply the changes. --- ## 🧰 Tools Overview <div style="display: flex; flex-wrap: wrap; gap: 2.5em; margin: 1.5em 0;"> <div style="margin-bottom: 1.5em;"> <b>🔍 Web Search Tool</b><br/> <code>web-search</code><br/> <ul> <li><b>query</b> (string, required): The search query</li> <li><b>page</b> (integer, optional, default: 1): Page number</li> <li><b>numResults</b> (integer, optional, default: 10): Number of results (1-20)</li> </ul> <i>Example: Search the web for "climate change solutions"</i> </div> <div style="margin-bottom: 1.5em;"> <b>🧠 Felo AI Search Tool</b><br/> <code>felo-search</code><br/> <ul> <li><b>query</b> (string, required): The search query or prompt</li> <li><b>stream</b> (boolean, optional, default: false): Whether to stream the response</li> </ul> <i>Example: Search Felo AI for "Explain quantum computing in simple terms"</i> </div> <div style="margin-bottom: 1.5em;"> <b>📄 Fetch URL Tool</b><br/> <code>fetch-url</code><br/> <ul> <li><b>url</b> (string, required): The URL to fetch</li> <li><b>maxLength</b> (integer, optional, default: 10000): Max content length</li> <li><b>extractMainContent</b> (boolean, optional, default: true): Extract main content</li> <li><b>includeLinks</b> (boolean, optional, default: true): Include link text</li> <li><b>includeImages</b> (boolean, optional, default: true): Include image alt text</li> <li><b>excludeTags</b> (array, optional): Tags to exclude</li> </ul> <i>Example: Fetch the content from "https://example.com"</i> </div> <div style="margin-bottom: 1.5em;"> <b>📊 URL Metadata Tool</b><br/> <code>url-metadata</code><br/> <ul> <li><b>url</b> (string, required): The URL to extract metadata from</li> </ul> <i>Example: Get metadata for "https://example.com"</i> </div> </div> --- ## 📁 Project Structure ```text bin/ # Command-line interface src/ index.js # Main entry point tools/ # Tool definitions and handlers searchTool.js fetchUrlTool.js metadataTool.js feloTool.js utils/ search.js # Search and URL utilities search_felo.js # Felo AI search utilities package.json README.md ``` --- ## 🤝 Contributing Contributions are welcome! Please open issues or submit pull requests. > [!NOTE] > Please follow the existing code style and add tests for new features. --- ## 📺 YouTube Channel <div align="center"> <a href="https://youtube.com/@OEvortex"><img src="https://img.shields.io/badge/YouTube-%40OEvortex-red.svg" alt="YouTube Channel" /></a> <br/> <a href="https://youtube.com/@OEvortex">youtube.com/@OEvortex</a> </div> --- ## 📄 License Apache License 2.0 > [!NOTE] > This project is licensed under the Apache License 2.0 – see the <a href="LICENSE">LICENSE</a> file for details. --- <div align="center"> <sub>Made with ❤️ by <a href="https://youtube.com/@OEvortex">@OEvortex</a></sub> </div> ``` -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- ```yaml runtime: typescript build: external: - canvas - utf-8-validate - bufferutil esbuild: bundle: true platform: node format: cjs target: node18 ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "ES2022", "moduleResolution": "node", "allowSyntheticDefaultImports": true, "esModuleInterop": true, "allowJs": true, "checkJs": false, "outDir": "./dist", "rootDir": "./src", "strict": false, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "declaration": true, "declarationMap": true, "sourceMap": true, "types": ["node"] }, "include": [ "src/**/*" ], "exclude": [ "node_modules", "dist", "bin" ] } ``` -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- ```dockerfile # Build stage: install dependencies FROM node:22-slim AS build WORKDIR /app # Copy package manifests and lockfile first for better caching COPY package.json package-lock.json ./ # Install production dependencies (use npm ci when lockfile exists) RUN if [ -f package-lock.json ]; then npm ci --production; else npm install --production; fi # Copy application source COPY . . # Final minimal runtime image FROM node:22-slim AS runtime WORKDIR /app # Copy node_modules and built app from build stage COPY --from=build /app/node_modules ./node_modules COPY --from=build /app . # Expose port in case the MCP server needs it EXPOSE 3000 # Default command: use the CLI entry which starts the MCP server CMD ["node", "bin/cli.js"] ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json {"name":"@oevortex/ddg_search","version":"1.1.2","description":"A Model Context Protocol server for web search using DuckDuckGo and Felo AI","main":"src/index.js","module":"src/index.ts","exports":{".":{"import":"./src/index.js","default":"./src/index.js"}},"bin":{"ddg-search-mcp":"bin/cli.js","oevortex-ddg-search":"bin/cli.js"},"scripts":{"test":"echo \"Error: no test specified\" && exit 1","start":"node bin/cli.js","prepublishOnly":"npm run lint","lint":"echo \"No linting configured\"","build":"npx @smithery/cli build","dev":"npx @smithery/cli dev"},"publishConfig":{"access":"public"},"keywords":["mcp","model-context-protocol","duckduckgo","felo","search","web-search","ai-search","claude","ai","llm"],"author":"OEvortex","license":"Apache-2.0","type":"module","dependencies":{"@modelcontextprotocol/sdk":"^1.17.4","axios":"^1.8.4","cheerio":"^1.0.0","jsdom":"^26.1.0","smithery":"^0.5.2","uuid":"^9.0.1"},"devDependencies":{"@types/node":"^24.3.0","tsx":"^4.20.4","typescript":"^5.9.2"}} ``` -------------------------------------------------------------------------------- /src/tools/metadataTool.js: -------------------------------------------------------------------------------- ```javascript import { extractUrlMetadata } from '../utils/search.js'; /** * URL metadata tool definition */ export const metadataToolDefinition = { name: 'url-metadata', title: 'URL Metadata Extractor', description: 'Extract metadata from a URL including title, description, Open Graph data, and favicon information', inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'The URL to extract metadata from (must be a valid HTTP/HTTPS URL)' } }, required: ['url'] } }; /** * URL metadata tool handler * @param {Object} params - The tool parameters * @returns {Promise<Object>} - The tool result */ export async function metadataToolHandler(params) { const { url } = params; console.log(`Extracting metadata from URL: ${url}`); const metadata = await extractUrlMetadata(url); // Format the metadata for display const formattedMetadata = ` ## URL Metadata for ${url} **Title:** ${metadata.title} **Description:** ${metadata.description} **Image:** ${metadata.ogImage || 'None'} **Favicon:** ${metadata.favicon || 'None'} `.trim(); return { content: [ { type: 'text', text: formattedMetadata } ] }; } ``` -------------------------------------------------------------------------------- /src/tools/searchTool.js: -------------------------------------------------------------------------------- ```javascript import { searchDuckDuckGo } from '../utils/search.js'; /** * Web search tool definition */ export const searchToolDefinition = { name: 'web-search', title: 'Web Search', description: 'Search the web using DuckDuckGo and return comprehensive results with titles, URLs, and snippets', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'The search query to find relevant web pages' }, page: { type: 'integer', description: 'Page number for pagination (default: 1)', default: 1, minimum: 1 }, numResults: { type: 'integer', description: 'Number of results to return per page (default: 10, max: 20)', default: 10, minimum: 1, maximum: 20 } }, required: ['query'] } }; /** * Web search tool handler * @param {Object} params - The tool parameters * @returns {Promise<Object>} - The tool result */ export async function searchToolHandler(params) { const { query, page = 1, numResults = 10 } = params; console.log(`Searching for: ${query} (page ${page}, ${numResults} results)`); const results = await searchDuckDuckGo(query, page, numResults); console.log(`Found ${results.length} results`); // Format the results for display const formattedResults = results.map((result, index) => `${index + 1}. [${result.title}](${result.url})\n ${result.snippet}` ).join('\n\n'); return { content: [ { type: 'text', text: formattedResults || 'No results found.' } ] }; } ``` -------------------------------------------------------------------------------- /src/tools/feloTool.js: -------------------------------------------------------------------------------- ```javascript import { searchFelo } from '../utils/search_felo.js'; /** * Felo AI search tool definition */ export const feloToolDefinition = { name: 'felo-search', title: 'Felo AI Advanced Search', description: 'Advanced AI-powered web search for technical intelligence. Retrieves up-to-date information including software releases, security advisories, migration guides, benchmarks, developer documentation, and community insights. Supports both standard and streaming responses.', inputSchema: { type: 'object', properties: { query: { type: 'string', description: 'A detailed search query or prompt describing the technical information needed. Supports natural language and keyword-based queries for precise results.' }, stream: { type: 'boolean', description: 'Enable streaming mode to receive incremental, real-time search results as they are discovered. Useful for monitoring live updates or large result sets. Default is false (returns full result at once).', default: false } }, required: ['query'] }, annotations: { readOnlyHint: true, openWorldHint: false } }; /** * Felo AI search tool handler * @param {Object} params - The tool parameters * @returns {Promise<Object>} - The tool result */ export async function feloToolHandler(params) { const { query, stream = false } = params; console.log(`Searching Felo AI for: "${query}" (stream: ${stream})`); try { if (stream) { // For streaming responses, we need to collect them and then return let fullResponse = ''; const chunks = []; for await (const chunk of await searchFelo(query, true)) { chunks.push(chunk); fullResponse += chunk; } // Format the response return { content: [ { type: 'text', text: fullResponse || 'No results found.' } ] }; } else { // For non-streaming responses const response = await searchFelo(query, false); return { content: [ { type: 'text', text: response || 'No results found.' } ] }; } } catch (error) { console.error(`Error in Felo search: ${error.message}`); return { isError: true, content: [ { type: 'text', text: `Error searching Felo: ${error.message}` } ] }; } } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; // Import tool definitions and handlers import { searchToolDefinition, searchToolHandler } from './tools/searchTool.js'; import { fetchUrlToolDefinition, fetchUrlToolHandler } from './tools/fetchUrlTool.js'; import { metadataToolDefinition, metadataToolHandler } from './tools/metadataTool.js'; import { feloToolDefinition, feloToolHandler } from './tools/feloTool.js'; // Required: Export default createServer function for Smithery export default function createServer({ config }: { config?: any } = {}) { console.log('Creating MCP server with latest SDK...'); // Global variable to track available tools const availableTools = [ searchToolDefinition, fetchUrlToolDefinition, metadataToolDefinition, feloToolDefinition ]; console.log('Available tools:', availableTools.map(t => t.name)); // Create the MCP server using the Server class const server = new Server({ name: 'ddg-search-mcp', version: '1.1.2' }, { capabilities: { tools: { listChanged: true } } }); // Define available tools server.setRequestHandler(ListToolsRequestSchema, async () => { console.log('Tools list requested, returning:', availableTools.length, 'tools'); return { tools: availableTools }; }); // Handle tool execution server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; console.log(`Tool call received: ${name} with args:`, args); // Route to the appropriate tool handler switch (name) { case 'web-search': return await searchToolHandler(args); case 'fetch-url': return await fetchUrlToolHandler(args); case 'url-metadata': return await metadataToolHandler(args); case 'felo-search': return await feloToolHandler(args); default: throw new Error(`Tool not found: ${name}`); } } catch (error: any) { console.error(`Error handling ${request.params.name} tool call:`, error); // Return proper tool execution error format return { isError: true, content: [ { type: 'text', text: `Error executing tool '${request.params.name}': ${error.message}` } ] }; } }); console.log('MCP server created successfully'); // Return the server instance (required for Smithery) return server; } // Optional: No configuration schema needed for this server // export const configSchema = z.object({}); ``` -------------------------------------------------------------------------------- /src/tools/fetchUrlTool.js: -------------------------------------------------------------------------------- ```javascript import { fetchUrlContent } from '../utils/search.js'; /** * Fetch URL tool definition */ export const fetchUrlToolDefinition = { name: 'fetch-url', title: 'Fetch URL Content', description: 'Fetch and extract the main content from any URL, with customizable extraction options for text, links, and images', inputSchema: { type: 'object', properties: { url: { type: 'string', description: 'The URL to fetch content from (must be a valid HTTP/HTTPS URL)' }, maxLength: { type: 'integer', description: 'Maximum length of content to return in characters (default: 10000)', default: 10000, minimum: 1000, maximum: 50000 }, extractMainContent: { type: 'boolean', description: 'Whether to attempt to extract main content only, filtering out navigation and ads (default: true)', default: true }, includeLinks: { type: 'boolean', description: 'Whether to include link text in the extracted content (default: true)', default: true }, includeImages: { type: 'boolean', description: 'Whether to include image alt text in the extracted content (default: true)', default: true }, excludeTags: { type: 'array', description: 'HTML tags to exclude from extraction (default: script, style, etc.)', items: { type: 'string' } } }, required: ['url'] } }; /** * Fetch URL tool handler * @param {Object} params - The tool parameters * @returns {Promise<Object>} - The tool result */ export async function fetchUrlToolHandler(params) { const { url, maxLength = 10000, extractMainContent = true, includeLinks = true, includeImages = true, excludeTags = ['script', 'style', 'noscript', 'iframe', 'svg', 'nav', 'footer', 'header', 'aside'] } = params; console.log(`Fetching content from URL: ${url} (maxLength: ${maxLength})`); try { // Fetch content with specified options const content = await fetchUrlContent(url, { extractMainContent, includeLinks, includeImages, excludeTags }); // Truncate content if it's too long const truncatedContent = content.length > maxLength ? content.substring(0, maxLength) + '... [Content truncated due to length]' : content; // Add metadata about the extraction const metadata = ` --- Extraction settings: - URL: ${url} - Main content extraction: ${extractMainContent ? 'Enabled' : 'Disabled'} - Links included: ${includeLinks ? 'Yes' : 'No'} - Images included: ${includeImages ? 'Yes (as alt text)' : 'No'} - Content length: ${content.length} characters${content.length > maxLength ? ` (truncated to ${maxLength})` : ''} --- `; return { content: [ { type: 'text', text: truncatedContent + metadata } ] }; } catch (error) { console.error(`Error fetching URL ${url}:`, error); return { isError: true, content: [ { type: 'text', text: `Error fetching URL: ${error.message}` } ] }; } } ``` -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- ```javascript import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; // Import tool definitions and handlers import { searchToolDefinition, searchToolHandler } from './tools/searchTool.js'; import { fetchUrlToolDefinition, fetchUrlToolHandler } from './tools/fetchUrlTool.js'; import { metadataToolDefinition, metadataToolHandler } from './tools/metadataTool.js'; import { feloToolDefinition, feloToolHandler } from './tools/feloTool.js'; // Required: Export default createServer function for Smithery export default function createServer({ config } = {}) { console.log('Creating MCP server with latest SDK...'); // Global variable to track available tools const availableTools = [ searchToolDefinition, fetchUrlToolDefinition, metadataToolDefinition, feloToolDefinition ]; console.log('Available tools:', availableTools.map(t => t.name)); // Create the MCP server using the Server class const server = new Server({ name: 'ddg-search-mcp', version: '1.1.2' }, { capabilities: { tools: { listChanged: true } } }); // Define available tools server.setRequestHandler(ListToolsRequestSchema, async () => { console.log('Tools list requested, returning:', availableTools.length, 'tools'); return { tools: availableTools }; }); // Handle tool execution server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; console.log(`Tool call received: ${name} with args:`, args); // Route to the appropriate tool handler switch (name) { case 'web-search': return await searchToolHandler(args); case 'fetch-url': return await fetchUrlToolHandler(args); case 'url-metadata': return await metadataToolHandler(args); case 'felo-search': return await feloToolHandler(args); default: throw new Error(`Tool not found: ${name}`); } } catch (error) { console.error(`Error handling ${request.params.name} tool call:`, error); // Return proper tool execution error format return { isError: true, content: [ { type: 'text', text: `Error executing tool '${request.params.name}': ${error.message}` } ] }; } }); console.log('MCP server created successfully'); // Return the server instance (required for Smithery) return server; } // Legacy standalone server support (for CLI usage) if (import.meta.url === `file://${process.argv[1]}`) { async function main() { try { const { StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js'); const server = createServer(); const transport = new StdioServerTransport(); await server.connect(transport); console.error('WebSearch MCP server started and listening on stdio'); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } } main(); } ``` -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- ```javascript #!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; // Import tool definitions and handlers const modulePath = new URL('../src', import.meta.url).pathname; // Dynamic imports async function startServer() { try { // Dynamically import the modules const { searchToolDefinition, searchToolHandler } = await import(`${modulePath}/tools/searchTool.js`); const { fetchUrlToolDefinition, fetchUrlToolHandler } = await import(`${modulePath}/tools/fetchUrlTool.js`); const { metadataToolDefinition, metadataToolHandler } = await import(`${modulePath}/tools/metadataTool.js`); const { feloToolDefinition, feloToolHandler } = await import(`${modulePath}/tools/feloTool.js`); // Create the MCP server const server = new Server({ id: 'ddg-search-mcp', name: 'DuckDuckGo & Felo AI Search MCP', description: 'A Model Context Protocol server for web search using DuckDuckGo and Felo AI', version: '1.1.2' }, { capabilities: { tools: { listChanged: true } } }); // Global variable to track available tools let availableTools = [ searchToolDefinition, fetchUrlToolDefinition, metadataToolDefinition, feloToolDefinition ]; // Define available tools server.setRequestHandler(ListToolsRequestSchema, async () => { return { tools: availableTools }; }); // Function to notify clients when tools list changes function notifyToolsChanged() { server.notification({ method: 'notifications/tools/list_changed' }); } // Handle tool execution server.setRequestHandler(CallToolRequestSchema, async (request) => { try { const { name, arguments: args } = request.params; // Validate tool name const validTools = ['web-search', 'fetch-url', 'url-metadata', 'felo-search']; if (!validTools.includes(name)) { throw new Error(`Unknown tool: ${name}`); } // Route to the appropriate tool handler switch (name) { case 'web-search': return await searchToolHandler(args); case 'fetch-url': return await fetchUrlToolHandler(args); case 'url-metadata': return await metadataToolHandler(args); case 'felo-search': return await feloToolHandler(args); default: throw new Error(`Tool not found: ${name}`); } } catch (error) { console.error(`Error handling ${request.params.name} tool call:`, error); // Return proper tool execution error format return { isError: true, content: [ { type: 'text', text: `Error executing tool '${request.params.name}': ${error.message}` } ] }; } }); // Display promotional message console.error('\n\x1b[36m╔════════════════════════════════════════════════════════════╗'); console.error('║ ║'); console.error('║ \x1b[1m\x1b[31mDuckDuckGo & Felo AI Search MCP\x1b[0m\x1b[36m by \x1b[1m\x1b[33m@OEvortex\x1b[0m\x1b[36m ║'); console.error('║ ║'); console.error('║ \x1b[0m👉 Subscribe to \x1b[1m\x1b[37myoutube.com/@OEvortex\x1b[0m\x1b[36m for more tools! ║'); console.error('║ ║'); console.error('╚════════════════════════════════════════════════════════════╝\x1b[0m\n'); // Start the server with stdio transport const transport = new StdioServerTransport(); await server.connect(transport); console.error('DuckDuckGo & Felo AI Search MCP server started and listening on stdio'); } catch (error) { console.error('Failed to start server:', error); process.exit(1); } } // Parse command line arguments const args = process.argv.slice(2); const helpFlag = args.includes('--help') || args.includes('-h'); const versionFlag = args.includes('--version') || args.includes('-v'); if (helpFlag) { console.log(` DuckDuckGo & Felo AI Search MCP - A Model Context Protocol server for web search Usage: npx -y @oevortex/ddg_search@latest [options] Options: -h, --help Show this help message -v, --version Show version information This MCP server provides the following tools: - web-search: Search the web using DuckDuckGo - fetch-url: Fetch and extract content from a URL - url-metadata: Extract metadata from a URL - felo-search: Search using Felo AI for AI-generated responses Created by @OEvortex Subscribe to youtube.com/@OEvortex for more tools and tutorials! For more information, visit: https://github.com/OEvortex/ddg_search `); process.exit(0); } if (versionFlag) { // Read version from package.json using fs import('fs/promises') .then(async ({ readFile }) => { try { const packageJson = JSON.parse( await readFile(new URL('../package.json', import.meta.url), 'utf8') ); console.log(`DuckDuckGo & Felo AI Search MCP v${packageJson.version}\nCreated by @OEvortex - Subscribe to youtube.com/@OEvortex!`); process.exit(0); } catch (err) { console.error('Error reading version information:', err); process.exit(1); } }) .catch(err => { console.error('Error importing fs module:', err); process.exit(1); }); } else { // Start the server startServer(); } ``` -------------------------------------------------------------------------------- /src/utils/search_felo.js: -------------------------------------------------------------------------------- ```javascript import axios from 'axios'; import { v4 as uuidv4 } from 'uuid'; import https from 'https'; // Rotating User Agents const USER_AGENTS = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Edge/120.0.0.0', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ]; // Cache results to avoid repeated requests const resultsCache = new Map(); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes // HTTPS agent configuration to handle certificate chain issues const httpsAgent = new https.Agent({ rejectUnauthorized: true, // Keep security enabled keepAlive: true, timeout: 30000, // Provide fallback for certificate issues while maintaining security secureProtocol: 'TLSv1_2_method' }); // Create a persistent axios instance to maintain session state const feloSession = axios.create({ timeout: 30000, httpsAgent: httpsAgent, headers: { 'accept': '*/*', 'accept-encoding': 'gzip, deflate, br, zstd', 'accept-language': 'en-US,en;q=0.9,en-IN;q=0.8', 'content-type': 'application/json', 'dnt': '1', 'origin': 'https://felo.ai', 'referer': 'https://felo.ai/', 'sec-ch-ua': '"Not)A;Brand";v="99", "Microsoft Edge";v="127", "Chromium";v="127"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"Windows"', 'sec-fetch-dest': 'empty', 'sec-fetch-mode': 'cors', 'sec-fetch-site': 'same-site', 'user-agent': getRandomUserAgent() } }); /** * Response class for Felo API responses */ class Response { /** * Create a new Response * @param {string} text - The text content of the response */ constructor(text) { this.text = text; } /** * String representation of the response * @returns {string} The text content */ toString() { return this.text; } } /** * Get a random user agent from the list * @returns {string} A random user agent string */ function getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; } /** * Generate a cache key for a search query * @param {string} query - The search query * @returns {string} The cache key */ function getCacheKey(query) { return `felo-${query}`; } /** * Clear old entries from the cache */ function clearOldCache() { const now = Date.now(); for (const [key, value] of resultsCache.entries()) { if (now - value.timestamp > CACHE_DURATION) { resultsCache.delete(key); } } } /** * Search using the Felo AI API * @param {string} prompt - The search query or prompt * @param {boolean} stream - If true, yields response chunks as they arrive * @param {boolean} raw - If true, returns raw response dictionaries * @returns {Promise<string|AsyncGenerator<string>>} The search results */ async function searchFelo(prompt, stream = false, raw = false) { // Clear old cache entries clearOldCache(); // Check cache first if not streaming if (!stream) { const cacheKey = getCacheKey(prompt); const cachedResults = resultsCache.get(cacheKey); if (cachedResults && Date.now() - cachedResults.timestamp < CACHE_DURATION) { return cachedResults.results; } } // Create payload for Felo API with proper structure from reference const payload = { query: prompt, search_uuid: uuidv4().replace(/-/g, ''), // Remove dashes like in reference lang: "", agent_lang: "en", search_options: { langcode: "en-US", search_image: true, search_video: true }, search_video: true, model: "", contexts_from: "google", auto_routing: true }; // Update user agent for this request feloSession.defaults.headers['user-agent'] = getRandomUserAgent(); // Define the streaming function async function* streamFunction() { try { const response = await feloSession.post('https://api.felo.ai/search/threads', payload, { responseType: 'stream' }); // Check for HTTP errors if (response.status !== 200) { throw new Error(`Failed to generate response - (${response.status}, ${response.statusText}) - ${response.data}`); } let streamingText = ''; let buffer = ''; // Process the stream as it comes in for await (const chunk of response.data) { buffer += chunk.toString(); const lines = buffer.split('\n'); buffer = lines.pop() || ''; // Keep the last (potentially incomplete) line in the buffer for (const line of lines) { if (line.startsWith('data:')) { try { const dataStr = line.substring(5).trim(); if (dataStr) { const data = JSON.parse(dataStr); if (data.type === 'answer' && 'text' in data.data) { const newText = data.data.text; if (newText.length > streamingText.length) { const delta = newText.substring(streamingText.length); streamingText = newText; if (raw) { yield { text: delta }; } else { yield new Response(delta).toString(); } } } } } catch (error) { // Ignore JSON parse errors and continue console.debug('JSON parse error:', error.message); } } } } // Cache the complete response if (streamingText) { resultsCache.set(getCacheKey(prompt), { results: streamingText, timestamp: Date.now() }); } } catch (error) { console.error('Error searching Felo:', error.message); // Handle specific API errors if (error.response) { const status = error.response.status; const statusText = error.response.statusText; const data = error.response.data; throw new Error(`Felo API error: ${status} ${statusText} - ${data}`); } throw new Error(`Failed to search Felo: ${error.message}`); } } // If streaming is requested, return the generator if (stream) { return streamFunction(); } // For non-streaming, collect all chunks and return as a single string let fullResponse = ''; try { for await (const chunk of streamFunction()) { if (raw) { fullResponse += chunk.text; } else { fullResponse += chunk; } } return fullResponse; } catch (error) { console.error('Error in non-streaming Felo search:', error.message); throw error; } } export { searchFelo }; ``` -------------------------------------------------------------------------------- /src/utils/search.js: -------------------------------------------------------------------------------- ```javascript import axios from 'axios'; import * as cheerio from 'cheerio'; import https from 'https'; // Constants const RESULTS_PER_PAGE = 10; const MAX_CACHE_PAGES = 5; // Rotating User Agents const USER_AGENTS = [ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) Edge/120.0.0.0', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:122.0) Gecko/20100101 Firefox/122.0', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ]; // Cache results to avoid repeated requests const resultsCache = new Map(); const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes // HTTPS agent configuration to handle certificate chain issues const httpsAgent = new https.Agent({ rejectUnauthorized: true, // Keep security enabled keepAlive: true, timeout: 10000, // Provide fallback for certificate issues while maintaining security secureProtocol: 'TLSv1_2_method' }); /** * Get a random user agent from the list * @returns {string} A random user agent string */ function getRandomUserAgent() { return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)]; } /** * Generate a cache key for a search query and page * @param {string} query - The search query * @param {number} page - The page number * @returns {string} The cache key */ function getCacheKey(query, page) { return `${query}-${page}`; } /** * Clear old entries from the cache */ function clearOldCache() { const now = Date.now(); for (const [key, value] of resultsCache.entries()) { if (now - value.timestamp > CACHE_DURATION) { resultsCache.delete(key); } } } /** * Extract the direct URL from a DuckDuckGo redirect URL * @param {string} duckduckgoUrl - The DuckDuckGo URL to extract from * @returns {string} The direct URL */ function extractDirectUrl(duckduckgoUrl) { try { // Handle relative URLs from DuckDuckGo if (duckduckgoUrl.startsWith('//')) { duckduckgoUrl = 'https:' + duckduckgoUrl; } else if (duckduckgoUrl.startsWith('/')) { duckduckgoUrl = 'https://duckduckgo.com' + duckduckgoUrl; } const url = new URL(duckduckgoUrl); // Extract direct URL from DuckDuckGo redirect if (url.hostname === 'duckduckgo.com' && url.pathname === '/l/') { const uddg = url.searchParams.get('uddg'); if (uddg) { return decodeURIComponent(uddg); } } // Handle ad redirects if (url.hostname === 'duckduckgo.com' && url.pathname === '/y.js') { const u3 = url.searchParams.get('u3'); if (u3) { try { const decodedU3 = decodeURIComponent(u3); const u3Url = new URL(decodedU3); const clickUrl = u3Url.searchParams.get('ld'); if (clickUrl) { return decodeURIComponent(clickUrl); } return decodedU3; } catch { return duckduckgoUrl; } } } return duckduckgoUrl; } catch { // If URL parsing fails, try to extract URL from a basic string match const urlMatch = duckduckgoUrl.match(/https?:\/\/[^\s<>"]+/); if (urlMatch) { return urlMatch[0]; } return duckduckgoUrl; } } /** * Get a favicon URL for a given website URL * @param {string} url - The website URL * @returns {string} The favicon URL */ function getFaviconUrl(url) { try { const urlObj = new URL(url); return `https://www.google.com/s2/favicons?domain=${urlObj.hostname}&sz=32`; } catch { return ''; // Return empty string if URL is invalid } } /** * Scrapes search results from DuckDuckGo HTML * @param {string} query - The search query * @param {number} page - The page number (default: 1) * @param {number} numResults - Number of results to return (default: 10) * @returns {Promise<Array>} - Array of search results */ async function searchDuckDuckGo(query, page = 1, numResults = 10) { try { // Clear old cache entries clearOldCache(); // Calculate start index for pagination const startIndex = (page - 1) * RESULTS_PER_PAGE; // Check cache first const cacheKey = getCacheKey(query, page); const cachedResults = resultsCache.get(cacheKey); if (cachedResults && Date.now() - cachedResults.timestamp < CACHE_DURATION) { return cachedResults.results.slice(0, numResults); } // Get a random user agent const userAgent = getRandomUserAgent(); // Fetch results const response = await axios.get( `https://duckduckgo.com/html/?q=${encodeURIComponent(query)}&s=${startIndex}`, { headers: { 'User-Agent': userAgent }, httpsAgent: httpsAgent } ); if (response.status !== 200) { throw new Error('Failed to fetch search results'); } const html = response.data; // Parse results using cheerio const $ = cheerio.load(html); const results = []; $('.result').each((i, result) => { const $result = $(result); const titleEl = $result.find('.result__title a'); const linkEl = $result.find('.result__url'); const snippetEl = $result.find('.result__snippet'); const title = titleEl.text()?.trim(); const rawLink = titleEl.attr('href'); const description = snippetEl.text()?.trim(); const displayUrl = linkEl.text()?.trim(); const directLink = extractDirectUrl(rawLink || ''); const favicon = getFaviconUrl(directLink); if (title && directLink) { results.push({ title, url: directLink, snippet: description || '', favicon: favicon, displayUrl: displayUrl || '' }); } }); // Get paginated results const paginatedResults = results.slice(0, numResults); // Cache the results resultsCache.set(cacheKey, { results: paginatedResults, timestamp: Date.now() }); // If cache is too big, remove oldest entries if (resultsCache.size > MAX_CACHE_PAGES) { const oldestKey = Array.from(resultsCache.keys())[0]; resultsCache.delete(oldestKey); } return paginatedResults; } catch (error) { console.error('Error searching DuckDuckGo:', error.message); throw error; } } /** * Fetches the content of a URL and returns it as text * @param {string} url - The URL to fetch * @param {Object} options - Options for content extraction * @param {boolean} options.extractMainContent - Whether to attempt to extract main content (default: true) * @param {boolean} options.includeLinks - Whether to include link text (default: true) * @param {boolean} options.includeImages - Whether to include image alt text (default: true) * @param {string[]} options.excludeTags - Tags to exclude from extraction * @returns {Promise<string>} - The content of the URL */ async function fetchUrlContent(url, options = {}) { try { // Default options const { extractMainContent = true, includeLinks = true, includeImages = true, excludeTags = ['script', 'style', 'noscript', 'iframe', 'svg', 'nav', 'footer', 'header', 'aside'] } = options; // Get a random user agent const userAgent = getRandomUserAgent(); const response = await axios.get(url, { headers: { 'User-Agent': userAgent }, timeout: 10000, // 10 second timeout httpsAgent: httpsAgent }); if (response.status !== 200) { throw new Error(`Failed to fetch URL: ${url}`); } // If the content is HTML, extract the text content const contentType = response.headers['content-type'] || ''; if (contentType.includes('text/html')) { const $ = cheerio.load(response.data); // Remove unwanted elements excludeTags.forEach(tag => { $(tag).remove(); }); // Remove ads and other common unwanted elements const unwantedSelectors = [ '[id*="ad"]', '[class*="ad"]', '[id*="banner"]', '[class*="banner"]', '[id*="popup"]', '[class*="popup"]', '[class*="cookie"]', '[id*="cookie"]', '[class*="newsletter"]', '[id*="newsletter"]', '[class*="social"]', '[id*="social"]', '[class*="share"]', '[id*="share"]' ]; unwantedSelectors.forEach(selector => { try { $(selector).remove(); } catch (e) { // Ignore invalid selectors } }); // Handle links and images if (!includeLinks) { $('a').each((i, link) => { $(link).replaceWith($(link).text()); }); } if (!includeImages) { $('img').remove(); } else { // Replace images with their alt text $('img').each((i, img) => { const alt = $(img).attr('alt'); if (alt) { $(img).replaceWith(`[Image: ${alt}]`); } else { $(img).remove(); } }); } // Try to extract main content if requested if (extractMainContent) { // Common content selectors in order of priority const contentSelectors = [ 'article', 'main', '[role="main"]', '.post-content', '.article-content', '.content', '#content', '.post', '.article', '.entry-content', '.page-content', '.post-body', '.post-text', '.story-body' ]; for (const selector of contentSelectors) { const mainContent = $(selector).first(); if (mainContent.length > 0) { // Clean up the content return cleanText(mainContent.text()); } } } // If no main content found or not requested, use the body return cleanText($('body').text()); } // For non-HTML content, return as is return response.data.toString(); } catch (error) { console.error('Error fetching URL content:', error.message); throw error; } } /** * Cleans up text by removing excessive whitespace and normalizing line breaks * @param {string} text - The text to clean * @returns {string} - The cleaned text */ function cleanText(text) { return text .replace(/\s+/g, ' ') // Replace multiple whitespace with single space .replace(/\n\s*\n/g, '\n\n') // Normalize multiple line breaks .replace(/^\s+|\s+$/g, '') // Trim start and end .trim(); } /** * Extracts metadata from a URL (title, description, etc.) * @param {string} url - The URL to extract metadata from * @returns {Promise<Object>} - The metadata */ async function extractUrlMetadata(url) { try { // Get a random user agent const userAgent = getRandomUserAgent(); const response = await axios.get(url, { headers: { 'User-Agent': userAgent }, httpsAgent: httpsAgent }); if (response.status !== 200) { throw new Error(`Failed to fetch URL: ${url}`); } const $ = cheerio.load(response.data); // Extract metadata const title = $('title').text() || ''; const description = $('meta[name="description"]').attr('content') || $('meta[property="og:description"]').attr('content') || ''; const ogImage = $('meta[property="og:image"]').attr('content') || ''; const favicon = $('link[rel="icon"]').attr('href') || $('link[rel="shortcut icon"]').attr('href') || ''; // Resolve relative URLs const resolvedFavicon = favicon ? new URL(favicon, url).href : getFaviconUrl(url); const resolvedOgImage = ogImage ? new URL(ogImage, url).href : ''; return { title, description, ogImage: resolvedOgImage, favicon: resolvedFavicon, url }; } catch (error) { console.error('Error extracting URL metadata:', error.message); throw error; } } export { searchDuckDuckGo, fetchUrlContent, extractUrlMetadata, extractDirectUrl, getFaviconUrl }; ```