TaskGroup body.
from __future__ import annotations import asyncio from dataclasses import dataclass from pathlib import Path from typing import Any import httpx import pytest from mcp.types import CallToolResult from searxng_mcp.browser import RenderedResponse from searxng_mcp.client import BackendResponse, FetchResponse from searxng_mcp.render import content_text, make_result from searxng_mcp.service import SearxngMCPService from searxng_mcp.settings import Settings def sc(result: CallToolResult) -> dict[str, Any]: data = result.structuredContent assert data is not None return data def meta_of(result: CallToolResult) -> dict[str, Any]: data = result.meta assert data is not None return data def text_of(result: CallToolResult) -> str: return content_text(result) def make_settings(tmp_path: Path) -> Settings: return Settings( base_url="http://searx.local", fallback_base_urls=(), transport="stdio", host="127.0.0.1", port=8811, search_timeout=5.0, fetch_timeout=5.0, search_concurrency=4, fetch_concurrency=2, search_cache_ttl=60, fetch_cache_ttl=60, cache_dir=tmp_path / "cache", cache_size_limit=32 * 1024 * 1024, default_language="en", default_categories="general", default_safesearch=0, default_max_results=5, default_excerpt_chars=300, search_connections=8, search_keepalive=4, fetch_connections=4, fetch_keepalive=2, fetch_verify_tls=True, render_timeout=10.0, render_wait_ms=1000, render_concurrency=2, render_headless=True, render_browser_path="", render_sandbox=False, render_block_resources=True, render_auto_fallback=True, render_auto_min_words=60, render_auto_min_chars=800, trust_env=False, user_agent="searxng-mcp-test", ) def make_http_response( url: str, body: str, content_type: str = "text/html; charset=utf-8" ) -> httpx.Response: return httpx.Response( 200, content=body.encode("utf-8"), headers={"content-type": content_type}, request=httpx.Request("GET", url), ) @dataclass class FakeSearchClient: payloads: dict[str, dict] calls: list[dict] def __init__(self, payloads: dict[str, dict]) -> None: self.payloads = payloads self.calls = [] async def search(self, params: dict) -> BackendResponse: self.calls.append(params) payload = self.payloads[params["q"]] return BackendResponse( backend_url="http://searx.local", url=f"http://searx.local/search?q={params['q']}", status_code=200, elapsed_ms=12.5, payload=payload, ) async def ping(self) -> BackendResponse: return BackendResponse( backend_url="http://searx.local", url="http://searx.local/", status_code=200, elapsed_ms=1.0, payload={}, ) async def close(self) -> None: return None @dataclass class FakeFetchClient: responses: dict[str, httpx.Response] calls: list[str] def __init__(self, responses: dict[str, httpx.Response]) -> None: self.responses = responses self.calls = [] async def get(self, url: str) -> FetchResponse: self.calls.append(url) response = self.responses[url] return FetchResponse( url=url, status_code=response.status_code, elapsed_ms=5.0, response=response ) async def close(self) -> None: return None @dataclass class FakeRenderClient: responses: dict[str, tuple[str, str]] calls: list[tuple[str, int | None]] def __init__(self, responses: dict[str, tuple[str, str]]) -> None: self.responses = responses self.calls = [] def status(self) -> dict[str, object]: return { "playwright_installed": True, "browser_path_configured": True, "browser_executable": "/usr/bin/chromium", "headless": True, "block_resources": True, "concurrency": 2, "timeout_s": 10.0, "wait_ms": 1000, } async def get(self, url: str, *, wait_ms: int | None = None) -> RenderedResponse: self.calls.append((url, wait_ms)) html, final_url = self.responses[url] return RenderedResponse( url=url, final_url=final_url, status_code=200, elapsed_ms=7.5, html=html, content_type="text/html; charset=utf-8", title="Rendered Example", ) async def close(self) -> None: return None @dataclass class AsyncStubContext: info_calls: list[tuple[str, dict]] progress_calls: list[tuple[float, float | None, str | None]] def __init__(self) -> None: self.info_calls = [] self.progress_calls = [] async def info(self, message: str, **extra: object) -> None: await asyncio.sleep(0) self.info_calls.append((message, dict(extra))) async def report_progress( self, progress: float, total: float | None = None, message: str | None = None ) -> None: await asyncio.sleep(0) self.progress_calls.append((progress, total, message)) @dataclass class FastMCPLikeContext: log_calls: list[tuple[str, str]] progress_calls: list[tuple[float, float | None, str | None]] def __init__(self) -> None: self.log_calls = [] self.progress_calls = [] async def log( self, level: str, message: str, *, logger_name: str | None = None ) -> None: await asyncio.sleep(0) self.log_calls.append((level, message)) async def info(self, message: str, **extra: object) -> None: logger_name = extra.get("logger_name") await self.log( "info", message, logger_name=str(logger_name) if logger_name is not None else None, ) async def report_progress( self, progress: float, total: float | None = None, message: str | None = None ) -> None: await asyncio.sleep(0) self.progress_calls.append((progress, total, message)) @dataclass class FailingFetchClient: error: Exception async def get(self, url: str) -> FetchResponse: raise self.error async def close(self) -> None: return None def payload_for(*results: dict) -> dict: return { "query": "placeholder", "number_of_results": len(results), "results": list(results), "answers": [], "corrections": [], "infoboxes": [], "suggestions": [], "unresponsive_engines": [], } def test_search_caches_and_returns_compact_summary(tmp_path: Path) -> None: search_client = FakeSearchClient( { "python asyncio gather": payload_for( { "url": "https://docs.python.org/3/library/asyncio-task.html", "title": "Coroutines and tasks", "content": "TaskGroup provides stronger safety guarantees than gather.", "engine": "brave", "score": 4.0, }, { "url": "https://stackoverflow.com/questions/42231161/asyncio-gather-vs-asyncio-wait-vs-asyncio-taskgroup", "title": "asyncio gather vs asyncio wait", "content": "Many cases compare gather and wait.", "engine": "bing", "score": 3.0, }, ) } ) service = SearxngMCPService( make_settings(tmp_path), search_client=search_client, fetch_client=FakeFetchClient({}), ) async def run() -> None: first = await service.search(query="python asyncio gather", max_results=1) second = await service.search(query="python asyncio gather", max_results=1) assert not first.isError assert sc(first)["cache_hit"] is False assert sc(first)["backend_url"] == "http://searx.local" assert sc(second)["cache_hit"] is True assert "Results: 2 total, 1 shown" in text_of(first) assert sc(first)["top_results"][0]["title"] == "Coroutines and tasks" assert len(search_client.calls) == 1 import asyncio asyncio.run(run()) def test_search_tolerates_fastmcp_context_log_signature_bug(tmp_path: Path) -> None: search_client = FakeSearchClient( { "python asyncio gather": payload_for( { "url": "https://docs.python.org/3/library/asyncio-task.html", "title": "Coroutines and tasks", "content": "TaskGroup provides stronger safety guarantees than gather.", "engine": "brave", "score": 4.0, } ) } ) service = SearxngMCPService( make_settings(tmp_path), search_client=search_client, fetch_client=FakeFetchClient({}), ) ctx = FastMCPLikeContext() async def run() -> None: result = await service.search( query="python asyncio gather", max_results=1, ctx=ctx ) assert not result.isError assert ctx.log_calls == [("info", "search completed")] asyncio.run(run()) def test_search_many_dedupes_and_merges(tmp_path: Path) -> None: search_client = FakeSearchClient( { "python asyncio gather": payload_for( { "url": "https://docs.python.org/3/library/asyncio-task.html", "title": "Coroutines and tasks", "content": "TaskGroup provides stronger safety guarantees than gather.", "engine": "brave", "score": 4.0, }, { "url": "https://example.com/a", "title": "A", "content": "First", "engine": "bing", "score": 1.0, }, ), "python taskgroup": payload_for( { "url": "https://docs.python.org/3/library/asyncio-task.html?utm_source=test", "title": "Coroutines and tasks", "content": "TaskGroup provides stronger safety guarantees than gather.", "engine": "google", "score": 5.0, }, { "url": "https://example.com/b", "title": "B", "content": "Second", "engine": "bing", "score": 2.0, }, ), } ) service = SearxngMCPService( make_settings(tmp_path), search_client=search_client, fetch_client=FakeFetchClient({}), ) async def run() -> None: result = await service.search_many( queries=["python asyncio gather", "python taskgroup"], max_results=10 ) assert not result.isError assert "Queries: 2" in text_of(result) assert sc(result)["unique_results"] == 3 assert ( sc(result)["merged_results"][0]["canonical_url"] == "https://docs.python.org/3/library/asyncio-task.html" ) import asyncio asyncio.run(run()) def test_async_context_callbacks_are_awaited(tmp_path: Path) -> None: search_client = FakeSearchClient( { "python asyncio gather": payload_for( { "url": "https://example.org/a", "title": "A", "content": "First", "engine": "brave", "score": 4.0, } ), "python taskgroup": payload_for( { "url": "https://example.org/b", "title": "B", "content": "Second", "engine": "bing", "score": 3.0, } ), } ) service = SearxngMCPService( make_settings(tmp_path), search_client=search_client, fetch_client=FakeFetchClient({}), ) ctx = AsyncStubContext() async def run() -> None: result = await service.search_many( queries=["python asyncio gather", "python taskgroup"], max_results=5, ctx=ctx, ) assert not result.isError assert len(ctx.progress_calls) == 2 assert len(ctx.info_calls) == 1 assert ctx.info_calls[0][0] == "search_many completed" assert ctx.progress_calls[0][2] == "searching python asyncio gather" assert ctx.progress_calls[1][2] == "searching python taskgroup" asyncio.run(run()) def test_research_merges_search_and_batch_fetch( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: search_client = FakeSearchClient( { "python asyncio gather": payload_for( { "url": "https://docs.python.org/3/library/asyncio-task.html", "title": "Coroutines and tasks", "content": "TaskGroup provides stronger safety guarantees than gather.", "engine": "brave", "score": 4.0, }, { "url": "https://example.com/a", "title": "A", "content": "First", "engine": "bing", "score": 1.0, }, ), "python taskgroup": payload_for( { "url": "https://docs.python.org/3/library/asyncio-task.html?utm_source=test", "title": "Coroutines and tasks", "content": "TaskGroup provides stronger safety guarantees than gather.", "engine": "google", "score": 5.0, }, { "url": "https://example.com/b", "title": "B", "content": "Second", "engine": "bing", "score": 2.0, }, ), } ) fetch_client = FakeFetchClient( { "https://docs.python.org/3/library/asyncio-task.html": make_http_response( "https://docs.python.org/3/library/asyncio-task.html", """
TaskGroup body.
Second body.
First paragraph.
Docs link Guide linkRendered body text with more detail about JavaScript hydration, dynamic rendering, and extracted content.
Docs GuideRendered fallback content.
Alpha body.
Beta body.
Loading application shell.
Rendered application body.
Loading application shell.
Rendered body text with the real content available after hydration.
Body text.