Files
oc_session/mcpctl.py
T

478 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
mcpctl —— 命令行版的 MCP 配置对调开关(与 TUI 插件 / server 插件同一套逻辑)
做什么:
status 查看当前模式与 MCP 开/关状态(只读,不改任何文件)
on 把配置对调为「含 MCP」的原始版,并调用 reload 热重载
off 把配置对调为「不含 MCP」的安静版,并调用 reload 热重载
mode 查看或修改 mcpcfg.txt 模式键(自动 / 手动 / 关闭)
模式语义(与插件完全一致):
自动 大模型可通过 server 插件切换;/mcpon /mcpoff 与本工具视为手动切换一次
手动 仅允许 /mcpon /mcpoff 与本工具(用户意图),大模型的切换请求会被拒绝
关闭 功能整体禁用,on/off 都会被拒绝
状态判定只看活文件(basename 为 opencode.json 的那份)里 mcp.servers 是否非空,
与 mcpcfg.txt「含MCP的配置」/「安静配置」两键的行语义无关,因此对调之后身份漂移也不影响判断。
"""
import argparse
import base64
import datetime
import json
import os
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path
# ---------------------------------------------------------------- 控制台编码
def _fix_console() -> None:
for stream in (sys.stdout, sys.stderr):
try:
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
except Exception:
pass
_fix_console()
MODE_LABEL = {"auto": "自动", "manual": "手动", "off": "关闭"}
LABEL_MODE = {
"自动": "auto",
"auto": "auto",
"audo": "auto", # 用户手误拼写,保持与插件一致
"手动": "manual",
"manual": "manual",
"hand": "manual",
"on": "manual",
"关闭": "off",
"off": "off",
"disable": "off",
"disabled": "off",
}
DEFAULT_DISTRO = os.environ.get("OC_SKILL_DISTRO", "Ubuntu-24.04")
DEFAULT_PORT = int(os.environ.get("OC_PORT", "4096"))
# ---------------------------------------------------------------- 路径工具
def _basename(path: str) -> str:
"""跨平台取文件名:同时认 / 与 \\,避免在 Windows 上解析 WSL 路径出错。"""
return re.split(r"[\\/]", path.strip())[-1]
def _home_config() -> Path:
return Path.home() / ".config" / "opencode"
def wsl_config_dir(distro: str) -> Path:
if os.name == "nt":
return Path(f"\\\\wsl.localhost\\{distro}\\root\\.config\\opencode")
return Path("/root/.config/opencode")
def resolve_cfg_file(target: str, distro: str) -> Path:
if target == "wsl":
return wsl_config_dir(distro) / "mcpcfg.txt"
return _home_config() / "mcpcfg.txt"
# ---------------------------------------------------------------- mcpcfg 解析
# 与 tui.js / ai-mcp.ts 的 readMcpCfg 保持一致:
# 优先按键值解析(模式: / 含MCP的配置: / 安静配置:,认中英文冒号),
# 解析不到再回退旧「裸三行」(第1行模式、第2行含MCP、第3行安静)。
def read_cfg(path: Path) -> dict:
if not path.exists():
return {
"mode": "off",
"error": f"缺少配置文件:{path}(配置缺失时功能视为关闭)",
}
try:
raw_text = path.read_text(encoding="utf-8", errors="replace")
except Exception as exc:
return {"mode": "off", "error": f"读取 {path} 失败:{exc}"}
lines = raw_text.splitlines()
kv: dict[str, str] = {}
for ln in lines:
line = ln.strip()
if not line or line.startswith("#") or line.startswith("//"):
continue
m = re.match(r"^([^::]+)[::]\s*(.*)$", line)
if m:
kv[m.group(1).strip().lower()] = m.group(2).strip()
mode_val = kv.get("模式", kv.get("mode", kv.get("第1行")))
on_val = kv.get("含mcp的配置", kv.get("含mcp配置", kv.get("含mcp", kv.get("on", kv.get("原始配置", kv.get("第2行"))))))
quiet_val = kv.get("安静配置", kv.get("安静", kv.get("quiet", kv.get("备份", kv.get("backup", kv.get("第3行"))))))
is_legacy = mode_val is None and on_val is None and quiet_val is None
if not is_legacy:
mode = LABEL_MODE.get((mode_val or "自动").strip().lower(), "auto")
on_path = (on_val or "").strip()
quiet_path = (quiet_val or "").strip()
else:
bare = [l.strip() for l in lines if l.strip() and not l.strip().startswith("#") and not l.strip().startswith("//")]
mode = LABEL_MODE.get((bare[0] if bare else "").strip().lower(), "auto" if not bare or bare[0] == "" else "auto")
on_path = (bare[1] if len(bare) > 1 else "").strip()
quiet_path = (bare[2] if len(bare) > 2 else "").strip()
active = shadow = None
if _basename(on_path) == "opencode.json":
active, shadow = on_path, quiet_path
elif _basename(quiet_path) == "opencode.json":
active, shadow = quiet_path, on_path
result = {
"mode": mode,
"on_path": on_path,
"quiet_path": quiet_path,
"active": active,
"shadow": shadow,
"legacy": is_legacy,
"error": None,
}
if mode != "off" and not active:
result["error"] = "mcpcfg.txt 两行路径都不是 opencode.json(「含MCP的配置」或「安静配置」需指向 opencode.json)"
elif mode != "off" and (not Path(on_path).exists() or not Path(quiet_path).exists()):
result["error"] = "mcpcfg.txt 指向的配置文件不存在(请检查「含MCP的配置」/「安静配置」两键的路径)"
return result
def write_cfg(path: Path, mode: str, on_path: str, quiet_path: str) -> None:
"""按键值格式写回(模式/含MCP的配置/安静配置),带注释头。"""
mode_cn = {"auto": "自动", "manual": "手动", "off": "关闭"}.get(mode, "自动")
body = "\n".join([
"# MCP 开关配置:改完保存,重启 OC 生效(第1个键=模式)",
"# 模式可填:自动(大模型可切换) / 手动(仅 /mcpon /mcpoff) / 关闭(禁用本功能)",
f"模式: {mode_cn}",
f"含MCP的配置: {on_path}",
f"安静配置: {quiet_path}",
"",
])
path.write_text(body, encoding="utf-8")
def set_mode_in_file(path: Path, mode: str) -> None:
"""只改模式键:键值文件按键替换;旧三行文件替换第1行(插件下次读会自动迁移)。"""
raw = path.read_text(encoding="utf-8", errors="replace")
lines = raw.splitlines()
mode_cn = {"auto": "自动", "manual": "手动", "off": "关闭"}.get(mode, "自动")
kv_pattern = re.compile(r"^(模式|mode)\s*[::]\s*.*$", re.IGNORECASE)
hit = False
for i, ln in enumerate(lines):
if kv_pattern.match(ln.strip()):
prefix = ":" if "模式" in ln.split(":")[0] or ":" in ln else ":"
lines[i] = f"模式{prefix} {mode_cn}"
hit = True
break
if not hit:
# 旧三行(或键缺失):第1行位放模式;若本来是键值缺模式键,插到最前面
if any(re.match(r"^([^::]+)[::]", l.strip()) for l in lines if l.strip() and not l.strip().startswith("#")):
lines.insert(0, f"模式: {mode_cn}")
else:
while len(lines) < 3:
lines.append("")
lines[0] = mode_cn
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def flag_path(cfg_dir_target: str, distro: str) -> Path:
"""显式开启标记 mcp.on.flag 的落点(与插件共用;wsl 目标落在 WSL 目录)。"""
base = wsl_config_dir(distro) if cfg_dir_target == "wsl" else _home_config()
return base / "mcp.on.flag"
def set_on_flag(cfg_dir_target: str, distro: str, on: bool) -> None:
try:
p = flag_path(cfg_dir_target, distro)
if on:
p.write_text(str(os.getpid()), encoding="utf-8")
elif p.exists():
p.unlink()
except Exception:
pass
def active_has_mcp(active: str | None) -> bool:
try:
if not active:
return False
data = json.loads(Path(active).read_text(encoding="utf-8"))
servers = (data.get("mcp") or {}).get("servers") or {}
return bool(servers)
except Exception:
return False
def swap_files(a: str, b: str) -> None:
pa, pb = Path(a), Path(b)
ca, cb = pa.read_bytes(), pb.read_bytes()
pa.write_bytes(cb)
pb.write_bytes(ca)
# ---------------------------------------------------------------- reload
def _read_password(cfg_dir: Path) -> str:
try:
data = json.loads((cfg_dir / "opencode.json").read_text(encoding="utf-8"))
pwd = (data.get("server") or {}).get("password")
return str(pwd) if pwd else ""
except Exception:
return ""
def _wsl_ip(distro: str) -> str:
if os.name != "nt":
return ""
try:
import subprocess
out = subprocess.run(
["wsl.exe", "-d", distro, "--", "bash", "-lc", "hostname -I"],
capture_output=True,
timeout=15,
)
for tok in (out.stdout or b"").decode("utf-8", "ignore").split():
if re.fullmatch(r"\d{1,3}(\.\d{1,3}){3}", tok):
return tok
except Exception:
pass
return ""
# 关键:本机/WSL 的 OC 绝不能走系统代理。
# 环境里若有 HTTP_PROXY/HTTPS_PROXY,urlopen 会把 localhost 请求丢给代理,
# 结果是代理回 502 —— 看起来像 OC 挂了,其实是被劫走了。
_NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
if os.environ.get("MCPCTL_USE_PROXY"):
_NO_PROXY_OPENER = urllib.request.build_opener()
def _post(url: str, password: str, timeout: float) -> tuple[int, str]:
req = urllib.request.Request(url, method="POST", data=b"{}")
req.add_header("content-type", "application/json")
req.add_header("accept", "application/json")
if password:
req.add_header("authorization", f"Bearer {password}")
try:
with _NO_PROXY_OPENER.open(req, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
if exc.code == 401 and password:
raw = base64.b64encode(f":{password}".encode()).decode("ascii")
req2 = urllib.request.Request(url, method="POST", data=b"{}")
req2.add_header("content-type", "application/json")
req2.add_header("authorization", f"Basic {raw}")
try:
with _NO_PROXY_OPENER.open(req2, timeout=timeout) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc2:
return exc2.code, str(exc2)
return exc.code, str(exc)
except Exception as exc:
return 0, str(exc)
def do_reload(target: str, args) -> tuple[bool, str]:
password = args.password or _read_password(
wsl_config_dir(args.distro) if target == "wsl" else _home_config()
)
hosts = [f"http://localhost:{args.port}", f"http://127.0.0.1:{args.port}"]
if target == "wsl" and os.name == "nt":
ip = _wsl_ip(args.distro)
if ip:
hosts.append(f"http://{ip}:{args.port}")
last = ""
for host in hosts:
code, body = _post(host + "/api/location/reload", password, args.timeout)
if 200 <= code < 300:
return True, f"热重载成功({host}/api/location/reload)"
if code in (401, 403):
last = f"{host} 鉴权失败(HTTP {code}),请用 --password 指定 server.password"
elif code == 404:
last = f"{host} 未提供 /api/location/reload(可能需要重启 OC 生效)"
elif code:
last = f"{host} 返回 HTTP {code}:{body[:200]}"
else:
last = f"{host} 连不上(OC 可能没在运行)"
return False, last
# ---------------------------------------------------------------- 子命令
def _stamp() -> str:
return datetime.datetime.now().strftime("%Y%m%d%H%M%S")
def print_state(cfg: dict) -> bool:
has = active_has_mcp(cfg.get("active"))
print(f"模式:{MODE_LABEL.get(cfg['mode'], cfg['mode'])}(mcpcfg.txt「模式」键)")
print(f"MCP :{'已开启(配置含 mcp.servers)' if has else '已关闭(安静,无 mcp 配置)'}")
print(f"活配置:{cfg.get('active') or '(未识别)'}")
if cfg.get("error"):
print(f"警告:{cfg['error']}")
return has
def cmd_status(args) -> int:
path = resolve_cfg_file(args.target, args.distro)
print(f"配置文件:{path}")
cfg = read_cfg(path)
print_state(cfg)
return 0 if not cfg.get("error") else 2
def _switch(args, want_on: bool) -> int:
path = resolve_cfg_file(args.target, args.distro)
print(f"配置文件:{path}")
cfg = read_cfg(path)
if cfg.get("error"):
print(f"失败:{cfg['error']}")
return 2
action = "开启" if want_on else "关闭"
if cfg["mode"] == "off" and not args.ignore_mode:
print("拒绝:MCP 开关功能已关闭(mcpcfg.txt「模式: 关闭」)。")
print("如需使用,请把模式改成「自动」或「手动」。")
return 3
has = active_has_mcp(cfg["active"])
if has == want_on:
print(f"无需切换:MCP 已是{'开启' if want_on else '关闭(安静)'}状态。")
print_state(cfg)
return 0
swap_files(cfg["on_path"], cfg["quiet_path"])
after = active_has_mcp(cfg["active"])
if after != want_on:
print("失败:配置对调后状态校验不符,请检查 mcpcfg.txt「含MCP的配置」/「安静配置」路径。")
return 4
# 显式意图落标记:开写 mcp.on.flag、关删标记(与 tui.js / ai-mcp 共用,
# 避免自动模式的“启动归位”把刚开启的状态又换回去)
set_on_flag(args.target, args.distro, want_on)
print(f"配置已对调,MCP 现在是「{'开启' if after else '关闭'}」。")
if args.no_reload:
print("已按 --no-reload 跳过热重载,需重启 OC 生效。")
return 0
ok, msg = do_reload(args.target, args)
if ok:
print(f"{msg},MCP 配置立即生效。")
return 0
print(f"热重载失败:{msg}")
print("配置文件已经对调完成,重启 OpenCode 也能生效。")
return 5
def cmd_on(args) -> int:
return _switch(args, True)
def cmd_off(args) -> int:
return _switch(args, False)
def cmd_mode(args) -> int:
path = resolve_cfg_file(args.target, args.distro)
if not path.exists():
print(f"配置文件不存在:{path}")
return 2
if not args.value:
cfg = read_cfg(path)
cur = MODE_LABEL.get(cfg.get("mode", "auto"), cfg.get("mode", "auto"))
print(f"当前模式:{cur}")
print("可选值:自动 / 手动 / 关闭(也支持 auto / manual / off)")
return 0
key = args.value.strip().lower()
if key not in LABEL_MODE:
print(f"无法识别的模式:{args.value}(可选:自动 / 手动 / 关闭 / auto / manual / off)")
return 2
set_mode_in_file(path, LABEL_MODE[key])
print(f"已把模式设为:{MODE_LABEL[LABEL_MODE[key]]}({path})")
return 0
def cmd_backup(args) -> int:
path = resolve_cfg_file(args.target, args.distro)
if not path.exists():
print(f"配置文件不存在:{path}")
return 2
bak = path.with_name(path.name + f".bak-{_stamp()}")
bak.write_bytes(path.read_bytes())
print(f"已备份:{bak}")
return 0
# ---------------------------------------------------------------- 入口
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="mcpctl",
description="MCP 配置对调开关(status / on / off / mode),与插件共用同一份 mcpcfg.txt",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"示例:\n"
" python mcpctl.py status\n"
" python mcpctl.py on\n"
" python mcpctl.py off --no-reload\n"
" python mcpctl.py mode 手动\n"
" python mcpctl.py status --target wsl\n"
),
)
p.add_argument("--target", choices=["local", "wsl"], default="local", help="操作本机还是 WSL 的配置(默认 local)")
p.add_argument("--distro", default=DEFAULT_DISTRO, help=f"WSL 发行版名(默认 {DEFAULT_DISTRO})")
p.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"OC 端口(默认 {DEFAULT_PORT})")
p.add_argument("--timeout", type=float, default=5.0, help="HTTP 超时秒数")
p.add_argument("--password", default="", help="OC server.password(默认自动从 opencode.json 读)")
p.add_argument("--no-reload", action="store_true", help="只对调文件,不调用热重载")
p.add_argument("--ignore-mode", action="store_true", help="忽略「模式: 关闭」限制强制切换(慎用)")
sub = p.add_subparsers(dest="cmd")
sp = sub.add_parser("status", help="查看当前模式与 MCP 开关状态")
sp.set_defaults(func=cmd_status)
sp = sub.add_parser("on", help="对调为含 MCP 的配置并热重载")
sp.set_defaults(func=cmd_on)
sp = sub.add_parser("off", help="对调为安静配置(删 MCP)并热重载")
sp.set_defaults(func=cmd_off)
sp = sub.add_parser("mode", help="查看或修改 mcpcfg.txt「模式」键(自动/手动/关闭)")
sp.add_argument("value", nargs="?", help="要设置的模式:自动 / 手动 / 关闭(不传则只查看)")
sp.set_defaults(func=cmd_mode)
sp = sub.add_parser("backup", help="备份当前 mcpcfg.txt")
sp.set_defaults(func=cmd_backup)
return p
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if not getattr(args, "cmd", None):
parser.print_help()
return 0
try:
return args.func(args)
except KeyboardInterrupt:
print("\n已取消。")
return 130
if __name__ == "__main__":
sys.exit(main())