Files
codex-app-server/cloud/websearch_mcp.py
T
host 397a46f850 websearch: add scope=code programming search + provider extract chain
- search(..., scope=code) restricts to 15 programming domains
  (official docs, GitHub, registries, Stack Overflow, CVE/NVD):
  native include_domains/search_domain_filter where supported
  (Tavily, Exa, Perplexity), post-filtering for all providers
  (Brave, SearXNG, DuckDuckGo have no native filter).
- web_fetch now uses fetch_best: Tavily extract, then Exa
  contents, then direct fetch; both-fail errors name both
  failures, direct-fetch 403 under the egress whitelist is
  fail-closed, not bypassed. Default general scope and direct
  fetch_url behaviour unchanged.
- MCP web_search gains scope enum (general/code); docs updated.
- Tests all mock HTTP, no real keys.
2026-11-01 08:16:48 +00:00

202 lines
8.1 KiB
Python

"""Stdio MCP server exposing web_search / web_fetch to codex.
Protocol: JSON-RPC 2.0 over stdio, one JSON object per line.
Logs go to stderr only -- anything on stdout would corrupt the
JSON-RPC stream.
This file is deliberately thin: all provider logic lives in
``websearch.py``. It can run two ways:
- inside the repo: ``python3 -m cloud.websearch_mcp`` (imports
``cloud.websearch``);
- copied flat into a tenant codex-home next to ``websearch.py``
(imports plain ``websearch`` from its own directory).
The ``ensure_codex_home_websearch`` helper in websearch.py does the
copying; it never writes API keys into config.toml -- keys are read
from the MCP server process environment by websearch.py.
"""
import json
import os
import sys
try:
from cloud.websearch import WebSearchError, fetch_best, search
except ImportError: # flat copy in a tenant codex-home
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from websearch import WebSearchError, fetch_best, search # noqa: E402
SERVER_NAME = "websearch"
SERVER_VERSION = "0.1.0"
_TOOLS = [
{
"name": "web_search",
"description": (
"Search the web and return ranked results with title, URL "
"and snippet, plus a synthesised answer when the provider "
"supplies one. Use web_fetch to read specific pages. "
"provider may be 'auto' (walk Perplexity, Exa, Tavily, "
"Brave, then keyless SearXNG / DuckDuckGo, falling back "
"on failure) or a single provider name to pin it. "
"scope 'code' restricts results to programming sources "
"only (official language docs, GitHub, package "
"registries, Stack Overflow, CVE/NVD); anything else "
"is filtered out, never returned. Default scope "
"'general' is unfiltered. This tool does not do "
"shopping, social, places, travel, general news, "
"weather, finance, or browser checkout."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string",
"description": "Search query"},
"limit": {"type": "integer", "default": 5,
"description": "Max results, 1-10"},
"provider": {"type": "string", "default": "auto",
"description": "auto or a provider name"},
"scope": {"type": "string", "default": "general",
"enum": ["general", "code"],
"description": "general: unfiltered web "
"search. code: programming "
"sources only, see tool "
"description."},
},
"required": ["query"],
},
},
{
"name": "web_fetch",
"description": (
"Fetch one URL and return clean text plus its links, "
"truncated head+tail when over char_limit. "
"Extraction first: Tavily extract, then Exa contents "
"(only backends with a configured key are tried), "
"direct fetch last. Under the egress proxy, direct "
"fetch of a domain outside the Tinyproxy whitelist "
"fails 403 fail-closed; that error is reported, not "
"bypassed. Only http(s) URLs; localhost / private IPs "
"are blocked. This is not a sandbox boundary."
),
"inputSchema": {
"type": "object",
"properties": {
"url": {"type": "string",
"description": "URL to fetch"},
"char_limit": {"type": "integer", "default": 15000,
"description": "Max characters, "
"clamped 2000-500000"},
},
"required": ["url"],
},
},
]
def _err(code, message):
return {"code": code, "message": message}
def handle_request(request, search_fn=None, fetch_fn=None):
"""Handle one JSON-RPC request dict. Returns a response dict or
None for notifications (no id). Pure function for testability:
the stdio loop passes the real search/fetch, tests pass fakes."""
search_fn = search_fn or search
fetch_fn = fetch_fn or fetch_best
if not isinstance(request, dict):
return {"jsonrpc": "2.0", "id": None,
"error": _err(-32700, "request is not an object")}
method = request.get("method")
req_id = request.get("id")
is_notification = "id" not in request
if method == "initialize":
if is_notification:
return None
return {"jsonrpc": "2.0", "id": req_id,
"result": {
"protocolVersion": request.get("params", {}).get(
"protocolVersion", "2024-11-05"),
"capabilities": {"tools": {}},
"serverInfo": {"name": SERVER_NAME,
"version": SERVER_VERSION}}}
if method in ("notifications/initialized", "notifications/cancelled"):
return None
if method == "ping":
if is_notification:
return None
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
if method == "tools/list":
if is_notification:
return None
return {"jsonrpc": "2.0", "id": req_id,
"result": {"tools": _TOOLS}}
if method == "tools/call":
if is_notification:
return None
params = request.get("params") or {}
name = params.get("name")
args = params.get("arguments") or {}
try:
if name == "web_search":
result = search_fn(
args.get("query"),
limit=args.get("limit", 5),
provider=args.get("provider", "auto"),
scope=args.get("scope", "general"))
elif name == "web_fetch":
result = fetch_fn(
args.get("url"),
char_limit=args.get("char_limit", 15000))
else:
return {"jsonrpc": "2.0", "id": req_id,
"error": _err(-32602, "unknown tool: %r" % name)}
except Exception as exc: # noqa: BLE001 - tool errors are data
# WebSearchError messages are already key-free by
# construction in websearch.py; other exceptions are
# rendered as type + message, never with env or headers.
return {"jsonrpc": "2.0", "id": req_id,
"result": {"content": [{"type": "text",
"text": "error: %s" % exc}],
"isError": True}}
return {"jsonrpc": "2.0", "id": req_id,
"result": {"content": [{"type": "text",
"text": json.dumps(
result, ensure_ascii=False)}],
"isError": False}}
if is_notification:
return None
return {"jsonrpc": "2.0", "id": req_id,
"error": _err(-32601, "method not found: %r" % method)}
def main():
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
request = json.loads(line)
except json.JSONDecodeError as exc:
sys.stdout.write(json.dumps(
{"jsonrpc": "2.0", "id": None,
"error": _err(-32700, "parse error: %s" % exc)}) + "\n")
sys.stdout.flush()
continue
try:
response = handle_request(request)
except Exception as exc: # noqa: BLE001 - server must not die
print("websearch_mcp: handler error: %s" % exc, file=sys.stderr)
if "id" in request:
response = {"jsonrpc": "2.0", "id": request["id"],
"error": _err(-32603, "internal error")}
else:
response = None
if response is not None:
sys.stdout.write(json.dumps(response) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()