commit 56dac008311ac6359c340aadb654f6076e90f847 Author: root Date: Wed Jul 15 17:53:14 2026 -0400 MCP server source diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a9f2c6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.13-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY server.py ratelimit.py ./ +ENV PYTHONUNBUFFERED=1 +EXPOSE 8899 +CMD ["python3", "server.py"] diff --git a/ratelimit.py b/ratelimit.py new file mode 100644 index 0000000..d574bbd --- /dev/null +++ b/ratelimit.py @@ -0,0 +1,232 @@ +""" +Rate limiting, TTL caching, and retry with backoff for Super Search MCP server. + +Provides: + - TokenBucket: async-aware token bucket rate limiter (per provider) + - TTLCache: dict-based cache with per-key expiration + - rate_limited_search: decorator that applies rate limiting + caching + retry +""" + +import asyncio +import time +import logging +from functools import wraps +from typing import Any, Callable, Optional + +logger = logging.getLogger("super-search.ratelimit") + +# ── Rate limits (tokens per interval in seconds) ──────────────────────────────── +RATE_LIMITS: dict[str, dict[str, float]] = { + "searxng": {"tokens": 30, "interval": 1.0}, # local, high limit + "exa": {"tokens": 10, "interval": 1.0}, # paid tier + "firecrawl": {"tokens": 5, "interval": 60.0}, # 1k/mo, be careful + "opencorporates": {"tokens": 1, "interval": 1.0}, # ~1/sec free tier + "courtlistener": {"tokens": 10, "interval": 6.0}, # ~100/min free tier + "duckduckgo": {"tokens": 5, "interval": 1.0}, # free, be respectful + "wikipedia": {"tokens": 30, "interval": 1.0}, # public API, generous +} + +# ── Cache TTLs (seconds) ──────────────────────────────────────────────────────── +CACHE_TTL: dict[str, int] = { + "searxng": 120, # 2 min — web results change + "exa": 300, # 5 min + "opencorporates": 3600, # 1 hour — company records don't change often + "courtlistener": 3600, # 1 hour — court cases are infrequent + "duckduckgo": 300, # 5 min — instant answers can change + "wikipedia": 3600, # 1 hour — encyclopedia articles are stable +} + +# ── Exceptions considered "retryable" ─────────────────────────────────────────── +RETRYABLE_STATUSES: set[int] = {429, 503, 502, 504} + + +# ══════════════════════════════════════════════════════════════════════════════════ +# Token Bucket Rate Limiter +# ══════════════════════════════════════════════════════════════════════════════════ + +class TokenBucket: + """Async-aware token bucket rate limiter. + + Tokens refill at a constant rate (tokens per interval). Each acquire() + consumes one token; if none are available the caller must wait. + """ + + def __init__(self, tokens: float, interval: float) -> None: + self._capacity = float(tokens) + self._tokens = float(tokens) + self._interval = float(interval) + self._refill_rate = self._capacity / self._interval + self._last_refill = time.monotonic() + self._lock = asyncio.Lock() + + def _refill(self) -> None: + now = time.monotonic() + elapsed = now - self._last_refill + self._tokens = min(self._capacity, self._tokens + elapsed * self._refill_rate) + self._last_refill = now + + async def acquire(self) -> None: + """Acquire one token, waiting if necessary until one is available.""" + async with self._lock: + self._refill() + if self._tokens >= 1.0: + self._tokens -= 1.0 + return + # Calculate wait time until next token + wait = (1.0 - self._tokens) / self._refill_rate + logger.debug("TokenBucket: waiting %.2fs for token", wait) + # Sleep outside the lock so other consumers can also wait + await asyncio.sleep(wait) + # Retry after sleep + async with self._lock: + self._refill() + self._tokens -= 1.0 + + +# ── Global bucket registry ────────────────────────────────────────────────────── + +_buckets: dict[str, TokenBucket] = {} + +def get_bucket(provider: str) -> TokenBucket: + """Get or create a token bucket for a provider.""" + if provider not in _buckets: + limits = RATE_LIMITS.get(provider, {"tokens": 5, "interval": 1.0}) + _buckets[provider] = TokenBucket( + tokens=limits["tokens"], + interval=limits["interval"], + ) + return _buckets[provider] + + +# ══════════════════════════════════════════════════════════════════════════════════ +# TTL Cache +# ══════════════════════════════════════════════════════════════════════════════════ + +class TTLCache: + """Simple dict-based TTL cache with per-key expiration.""" + + def __init__(self) -> None: + self._cache: dict[str, tuple[Any, float]] = {} # key → (value, expires_at) + self._lock = asyncio.Lock() + + async def get(self, key: str) -> Optional[Any]: + """Return cached value if not expired, else None.""" + async with self._lock: + entry = self._cache.get(key) + if entry is None: + return None + value, expires_at = entry + if time.monotonic() >= expires_at: + del self._cache[key] + return None + return value + + async def set(self, key: str, value: Any, ttl: int) -> None: + """Store a value with the given TTL in seconds.""" + async with self._lock: + self._cache[key] = (value, time.monotonic() + ttl) + + async def invalidate(self, key: str) -> None: + """Remove an entry from the cache.""" + async with self._lock: + self._cache.pop(key, None) + + +# ── Global cache instance ─────────────────────────────────────────────────────── + +_cache = TTLCache() + + +def cache_key(provider: str, query: str, limit: int) -> str: + """Build a deterministic cache key for a search.""" + return f"{provider}:{query.strip().lower()}:{limit}" + + +# ══════════════════════════════════════════════════════════════════════════════════ +# Retry with exponential backoff +# ══════════════════════════════════════════════════════════════════════════════════ + +import httpx # for HTTPStatusError type hint + +async def _retry_with_backoff( + coro_factory: Callable[[], Any], + provider: str, + max_retries: int = 3, + base_delay: float = 1.0, +) -> Any: + """Execute a coroutine with retry on 429/5xx, using exponential backoff. + + Respects Retry-After header when present. + """ + for attempt in range(max_retries + 1): + try: + result = await coro_factory() + return result + except httpx.HTTPStatusError as e: + status = e.response.status_code + if status not in RETRYABLE_STATUSES or attempt == max_retries: + raise + + # Check for Retry-After header + retry_after = e.response.headers.get("Retry-After", "") + if retry_after and retry_after.isdigit(): + delay = float(retry_after) + else: + delay = base_delay * (2 ** attempt) + + logger.warning( + "Provider %s returned %d (attempt %d/%d). Retrying in %.1fs…", + provider, status, attempt + 1, max_retries, delay, + ) + await asyncio.sleep(delay) + + +# ══════════════════════════════════════════════════════════════════════════════════ +# Rate-limited + cached search wrapper +# ══════════════════════════════════════════════════════════════════════════════════ + +def rate_limited_search(provider: str): + """Decorator: wraps a search coroutine with rate limiting + TTL caching. + + Usage: + @rate_limited_search("opencorporates") + async def _opencorporates_search(query, limit=5) -> list[dict]: + ... + """ + + def decorator(func): + @wraps(func) + async def wrapper(query: str, limit: int = 5, **kwargs): + # 1. Check cache + key = cache_key(provider, query, limit) + cached = await _cache.get(key) + if cached is not None: + logger.debug("Cache HIT for %s:%s", provider, key) + return cached + + # 2. Acquire rate-limit token + bucket = get_bucket(provider) + await bucket.acquire() + + # 3. Execute with retry + ttl = CACHE_TTL.get(provider, 300) + + async def _call(): + return await func(query, limit=limit, **kwargs) + + try: + result = await _retry_with_backoff(_call, provider) + except Exception: + # Don't cache failures + raise + + # 4. Cache result + if result: + await _cache.set(key, result, ttl) + logger.debug("Cache SET for %s:%s (TTL=%ds)", provider, key, ttl) + + return result + + return wrapper + + return decorator diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e50ad6f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastmcp>=2.0.0 +httpx>=0.27.0 +trafilatura>=1.12.0 +python-dotenv>=1.0.0 +firecrawl-py>=1.0.0 diff --git a/server.py b/server.py new file mode 100644 index 0000000..ac42053 --- /dev/null +++ b/server.py @@ -0,0 +1,1062 @@ +#!/usr/bin/env python3 +""" +Super Search MCP Server v2.0.0 — Unified web search + extraction via FastMCP. + +Tools (10 total): + 1. web_search(query, limit, category, time_range, site_filter) + → SearXNG → Exa → OpenCorporates → CourtListener → DuckDuckGo → Firecrawl + 2. web_extract(url, char_limit) + → Trafilatura → Firecrawl + 3. web_search_premium(query, limit, include_domains, exclude_domains, date_start, date_end) + → Exa direct (DRE-grade) + 4. web_search_news(query, limit, days) + → SearXNG (news) → Exa + 5. web_search_academic(query, limit) + → SearXNG (science) → Exa + 6. web_extract_batch(urls, char_limit) + → Parallel Trafilatura → Firecrawl per URL + 7. web_suggest_queries(query, limit) + → SearXNG suggestions + 8. health_check() + → Provider statuses + cache stats + 9. web_lookup(query, limit) + → DuckDuckGo Instant Answers + Wikipedia + 10. web_search_images(query, limit) + → SearXNG (images) → Exa + +Providers: SearXNG, Exa, OpenCorporates, CourtListener, DuckDuckGo, Wikipedia, Firecrawl +Features: token-bucket rate limiting, TTL caching, retry with exponential backoff, provider telemetry +Endpoint: http://127.0.0.1:8899/mcp +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import os +import sys +import time +from pathlib import Path +from typing import Any + +import httpx +import trafilatura +from dotenv import load_dotenv +from fastmcp import FastMCP +from firecrawl import FirecrawlApp + +from ratelimit import ( + CACHE_TTL, + RATE_LIMITS, + TTLCache, + TokenBucket, + cache_key, + rate_limited_search, + get_bucket, +) + +# ── Logging ────────────────────────────────────────────────────────────────────── + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s") +logger = logging.getLogger("super-search") + +# ── Environment ───────────────────────────────────────────────────────────────── + +env_path = Path.home() / ".hermes" / ".env" +if env_path.exists(): + load_dotenv(env_path) + +FIRECRAWL_API_KEY = os.getenv("FIRECRAWL_API_KEY", "") +EXA_API_KEY = os.getenv("EXA_API_KEY", "") + +# ── Provider URLs ─────────────────────────────────────────────────────────────── + +SEARXNG_URL = "http://127.0.0.1:8888/search" +EXA_SEARCH_URL = "https://api.exa.ai/search" +OPENCORPORATES_URL = "https://api.opencorporates.com/v0.4/companies/search" +COURTLISTENER_URL = "https://www.courtlistener.com/api/rest/v3/opinions/" +DUCKDUCKGO_URL = "https://api.duckduckgo.com/" +WIKIPEDIA_API_URL = "https://en.wikipedia.org/w/api.php" +WIKIPEDIA_REST_URL = "https://en.wikipedia.org/api/rest_v1/page/summary/" + +# ── SearXNG category map ──────────────────────────────────────────────────────── + +SEARXNG_CATEGORIES = { + "general": "general", + "news": "news", + "science": "science", + "images": "images", + "videos": "videos", + "files": "files", + "social_media": "social+media", + "music": "music", + "map": "map", + "it": "it", +} + +# ── Server ────────────────────────────────────────────────────────────────────── + +mcp = FastMCP("Super Search", version="2.0.0") + +# ── Globals ───────────────────────────────────────────────────────────────────── + +_cache = TTLCache() +_server_start = time.time() + +# ═══════════════════════════════════════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════════════════════════════════════ + +def _get_firecrawl(): + """Lazily create FirecrawlApp — avoids crash if key is missing until needed.""" + if not FIRECRAWL_API_KEY: + raise RuntimeError("FIRECRAWL_API_KEY not set in ~/.hermes/.env") + return FirecrawlApp(api_key=FIRECRAWL_API_KEY) + + +def _normalize_results(raw: list[dict]) -> list[dict]: + """Ensure every result has title, url, description keys.""" + out = [] + for r in raw: + if not isinstance(r, dict): + continue + out.append({ + "title": r.get("title", "") or "", + "url": r.get("url", "") or "", + "description": r.get("description", "") or r.get("snippet", "") or "", + }) + return out + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Provider: SearXNG +# ═══════════════════════════════════════════════════════════════════════════════ + +async def _searxng_raw( + query: str, + limit: int = 5, + category: str = "general", + time_range: str = "", +) -> dict[str, Any]: + """Query SearXNG and return results plus health telemetry.""" + cat = SEARXNG_CATEGORIES.get(category, "general") + params: dict[str, Any] = { + "q": query, + "format": "json", + "language": "en-US", + "categories": cat, + } + if time_range: + params["time_range"] = time_range # day, week, month, year + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get(SEARXNG_URL, params=params) + resp.raise_for_status() + data = resp.json() + + results = data.get("results", [])[:limit] + normalized = _normalize_results(results) + + return { + "results": normalized, + "health": { + "result_count": len(normalized), + "unresponsive_engines": data.get("unresponsive_engines", []), + "answers_count": len((data.get("answers") or [])), + "suggestions_count": len((data.get("suggestions") or [])), + }, + } + + +async def _searxng_search( + query: str, + limit: int = 5, + category: str = "general", + time_range: str = "", +) -> list[dict]: + return (await _searxng_raw(query, limit, category, time_range))["results"] + + +async def _searxng_suggestions(query: str) -> list[str]: + """Return suggested queries from SearXNG.""" + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + SEARXNG_URL, + params={"q": query, "format": "json", "categories": "general"}, + ) + resp.raise_for_status() + data = resp.json() + return data.get("suggestions", []) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Provider: Exa +# ═══════════════════════════════════════════════════════════════════════════════ + +async def _exa_search( + query: str, + limit: int = 5, + search_type: str = "keyword", + include_domains: list[str] | None = None, + exclude_domains: list[str] | None = None, + start_date: str = "", + end_date: str = "", +) -> list[dict]: + """Search via Exa API with optional filters.""" + if not EXA_API_KEY: + raise RuntimeError("EXA_API_KEY not set in ~/.hermes/.env") + + body: dict[str, Any] = { + "query": query, + "numResults": limit, + "type": search_type, + } + if include_domains: + body["includeDomains"] = include_domains + if exclude_domains: + body["excludeDomains"] = exclude_domains + if start_date: + body["startPublishedDate"] = start_date + if end_date: + body["endPublishedDate"] = end_date + + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.post( + EXA_SEARCH_URL, + headers={ + "Authorization": f"Bearer {EXA_API_KEY}", + "Content-Type": "application/json", + }, + json=body, + ) + resp.raise_for_status() + data = resp.json() + + results = data.get("results", []) + return [ + { + "title": r.get("title", ""), + "url": r.get("url", ""), + "description": r.get("text", "")[:500] if r.get("text") else r.get("snippet", ""), + "published_date": r.get("publishedDate", ""), + } + for r in results + ] + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Provider: Firecrawl +# ═══════════════════════════════════════════════════════════════════════════════ + +async def _firecrawl_search(query: str, limit: int = 5) -> list[dict]: + """Search via Firecrawl API.""" + fc = _get_firecrawl() + response = fc.search(query=query, limit=limit) + + results = [] + if isinstance(response, dict): + results = response.get("data") or response.get("results") or [] + elif hasattr(response, "data"): + results = getattr(response, "data") or [] + elif hasattr(response, "results"): + results = getattr(response, "results") or [] + else: + results = response or [] + + normalized = [] + for r in results: + if not isinstance(r, dict): + r = getattr(r, "__dict__", {}) + normalized.append({ + "title": r.get("title") or r.get("markdown", "")[:80] or r.get("url", ""), + "url": r.get("url", ""), + "description": r.get("description") or r.get("content") or "", + }) + return normalized + + +def _firecrawl_extract(url: str) -> str: + """Extract via Firecrawl scrape API.""" + fc = _get_firecrawl() + response = fc.scrape(url, formats=["markdown"]) + if isinstance(response, dict): + return response.get("markdown") or response.get("content") or json.dumps(response) + if hasattr(response, "markdown"): + return getattr(response, "markdown") or "" + if hasattr(response, "content"): + return getattr(response, "content") or "" + return str(response) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Provider: DuckDuckGo Instant Answers +# ═══════════════════════════════════════════════════════════════════════════════ + +@rate_limited_search("duckduckgo") +async def _duckduckgo_lookup(query: str, limit: int = 5) -> list[dict]: + """Instant answers + related topics from DuckDuckGo (free, no key).""" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + DUCKDUCKGO_URL, + params={"q": query, "format": "json", "no_html": "1", "skip_disambig": "1"}, + ) + resp.raise_for_status() + data = resp.json() + + results = [] + + # Instant answer / abstract + abstract = data.get("AbstractText", "") + abstract_url = data.get("AbstractURL", "") + abstract_source = data.get("AbstractSource", "") + heading = data.get("Heading", "") + + if abstract: + results.append({ + "title": heading or abstract_source or "Instant Answer", + "url": abstract_url or "", + "description": abstract, + }) + + # Related topics + for topic in data.get("RelatedTopics", [])[:limit]: + if isinstance(topic, dict) and "Text" in topic: + results.append({ + "title": topic.get("FirstURL", "").split("/")[-1].replace("_", " "), + "url": topic.get("FirstURL", ""), + "description": topic.get("Text", ""), + }) + + # Infobox / definition + answer = data.get("Answer", "") + answer_type = data.get("AnswerType", "") + if answer and answer_type not in ("calc",): + results.append({ + "title": f"{answer_type.title() if answer_type else 'Answer'}", + "url": "", + "description": answer, + }) + + return results[:limit] + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Provider: Wikipedia +# ═══════════════════════════════════════════════════════════════════════════════ + +@rate_limited_search("wikipedia") +async def _wikipedia_search(query: str, limit: int = 5) -> list[dict]: + """Search Wikipedia articles.""" + async with httpx.AsyncClient(timeout=10) as client: + # Search for page titles + resp = await client.get( + WIKIPEDIA_API_URL, + params={ + "action": "query", + "list": "search", + "srsearch": query, + "format": "json", + "srlimit": limit, + }, + ) + resp.raise_for_status() + data = resp.json() + + results = [] + for hit in data.get("query", {}).get("search", [])[:limit]: + title = hit.get("title", "") + snippet = hit.get("snippet", "") + # Strip HTML from snippet + snippet = snippet.replace("", "").replace("", "") + results.append({ + "title": title, + "url": f"https://en.wikipedia.org/wiki/{title.replace(' ', '_')}", + "description": snippet, + }) + return results + + +async def _wikipedia_summary(title: str) -> str | None: + """Get the extract/summary for a Wikipedia article.""" + async with httpx.AsyncClient(timeout=10) as client: + resp = await client.get( + WIKIPEDIA_REST_URL + title.replace(" ", "_"), + headers={"User-Agent": "SuperSearch/2.0.0"}, + ) + if resp.status_code == 404: + return None + resp.raise_for_status() + data = resp.json() + return data.get("extract", "") + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Provider: OpenCorporates +# ═══════════════════════════════════════════════════════════════════════════════ + +@rate_limited_search("opencorporates") +async def _opencorporates_search(query: str, limit: int = 5) -> list[dict]: + """Search company records via OpenCorporates API (free, no key needed).""" + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + OPENCORPORATES_URL, + params={"q": query, "per_page": limit}, + headers={"User-Agent": "SuperSearch/2.0.0"}, + ) + resp.raise_for_status() + data = resp.json() + + companies = data.get("results", {}).get("companies", [])[:limit] + results = [] + for entry in companies: + company = entry.get("company", {}) + name = company.get("name", "") + company_number = company.get("company_number", "") + jurisdiction = company.get("jurisdiction_code", "") + raw_address = company.get("registered_address_in_full", "") + url = f"https://opencorporates.com/companies/{jurisdiction}/{company_number}" + + desc_parts = [] + if raw_address: + desc_parts.append(raw_address) + if jurisdiction: + desc_parts.append(f"Jurisdiction: {jurisdiction.upper()}") + + results.append({ + "title": name or company_number, + "url": url, + "description": "; ".join(desc_parts) if desc_parts else f"Company #{company_number}", + }) + return results + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Provider: CourtListener +# ═══════════════════════════════════════════════════════════════════════════════ + +@rate_limited_search("courtlistener") +async def _courtlistener_search(query: str, limit: int = 5) -> list[dict]: + """Search US court opinions via CourtListener API (free, no key needed).""" + async with httpx.AsyncClient(timeout=15) as client: + resp = await client.get( + COURTLISTENER_URL, + params={"q": query, "format": "json"}, + headers={"User-Agent": "SuperSearch/2.0.0"}, + ) + resp.raise_for_status() + data = resp.json() + + opinions = data.get("results", [])[:limit] + results = [] + for opinion in opinions: + case_name = opinion.get("caseName", "") + court = opinion.get("court", "") + date_filed = opinion.get("dateFiled", "")[:10] if opinion.get("dateFiled") else "" + snippet = (opinion.get("snippet") or opinion.get("plain_text") or "")[:200] + + title = case_name or "Untitled" + abs_url = opinion.get("absolute_url", "") + + desc_parts = [] + if court: + desc_parts.append(court) + if date_filed: + desc_parts.append(date_filed) + if snippet: + desc_parts.append(snippet) + + results.append({ + "title": title, + "url": abs_url, + "description": " | ".join(desc_parts) if desc_parts else "", + }) + return results + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Extraction +# ═══════════════════════════════════════════════════════════════════════════════ + +async def _trafilatura_extract(url: str) -> str: + """Extract clean text from a URL using trafilatura.""" + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + resp = await client.get( + url, + headers={"User-Agent": "Mozilla/5.0 (compatible; SuperSearch/2.0)"}, + ) + resp.raise_for_status() + text = trafilatura.extract( + resp.text, + include_comments=False, + include_tables=True, + include_images=False, + include_links=True, + output_format="markdown", + ) + if not text: + raise ValueError("Trafilatura could not extract content") + return text + + +async def _extract_one(url: str, char_limit: int = 0) -> dict[str, Any]: + """Extract a single URL, returning {url, content, provider, error?}.""" + # Try Trafilatura + try: + text = await _trafilatura_extract(url) + if char_limit and len(text) > char_limit: + text = text[:char_limit] + f"\n\n[...truncated at {char_limit} chars]" + return {"url": url, "content": text, "provider": "trafilatura"} + except Exception as e: + traf_err = str(e) + + # Fallback to Firecrawl + try: + text = _firecrawl_extract(url) + if char_limit and len(text) > char_limit: + text = text[:char_limit] + f"\n\n[...truncated at {char_limit} chars]" + return {"url": url, "content": text, "provider": "firecrawl"} + except Exception as e: + return {"url": url, "content": "", "provider": "none", "error": f"Trafilatura: {traf_err}; Firecrawl: {e}"} + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Cache stats helper +# ═══════════════════════════════════════════════════════════════════════════════ + +def _cache_stats() -> dict: + """Return cache statistics.""" + # Access internals of the global _cache for monitoring + cache_dict = getattr(_cache, "_cache", {}) + total_entries = len(cache_dict) + active_entries = 0 + now = time.monotonic() + for key, (_, expires_at) in cache_dict.items(): + if expires_at > now: + active_entries += 1 + return { + "total_entries": total_entries, + "active_entries": active_entries, + "expired_entries": total_entries - active_entries, + } + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tool 1: web_search (enhanced) +# ═══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool(description="Search the web. Tries SearXNG first, falls back to Exa, OpenCorporates, CourtListener, DuckDuckGo, then Firecrawl. Supports category, time_range, and site_filter.") +async def web_search( + query: str, + limit: int = 5, + category: str = "general", + time_range: str = "", + site_filter: str = "", +) -> str: + """Search the web with provider fallback. + + Args: + query: Search query string. + limit: Max results (1-20, default 5). + category: One of general, news, science, images, videos, files, social_media, music, map, it. + time_range: Optional — day, week, month, year. + site_filter: Optional — limit to domain (e.g. "github.com"). + + Returns JSON with provider, results, and telemetry. + """ + limit = max(1, min(limit, 20)) + telemetry: list[dict] = [] + + # Build query with site filter + search_query = query + if site_filter: + search_query = f"{query} site:{site_filter}" + + # 1. Try SearXNG + try: + searx = await _searxng_raw(search_query, limit, category, time_range) + results = searx["results"] + telemetry.append({"provider": "searxng", "status": "ok", **searx.get("health", {})}) + if results: + return json.dumps( + {"provider": "searxng", "results": results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "searxng", "status": "error", "error": str(e)[:200]}) + + # 2. Fallback to Exa + try: + results = await _exa_search(search_query, limit) + telemetry.append({"provider": "exa", "status": "ok", "result_count": len(results)}) + if results: + return json.dumps( + {"provider": "exa", "results": results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "exa", "status": "error", "error": str(e)[:200]}) + + # 3. Fallback to OpenCorporates + try: + results = await _opencorporates_search(query, limit) + telemetry.append({"provider": "opencorporates", "status": "ok", "result_count": len(results)}) + if results: + return json.dumps( + {"provider": "opencorporates", "results": results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "opencorporates", "status": "error", "error": str(e)[:200]}) + + # 4. Fallback to CourtListener + try: + results = await _courtlistener_search(query, limit) + telemetry.append({"provider": "courtlistener", "status": "ok", "result_count": len(results)}) + if results: + return json.dumps( + {"provider": "courtlistener", "results": results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "courtlistener", "status": "error", "error": str(e)[:200]}) + + # 5. Fallback to DuckDuckGo + try: + results = await _duckduckgo_lookup(query, limit) + telemetry.append({"provider": "duckduckgo", "status": "ok", "result_count": len(results)}) + if results: + return json.dumps( + {"provider": "duckduckgo", "results": results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "duckduckgo", "status": "error", "error": str(e)[:200]}) + + # 6. Last resort: Firecrawl + try: + results = await _firecrawl_search(query, limit) + telemetry.append({"provider": "firecrawl", "status": "ok", "result_count": len(results)}) + return json.dumps( + {"provider": "firecrawl", "results": results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "firecrawl", "status": "error", "error": str(e)[:200]}) + return json.dumps( + {"error": "All search providers failed", "telemetry": telemetry}, + indent=2, + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tool 2: web_extract (enhanced) +# ═══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool(description="Extract clean text (markdown) from a URL. Tries Trafilatura first, falls back to Firecrawl.") +async def web_extract(url: str, char_limit: int = 0) -> str: + """Extract clean markdown text from a URL. + + Args: + url: The URL to extract content from. + char_limit: Optional max characters (0 = no limit). + + Returns markdown text or error message. + """ + result = await _extract_one(url, char_limit) + if result.get("error"): + return f"Extraction failed for {url}: {result['error']}" + return result["content"] + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tool 3: web_search_premium (enhanced) +# ═══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool(description="Premium web search using Exa API directly. Best for DRE/debt-recovery research and skip tracing. Supports domain and date filters.") +async def web_search_premium( + query: str, + limit: int = 5, + include_domains: str = "", + exclude_domains: str = "", + date_start: str = "", + date_end: str = "", +) -> str: + """Search the web using Exa's neural search API with advanced filters. + + Args: + query: Search query. + limit: Max results (1-20, default 5). + include_domains: Comma-separated domains to include (e.g. "linkedin.com,github.com"). + exclude_domains: Comma-separated domains to exclude. + date_start: Start date in YYYY-MM-DD format for filtering results. + date_end: End date in YYYY-MM-DD format. + + Returns JSON with provider and results. + """ + limit = max(1, min(limit, 20)) + inc = [d.strip() for d in include_domains.split(",") if d.strip()] if include_domains else None + exc = [d.strip() for d in exclude_domains.split(",") if d.strip()] if exclude_domains else None + + try: + results = await _exa_search( + query, + limit=limit, + include_domains=inc, + exclude_domains=exc, + start_date=date_start, + end_date=date_end, + ) + return json.dumps( + {"provider": "exa-premium", "results": results, "result_count": len(results)}, + indent=2, + ) + except Exception as e: + return json.dumps({"error": f"Exa search failed: {e}", "results": []}, indent=2) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tool 4: web_search_news (NEW) +# ═══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool(description="Search news articles. Uses SearXNG news category, falls back to Exa with date filtering for recent results.") +async def web_search_news(query: str, limit: int = 5, days: int = 7) -> str: + """Search for recent news articles. + + Args: + query: Search query. + limit: Max results (1-20, default 5). + days: Look back N days (default 7, max 365). + + Returns JSON with provider, results, and telemetry. + """ + limit = max(1, min(limit, 20)) + days = max(1, min(days, 365)) + telemetry: list[dict] = [] + + # 1. Try SearXNG news category + try: + searx = await _searxng_raw(query, limit, category="news") + results = searx["results"] + telemetry.append({"provider": "searxng-news", "status": "ok", **searx.get("health", {})}) + if results: + return json.dumps( + {"provider": "searxng-news", "results": results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "searxng-news", "status": "error", "error": str(e)[:200]}) + + # 2. Fallback to Exa with date range + try: + from datetime import date, timedelta + end = date.today().isoformat() + start = (date.today() - timedelta(days=days)).isoformat() + results = await _exa_search(query, limit=limit, start_date=start, end_date=end) + telemetry.append({"provider": "exa", "status": "ok", "result_count": len(results)}) + if results: + return json.dumps( + {"provider": "exa", "results": results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "exa", "status": "error", "error": str(e)[:200]}) + + return json.dumps( + {"error": "News search failed — all providers unavailable", "telemetry": telemetry}, + indent=2, + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tool 5: web_search_academic (NEW) +# ═══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool(description="Search academic/scholarly articles. Uses SearXNG science category, falls back to Exa.") +async def web_search_academic(query: str, limit: int = 5) -> str: + """Search for academic papers and scholarly articles. + + Args: + query: Search query (e.g., author name, paper title, topic). + limit: Max results (1-20, default 5). + + Returns JSON with provider, results, and telemetry. + """ + limit = max(1, min(limit, 20)) + telemetry: list[dict] = [] + + # 1. Try SearXNG science category + try: + searx = await _searxng_raw(query, limit, category="science") + results = searx["results"] + telemetry.append({"provider": "searxng-science", "status": "ok", **searx.get("health", {})}) + if results: + return json.dumps( + {"provider": "searxng-science", "results": results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "searxng-science", "status": "error", "error": str(e)[:200]}) + + # 2. Fallback to Exa + try: + results = await _exa_search(f"{query} research paper", limit=limit) + telemetry.append({"provider": "exa", "status": "ok", "result_count": len(results)}) + if results: + return json.dumps( + {"provider": "exa", "results": results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "exa", "status": "error", "error": str(e)[:200]}) + + return json.dumps( + {"error": "Academic search failed — all providers unavailable", "telemetry": telemetry}, + indent=2, + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tool 6: web_extract_batch (NEW) +# ═══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool(description="Extract clean text from multiple URLs in parallel. Each URL tries Trafilatura first, then falls back to Firecrawl.") +async def web_extract_batch(urls: str, char_limit: int = 0) -> str: + """Extract content from multiple URLs concurrently. + + Args: + urls: Comma-separated or newline-separated list of URLs (max 10). + char_limit: Optional max characters per extraction (0 = no limit). + + Returns JSON array with {url, content, provider} per URL. + """ + # Parse URLs + url_list = [] + for part in urls.replace("\n", ",").split(","): + stripped = part.strip() + if stripped: + url_list.append(stripped) + + url_list = url_list[:10] # Hard limit at 10 + + if not url_list: + return json.dumps({"error": "No URLs provided"}, indent=2) + + # Extract all in parallel + tasks = [_extract_one(url, char_limit) for url in url_list] + results = await asyncio.gather(*tasks, return_exceptions=True) + + output = [] + for i, result in enumerate(results): + if isinstance(result, Exception): + output.append({"url": url_list[i], "content": "", "provider": "none", "error": str(result)}) + else: + output.append(result) + + return json.dumps(output, indent=2) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tool 7: web_suggest_queries (NEW) +# ═══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool(description="Get related search query suggestions from SearXNG. Useful for expanding or refining a search.") +async def web_suggest_queries(query: str, limit: int = 5) -> str: + """Get suggested related queries for a search term. + + Args: + query: The search query to get suggestions for. + limit: Max suggestions to return (1-10, default 5). + + Returns JSON with suggestions array. + """ + limit = max(1, min(limit, 10)) + try: + suggestions = await _searxng_suggestions(query) + return json.dumps( + {"query": query, "suggestions": suggestions[:limit]}, + indent=2, + ) + except Exception as e: + return json.dumps({"query": query, "suggestions": [], "error": str(e)[:200]}, indent=2) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tool 8: health_check (NEW) +# ═══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool(description="Check the health and status of all search providers. Returns uptime, provider reachability, and cache statistics.") +async def health_check() -> str: + """Check provider health and server status. + + Returns JSON with uptime, provider statuses, and cache stats. + """ + uptime = time.time() - _server_start + providers: dict[str, dict] = {} + + # Test each provider + provider_tests = [ + ("searxng", lambda: httpx.get("http://127.0.0.1:8888/health", timeout=5)), + ("exa", lambda: httpx.get("https://api.exa.ai/health", timeout=5) if EXA_API_KEY else None), + ("duckduckgo", lambda: httpx.get("https://api.duckduckgo.com/?q=test&format=json&no_html=1", timeout=5)), + ("wikipedia", lambda: httpx.get("https://en.wikipedia.org/w/api.php?action=query&meta=siteinfo&format=json", timeout=5)), + ("opencorporates", lambda: httpx.get("https://api.opencorporates.com/v0.4/companies/search?q=test&per_page=1", timeout=5)), + ("courtlistener", lambda: httpx.get("https://www.courtlistener.com/api/rest/v3/opinions/?format=json", timeout=5)), + ("firecrawl", lambda: (lambda fc: fc.search(query="test", limit=1))(_get_firecrawl()) if FIRECRAWL_API_KEY else None), + ] + + async def _test_one(name: str, factory) -> tuple[str, dict]: + try: + if factory is None: + return name, {"status": "disabled", "reason": "no credentials"} + result = factory() + if asyncio.iscoroutine(result): + result = await result + return name, {"status": "ok"} + except Exception as e: + return name, {"status": "error", "error": str(e)[:200]} + + tests = [_test_one(name, factory) for name, factory in provider_tests] + results = await asyncio.gather(*tests) + + for name, status in results: + providers[name] = status + + # Rate limit bucket statuses + from ratelimit import _buckets + buckets = {} + for name, bucket in _buckets.items(): + buckets[name] = { + "tokens_available": round(bucket._tokens, 2), + "capacity": round(bucket._capacity, 2), + "refill_rate": round(bucket._refill_rate, 2), + } + + return json.dumps({ + "server": { + "version": "2.0.0", + "uptime_seconds": round(uptime), + "uptime_human": f"{int(uptime // 3600)}h {int((uptime % 3600) // 60)}m", + }, + "providers": providers, + "cache": _cache_stats(), + "rate_limits": buckets, + }, indent=2) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tool 9: web_lookup (NEW) +# ═══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool(description="Look up a factual query using DuckDuckGo Instant Answers and Wikipedia. Best for definitions, facts, entities, and encyclopedic information.") +async def web_lookup(query: str, limit: int = 5) -> str: + """Look up factual information using DuckDuckGo + Wikipedia. + + Best for: definitions, entity lookups, encyclopedic facts, quick answers. + Not for: current news, opinion pieces, product comparisons. + + Args: + query: What to look up (person, place, concept, definition, etc.). + limit: Max results from each source (1-10, default 5). + + Returns JSON with instant_answer, ddg_results, and wiki_results. + """ + limit = max(1, min(limit, 10)) + output: dict[str, Any] = {"query": query} + + # DuckDuckGo instant answer + topics + try: + ddg = await _duckduckgo_lookup(query, limit) + # Separate the first result (instant answer) from the rest + if ddg: + output["instant_answer"] = ddg[0]["description"] if ddg[0].get("description") else None + output["ddg_source"] = ddg[0].get("title", "") + output["ddg_url"] = ddg[0].get("url", "") + output["ddg_results"] = ddg[1:limit] if len(ddg) > 1 else [] + else: + output["ddg_results"] = [] + except Exception as e: + output["ddg_results"] = [] + output["ddg_error"] = str(e)[:200] + + # Wikipedia search + try: + wiki = await _wikipedia_search(query, limit) + output["wiki_results"] = wiki + # If we have a good match, try to get the summary + if wiki and wiki[0].get("title"): + try: + summary = await _wikipedia_summary(wiki[0]["title"]) + if summary: + output["wiki_summary"] = summary[:1000] + except Exception: + pass + except Exception as e: + output["wiki_results"] = [] + output["wiki_error"] = str(e)[:200] + + return json.dumps(output, indent=2) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Tool 10: web_search_images (NEW) +# ═══════════════════════════════════════════════════════════════════════════════ + +@mcp.tool(description="Search for images. Uses SearXNG images category. Returns image URLs, thumbnails, source pages, and dimensions.") +async def web_search_images(query: str, limit: int = 5) -> str: + """Search for images on the web. + + Args: + query: What images to search for. + limit: Max results (1-20, default 5). + + Returns JSON with image results (url, thumbnail, source, dimensions, title). + """ + limit = max(1, min(limit, 20)) + telemetry: list[dict] = [] + + # 1. Try SearXNG images category + try: + searx = await _searxng_raw(query, limit, category="images") + results = searx["results"] + # For images, the SearXNG result format includes img_src, thumbnail_src + telemetry.append({"provider": "searxng-images", "status": "ok", **searx.get("health", {})}) + if results: + return json.dumps( + {"provider": "searxng-images", "results": results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "searxng-images", "status": "error", "error": str(e)[:200]}) + + # 2. Fallback: SearXNG general search (image results embed in general too) + try: + searx = await _searxng_raw(query, limit, category="general") + # Filter for results that have img_src + img_results = [r for r in searx["results"] if r.get("img_src") or r.get("thumbnail")] + telemetry.append({"provider": "searxng-general", "status": "ok", "result_count": len(img_results)}) + if img_results: + return json.dumps( + {"provider": "searxng-general", "results": img_results, "telemetry": telemetry}, + indent=2, + ) + except Exception as e: + telemetry.append({"provider": "searxng-general", "status": "error", "error": str(e)[:200]}) + + return json.dumps( + {"error": "Image search failed — all providers unavailable", "telemetry": telemetry}, + indent=2, + ) + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Entrypoint +# ═══════════════════════════════════════════════════════════════════════════════ + +if __name__ == "__main__": + mcp.run( + transport="http", + host="127.0.0.1", + port=8899, + path="/mcp", + )