"""Web access MCP server — exposes Firecrawl search + scrape as MCP tools. Runs over stdio inside the voice container. The agent attaches it via MCPServerStdio, so the LLM can call web_search / web_scrape during a conversation to look things up in real time. Backed by the self-hosted Firecrawl stack on xNAS (no API key needed): - POST {FIRECRAWL_BASE}/v1/search -> ranked results with title/description - POST {FIRECRAWL_BASE}/v1/scrape -> page content as markdown Config (env vars, all optional): FIRECRAWL_BASE default http://192.168.86.2:3002 FIRECRAWL_API_KEY only needed if the Firecrawl instance requires auth WEB_SEARCH_LIMIT default 5 results per search """ from __future__ import annotations import json import os import httpx from mcp.server.fastmcp import FastMCP FIRECRAWL_BASE = os.environ.get("FIRECRAWL_BASE", "http://192.168.86.2:3002").rstrip("/") FIRECRAWL_API_KEY = os.environ.get("FIRECRAWL_API_KEY", "") WEB_SEARCH_LIMIT = int(os.environ.get("WEB_SEARCH_LIMIT", "5")) mcp = FastMCP("web-access") def _headers() -> dict[str, str]: h = {"Content-Type": "application/json"} if FIRECRAWL_API_KEY: h["Authorization"] = f"Bearer {FIRECRAWL_API_KEY}" return h @mcp.tool() async def web_search(query: str, limit: int | None = None) -> str: """Search the web and return ranked results with titles, URLs, and descriptions. Use this to find current information, news, facts, or sources about a topic. Returns a compact text summary — not raw JSON. """ limit = min(limit or WEB_SEARCH_LIMIT, 10) async with httpx.AsyncClient(timeout=30) as client: resp = await client.post( f"{FIRECRAWL_BASE}/v1/search", headers=_headers(), json={"query": query, "limit": limit}, ) resp.raise_for_status() data = resp.json() if not data.get("success"): return f"Search failed: {data.get('error', 'unknown error')}" results = data.get("data", []) if not results: return f"No results found for: {query}" lines = [f"Web search results for: {query}", ""] for i, r in enumerate(results, 1): title = (r.get("title") or "(no title)").strip() url = (r.get("url") or "").strip() desc = (r.get("description") or "").strip().replace("\n", " ") lines.append(f"{i}. {title}") if desc: lines.append(f" {desc[:300]}") if url: lines.append(f" URL: {url}") return "\n".join(lines) @mcp.tool() async def web_scrape(url: str, max_chars: int = 8000) -> str: """Fetch a web page and return its content as readable markdown. Use this after web_search to read the full content of a promising result. The content is truncated to max_chars (default 8000) to stay within context. """ async with httpx.AsyncClient(timeout=90) as client: resp = await client.post( f"{FIRECRAWL_BASE}/v1/scrape", headers=_headers(), json={"url": url, "formats": ["markdown"], "timeout": 60000}, ) resp.raise_for_status() data = resp.json() if not data.get("success"): return f"Scrape failed: {data.get('error', 'unknown error')}" result = data.get("data", {}) markdown = (result.get("markdown") or "").strip() if not markdown: return f"No content extracted from {url}" title = (result.get("metadata", {}).get("title") or "").strip() header = f"Page: {title}\nURL: {url}\n\n" if title else f"URL: {url}\n\n" if len(markdown) > max_chars: markdown = markdown[:max_chars] + "\n\n[content truncated]" return header + markdown if __name__ == "__main__": mcp.run(transport="stdio")