# Directory Structure ``` ├── .github │ └── workflows │ └── publish.yaml ├── .gitignore ├── .python-version ├── pyproject.toml ├── README.md ├── src │ └── ddg_mcp │ ├── __init__.py │ └── server.py └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 3.11 ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` # Python-generated files __pycache__/ *.py[oc] build/ dist/ wheels/ *.egg-info # Virtual environments .venv ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # ddg-mcp MCP server DuckDuckGo search API MCP - A server that provides DuckDuckGo search capabilities through the Model Context Protocol. ## Components ### Prompts The server provides the following prompts: - **search-results-summary**: Creates a summary of DuckDuckGo search results - Required "query" argument for the search term - Optional "style" argument to control detail level (brief/detailed) ### Tools The server implements the following DuckDuckGo search tools: - **ddg-text-search**: Search the web for text results using DuckDuckGo - Required: "keywords" - Search query keywords - Optional: "region", "safesearch", "timelimit", "max_results" - **ddg-image-search**: Search the web for images using DuckDuckGo - Required: "keywords" - Search query keywords - Optional: "region", "safesearch", "timelimit", "size", "color", "type_image", "layout", "license_image", "max_results" - **ddg-news-search**: Search for news articles using DuckDuckGo - Required: "keywords" - Search query keywords - Optional: "region", "safesearch", "timelimit", "max_results" - **ddg-video-search**: Search for videos using DuckDuckGo - Required: "keywords" - Search query keywords - Optional: "region", "safesearch", "timelimit", "resolution", "duration", "license_videos", "max_results" - **ddg-ai-chat**: Chat with DuckDuckGo AI - Required: "keywords" - Message or question to send to the AI - Optional: "model" - AI model to use (options: "gpt-4o-mini", "llama-3.3-70b", "claude-3-haiku", "o3-mini", "mistral-small-3") ## Installation ### Prerequisites - Python 3.9 or higher - [uv](https://github.com/astral-sh/uv) (recommended) or pip ### Install from PyPI ```bash # Using uv uv install ddg-mcp # Using pip pip install ddg-mcp ``` ### Install from Source 1. Clone the repository: ```bash git clone https://github.com/misanthropic-ai/ddg-mcp.git cd ddg-mcp ``` 2. Install the package: ```bash # Using uv uv install -e . # Using pip pip install -e . ``` ## Configuration ### Required Dependencies The server requires the `duckduckgo-search` package, which will be installed automatically when you install `ddg-mcp`. If you need to install it manually: ```bash uv install duckduckgo-search # or pip install duckduckgo-search ``` ## DuckDuckGo Search Parameters ### Common Parameters These parameters are available for most search types: - **region**: Region code for localized results (default: "wt-wt") - Examples: "us-en" (US English), "uk-en" (UK English), "ru-ru" (Russian) - See [DuckDuckGo regions](https://duckduckgo.com/params) for more options - **safesearch**: Content filtering level (default: "moderate") - "on": Strict filtering - "moderate": Moderate filtering - "off": No filtering - **timelimit**: Time range for results - "d": Last day - "w": Last week - "m": Last month - "y": Last year (not available for news/videos) - **max_results**: Maximum number of results to return (default: 10) ### Search Operators You can use these operators in your search keywords: - `cats dogs`: Results about cats or dogs - `"cats and dogs"`: Results for exact term "cats and dogs" - `cats -dogs`: Fewer dogs in results - `cats +dogs`: More dogs in results - `cats filetype:pdf`: PDFs about cats (supported: pdf, doc(x), xls(x), ppt(x), html) - `dogs site:example.com`: Pages about dogs from example.com - `cats -site:example.com`: Pages about cats, excluding example.com - `intitle:dogs`: Page title includes the word "dogs" - `inurl:cats`: Page URL includes the word "cats" ### Image Search Specific Parameters - **size**: "Small", "Medium", "Large", "Wallpaper" - **color**: "color", "Monochrome", "Red", "Orange", "Yellow", "Green", "Blue", "Purple", "Pink", "Brown", "Black", "Gray", "Teal", "White" - **type_image**: "photo", "clipart", "gif", "transparent", "line" - **layout**: "Square", "Tall", "Wide" - **license_image**: "any", "Public", "Share", "ShareCommercially", "Modify", "ModifyCommercially" ### Video Search Specific Parameters - **resolution**: "high", "standard" - **duration**: "short", "medium", "long" - **license_videos**: "creativeCommon", "youtube" ### AI Chat Models - **gpt-4o-mini**: OpenAI's GPT-4o mini model - **llama-3.3-70b**: Meta's Llama 3.3 70B model - **claude-3-haiku**: Anthropic's Claude 3 Haiku model - **o3-mini**: OpenAI's O3 mini model - **mistral-small-3**: Mistral AI's small model ## Quickstart ### Install #### Claude Desktop On MacOS: `~/Library/Application\ Support/Claude/claude_desktop_config.json` On Windows: `%APPDATA%/Claude/claude_desktop_config.json` <details> <summary>Development/Unpublished Servers Configuration</summary> ``` "mcpServers": { "ddg-mcp": { "command": "uv", "args": [ "--directory", "/Users/shannon/Workspace/artivus/ddg-mcp", "run", "ddg-mcp" ] } } ``` </details> <details> <summary>Published Servers Configuration</summary> ``` "mcpServers": { "ddg-mcp": { "command": "uvx", "args": [ "ddg-mcp" ] } } ``` </details> ## Usage Examples ### Text Search ``` Use the ddg-text-search tool to search for "climate change solutions" ``` Advanced example: ``` Use the ddg-text-search tool to search for "renewable energy filetype:pdf site:edu" with region "us-en", safesearch "off", timelimit "y", and max_results 20 ``` ### Image Search ``` Use the ddg-image-search tool to find images of "renewable energy" with color set to "Green" ``` Advanced example: ``` Use the ddg-image-search tool to find images of "mountain landscape" with size "Large", color "Blue", type_image "photo", layout "Wide", and license_image "Public" ``` ### News Search ``` Use the ddg-news-search tool to find recent news about "artificial intelligence" from the last day ``` Advanced example: ``` Use the ddg-news-search tool to search for "space exploration" with region "uk-en", timelimit "w", and max_results 15 ``` ### Video Search ``` Use the ddg-video-search tool to find videos about "machine learning tutorials" with duration set to "medium" ``` Advanced example: ``` Use the ddg-video-search tool to search for "cooking recipes" with resolution "high", duration "short", license_videos "creativeCommon", and max_results 10 ``` ### AI Chat ``` Use the ddg-ai-chat tool to ask "What are the latest developments in quantum computing?" using the claude-3-haiku model ``` ### Search Results Summary ``` Use the search-results-summary prompt with query "space exploration" and style "detailed" ``` ## Claude config "ddg-mcp": { "command": "uv", "args": [ "--directory", "/PATH/TO/YOUR/INSTALLATION/ddg-mcp", "run", "ddg-mcp" ] }, ## Development ### Building and Publishing To prepare the package for distribution: 1. Sync dependencies and update lockfile: ```bash uv sync ``` 2. Build package distributions: ```bash uv build ``` This will create source and wheel distributions in the `dist/` directory. 3. Publish to PyPI: ```bash uv publish ``` Note: You'll need to set PyPI credentials via environment variables or command flags: - Token: `--token` or `UV_PUBLISH_TOKEN` - Or username/password: `--username`/`UV_PUBLISH_USERNAME` and `--password`/`UV_PUBLISH_PASSWORD` ### Automated Publishing with GitHub Actions This repository includes a GitHub Actions workflow for automated publishing to PyPI. The workflow is triggered when: 1. A new GitHub Release is created 2. The workflow is manually triggered via the GitHub Actions interface To set up automated publishing: 1. Generate a PyPI API token: - Go to https://pypi.org/manage/account/token/ - Create a new token with scope limited to the `ddg-mcp` project - Copy the token value (you'll only see it once) 2. Add the token to your GitHub repository secrets: - Go to your repository on GitHub - Navigate to Settings > Secrets and variables > Actions - Click "New repository secret" - Name: `PYPI_API_TOKEN` - Value: Paste your PyPI token - Click "Add secret" 3. To publish a new version: - Update the version number in `pyproject.toml` - Create a new release on GitHub or manually trigger the workflow ### Debugging Since MCP servers run over stdio, debugging can be challenging. For the best debugging experience, we strongly recommend using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). You can launch the MCP Inspector via [`npm`](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) with this command: ```bash npx @modelcontextprotocol/inspector uv --directory /path/to/your/ddg-mcp run ddg-mcp ``` Upon launching, the Inspector will display a URL that you can access in your browser to begin debugging. ``` -------------------------------------------------------------------------------- /src/ddg_mcp/__init__.py: -------------------------------------------------------------------------------- ```python from . import server import asyncio def main(): """Main entry point for the package.""" asyncio.run(server.main()) # Optionally expose other important items at package level __all__ = ['main', 'server'] ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml [project] name = "ddg-mcp" version = "0.1.1" description = "DuckDuckGo search API MCP" readme = "README.md" requires-python = ">=3.11" dependencies = [ "duckduckgo-search>=7.5.1", "mcp>=1.3.0", ] [[project.authors]] name = "Shannon Sands" email = "[email protected]" [build-system] requires = [ "hatchling",] build-backend = "hatchling.build" [project.scripts] ddg-mcp = "ddg_mcp:main" ``` -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- ```yaml name: Publish to PyPI on: release: types: [created] workflow_dispatch: # Allow manual triggering jobs: build-and-publish: runs-on: ubuntu-latest steps: - name: Check out repository uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.11' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build twine - name: Build package run: | python -m build - name: Check distribution run: | python -m twine check dist/* - name: Publish to PyPI if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} skip-existing: true verbose: true ``` -------------------------------------------------------------------------------- /src/ddg_mcp/server.py: -------------------------------------------------------------------------------- ```python import asyncio from mcp.server.models import InitializationOptions import mcp.types as types from mcp.server import NotificationOptions, Server from pydantic import AnyUrl import mcp.server.stdio from duckduckgo_search import DDGS server = Server("ddg-mcp") @server.list_resources() async def handle_list_resources() -> list[types.Resource]: """ List available resources. Currently, no resources are exposed. """ return [] @server.list_prompts() async def handle_list_prompts() -> list[types.Prompt]: """ List available prompts. Each prompt can have optional arguments to customize its behavior. """ return [ types.Prompt( name="search-results-summary", description="Creates a summary of search results", arguments=[ types.PromptArgument( name="query", description="Search query to summarize results for", required=True, ), types.PromptArgument( name="style", description="Style of the summary (brief/detailed)", required=False, ) ], ) ] @server.get_prompt() async def handle_get_prompt( name: str, arguments: dict[str, str] | None ) -> types.GetPromptResult: """ Generate a prompt by combining arguments with server state. """ if name == "search-results-summary": if not arguments or "query" not in arguments: raise ValueError("Missing required 'query' argument") query = arguments.get("query") style = arguments.get("style", "brief") detail_prompt = " Give extensive details." if style == "detailed" else "" # Perform search and get results ddgs = DDGS() results = ddgs.text(query, max_results=10) results_text = "\n\n".join([ f"Title: {result.get('title', 'No title')}\n" f"URL: {result.get('href', 'No URL')}\n" f"Description: {result.get('body', 'No description')}" for result in results ]) return types.GetPromptResult( description=f"Summarize search results for '{query}'", messages=[ types.PromptMessage( role="user", content=types.TextContent( type="text", text=f"Here are the search results for '{query}'. Please summarize them{detail_prompt}:\n\n{results_text}", ), ) ], ) else: raise ValueError(f"Unknown prompt: {name}") @server.list_tools() async def handle_list_tools() -> list[types.Tool]: """ List available tools. Each tool specifies its arguments using JSON Schema validation. """ return [ types.Tool( name="ddg-text-search", description="Search the web for text results using DuckDuckGo", inputSchema={ "type": "object", "properties": { "keywords": {"type": "string", "description": "Search query keywords"}, "region": {"type": "string", "description": "Region code (e.g., wt-wt, us-en, uk-en)", "default": "wt-wt"}, "safesearch": {"type": "string", "enum": ["on", "moderate", "off"], "description": "Safe search level", "default": "moderate"}, "timelimit": {"type": "string", "enum": ["d", "w", "m", "y"], "description": "Time limit (d=day, w=week, m=month, y=year)"}, "max_results": {"type": "integer", "description": "Maximum number of results to return", "default": 10}, }, "required": ["keywords"], }, ), types.Tool( name="ddg-image-search", description="Search the web for images using DuckDuckGo", inputSchema={ "type": "object", "properties": { "keywords": {"type": "string", "description": "Search query keywords"}, "region": {"type": "string", "description": "Region code (e.g., wt-wt, us-en, uk-en)", "default": "wt-wt"}, "safesearch": {"type": "string", "enum": ["on", "moderate", "off"], "description": "Safe search level", "default": "moderate"}, "timelimit": {"type": "string", "enum": ["d", "w", "m", "y"], "description": "Time limit (d=day, w=week, m=month, y=year)"}, "size": {"type": "string", "enum": ["Small", "Medium", "Large", "Wallpaper"], "description": "Image size"}, "color": {"type": "string", "enum": ["color", "Monochrome", "Red", "Orange", "Yellow", "Green", "Blue", "Purple", "Pink", "Brown", "Black", "Gray", "Teal", "White"], "description": "Image color"}, "type_image": {"type": "string", "enum": ["photo", "clipart", "gif", "transparent", "line"], "description": "Image type"}, "layout": {"type": "string", "enum": ["Square", "Tall", "Wide"], "description": "Image layout"}, "license_image": {"type": "string", "enum": ["any", "Public", "Share", "ShareCommercially", "Modify", "ModifyCommercially"], "description": "Image license type"}, "max_results": {"type": "integer", "description": "Maximum number of results to return", "default": 10}, }, "required": ["keywords"], }, ), types.Tool( name="ddg-news-search", description="Search for news articles using DuckDuckGo", inputSchema={ "type": "object", "properties": { "keywords": {"type": "string", "description": "Search query keywords"}, "region": {"type": "string", "description": "Region code (e.g., wt-wt, us-en, uk-en)", "default": "wt-wt"}, "safesearch": {"type": "string", "enum": ["on", "moderate", "off"], "description": "Safe search level", "default": "moderate"}, "timelimit": {"type": "string", "enum": ["d", "w", "m"], "description": "Time limit (d=day, w=week, m=month)"}, "max_results": {"type": "integer", "description": "Maximum number of results to return", "default": 10}, }, "required": ["keywords"], }, ), types.Tool( name="ddg-video-search", description="Search for videos using DuckDuckGo", inputSchema={ "type": "object", "properties": { "keywords": {"type": "string", "description": "Search query keywords"}, "region": {"type": "string", "description": "Region code (e.g., wt-wt, us-en, uk-en)", "default": "wt-wt"}, "safesearch": {"type": "string", "enum": ["on", "moderate", "off"], "description": "Safe search level", "default": "moderate"}, "timelimit": {"type": "string", "enum": ["d", "w", "m"], "description": "Time limit (d=day, w=week, m=month)"}, "resolution": {"type": "string", "enum": ["high", "standard"], "description": "Video resolution"}, "duration": {"type": "string", "enum": ["short", "medium", "long"], "description": "Video duration"}, "license_videos": {"type": "string", "enum": ["creativeCommon", "youtube"], "description": "Video license type"}, "max_results": {"type": "integer", "description": "Maximum number of results to return", "default": 10}, }, "required": ["keywords"], }, ), types.Tool( name="ddg-ai-chat", description="Chat with DuckDuckGo AI", inputSchema={ "type": "object", "properties": { "keywords": {"type": "string", "description": "Message or question to send to the AI"}, "model": {"type": "string", "enum": ["gpt-4o-mini", "llama-3.3-70b", "claude-3-haiku", "o3-mini", "mistral-small-3"], "description": "AI model to use", "default": "gpt-4o-mini"}, }, "required": ["keywords"], }, ), ] @server.call_tool() async def handle_call_tool( name: str, arguments: dict | None ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]: """ Handle tool execution requests. """ if not arguments: raise ValueError("Missing arguments") if name == "ddg-text-search": keywords = arguments.get("keywords") if not keywords: raise ValueError("Missing keywords") region = arguments.get("region", "wt-wt") safesearch = arguments.get("safesearch", "moderate") timelimit = arguments.get("timelimit") max_results = arguments.get("max_results", 10) # Perform search ddgs = DDGS() results = ddgs.text( keywords=keywords, region=region, safesearch=safesearch, timelimit=timelimit, max_results=max_results ) # Format results formatted_results = f"Search results for '{keywords}':\n\n" for i, result in enumerate(results, 1): formatted_results += ( f"{i}. {result.get('title', 'No title')}\n" f" URL: {result.get('href', 'No URL')}\n" f" {result.get('body', 'No description')}\n\n" ) return [ types.TextContent( type="text", text=formatted_results, ) ] elif name == "ddg-image-search": keywords = arguments.get("keywords") if not keywords: raise ValueError("Missing keywords") region = arguments.get("region", "wt-wt") safesearch = arguments.get("safesearch", "moderate") timelimit = arguments.get("timelimit") size = arguments.get("size") color = arguments.get("color") type_image = arguments.get("type_image") layout = arguments.get("layout") license_image = arguments.get("license_image") max_results = arguments.get("max_results", 10) # Perform search ddgs = DDGS() results = ddgs.images( keywords=keywords, region=region, safesearch=safesearch, timelimit=timelimit, size=size, color=color, type_image=type_image, layout=layout, license_image=license_image, max_results=max_results ) # Format results formatted_results = f"Image search results for '{keywords}':\n\n" text_results = [] image_results = [] for i, result in enumerate(results, 1): text_results.append( types.TextContent( type="text", text=f"{i}. {result.get('title', 'No title')}\n" f" Source: {result.get('source', 'Unknown')}\n" f" URL: {result.get('url', 'No URL')}\n" f" Size: {result.get('width', 'N/A')}x{result.get('height', 'N/A')}\n" ) ) image_url = result.get('image') if image_url: image_results.append( types.ImageContent( type="image", url=image_url, alt_text=result.get('title', 'Image search result') ) ) # Interleave text and image results combined_results = [] for text, image in zip(text_results, image_results): combined_results.extend([text, image]) return combined_results elif name == "ddg-news-search": keywords = arguments.get("keywords") if not keywords: raise ValueError("Missing keywords") region = arguments.get("region", "wt-wt") safesearch = arguments.get("safesearch", "moderate") timelimit = arguments.get("timelimit") max_results = arguments.get("max_results", 10) # Perform search ddgs = DDGS() results = ddgs.news( keywords=keywords, region=region, safesearch=safesearch, timelimit=timelimit, max_results=max_results ) # Format results formatted_results = f"News search results for '{keywords}':\n\n" for i, result in enumerate(results, 1): formatted_results += ( f"{i}. {result.get('title', 'No title')}\n" f" Source: {result.get('source', 'Unknown')}\n" f" Date: {result.get('date', 'No date')}\n" f" URL: {result.get('url', 'No URL')}\n" f" {result.get('body', 'No description')}\n\n" ) return [ types.TextContent( type="text", text=formatted_results, ) ] elif name == "ddg-video-search": keywords = arguments.get("keywords") if not keywords: raise ValueError("Missing keywords") region = arguments.get("region", "wt-wt") safesearch = arguments.get("safesearch", "moderate") timelimit = arguments.get("timelimit") resolution = arguments.get("resolution") duration = arguments.get("duration") license_videos = arguments.get("license_videos") max_results = arguments.get("max_results", 10) # Perform search ddgs = DDGS() results = ddgs.videos( keywords=keywords, region=region, safesearch=safesearch, timelimit=timelimit, resolution=resolution, duration=duration, license_videos=license_videos, max_results=max_results ) # Format results formatted_results = f"Video search results for '{keywords}':\n\n" for i, result in enumerate(results, 1): formatted_results += ( f"{i}. {result.get('title', 'No title')}\n" f" Publisher: {result.get('publisher', 'Unknown')}\n" f" Duration: {result.get('duration', 'Unknown')}\n" f" URL: {result.get('content', 'No URL')}\n" f" Published: {result.get('published', 'No date')}\n" f" {result.get('description', 'No description')}\n\n" ) return [ types.TextContent( type="text", text=formatted_results, ) ] elif name == "ddg-ai-chat": keywords = arguments.get("keywords") if not keywords: raise ValueError("Missing keywords") model = arguments.get("model", "gpt-4o-mini") # Perform AI chat ddgs = DDGS() result = ddgs.chat( keywords=keywords, model=model ) return [ types.TextContent( type="text", text=f"DuckDuckGo AI ({model}) response:\n\n{result}", ) ] else: raise ValueError(f"Unknown tool: {name}") async def main(): # Run the server using stdin/stdout streams async with mcp.server.stdio.stdio_server() as (read_stream, write_stream): await server.run( read_stream, write_stream, InitializationOptions( server_name="ddg-mcp", server_version="0.1.0", capabilities=server.get_capabilities( notification_options=NotificationOptions(), experimental_capabilities={}, ), ), ) ```