Source code for ralph.mcp.tools.websearch

"""MCP tool handler for web search across pluggable backends.

Exposes ``handle_web_search``, which dispatches a search query through the
configured backend (and optional fallbacks) and returns a ``ToolResult``.
Backends are loaded lazily; the dispatch order is taken from ``WebSearchConfig``.
"""

from __future__ import annotations

from typing import TYPE_CHECKING

from loguru import logger

from ralph.config.mcp_models import WebSearchConfig
from ralph.mcp.tools.coordination import (
    CapabilityDeniedError,
    CoordinationSessionLike,
    InvalidParamsError,
    ToolContent,
    ToolResult,
    require_capability,
)
from ralph.mcp.tools.workspace import required_string_param
from ralph.mcp.websearch.backends.base import SearchResult, WebSearchBackend, WebSearchError
from ralph.mcp.websearch.backends.brave import BraveBackend
from ralph.mcp.websearch.backends.ddgs import DdgsBackend
from ralph.mcp.websearch.backends.exa import ExaBackend
from ralph.mcp.websearch.backends.searxng import SearxngBackend
from ralph.mcp.websearch.backends.tavily import TavilyBackend
from ralph.mcp.websearch.secrets import resolve_secret

if TYPE_CHECKING:
    from ralph.workspace import Workspace

WEB_SEARCH_CAPABILITY = "WebSearch"
_DEFAULT_LIMIT = 10
MIN_LIMIT = 1
MAX_LIMIT = 25


def _build_backend(name: str, config: WebSearchConfig) -> WebSearchBackend:
    if name == "ddgs":
        return DdgsBackend()
    if name == "searxng":
        spec = config.backends.get("searxng")
        url = spec.url if spec is not None else None
        if not url:
            raise WebSearchError("searxng backend requires url in config")
        return SearxngBackend(url=url)
    spec = config.backends.get(name)
    if spec is None:
        raise WebSearchError(f"backend {name!r} not configured")
    resolved_key = resolve_secret(spec.api_key, spec.api_key_env)
    if name == "tavily":
        return TavilyBackend(api_key=resolved_key)
    if name == "brave":
        return BraveBackend(api_key=resolved_key)
    if name == "exa":
        return ExaBackend(api_key=resolved_key)
    raise WebSearchError(f"unsupported backend: {name!r}")


def _deduplicated(names: list[str]) -> list[str]:
    seen: set[str] = set()
    result: list[str] = []
    for name in names:
        if name not in seen:
            seen.add(name)
            result.append(name)
    return result


def _clamp_limit(value: object) -> int:
    if isinstance(value, int):
        return max(MIN_LIMIT, min(MAX_LIMIT, value))
    return _DEFAULT_LIMIT


def _format_results(results: list[SearchResult]) -> str:
    if not results:
        return "(no results)"
    blocks = [f"Title: {r.title}\nURL: {r.url}\nSnippet: {r.snippet}" for r in results]
    return "\n\n".join(blocks)






build_backend = _build_backend

__all__ = ["MAX_LIMIT", "MIN_LIMIT", "WEB_SEARCH_CAPABILITY", "handle_web_search"]