"""MCP server for Navi web tools (search, browse, HTTP)."""
from __future__ import annotations
import json
import os
from contextlib import asynccontextmanager
from typing import Annotated, Any
from mcp.server.fastmcp import FastMCP
from pydantic import Field
from .browse import close_browser, web_view
from .config import Settings
from .request import http_request
from .search import web_search
INSTRUCTIONS = """
Navi Web MCP server provides web search, browsing, and raw HTTP tools.
Use it when the task involves:
- searching the web for current info, docs, or real-time data;
- opening a URL in a browser to read human-readable content;
- making REST API calls, webhooks, or raw HTTP requests.
Workflow:
1. web_search — find relevant pages or facts.
2. web_view — open promising URLs to read full content.
3. http_request — call APIs or services requiring headers/auth.
All three tools are stateless and work with public URLs.
No session_id or filesystem paths are required.
""".strip()
@asynccontextmanager
async def _lifespan(_server: FastMCP):
"""Keep one Chromium for the server's lifetime; stop it on shutdown."""
try:
yield {}
finally:
await close_browser()
mcp = FastMCP("navi-web", instructions=INSTRUCTIONS, lifespan=_lifespan)
def _json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, indent=2)
def _settings() -> Settings:
return Settings()
@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."
)
),
],
max_results: Annotated[int, Field(description="Number of results (default 5).")] = 5,
) -> str:
"""Search the web (SearXNG primary, DDG fallback, Brave tertiary).
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.
"""
result = await web_search(_settings(), query, max_results)
if not result["success"]:
# A failed search is a failed tool call. Returning the JSON with
# "success": false kept the journal green — the MCP call itself
# succeeded — so a broken search left no trace except the model's own
# wording. Raising turns it into ``isError``, which Navi logs.
raise RuntimeError(result["output"])
return _json(result)
@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."
),
],
screenshot: Annotated[bool, Field(description="Also capture a screenshot (single URL only).")] = False,
wait_until: Annotated[
str,
Field(
description="When to consider the page loaded: load, domcontentloaded, or networkidle (default)."
),
] = "networkidle",
) -> str:
"""Open a URL in a headless browser and return its main content as markdown.
Navigation, ads and footers are stripped; headings, code blocks and table
rows keep their shape. Links from the page follow the text under
"Links on this page" with absolute URLs, so you can open one by passing it
to this tool.
Pass several URLs in one call when you already know which pages you need:
they open concurrently, so three pages cost about one page's time.
"""
result = await web_view(url, screenshot=screenshot, wait_until=wait_until)
if not result["success"]:
# Same rule as the search: no page opened is a failed tool call, not a
# JSON with "success": false that leaves the journal green. A page the
# browser could not launch at all used to look like an answer.
raise RuntimeError(result["output"])
return _json(result)
@mcp.tool(name="http_request")
async def request_tool(
method: Annotated[str, Field(description="HTTP method: GET, POST, PUT, PATCH, DELETE.")],
url: Annotated[str, Field(description="Full URL to request.")],
headers: Annotated[dict[str, str] | None, Field(description="Optional HTTP headers.")] = None,
body: Annotated[dict[str, Any] | None, Field(description="Optional JSON body.")] = None,
params: Annotated[dict[str, str] | None, Field(description="Optional query parameters.")] = None,
) -> str:
"""Make a raw HTTP request."""
result = await http_request(method, url, headers=headers, body=body, params=params)
return _json(result)
def main() -> None:
transport = os.environ.get("NAVI_WEB_MCP_TRANSPORT", "stdio")
if transport not in {"stdio", "sse", "streamable-http"}:
raise SystemExit("NAVI_WEB_MCP_TRANSPORT must be stdio, sse, or streamable-http")
mcp.run(transport=transport) # type: ignore[arg-type]
if __name__ == "__main__":
main()