message / plugins /browser /api.py
hunian
refactor(plugins): 插件短名并统一 MCP tool 为 {plugin}-{tool}
cc826a1
Raw
History Blame Contribute Delete
5.52 kB
"""
浏览器MCP插件 API 路由(v3.0)。
端点:
- GET / API 根
- GET /status 插件状态
- GET /binary-check Lightpanda 二进制探测
- GET /tools 列出 28 个上游工具(含 schema)
- GET /health 健康检查(二进制 + 子进程 + 漂移校验)
- POST /mcp/{tool} 同步调用 MCP 工具(前端用)
- POST /process/stop 停止 lightpanda 子进程
- GET /process/logs 查看子进程 stderr 最近 N 行
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException
from .browser.binary_locator import locate_lightpanda
from .browser.client import LightpandaClient
from .browser.tools_meta import TOOLS_META
logger = logging.getLogger(__name__)
router = APIRouter()
# 运行时注入的插件实例(由 plugin_loader 调用 set_plugin_instance 注入)
plugin = None
def set_plugin_instance(plugin_instance):
"""由系统调用,注入插件实例。"""
global plugin
plugin = plugin_instance
@router.get("/")
async def api_root():
"""API 根路径。"""
return {"message": "浏览器MCP插件API", "backend": "lightpanda", "status": "运行中"}
@router.get("/status")
async def get_status():
"""获取插件状态。"""
if plugin is None:
return {
"name": "browser",
"enabled": False,
"message": "插件未加载",
}
return plugin.get_status()
@router.get("/binary-check")
async def binary_check():
"""Lightpanda 二进制结构化探测(路径/版本/install_hints)。"""
return locate_lightpanda().to_dict()
@router.get("/tools")
async def list_tools():
"""列出 28 个上游工具的元数据(含 input_schema,供前端动态表单)。"""
return {
"tools": [
{
"name": f"browser-{m.name}",
"upstream_name": m.name,
"title": m.title,
"summary": m.summary,
"description": m.description,
"input_schema": m.input_schema,
"read_only": m.read_only,
}
for m in TOOLS_META
],
"total": len(TOOLS_META),
}
@router.get("/health")
async def health():
"""健康检查:二进制可用性 + 子进程存活 + 上游工具集漂移校验。"""
check = locate_lightpanda()
client = LightpandaClient.instance()
process_alive = False
drift: Optional[Dict[str, Any]] = None
try:
process_alive = client.process_alive
except Exception:
process_alive = False
# 子进程已启动时校验上游工具集与静态 meta 是否漂移
cached = client.tools_cache
if cached is not None:
upstream_names = {t.get("name") for t in cached if isinstance(t, dict)}
meta_names = {m.name for m in TOOLS_META}
missing = sorted(meta_names - upstream_names) # meta 有、上游无
extra = sorted(upstream_names - meta_names) # 上游有、meta 无
if missing or extra:
drift = {"missing_in_upstream": missing, "extra_in_upstream": extra}
return {
"binary_available": check.available,
"binary_path": check.path,
"binary_version": check.version,
"process_alive": process_alive,
"tools_cached": cached is not None,
"tools_count": len(cached) if cached else 0,
"drift": drift,
}
@router.post("/mcp/{tool_name}")
async def call_mcp_tool(tool_name: str, body: Dict[str, Any]):
"""同步调用 MCP 工具;返回结构化结果(run_id + result/error_code)。
前端轮询 run_log 时用返回的 run_id。
"""
from . import mcp as mcp_module
# tool_name 形如 "browser-goto" 或 "goto",统一查 _mcp_tool_def
func = None
for name in dir(mcp_module):
attr = getattr(mcp_module, name, None)
if callable(attr) and hasattr(attr, "_mcp_tool_def"):
defn = attr._mcp_tool_def
registered = defn.get("name")
if registered == tool_name or registered == f"browser-{tool_name}" or registered.endswith(f"-{tool_name}"):
func = attr
break
if func is None:
raise HTTPException(status_code=404, detail=f"未找到 MCP 工具: {tool_name}")
try:
# 工具签名: async def _tool(params: Optional[dict] = None)
result = await func(body or {})
except Exception as e:
raise HTTPException(status_code=500, detail=f"工具执行失败: {e}")
return result
@router.post("/process/stop")
async def process_stop():
"""停止 lightpanda 子进程(下次工具调用会重新懒启动)。"""
await LightpandaClient.instance().close()
return {"success": True, "message": "lightpanda 子进程已停止"}
@router.get("/process/logs")
async def process_logs(tail: int = 50):
"""查看子进程 stderr 最近 N 行。"""
from pathlib import Path
log_path = Path("data/lightpanda") / "mcp.log"
if not log_path.exists():
return {"lines": [], "message": "日志文件不存在(子进程尚未启动过)"}
try:
data = log_path.read_bytes()
text = data.decode("utf-8", errors="replace")
lines = text.splitlines()[-tail:]
return {"lines": lines, "log_path": str(log_path)}
except OSError as e:
raise HTTPException(status_code=500, detail=f"读取日志失败: {e}")