Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ services:
- ./workspace:/workspace
environment:
- PYTHONUNBUFFERED=1
# - SEARCH_PROVIDER=tavily # Options: 'searxng' (default), 'tavily'
# - TAVILY_API_KEY=tvly-YOUR_API_KEY
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ dependencies = [
"beautifulsoup4>=4.12",
]

[project.optional-dependencies]
tavily = [
"tavily-python>=0.5.0",
]

[dependency-groups]
dev = [
"pytest>=8.0",
Expand Down
110 changes: 83 additions & 27 deletions src/devtools/tools/web_search.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Web search tool using SearXNG."""
"""Web search tool using SearXNG or Tavily."""

import os

Expand All @@ -10,37 +10,54 @@
"SEARXNG_URL", "http://searxng.searxng.svc.cluster.local:8080"
)

SEARCH_PROVIDER = os.environ.get("SEARCH_PROVIDER", "searxng").lower()

@mcp.tool()
def web_search(
query: str,
categories: str = "general",
language: str = "en",
max_results: int = 10,
timeout: int = 30,
) -> str:
"""Search the web using SearXNG metasearch engine.

The query supports SearXNG search syntax:
- Select engines with `!` prefix: `!google python`, `!wp berlin`, `!ddg test`
- Chain multiple engines/categories: `!map !ddg !wp paris`
- Filter by language with `:` prefix: `:fr !wp Wau Holland`, `:de berlin`
- Use `!!` for external bangs (DuckDuckGo-style): `!!wfr Wau Holland`
- Use `!!` alone to auto-redirect to first result: `!! Wau Holland`
def _search_tavily(query: str, max_results: int) -> str:
"""Execute a search using the Tavily API."""
if not os.environ.get("TAVILY_API_KEY"):
raise ValueError(
"TAVILY_API_KEY environment variable is required when SEARCH_PROVIDER=tavily"
)

Args:
query: The search query string. Supports SearXNG operators described above.
categories: Comma-separated search categories (general, images, news, science, files, it, map, music, social media, videos).
language: Search language code (default "en"). Can also be set in query with `:lang` prefix.
max_results: Maximum number of results to return (default 10).
timeout: Request timeout in seconds.
from tavily import TavilyClient

client = TavilyClient()
response = client.search(query=query, max_results=max_results)

results = response.get("results", [])
if not results:
return f"No results found for: {query}"

lines = [f"Search results for: {query}\n"]
for i, r in enumerate(results, 1):
title = r.get("title", "No title")
url = r.get("url", "")
snippet = r.get("content", "")
score = r.get("score")

lines.append(f"{i}. {title}")
lines.append(f" URL: {url}")
if snippet:
lines.append(f" {snippet}")
meta_parts = []
if score is not None:
meta_parts.append(f"score: {score}")
if meta_parts:
lines.append(f" [{' | '.join(meta_parts)}]")
lines.append("")

return "\n".join(lines)

Returns:
Formatted search results with title, URL, snippet, score, and metadata.
"""
if not query.strip():
raise ValueError("Search query cannot be empty.")

def _search_searxng(
query: str,
categories: str,
language: str,
max_results: int,
timeout: int,
) -> str:
"""Execute a search using SearXNG."""
params = {
"q": query,
"format": "json",
Expand Down Expand Up @@ -103,3 +120,42 @@ def web_search(
lines.append(f" {box_content}")

return "\n".join(lines)


@mcp.tool()
def web_search(
query: str,
categories: str = "general",
language: str = "en",
max_results: int = 10,
timeout: int = 30,
) -> str:
"""Search the web using SearXNG metasearch engine or Tavily.

The search provider is selected via the SEARCH_PROVIDER env var
('searxng' [default] or 'tavily').

When using SearXNG, the query supports SearXNG search syntax:
- Select engines with `!` prefix: `!google python`, `!wp berlin`, `!ddg test`
- Chain multiple engines/categories: `!map !ddg !wp paris`
- Filter by language with `:` prefix: `:fr !wp Wau Holland`, `:de berlin`
- Use `!!` for external bangs (DuckDuckGo-style): `!!wfr Wau Holland`
- Use `!!` alone to auto-redirect to first result: `!! Wau Holland`

Args:
query: The search query string. Supports SearXNG operators when using SearXNG provider.
categories: Comma-separated search categories (SearXNG only).
language: Search language code (default "en"). SearXNG only.
max_results: Maximum number of results to return (default 10).
timeout: Request timeout in seconds (SearXNG only).

Returns:
Formatted search results with title, URL, snippet, score, and metadata.
"""
if not query.strip():
raise ValueError("Search query cannot be empty.")

if SEARCH_PROVIDER == "tavily":
return _search_tavily(query, max_results)

return _search_searxng(query, categories, language, max_results, timeout)
109 changes: 109 additions & 0 deletions tests/test_web_search.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for web_search tool."""

from unittest.mock import MagicMock, patch

import httpx
import respx

Expand Down Expand Up @@ -183,3 +185,110 @@ def test_search_passes_categories():
assert route.called
request = route.calls[0].request
assert "categories=news" in str(request.url)


# --- Tavily provider tests ---

TAVILY_SEARCH_RESPONSE = {
"query": "python",
"results": [
{
"title": "Welcome to Python.org",
"url": "https://www.python.org/",
"content": "Python is a versatile and easy-to-learn language.",
"score": 0.95,
},
{
"title": "Python Tutorial - W3Schools",
"url": "https://www.w3schools.com/python/",
"content": "W3Schools offers free online tutorials.",
"score": 0.88,
},
],
}


@patch.dict("os.environ", {"TAVILY_API_KEY": "tvly-test-key"})
@patch("tavily.TavilyClient")
@patch("devtools.tools.web_search.SEARCH_PROVIDER", "tavily")
def test_tavily_search_success(mock_tavily_cls):
mock_client = MagicMock()
mock_client.search.return_value = TAVILY_SEARCH_RESPONSE
mock_tavily_cls.return_value = mock_client

from devtools.tools.web_search import _search_tavily

result = _search_tavily("python", 10)

assert "Welcome to Python.org" in result
assert "https://www.python.org/" in result
assert "Python is a versatile" in result
assert "score: 0.95" in result
mock_client.search.assert_called_once_with(query="python", max_results=10)


@patch.dict("os.environ", {"TAVILY_API_KEY": "tvly-test-key"})
@patch("tavily.TavilyClient")
@patch("devtools.tools.web_search.SEARCH_PROVIDER", "tavily")
def test_tavily_search_no_results(mock_tavily_cls):
mock_client = MagicMock()
mock_client.search.return_value = {"query": "xyznonexistent", "results": []}
mock_tavily_cls.return_value = mock_client

from devtools.tools.web_search import _search_tavily

result = _search_tavily("xyznonexistent", 10)
assert "No results found" in result


@patch.dict("os.environ", {"TAVILY_API_KEY": "tvly-test-key"})
@patch("tavily.TavilyClient")
@patch("devtools.tools.web_search.SEARCH_PROVIDER", "tavily")
def test_tavily_search_max_results(mock_tavily_cls):
mock_client = MagicMock()
mock_client.search.return_value = TAVILY_SEARCH_RESPONSE
mock_tavily_cls.return_value = mock_client

from devtools.tools.web_search import _search_tavily

result = _search_tavily("python", 5)
mock_client.search.assert_called_once_with(query="python", max_results=5)
assert "Welcome to Python.org" in result


@patch.dict("os.environ", {"TAVILY_API_KEY": "tvly-test-key"})
@patch("tavily.TavilyClient")
@patch("devtools.tools.web_search.SEARCH_PROVIDER", "tavily")
def test_tavily_provider_routing(mock_tavily_cls):
"""Verify web_search dispatches to Tavily when SEARCH_PROVIDER=tavily."""
mock_client = MagicMock()
mock_client.search.return_value = TAVILY_SEARCH_RESPONSE
mock_tavily_cls.return_value = mock_client

result = web_search("python")
assert "Welcome to Python.org" in result


@patch.dict("os.environ", {}, clear=False)
@patch("devtools.tools.web_search.SEARCH_PROVIDER", "tavily")
def test_tavily_missing_api_key():
"""Missing TAVILY_API_KEY should raise ValueError with clear message."""
import os
os.environ.pop("TAVILY_API_KEY", None)

from devtools.tools.web_search import _search_tavily

try:
_search_tavily("python", 10)
assert False, "Should have raised"
except ValueError as e:
assert "TAVILY_API_KEY" in str(e)


def test_tavily_empty_query():
"""Empty query should raise ValueError regardless of provider."""
try:
web_search(" ")
assert False, "Should have raised"
except ValueError as e:
assert "empty" in str(e).lower()
Loading