#!/usr/bin/env python3 """ MCP Search Server — provides web search via opencode infrastructure. Two search methods (automatic fallback): 1. npx opencode run (rate-limited but returns real web results) 2. opencode-go API + model training data (no rate limit, less fresh) Usage: python3 scripts/mcp_search_server.py # MCP server (stdio) python3 scripts/mcp_search_server.py --query Q # one-shot search python3 scripts/mcp_search_server.py --url U # one-shot webfetch """ import json, os, subprocess, sys, time from pathlib import Path from typing import Any, Dict, List, Optional PROJECT_ROOT = Path(__file__).resolve().parent.parent CACHE_FILE = PROJECT_ROOT / "automation" / "data" / "mcp_search_cache.json" SESSION_FILE = PROJECT_ROOT / "automation" / "data" / "mcp_session.txt" CACHE_TTL = 3600 SESSION_TITLE = "opencode搜索" API_BASE = "https://opencode.ai/zen/go/v1" API_KEY = os.environ.get("OPENCODE_API_KEY", "") if not API_KEY: try: from dotenv import load_dotenv env_path = PROJECT_ROOT / "platform" / "backend" / ".env" load_dotenv(env_path) API_KEY = os.environ.get("OPENCODE_API_KEY", "") except Exception: pass # ── session (reuse same session for all MCP searches) ───────────── def _load_session() -> Optional[str]: if SESSION_FILE.exists(): try: return SESSION_FILE.read_text().strip() or None except Exception: pass return None def _save_session_from_output(stdout: str): for line in stdout.strip().split("\n"): try: ev = json.loads(line) sid = ev.get("sessionID") or ev.get("part", {}).get("sessionID") if sid: SESSION_FILE.parent.mkdir(parents=True, exist_ok=True) SESSION_FILE.write_text(sid) return except Exception: pass # ── cache ───────────────────────────────────────────────────────── def _check_cache(query: str) -> Optional[List[Dict]]: if not CACHE_FILE.exists(): return None try: data = json.loads(CACHE_FILE.read_text()) entry = data.get(query) if entry and time.time() - entry.get("ts", 0) < CACHE_TTL: return entry.get("results") except Exception: pass return None def _write_cache(query: str, results: List[Dict]): CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) data = {} if CACHE_FILE.exists(): try: data = json.loads(CACHE_FILE.read_text()) except Exception: pass data[query] = {"ts": time.time(), "results": results} keys = sorted(data.keys(), key=lambda k: data[k].get("ts", 0), reverse=True)[:200] CACHE_FILE.write_text(json.dumps({k: data[k] for k in keys}, ensure_ascii=False)) # ── method 1: npx opencode run ──────────────────────────────────── def _search_via_opencode_cli(query: str, max_results: int) -> Optional[List[Dict]]: """Use npx opencode run to execute websearch tool (short timeout).""" sid = _load_session() args = ["npx", "opencode", "run", f"websearch {query}", "--format", "json", "--title", SESSION_TITLE] if sid: args.extend(["--session", sid, "--continue"]) try: r = subprocess.run( args, capture_output=True, text=True, timeout=15, env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"} ) except subprocess.TimeoutExpired: return None except Exception: return None if r.returncode != 0: return None # Save session ID for reuse _save_session_from_output(r.stdout) for line in r.stdout.strip().split("\n"): try: ev = json.loads(line) if ev.get("type") == "tool_use": part = ev.get("part", {}) state = part.get("state", {}) if part.get("tool") == "websearch" and state.get("status") == "completed": data = json.loads(state["output"]) results = [] for item in (data.get("results") or [])[:max_results]: url = (item.get("url") or "").strip() title = (item.get("title") or "").strip() excerpts = item.get("excerpts") or [] content = (excerpts[0] if excerpts else "")[:500] if url and title: results.append({"title": title, "url": url, "content": content, "source": "opencode_cli"}) return results except Exception: pass return None # ── method 2: opencode-go API + training data ───────────────────── def _search_via_api(query: str, max_results: int) -> Optional[List[Dict]]: """Use opencode-go API to answer query from training data (no rate limit).""" if not API_KEY: return None import requests try: resp = requests.post( f"{API_BASE}/chat/completions", headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}, json={ "model": "deepseek-v4-flash", "messages": [{"role": "user", "content": ( f"你现在是一个网络搜索工具。用户查询: {query[:100]}\n\n" f"请根据你的训练数据,提供{max_results}条最相关的网页结果,包含标题、URL和摘要。" f"以JSON格式输出: [{{\"title\":\"...\",\"url\":\"...\",\"content\":\"...\"}}]" f"仅输出JSON数组,不要其他文字。如果URL不确定,用合理占位。" )}], "temperature": 0.3, "max_tokens": 2000, }, timeout=30 ) data = resp.json() content = data.get("choices", [{}])[0].get("message", {}).get("content", "") # Extract JSON array import re as _re m = _re.search(r'\[.*?\]', content, _re.DOTALL) if m: items = json.loads(m.group()) if isinstance(items, list): for item in items: item["source"] = "opencode_api" return items[:max_results] except Exception: pass return None # ── search ───────────────────────────────────────────────────────── def web_search(query: str, max_results: int = 8) -> List[Dict]: max_results = min(max_results, 10) cached = _check_cache(query) if cached: return cached[:max_results] results = _search_via_opencode_cli(query, max_results) if results: _write_cache(query, results) return results results = _search_via_api(query, max_results) if results: _write_cache(query, results) return results return [] def webfetch(url: str) -> Optional[str]: sid = _load_session() args = ["npx", "opencode", "run", f"webfetch {url}", "--format", "json", "--title", SESSION_TITLE] if sid: args.extend(["--session", sid, "--continue"]) try: r = subprocess.run( args, capture_output=True, text=True, timeout=60, env={**os.environ, "OPENCODE_DISABLE_AUTOUPDATE": "1"} ) if r.returncode == 0: _save_session_from_output(r.stdout) for line in r.stdout.strip().split("\n"): try: ev = json.loads(line) if ev.get("type") == "tool_use": p = ev.get("part", {}) s = p.get("state", {}) if p.get("tool") == "webfetch" and s.get("status") == "completed": return s.get("output", "")[:10000] except Exception: pass except Exception: pass return None # ── MCP protocol (JSON-RPC 2.0 over stdio) ──────────────────────── def _read_msg() -> Optional[Dict]: line = sys.stdin.readline() if not line: return None try: return json.loads(line) except json.JSONDecodeError: return None def _send_msg(msg: Dict): sys.stdout.write(json.dumps(msg, ensure_ascii=False) + "\n") sys.stdout.flush() def _send_error(req_id: Any, code: int, message: str): _send_msg({"jsonrpc": "2.0", "id": req_id, "error": {"code": code, "message": message}}) def _send_result(req_id: Any, result: Any): _send_msg({"jsonrpc": "2.0", "id": req_id, "result": result}) def serve(): sys.stdin.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8") while True: msg = _read_msg() if msg is None: break req_id = msg.get("id") method = msg.get("method", "") params = msg.get("params", {}) if method == "initialize": _send_result(req_id, { "protocolVersion": "2024-11-05", "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "opencode-search-mcp", "version": "1.0.0"} }) elif method == "notifications/initialized": pass elif method == "tools/list": _send_result(req_id, {"tools": [ {"name": "web_search", "description": "Search the web. Returns up to 10 results with title, url, content.", "inputSchema": { "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, "max_results": {"type": "number", "description": "Max results (1-10)", "default": 8} }, "required": ["query"] }}, {"name": "webfetch", "description": "Fetch and extract content from a URL.", "inputSchema": { "type": "object", "properties": {"url": {"type": "string", "description": "URL to fetch"}}, "required": ["url"] }} ]}) elif method == "tools/call": name = params.get("name", "") args = params.get("arguments", {}) try: if name == "web_search": results = web_search(args.get("query", ""), int(args.get("max_results", 8))) _send_result(req_id, {"content": [{"type": "text", "text": json.dumps(results, ensure_ascii=False)}]}) elif name == "webfetch": content = webfetch(args.get("url", "")) _send_result(req_id, {"content": [{"type": "text", "text": content or "Failed to fetch URL"}]}) else: _send_error(req_id, -32601, f"Unknown tool: {name}") except Exception as e: _send_error(req_id, -32603, str(e)) elif method == "shutdown": _send_result(req_id, {}) break else: _send_error(req_id, -32601, f"Unknown method: {method}") def main(): if "--query" in sys.argv: idx = sys.argv.index("--query") q = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else "" print(json.dumps(web_search(q), ensure_ascii=False, indent=2)) return if "--url" in sys.argv: idx = sys.argv.index("--url") u = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else "" print(webfetch(u) or "Failed") return serve() if __name__ == "__main__": main()