fix: content quality, image format, task monitor, calendar data source, search UI & sort
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user