Files
codex-app-server/cloud/websearch.py
T
host f8acdffb4a feat: web search MCP v1 (oh-my-pi pattern, Python stdlib)
web_search auto chain Perplexity/Exa/Tavily/Brave/SearXNG/DuckDuckGo
with pinned-provider no-fallback, web_fetch with head+tail truncation,
codex-home seeding helper (idempotent, never writes keys).
Tests: cloud/tests/test_websearch_mcp.py 12/12 (mocked HTTP).
Not wired into worker, not deployed, paid backends not live-tested.
2026-10-28 17:14:22 +08:00

636 lines
24 KiB
Python

"""Web search + fetch for codex tasks, following the oh-my-pi pattern.
Zero third-party dependencies; Python standard library only.
Design notes (what is copied from oh-my-pi, and what is not):
- Copied: one unified ``web_search`` tool, a ``SearchProvider`` abstraction,
and an ``auto`` chain that walks providers in order and falls back to
the next one on failure. Pinning a single provider by name disables
fallback, exactly like oh-my-pi's pinned provider.
- Copied: a two-step workflow -- ``web_search`` finds candidate URLs,
``web_fetch`` reads a specific page -- and site-agnostic extraction
that returns clean text plus a link list, not raw HTML.
- NOT copied: oh-my-pi's 23 providers. v1 ships the five that matter
for a codex task: Perplexity (synthesised answer + citations),
Exa (semantic search + highlights), Tavily (research + extract),
Brave (fast, independent index) and two keyless fallbacks
(SearXNG, DuckDuckGo). Model-native OAuth search providers
(Gemini / Anthropic / Codex / xAI) are deliberately excluded: inside
a codex task they add no provider the task does not already have.
- NOT copied: oh-my-pi's TypeScript / Rust extraction stack. Fetch
here is a small stdlib HTML parser: script/style stripped, text
collapsed, long pages truncated head+tail like Hermes' deterministic
budget (default 15000 chars, 75% head / 25% tail, ``[TRUNCATED]``).
Security notes:
- API keys come only from the process environment, never from
arguments, config files, or this module's return values. Error
strings contain provider names and HTTP statuses only; they never
format headers or key values, and the chain test asserts this.
- ``web_fetch`` is not a security boundary: a codex task running
danger-full-access can already curl anything, so fetch does not
pretend otherwise. It does refuse non-http(s) URLs and literal
localhost / private-IP URLs, which stops the most trivial SSRF
accidents, but it is not a sandbox.
"""
import html as _html
import ipaddress
import json
import os
import re
import socket
import urllib.error
import urllib.parse
import urllib.request
from html.parser import HTMLParser
__all__ = [
"DEFAULT_CHAIN",
"WebSearchError",
"SearchResult",
"available_providers",
"resolve_provider_chain",
"search",
"fetch_url",
"ensure_codex_home_websearch",
"DEFAULT_FETCH_CHAR_LIMIT",
]
DEFAULT_CHAIN = ("perplexity", "exa", "tavily", "brave", "searxng",
"duckduckgo")
DEFAULT_FETCH_CHAR_LIMIT = 15000
_USER_AGENT = "codex-websearch-mcp/0.1 (+codex-app-server)"
_KEY_ENV = {
"perplexity": "PERPLEXITY_API_KEY",
"exa": "EXA_API_KEY",
"tavily": "TAVILY_API_KEY",
"brave": "BRAVE_API_KEY",
}
_FREE_PROVIDERS = ("searxng", "duckduckgo")
class WebSearchError(Exception):
"""All providers in the chain failed (or the pinned one did)."""
def __init__(self, message, attempted=None):
super().__init__(message)
self.attempted = list(attempted or [])
class SearchResult(dict):
"""A single search hit: title, url, snippet, optional score."""
def __init__(self, title, url, snippet="", score=None):
super().__init__(title=title or "", url=url or "",
snippet=snippet or "")
if score is not None:
self["score"] = score
# ---------------------------------------------------------------- HTTP
class HttpClient:
"""Thin urllib wrapper, injectable so tests never touch the net."""
def __init__(self, timeout=20):
self.timeout = timeout
def post_json(self, url, headers, payload):
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url, data=data,
headers=dict(headers, **{"Content-Type": "application/json"}),
method="POST")
return self._read(req)
def get(self, url, headers=None):
req = urllib.request.Request(
url, headers=headers or {}, method="GET")
return self._read(req)
def _read(self, req):
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read()
return resp.status, body, resp.headers.get_content_type()
except urllib.error.HTTPError as exc:
# Never include headers (keys live there) in the error.
body = exc.read()[:500]
raise WebSearchError(
"HTTP %s" % exc.code) from _ProviderHttpError(
exc.code, body)
def get_text(self, url, headers=None):
status, body, _ctype = self.get(url, headers)
charset = "utf-8"
try:
text = body.decode(charset, errors="replace")
except Exception:
text = body.decode("utf-8", errors="replace")
return status, text
class _ProviderHttpError(Exception):
def __init__(self, status, body):
super().__init__("HTTP %s" % status)
self.status = status
self.body = body
def _http_error_message(exc):
"""Render a provider failure without ever leaking credentials."""
if isinstance(exc, _ProviderHttpError):
return "HTTP %s" % exc.status
if isinstance(exc, WebSearchError):
return str(exc)
return "%s: %s" % (type(exc).__name__, exc)
def _default_headers(env, provider):
key_env = _KEY_ENV.get(provider)
key = env.get(key_env) if key_env else None
if provider == "exa":
return {"x-api-key": key, "User-Agent": _USER_AGENT}
if provider == "tavily":
return {"Authorization": "Bearer %s" % key,
"User-Agent": _USER_AGENT}
if provider == "brave":
return {"X-Subscription-Token": key, "Accept": "application/json",
"User-Agent": _USER_AGENT}
if provider == "perplexity":
return {"Authorization": "Bearer %s" % key,
"User-Agent": _USER_AGENT}
return {"User-Agent": _USER_AGENT}
def _clamp_limit(limit, default=5, lo=1, hi=10):
try:
value = int(limit)
except (TypeError, ValueError):
return default
return max(lo, min(hi, value))
# ------------------------------------------------------------ providers
def _provider_perplexity(query, limit, env, http):
# Sonar is a synthesis API: one OpenAI-shaped chat call returns a
# written, cited answer. We surface both the answer and the
# underlying sources, so web_search keeps its "answer + citations"
# shape instead of degrading to a link list.
_status, body, _ctype = http.post_json(
"https://api.perplexity.ai/chat/completions",
_default_headers(env, "perplexity"),
{"model": "sonar",
"messages": [{"role": "user", "content": query}]})
data = json.loads(body.decode("utf-8", errors="replace"))
choices = data.get("choices") or []
message = (choices[0].get("message") or {}) if choices else {}
answer = message.get("content") or None
results = []
for item in data.get("search_results") or []:
if isinstance(item, dict) and item.get("url"):
results.append(SearchResult(item.get("title") or item["url"],
item["url"],
item.get("snippet") or ""))
if not results:
for url in data.get("citations") or []:
if isinstance(url, str) and url.startswith("http"):
results.append(SearchResult(url, url, ""))
return results, answer
def _provider_exa(query, limit, env, http):
_status, body, _ctype = http.post_json(
"https://api.exa.ai/search",
_default_headers(env, "exa"),
{"query": query, "numResults": limit, "type": "auto",
"contents": {"text": {"maxCharacters": 2000},
"highlights": True}})
data = json.loads(body.decode("utf-8", errors="replace"))
results = []
for item in data.get("results") or []:
if not isinstance(item, dict) or not item.get("url"):
continue
highlights = item.get("highlights") or []
snippet = item.get("text") or ""
if highlights and not snippet:
snippet = " ".join(str(h) for h in highlights[:3])
elif highlights:
snippet = snippet[:2000]
results.append(SearchResult(item.get("title") or item["url"],
item["url"], snippet,
item.get("score")))
return results, None
def _provider_tavily(query, limit, env, http):
_status, body, _ctype = http.post_json(
"https://api.tavily.com/search",
_default_headers(env, "tavily"),
{"query": query, "search_depth": "basic",
"max_results": limit, "include_answer": False,
"include_raw_content": False})
data = json.loads(body.decode("utf-8", errors="replace"))
results = []
for item in data.get("results") or []:
if not isinstance(item, dict) or not item.get("url"):
continue
results.append(SearchResult(item.get("title") or item["url"],
item["url"],
item.get("content") or "",
item.get("score")))
return results, data.get("answer") or None
def _provider_brave(query, limit, env, http):
url = ("https://api.search.brave.com/res/v1/web/search?"
+ urllib.parse.urlencode(
{"q": query, "count": limit, "extra_snippets": "true"}))
_status, body, _ctype = http.get(url, _default_headers(env, "brave"))
data = json.loads(body.decode("utf-8", errors="replace"))
results = []
for item in (data.get("web") or {}).get("results") or []:
if not isinstance(item, dict) or not item.get("url"):
continue
parts = [item.get("description") or ""]
parts.extend(str(s) for s in (item.get("extra_snippets") or []))
snippet = " ".join(p for p in parts if p)
results.append(SearchResult(item.get("title") or item["url"],
item["url"], snippet))
return results, None
def _provider_searxng(query, limit, env, http):
base = (env.get("SEARXNG_URL") or "").rstrip("/")
if not base:
raise WebSearchError("searxng not configured (SEARXNG_URL unset)")
url = (base + "/search?"
+ urllib.parse.urlencode({"q": query, "format": "json"}))
_status, body, _ctype = http.get(url, _default_headers(env, "searxng"))
data = json.loads(body.decode("utf-8", errors="replace"))
results = []
for item in data.get("results") or []:
if not isinstance(item, dict) or not item.get("url"):
continue
results.append(SearchResult(item.get("title") or item["url"],
item["url"],
item.get("content") or ""))
return results[:limit], None
class _DuckDuckGoParser(HTMLParser):
"""Best-effort parser for DuckDuckGo's HTML results page.
Only the last-resort free path uses this; it is deliberately
tolerant of layout drift (missing classes just yield no results,
which moves the chain on, it never crashes the tool).
"""
def __init__(self):
super().__init__()
self.results = []
self._current = None
self._in_title = False
self._in_snippet = False
def handle_starttag(self, tag, attrs):
attrs = dict(attrs)
cls = attrs.get("class") or ""
if tag == "a" and "result__a" in cls:
href = attrs.get("href") or ""
self._current = {"title": "", "url": _unwrap_ddg(href),
"snippet": ""}
self._in_title = True
elif tag == "a" and "result__snippet" in cls:
self._in_snippet = True
def handle_endtag(self, tag):
if tag == "a" and self._in_title:
self._in_title = False
if self._current and self._current["url"]:
self.results.append(self._current)
self._current = None
if tag == "a" and self._in_snippet:
self._in_snippet = False
def handle_data(self, data):
if self._in_title and self._current is not None:
self._current["title"] += data
elif self._in_snippet and self.results:
self.results[-1]["snippet"] += data
def _unwrap_ddg(href):
"""DuckDuckGo wraps result links as //duckduckgo.com/l/?uddg=<url>."""
if not href:
return ""
parsed = urllib.parse.urlparse(href)
if "duckduckgo.com" in (parsed.netloc or ""):
target = urllib.parse.parse_qs(parsed.query).get("uddg")
if target:
return target[0]
return ""
if href.startswith("//"):
return "https:" + href
return href
def _provider_duckduckgo(query, limit, env, http):
url = ("https://html.duckduckgo.com/html/?"
+ urllib.parse.urlencode({"q": query}))
_status, body, _ctype = http.get(
url, {"User-Agent": _USER_AGENT})
parser = _DuckDuckGoParser()
parser.feed(body.decode("utf-8", errors="replace"))
results = []
for item in parser.results[:limit]:
results.append(SearchResult(item["title"].strip(),
item["url"],
item["snippet"].strip()))
return results, None
_PROVIDERS = {
"perplexity": _provider_perplexity,
"exa": _provider_exa,
"tavily": _provider_tavily,
"brave": _provider_brave,
"searxng": _provider_searxng,
"duckduckgo": _provider_duckduckgo,
}
def available_providers(env=None):
"""Providers usable right now: keyed ones with a key, plus free ones."""
env = env if env is not None else os.environ
out = []
for name in DEFAULT_CHAIN:
if name in _FREE_PROVIDERS:
if name == "searxng" and not env.get("SEARXNG_URL"):
continue
out.append(name)
elif env.get(_KEY_ENV[name]):
out.append(name)
return out
def resolve_provider_chain(provider="auto", env=None):
"""Resolve the chain oh-my-pi style: auto walks, pinned does not.
``provider`` may be "auto" (default chain, filtered to what is
configured), a single name (that provider only, even if it fails),
or a comma-separated list (that order only). Unknown names raise
ValueError before any HTTP happens.
"""
env = env if env is not None else os.environ
if provider is None or str(provider).strip() in ("", "auto"):
chain = list(available_providers(env))
if not chain:
chain = list(_FREE_PROVIDERS)
# duckduckgo needs no configuration; searxng without a URL
# will fail fast with a clear message, which is better
# than pretending no provider exists at all.
chain = ["duckduckgo"]
return chain
names = [p.strip() for p in str(provider).split(",") if p.strip()]
for name in names:
if name not in _PROVIDERS:
raise ValueError("unknown search provider: %r" % name)
return names
def search(query, limit=5, provider="auto", env=None, http=None):
"""Search the web. Returns {provider, results, answer, attempted}.
Raises WebSearchError (with .attempted) when every provider in
the chain fails or returns no results. Error strings never contain
API keys; see the module docstring.
"""
if not query or not str(query).strip():
raise ValueError("query must be a non-empty string")
env = env if env is not None else os.environ
http = http or HttpClient(
timeout=int(env.get("WEBSEARCH_TIMEOUT", "20") or 20))
limit = _clamp_limit(limit)
chain = resolve_provider_chain(provider, env)
attempted = []
for name in chain:
if name in _KEY_ENV and not env.get(_KEY_ENV[name]):
attempted.append({"provider": name, "ok": False,
"error": "missing API key (%s unset)"
% _KEY_ENV[name]})
continue
try:
results, answer = _PROVIDERS[name](
str(query), limit, env, http)
except Exception as exc: # noqa: BLE001 - chain must continue
attempted.append({"provider": name, "ok": False,
"error": _http_error_message(exc)})
continue
if not results:
attempted.append({"provider": name, "ok": False,
"error": "no results"})
continue
attempted.append({"provider": name, "ok": True, "error": None})
return {"provider": name,
"results": [dict(r) for r in results[:limit]],
"answer": answer,
"attempted": attempted}
raise WebSearchError(
"all search providers failed: %s"
% "; ".join("%s (%s)" % (a["provider"], a["error"])
for a in attempted),
attempted=attempted)
# ---------------------------------------------------------------- fetch
class _TextExtractor(HTMLParser):
def __init__(self):
super().__init__()
self.title = ""
self._in_title = False
self._skip = 0
self.blocks = []
self.links = []
self._current_block = []
def handle_starttag(self, tag, attrs):
if tag in ("script", "style", "noscript"):
self._skip += 1
return
if tag == "title":
self._in_title = True
if tag == "a":
href = dict(attrs).get("href") or ""
if href.startswith("http"):
self.links.append(href)
if tag in ("p", "h1", "h2", "h3", "h4", "li", "pre", "blockquote"):
self._flush()
def handle_endtag(self, tag):
if tag in ("script", "style", "noscript"):
self._skip = max(0, self._skip - 1)
if tag == "title":
self._in_title = False
if tag in ("p", "h1", "h2", "h3", "h4", "li", "pre", "blockquote"):
self._flush()
def handle_data(self, data):
if self._skip:
return
if self._in_title:
self.title += data
return
text = data.strip()
if text:
self._current_block.append(text)
def _flush(self):
if self._current_block:
self.blocks.append(" ".join(self._current_block))
self._current_block = []
def text(self):
self._flush()
return "\n\n".join(self.blocks)
def _truncate(text, char_limit):
if len(text) <= char_limit:
return text, False
head = int(char_limit * 0.75)
tail = char_limit - head
return (text[:head] + "\n\n[TRUNCATED]\n\n" + text[-tail:]
if tail > 0 else text[:head] + "\n\n[TRUNCATED]"), True
def _is_blocked_fetch_url(url):
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
return "only http(s) URLs can be fetched"
host = (parsed.hostname or "").lower()
if host in ("localhost",):
return "localhost URLs are blocked"
try:
addr = ipaddress.ip_address(host)
except ValueError:
return None
if addr.is_private or addr.is_loopback or addr.is_link_local:
return "private / loopback URLs are blocked"
return None
def fetch_url(url, char_limit=DEFAULT_FETCH_CHAR_LIMIT, env=None,
http=None):
"""Fetch one URL and return clean text + links (truncated)."""
if not url or not str(url).strip():
raise ValueError("url must be a non-empty string")
env = env if env is not None else os.environ
if env.get("WEBSEARCH_ALLOW_PRIVATE_URLS") != "1":
blocked = _is_blocked_fetch_url(str(url))
if blocked:
raise WebSearchError(blocked)
http = http or HttpClient(
timeout=int(env.get("WEBSEARCH_TIMEOUT", "20") or 20))
try:
char_limit = int(char_limit)
except (TypeError, ValueError):
char_limit = DEFAULT_FETCH_CHAR_LIMIT
char_limit = max(2000, min(500000, char_limit))
_status, body, ctype = http.get(str(url), {"User-Agent": _USER_AGENT})
if ctype and not any(
token in ctype for token in
("text/html", "text/plain", "application/json",
"application/xml", "text/")):
raise WebSearchError("unsupported content type: %s" % ctype)
text_in = body.decode("utf-8", errors="replace")
if "text/html" in (ctype or "") or "<html" in text_in[:2000].lower():
parser = _TextExtractor()
parser.feed(text_in)
title = _html.unescape(parser.title.strip())
text = _html.unescape(parser.text())
links = parser.links
else:
title = ""
text = text_in
links = []
text, truncated = _truncate(text, char_limit)
return {"url": str(url), "title": title, "text": text,
"links": links[:100], "truncated": truncated,
"char_limit": char_limit}
# ------------------------------------------------------- codex-home glue
def ensure_codex_home_websearch(codex_home_dir, command_args=None,
script_dir=None):
"""Seed websearch MCP files + config into a tenant codex-home.
Idempotent and non-destructive by design:
- copies ``websearch.py`` and ``websearch_mcp.py`` only when the
destination is missing or differs (so a tenant's own edits to
the script are never silently overwritten -- differing content
is left alone and reported);
- appends the ``[mcp_servers.websearch]`` section only when that
section header is absent; an existing section is never rewritten
(keys are never written into config.toml by this helper).
Returns {config_path, script_path, config_changed, script_changed}.
``command_args`` defaults to ``python3 <codex-home>/websearch_mcp.py``
on the host; Docker tenants must pass the container path
(``/var/lib/codex-app-server/codex-home/websearch_mcp.py``)
instead, because the same config.toml cannot hold both paths.
"""
import shutil
home = os.path.abspath(codex_home_dir)
os.makedirs(home, exist_ok=True)
here = os.path.abspath(script_dir or os.path.dirname(__file__))
script_changed = False
script_differs = []
for name in ("websearch.py", "websearch_mcp.py"):
src = os.path.join(here, name)
dst = os.path.join(home, name)
if not os.path.exists(src):
continue
if not os.path.exists(dst):
shutil.copyfile(src, dst)
script_changed = True
else:
with open(src, "rb") as fh:
src_bytes = fh.read()
with open(dst, "rb") as fh:
dst_bytes = fh.read()
if src_bytes != dst_bytes:
# Do not clobber a tenant-modified script; report it.
script_differs.append(name)
continue
config_path = os.path.join(home, "config.toml")
if command_args is None:
command_args = ["python3",
os.path.join(home, "websearch_mcp.py")]
section = ("\n[mcp_servers.websearch]\n"
"command = %s\n"
"args = [%s]\n"
% (json.dumps(command_args[0]),
", ".join(json.dumps(a) for a in command_args[1:])))
config_changed = False
existing = ""
if os.path.exists(config_path):
with open(config_path, encoding="utf-8") as fh:
existing = fh.read()
if "[mcp_servers.websearch]" not in existing:
with open(config_path, "a", encoding="utf-8") as fh:
if existing and not existing.endswith("\n"):
fh.write("\n")
fh.write(section)
config_changed = True
return {"config_path": config_path,
"script_path": os.path.join(home, "websearch_mcp.py"),
"config_changed": config_changed,
"script_changed": script_changed,
"script_differs": script_differs}