#!/usr/bin/env python3
"""
DuckDuckGo Search MCP Tool
This tool allows searching the web using DuckDuckGo through the MCP (Model Context Protocol) framework.
It integrates with the ddgs library to provide reliable search results.
"""
import logging
from typing import Dict, List, Union
from ddgs import DDGS
from ddgs.exceptions import DDGSException
from .server import mcp
logger = logging.getLogger(__name__)
def _format_search_result(result: Dict) -> Dict[str, str]:
"""Transform a raw DuckDuckGo result to the standard format."""
return {
"title": result.get("title", ""),
"url": result.get("href", ""),
"snippet": result.get("body", ""),
}
def _format_results_as_text(results: List[Dict[str, str]], query: str) -> str:
"""
Format search results as LLM-friendly natural language text.
Args:
results: List of search result dictionaries
query: The original search query (for context in error messages)
Returns:
Formatted string with numbered results
"""
if not results:
return (
f"No results found for '{query}'. "
"This could be due to DuckDuckGo rate limiting, the query returning no matches, "
"or network issues. Try rephrasing your search or try again in a few minutes."
)
lines = [f"Found {len(results)} search results:\n"]
for position, result in enumerate(results, start=1):
lines.append(f"{position}. {result.get('title', 'No title')}")
lines.append(f" URL: {result.get('url', 'No URL')}")
lines.append(f" Summary: {result.get('snippet', 'No summary available')}")
lines.append("") # Empty line between results
return "\n".join(lines)
def _execute_search(
query: str,
region: str,
safesearch: str,
max_results: int,
timeout: int,
backend: str,
) -> List[Dict[str, str]]:
"""
Execute a search with the specified parameters.
Args:
query: Search query string
region: Region code for localized results
safesearch: Safe search setting
max_results: Maximum number of results
timeout: Request timeout in seconds
backend: Backend to use ('auto', 'bing', 'brave', 'google', 'mojeek', etc.)
Returns:
List of formatted search results
"""
ddgs = DDGS(timeout=timeout)
results = ddgs.text(
query=query,
region=region,
safesearch=safesearch,
max_results=max_results,
backend=backend,
)
return [_format_search_result(r) for r in results]
def _try_fallback_search(
query: str,
region: str,
safesearch: str,
max_results: int,
timeout: int,
original_error: Exception,
) -> List[Dict[str, str]]:
"""
Attempt a fallback search using the brave backend.
Args:
query: Search query string
region: Region code for localized results
safesearch: Safe search setting
max_results: Maximum number of results
timeout: Request timeout in seconds
original_error: The original exception that triggered the fallback
Returns:
List of formatted search results, or empty list on failure
"""
# Don't retry if the error was already about the backend
if "backend" in str(original_error).lower():
return []
logger.info("Retrying with brave backend as fallback")
try:
return _execute_search(query, region, safesearch, max_results, timeout, "brave")
except Exception as e:
logger.error(f"Fallback search failed: {str(e)}")
return []
def _validate_search_params(query: str, max_results: int, safesearch: str) -> str:
"""
Validate search parameters and return normalized safesearch value.
Args:
query: Search query string
max_results: Maximum number of results
safesearch: Safe search setting
Returns:
Normalized safesearch value
Raises:
ValueError: If query or max_results is invalid
"""
if not query or not isinstance(query, str):
raise ValueError("Query must be a non-empty string")
if not isinstance(max_results, int) or max_results <= 0:
raise ValueError("max_results must be a positive integer")
valid_safesearch = ["on", "moderate", "off"]
if safesearch not in valid_safesearch:
logger.warning(
f"Invalid safesearch value: '{safesearch}'. Using 'moderate' instead."
)
return "moderate"
return safesearch
def search_duckduckgo(
query: str,
max_results: int = 5,
safesearch: str = "moderate",
region: str = "wt-wt",
timeout: int = 15,
) -> List[Dict[str, str]]:
"""
Search DuckDuckGo using the ddgs library and return parsed results.
Args:
query: The search query string
max_results: Maximum number of results to return
safesearch: Safe search setting ('on', 'moderate', 'off')
region: Region code for localized results (default: wt-wt for no region)
timeout: Request timeout in seconds
Returns:
List of dictionaries containing search results with title, url, and snippet
"""
safesearch = _validate_search_params(query, max_results, safesearch)
try:
return _execute_search(
query, region, safesearch, max_results, timeout, "duckduckgo"
)
except DDGSException as e:
logger.error(f"DuckDuckGo search error: {str(e)}")
return _try_fallback_search(query, region, safesearch, max_results, timeout, e)
except Exception as e:
logger.error(f"Unexpected error during search: {str(e)}")
return []
@mcp.tool()
def duckduckgo_search(
query: str,
max_results: int = 5,
safesearch: str = "moderate",
output_format: str = "json",
) -> Union[List[Dict[str, str]], str]:
"""
Search the web using DuckDuckGo.
Args:
query: The search query
max_results: Maximum number of search results to return (default: 5)
safesearch: Safe search setting ('on', 'moderate', 'off'; default: 'moderate')
output_format: Output format - 'json' returns list of dicts, 'text' returns
LLM-friendly formatted string (default: 'json')
Returns:
List of search results (json) or formatted string (text)
"""
# Type coercion for MCP clients that may pass strings
if not isinstance(max_results, int):
try:
max_results = int(max_results)
except (ValueError, TypeError):
raise ValueError("max_results must be a valid positive integer")
# Validate output_format
output_format = output_format.lower() if output_format else "json"
if output_format not in ("json", "text"):
logger.warning(
f"Invalid output_format: '{output_format}'. Using 'json' instead."
)
output_format = "json"
results = search_duckduckgo(query, max_results, safesearch)
if not results:
logger.warning(f"No results found for query: '{query}'")
# Return based on output format
if output_format == "text":
return _format_results_as_text(results, query)
return results