diff --git a/deploy/env.template b/deploy/env.template index 69ea6cc..a95fb16 100644 --- a/deploy/env.template +++ b/deploy/env.template @@ -77,10 +77,6 @@ # rate-limited). The config maps this through ${SEARXNG_URL:-}, so a missing # value costs you the backend, not the server. See docs/mcp.md#navi-web-settings. #SEARXNG_URL=http://127.0.0.1:8888 -# Optional Brave Search API key (third backend, used when SearXNG and the -# ddgs fallback both fail). Empty means off — the config maps it with a ":-" -# fallback, so the server still connects without it. -#NAVI_MCP_NAVI_WEB_BRAVE_KEY= # ─── MCP server credentials ────────────────────────────────────────── # mcp_servers.d/*.json is tracked in git and must never hold a live token: it diff --git a/docs/config.md b/docs/config.md index 5dd821d..1ff65d5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -124,9 +124,9 @@ ## Web Search Web search is not a `Settings` field: it is the `navi-web` MCP server. Its settings -reach it through `mcp_servers.d/navi-web.json`, which maps `SEARXNG_URL` (and, -optionally, `NAVI_MCP_NAVI_WEB_BRAVE_KEY`) from `.env` into the server's own -environment. `SEARXNG_URL` is therefore a real `.env` setting — a self-hosted +reach it through `mcp_servers.d/navi-web.json`, which maps `SEARXNG_URL` from +`.env` into the server's own environment. `SEARXNG_URL` is therefore a real `.env` +setting — a self-hosted SearXNG, e.g. `http://localhost:8888` — just not one Navi itself reads. Without it the server falls back to the pinned `ddgs` engines. See [`mcp.md`](mcp.md#navi-web-settings). diff --git a/docs/mcp.md b/docs/mcp.md index a41a843..e1683f6 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -87,12 +87,14 @@ |---|---|---| | `NAVI_WEB_MCP_TRANSPORT` | — | `stdio`, declared inline | | `NAVI_WEB_SEARXNG_URL` | `SEARXNG_URL` (via `${SEARXNG_URL:-}`) | Self-hosted SearXNG base URL — the primary backend | -| `NAVI_WEB_BRAVE_API_KEY` | `NAVI_MCP_NAVI_WEB_BRAVE_KEY` (via `${NAVI_MCP_NAVI_WEB_BRAVE_KEY:-}`) | Optional third backend. Empty means off; the key is optional, so the placeholder carries a fallback (see above) | | `NAVI_WEB_DDG_REGION` | — | DuckDuckGo region, default `ua-uk` | Without `NAVI_WEB_SEARXNG_URL` the server starts straight at the `ddgs` merge — -the engines pinned in `search._DDG_BACKENDS` (DuckDuckGo, Brave, Mojeek, Yahoo, -Wikipedia, Grokipedia; Yandex is excluded on purpose). It is slower and the +the engines pinned in `search._DDG_BACKENDS` (DuckDuckGo, Mojeek, Yahoo, +Wikipedia, Grokipedia; Yandex and Brave are excluded on purpose — Yandex by the +owner's choice, Brave because brave.com answers nothing from this host). There is +no keyed third backend: the Brave Search API refused to issue a key (403 at +signup), so the chain is SearXNG → ddgs and nothing else. It is slower and the first thing to be rate-limited. The `${SEARXNG_URL:-}` fallback is what keeps a host with no SearXNG working: an unset value costs the backend, not the server's three tools. `navi.config.Settings` has no search fields: `SEARXNG_URL` is read @@ -110,18 +112,33 @@ #### Batching is the latency lever -`web_search` takes one query or up to three, `web_view` one URL or up to three; -several run concurrently, so the call costs the slowest item instead of the sum -— three pages measured 17.3 s one by one and 1.9 s together, three searches -3.5 s and 0.6 s. The caps live in the server: three items, deduplicated, extras -dropped with a note in the output. +`web_search` takes a list of up to three queries, `web_view` a list of up to +three URLs; the items run concurrently, so the call costs the slowest item +instead of the sum — three pages measured 17.3 s one by one and 1.9 s together, +three searches 3.5 s and 0.6 s. The caps live in the server: three items, +deduplicated, extras dropped with a note in the output. -The model is pointed at the feature twice — by the tool descriptions, which -travel in every request, and by `summary` in `mcp_servers.d/navi-web.json`, +**The parameter is an array in the schema, and that is the load-bearing part.** +It used to be `str | list[str]`, which the model sees as +`anyOf: [string, array]`; it took the first branch every time — 20 of 20 recorded +`web_search` calls and 8 of 8 `web_view` calls carried a scalar, across sessions +where three searches on one topic were issued one after another. Prose did not +move it: the same recommendation already stood in the parameter description, the +tool docstring and the `summary` line. Annotating the parameter as +`list[str]` with `minItems`/`maxItems` and an `examples` entry removes the choice; +a `BeforeValidator` still folds a bare `"query": "…"` into a one-item list, so a +laxer caller is not refused. The `minItems`/`maxItems` ride in +`json_schema_extra` rather than in pydantic's `min_length`/`max_length` on +purpose: the cap is advisory, and four queries still reach the code that runs +three and names the rest. + +The model is pointed at the feature three times — by the parameter description +and `examples` in the schema, by `summary` in `mcp_servers.d/navi-web.json`, which is the line that reaches the system prompt (`instructions` carries the -long form for `tool_manual`). The wording is a trigger, not a speed plea: batch -when the lookups are already known to be independent, never a follow-up whose -query depends on the previous answer. +long form for `tool_manual`), and by the persona's parallelism rule, which now +separates "several items in one call" from "several calls in one message". The +wording is a trigger, not a speed plea: batch when the lookups are already known +to be independent, never a follow-up whose query depends on the previous answer. A per-item deadline is deliberate. One hung backend or page is named in the output rather than erasing what the others already returned, and the tool diff --git a/docs/tools.md b/docs/tools.md index fe8460a..fa3fd3e 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -14,8 +14,8 @@ | Server | Tool name | Description | |---|---|---| -| `navi-web` | `mcp__navi-web__web_search` | Web search (SearXNG primary, DDG fallback, Brave tertiary). One query or up to 3, run in parallel | -| `navi-web` | `mcp__navi-web__web_view` | Open a URL in a headless browser and return the main content as markdown plus a links appendix. One URL or up to 3, opened in parallel under a shared 40k-char text budget | +| `navi-web` | `mcp__navi-web__web_search` | Web search (SearXNG primary, ddgs fallback). A list of up to 3 queries, run in parallel | +| `navi-web` | `mcp__navi-web__web_view` | Open a URL in a headless browser and return the main content as markdown plus a links appendix. A list of up to 3 URLs, opened in parallel under a shared 40k-char text budget | | `navi-web` | `mcp__navi-web__http_request` | Raw HTTP request (GET/POST/PUT/PATCH/DELETE) | | `navi-3d` | `mcp__navi-3d__compile_scad` | Compile an OpenSCAD script into a binary STL file | | `navi-3d` | `mcp__navi-3d__lint_scad` | Lightweight OpenSCAD source linting before STL compilation | diff --git a/mcp-servers/navi-web/app/config.py b/mcp-servers/navi-web/app/config.py index db975de..d72093f 100644 --- a/mcp-servers/navi-web/app/config.py +++ b/mcp-servers/navi-web/app/config.py @@ -10,5 +10,4 @@ def __init__(self) -> None: self.searxng_url = os.environ.get("NAVI_WEB_SEARXNG_URL", "").rstrip("/") - self.brave_api_key = os.environ.get("NAVI_WEB_BRAVE_API_KEY", "") self.ddg_region = os.environ.get("NAVI_WEB_DDG_REGION", "ua-uk") diff --git a/mcp-servers/navi-web/app/mcp_server.py b/mcp-servers/navi-web/app/mcp_server.py index 8957f83..fd535d5 100644 --- a/mcp-servers/navi-web/app/mcp_server.py +++ b/mcp-servers/navi-web/app/mcp_server.py @@ -8,7 +8,7 @@ from typing import Annotated, Any from mcp.server.fastmcp import FastMCP -from pydantic import Field +from pydantic import BeforeValidator, Field from .browse import close_browser, web_view from .config import Settings @@ -28,6 +28,11 @@ 2. web_view — open promising URLs to read full content. 3. http_request — call APIs or services requiring headers/auth. +web_search takes a list of up to 3 queries and web_view a list of up to 3 URLs; +the items in one call run in parallel, so an independent pair costs the slower +one rather than both. Batch what you already know is independent, never a +follow-up whose wording depends on the previous answer. + All three tools are stateless and work with public URLs. No session_id or filesystem paths are required. """.strip() @@ -44,6 +49,56 @@ mcp = FastMCP("navi-web", instructions=INSTRUCTIONS, lifespan=_lifespan) +def _one_or_many(value: Any) -> Any: + """A bare string is still a list of one. + + The schema advertises an array on purpose: with ``str | list[str]`` the model + was shown ``anyOf: [string, array]``, always took the first branch, and never + batched (20 of 20 recorded calls were scalar). The array is the shape the + model should write; this validator keeps a single ``"query": "…"`` working + instead of failing validation. + """ + return [value] if isinstance(value, str) else value + + +# ``minItems``/``maxItems`` ride in the schema rather than through pydantic's +# ``min_length``/``max_length`` so the cap stays advisory: the server already +# answers four queries by running three and naming the rest, which tells the +# model what happened instead of refusing the call outright. +_BATCH_SCHEMA = {"minItems": 1, "maxItems": 3} + +_QueryArg = Annotated[ + list[str], + BeforeValidator(_one_or_many), + Field( + description=( + "What to search for, as a list — even for a single query. Put 2-3 " + "independent queries in ONE call: they run in parallel, so the call " + "costs the slowest instead of the sum. Anything past 3 is dropped " + "with a note." + ), + json_schema_extra={**_BATCH_SCHEMA, "examples": [["first angle", "second angle"]]}, + ), +] + +_UrlArg = Annotated[ + list[str], + BeforeValidator(_one_or_many), + Field( + description=( + "Page URL, as a list — even for a single page. Put 2-3 pages you " + "already know you need in ONE call: they open in parallel and share " + "one text budget, so the call costs the slowest page instead of the " + "sum. Anything past 3 is dropped with a note." + ), + json_schema_extra={ + **_BATCH_SCHEMA, + "examples": [["https://docs.example.com/guide", "https://example.com/api"]], + }, + ), +] + + def _json(data: Any) -> str: return json.dumps(data, ensure_ascii=False, indent=2) @@ -54,17 +109,10 @@ @mcp.tool(name="web_search") async def search_tool( - query: Annotated[ - str | list[str], - Field( - description=( - "One query, or up to 3 independent queries, which run in parallel." - ) - ), - ], + query: _QueryArg, max_results: Annotated[int, Field(description="Number of results (default 5).")] = 5, ) -> str: - """Search the web (SearXNG primary, DDG fallback, Brave tertiary). + """Search the web (SearXNG primary, DDG fallback). Pass several queries in one call when you already know they are independent: they run concurrently, so the call costs the slowest one instead of the sum. @@ -81,12 +129,7 @@ @mcp.tool(name="web_view") async def view_tool( - url: Annotated[ - str | list[str], - Field( - description="One URL, or up to 3 URLs, which are opened in parallel." - ), - ], + url: _UrlArg, screenshot: Annotated[bool, Field(description="Also capture a screenshot (single URL only).")] = False, wait_until: Annotated[ str, diff --git a/mcp-servers/navi-web/app/search.py b/mcp-servers/navi-web/app/search.py index 3dafd14..b84fef9 100644 --- a/mcp-servers/navi-web/app/search.py +++ b/mcp-servers/navi-web/app/search.py @@ -1,4 +1,4 @@ -"""web_search — SearXNG primary, DDG fallback, Brave tertiary.""" +"""web_search — SearXNG primary, DDG fallback.""" from __future__ import annotations @@ -12,25 +12,27 @@ # Every wait on this path is bounded, including the chain as a whole. The caller # is sitting on this call, and the backends used to add up: 15s SearXNG + two 5s -# DDG attempts + a 2s pause + 15s Brave — half a minute in which a slow search -# and a broken one looked the same. The budgets below make the worst case ~20s. -# The DDG leg is the one that needs the room: when SearXNG is down it becomes the -# only backend, and a real multi-engine scrape behind it has been measured at -# 1-13s — a 4s cap cut healthy searches off as "timeouts". +# DDG attempts + a 2s pause — half a minute in which a slow search and a broken +# one looked the same. The budgets below make the worst case ~20s. The DDG leg is +# the one that needs the room: when SearXNG is down it becomes the only backend, +# and a real multi-engine scrape behind it has been measured at 1-13s — a 4s cap +# cut healthy searches off as "timeouts". _SEARXNG_TIMEOUT = 6.0 _DDG_TIMEOUT = 10.0 -_BRAVE_TIMEOUT = 6.0 _DDG_RETRY_DELAY = 1.0 _TOTAL_TIMEOUT = 20.0 # Which ddgs engines the fallback may use. ``backend`` is a comma-separated list -# of engine names (ddgs v9: duckduckgo, brave, mojeek, yahoo, yandex, wikipedia, -# grokipedia) whose results ddgs merges. It used to read "html", which is not an -# engine name: ddgs warns "backends do not exist or are disabled" and silently -# runs *auto* — every engine, yandex and grokipedia included, shuffled. That is -# where the slow, flaky, mixed-provenance results came from. The set is pinned -# explicitly so it is known, and yandex is excluded on purpose. -_DDG_BACKENDS = "duckduckgo,brave,mojeek,yahoo,wikipedia,grokipedia" +# of engine names (ddgs v9: duckduckgo, mojeek, yahoo, yandex, wikipedia, +# grokipedia, brave) whose results ddgs merges. It used to read "html", which is +# not an engine name: ddgs warns "backends do not exist or are disabled" and +# silently runs *auto* — every engine, yandex and grokipedia included, shuffled. +# That is where the slow, flaky, mixed-provenance results came from. The set is +# pinned explicitly so it is known. Two names are left out on purpose: yandex by +# the owner's choice, and brave because brave.com refuses this host (its HTML +# engine answers nothing) and the Brave Search API it would stand in for refused +# to issue a key. An engine that cannot answer only costs time in the merge. +_DDG_BACKENDS = "duckduckgo,mojeek,yahoo,wikipedia,grokipedia" # One call may carry several independent queries: they run concurrently, so the # call costs the slowest instead of the sum. The cap is what keeps a batch from @@ -164,7 +166,7 @@ "success": False, "output": ( f"Search did not finish within {_TOTAL_TIMEOUT:.0f}s " - f"(SearXNG -> DDG -> Brave) for query: {query!r}" + f"(SearXNG -> DDG) for query: {query!r}" ), "error": "timeout", "metadata": {}, @@ -213,20 +215,6 @@ if attempt == 0: await asyncio.sleep(_DDG_RETRY_DELAY) - # 3. Brave Search fallback, only when a key is configured. - if settings.brave_api_key: - try: - results = await asyncio.wait_for( - _brave(settings, query, max_results), timeout=_BRAVE_TIMEOUT - ) - if results: - return _format(results, source="Brave") - last_error = "Brave: empty response" - except TimeoutError: - last_error = f"Brave: no answer in {_BRAVE_TIMEOUT:.0f}s" - except Exception as e: - last_error = f"Brave: {e}" - return { "success": False, "output": f"All search backends failed for query: {query!r}\nLast error: {last_error}", @@ -277,25 +265,6 @@ ] -async def _brave(settings: Settings, query: str, max_results: int) -> list[dict]: - async with httpx.AsyncClient(timeout=_BRAVE_TIMEOUT) as client: - resp = await client.get( - "https://api.search.brave.com/res/v1/web/search", - params={"q": query, "count": min(max_results, 20), "country": "ALL"}, - headers={ - "Accept": "application/json", - "Accept-Encoding": "gzip", - "X-Subscription-Token": settings.brave_api_key, - }, - ) - resp.raise_for_status() - items = resp.json().get("web", {}).get("results", []) - return [ - {"title": r["title"], "href": r["url"], "body": r.get("description", "")} - for r in items - ] - - def _format(results: list[dict], source: str) -> dict[str, Any]: lines = [ f"[{i+1}] {r.get('title', '')} — {r.get('href') or r.get('url', '')}" diff --git a/mcp_servers.d/navi-web.json b/mcp_servers.d/navi-web.json index c4ebb3a..1b06aad 100644 --- a/mcp_servers.d/navi-web.json +++ b/mcp_servers.d/navi-web.json @@ -8,8 +8,7 @@ "cwd": "./mcp-servers/navi-web", "env": { "NAVI_WEB_MCP_TRANSPORT": "stdio", - "NAVI_WEB_SEARXNG_URL": "${SEARXNG_URL:-}", - "NAVI_WEB_BRAVE_API_KEY": "${NAVI_MCP_NAVI_WEB_BRAVE_KEY:-}" + "NAVI_WEB_SEARXNG_URL": "${SEARXNG_URL:-}" }, "groups": { "search": [ @@ -22,6 +21,6 @@ "http_request" ] }, - "instructions": "Navi Web MCP server provides web search, browsing, and raw HTTP tools.\n\nUse it when the task involves:\n- searching the web for current info, docs, or real-time data;\n- opening a URL in a browser to read human-readable content;\n- making REST API calls, webhooks, or raw HTTP requests.\n\nWorkflow:\n1. search — find relevant pages or facts.\n2. view — open promising URLs to read full content.\n3. request — call APIs or services requiring headers/auth.\n\nBatching: web_search takes one query or up to 3, web_view one URL or up to 3 — several run in parallel, so the call costs the slowest instead of the sum. Use it when you already know the lookups are independent: after a search, open the 2-3 pages you need in one web_view call; when a request needs several unrelated facts, ask the searches together. A failed item in a batch is named in the output rather than dropping the whole call. Do not batch a follow-up that depends on what the previous answer said — that is a sequence, not a batch.\n\nAll three tools are stateless and work with public URLs.\nNo session_id or filesystem paths are required.\n\nABSOLUTE RULE — NEVER bypass MCP tools:\nYou MUST NOT use filesystem, terminal, code_exec, or any direct file access to read or write web content. Use only the MCP tools listed above.", - "summary": "Web search, page browsing and raw HTTP; up to 3 queries or URLs in one call run in parallel, so batch independent lookups. Never fetch web content with filesystem, terminal or code_exec — these are the only valid interface. Details: tool_manual(\"navi-web\")." + "instructions": "Navi Web MCP server provides web search, browsing, and raw HTTP tools.\n\nUse it when the task involves:\n- searching the web for current info, docs, or real-time data;\n- opening a URL in a browser to read human-readable content;\n- making REST API calls, webhooks, or raw HTTP requests.\n\nWorkflow:\n1. search — find relevant pages or facts.\n2. view — open promising URLs to read full content.\n3. request — call APIs or services requiring headers/auth.\n\nBatching: web_search takes a list of up to 3 queries and web_view a list of up to 3 URLs; the items in one call run in parallel, so the call costs the slowest instead of the sum. When a question has several angles, or a search turns up several pages worth reading, put them in ONE call — after a search, open the 2-3 promising pages together rather than one per turn. Batch only what you already know is independent: a follow-up whose wording depends on the previous answer is a sequence, not a batch. A failed item is named in the output rather than dropping the whole call.\n\nAll three tools are stateless and work with public URLs.\nNo session_id or filesystem paths are required.\n\nABSOLUTE RULE — NEVER bypass MCP tools:\nYou MUST NOT use filesystem, terminal, code_exec, or any direct file access to read or write web content. Use only the MCP tools listed above.", + "summary": "Web search, page reading and raw HTTP. When a question has several angles, or a search turns up several pages worth reading, put them in one call — up to 3 queries or URLs run together in the time of one. Batch only what is already independent; a follow-up whose wording depends on the previous answer stays its own call. Never fetch web content with filesystem, terminal or code_exec — these are the only valid interface. Details: tool_manual(\"navi-web\")." } diff --git a/persona.txt b/persona.txt index 4119888..4101295 100644 --- a/persona.txt +++ b/persona.txt @@ -84,7 +84,9 @@ - Never start new background tasks merely because a task-results note arrived — that note is a report, not a request. - Use `tasks` action `cancel` to kill a task that is no longer needed. Stopping a run does NOT stop background tasks. - For detached `terminal`/`code_exec`/`ssh_exec` calls pass `timeout` explicitly when the command may run longer than the tool's default — otherwise a backgrounded timeout is lifted to 300s and the task completes when the timeout (or the command) ends. -- When the profile allows parallel tool calls, you may put several independent tool calls in one batch: they start together and results are reported in call order. Batch only independent calls; NEVER put two actions on the same terminal (or the same ssh host state) into one batch. +- Some tools take several items in ONE call — a list of queries, URLs or paths — and run them together, so the call costs the slowest item instead of the sum. Use that whenever the items are independent of each other. +- When the profile allows parallel tool calls, you may also put several separate tool calls in one batch: they start together and results are reported in call order. +- Batch only independent work. NEVER put two actions on the same terminal (or the same ssh host state) into one batch, and never batch a follow-up whose wording depends on the answer to the previous call — that is a sequence. REFLECTION: You have a reflect tool: Critic (challenges assumptions, surfaces risks), Pragmatist (finds simplest path), Detailer (spots missing requirements). All three run in parallel — it is fast. diff --git a/tests/unit/mcp/conftest.py b/tests/unit/mcp/conftest.py index 2ebb42c..121f1a5 100644 --- a/tests/unit/mcp/conftest.py +++ b/tests/unit/mcp/conftest.py @@ -72,6 +72,5 @@ """Settings with every optional backend off, so each test enables its own.""" settings = _load("navi-web", "config").Settings() settings.searxng_url = "" - settings.brave_api_key = "" settings.ddg_region = "ru-ru" return settings diff --git a/tests/unit/mcp/test_navi_web_schemas.py b/tests/unit/mcp/test_navi_web_schemas.py new file mode 100644 index 0000000..1d9a65f --- /dev/null +++ b/tests/unit/mcp/test_navi_web_schemas.py @@ -0,0 +1,78 @@ +"""What the model sees in the tool schema — the part that makes it batch. + +The parameter used to be ``str | list[str]``, i.e. ``anyOf: [string, array]``, and +the model took the first branch every time: 20 of 20 recorded ``web_search`` calls +and 8 of 8 ``web_view`` calls in production carried a scalar, including sessions +where three searches on one topic went out one after another. Prose did not move +it — the same recommendation already stood in the parameter description, in the +tool docstring and in the server's ``summary`` line. + +So these tests pin the shape, not the wording: the property must be an array, and +a caller that still sends a bare string must keep working. +""" + +import asyncio + + +def _property(navi_web_server, tool: str, name: str) -> dict: + tools = asyncio.run(navi_web_server.mcp.list_tools()) + schema = next(t for t in tools if t.name == tool).inputSchema + return schema["properties"][name] + + +class TestBatchParameters: + def test_web_search_takes_a_list_of_queries(self, navi_web_server): + param = _property(navi_web_server, "web_search", "query") + + assert param["type"] == "array", "a union with a string branch invites a scalar" + assert param["items"] == {"type": "string"} + assert param["minItems"] == 1 + assert param["maxItems"] == 3 + assert param["examples"], "an example batch is the shape the model copies" + + def test_web_view_takes_a_list_of_urls(self, navi_web_server): + param = _property(navi_web_server, "web_view", "url") + + assert param["type"] == "array" + assert param["items"] == {"type": "string"} + assert param["maxItems"] == 3 + assert param["examples"] + + def test_a_bare_string_still_validates(self, navi_web_server, monkeypatch): + """The array is the advertised shape, not a breaking change.""" + + seen: dict = {} + + async def fake(settings, query, max_results=5): + seen["query"] = query + return {"success": True, "output": "ok", "error": None, "metadata": {}} + + monkeypatch.setattr(navi_web_server, "web_search", fake) + + asyncio.run(navi_web_server.mcp.call_tool("web_search", {"query": "one"})) + + assert seen["query"] == ["one"] + + def test_the_cap_is_advisory_so_the_server_can_explain_itself( + self, navi_web_server, monkeypatch + ): + """Four queries must reach the code that runs three and says so. + + ``maxItems`` rides in the schema, not in pydantic's ``max_length``: a + validation error tells the model nothing, while "only the first 3 of 4 + ran" tells it what happened. + """ + + seen: dict = {} + + async def fake(settings, query, max_results=5): + seen["query"] = query + return {"success": True, "output": "ok", "error": None, "metadata": {}} + + monkeypatch.setattr(navi_web_server, "web_search", fake) + + asyncio.run( + navi_web_server.mcp.call_tool("web_search", {"query": ["a", "b", "c", "d"]}) + ) + + assert seen["query"] == ["a", "b", "c", "d"] diff --git a/tests/unit/mcp/test_navi_web_search.py b/tests/unit/mcp/test_navi_web_search.py index ad07271..ecae5f6 100644 --- a/tests/unit/mcp/test_navi_web_search.py +++ b/tests/unit/mcp/test_navi_web_search.py @@ -61,37 +61,6 @@ assert result["metadata"]["source"] == "DDG" - async def test_brave_is_last_and_needs_a_key( - self, navi_web_search, monkeypatch, navi_web_settings - ): - calls: list[str] = [] - - async def searxng(s, q, n): - calls.append("searxng") - return [] - - def ddg(s, q, n): - calls.append("ddg") - return [] - - async def brave(s, q, n): - calls.append("brave") - return [_hit()] - - monkeypatch.setattr(navi_web_search, "_searxng", searxng) - monkeypatch.setattr(navi_web_search, "_ddg", ddg) - monkeypatch.setattr(navi_web_search, "_brave", brave) - navi_web_settings.searxng_url = "http://searx" - - without_key = await navi_web_search.web_search(navi_web_settings, "q") - assert without_key["success"] is False - assert "brave" not in calls - - navi_web_settings.brave_api_key = "key" - with_key = await navi_web_search.web_search(navi_web_settings, "q") - assert with_key["metadata"]["source"] == "Brave" - assert calls[-1] == "brave" - async def test_all_backends_fail_names_the_last_error( self, navi_web_search, monkeypatch, navi_web_settings ): @@ -218,6 +187,10 @@ pinned = set(navi_web_search._DDG_BACKENDS.split(",")) assert pinned <= known, f"unknown ddgs backends: {sorted(pinned - known)}" assert "yandex" not in pinned + # Brave refused this host twice over: its HTML engine answers nothing and + # the Search API would not issue a key (403 at signup). A pinned engine + # that cannot answer only costs time in the merge. + assert "brave" not in pinned class TestSearxngRequest: