netx/scripts/mcp_acceptance.py
oliver 9085cac742 MCP: add bizMonitor server, align ops/topology tools, harden CLI exec
- New packages/netx-bizmonitor-mcp: 8 read-only cutover/biz-state tools
  mirroring the netxops bizMonitor group.
- Align netx-mcp (15) and netx-topology-mcp (16) with backend HTTP
  contracts; surface partial batch failures as tool errors.
- CLI hard gate: single-line show/display only; exec job IDs, path
  containment, owner and ne:exec checks.
- Cisco IOSv collection: stable prompt/echo handling over CR.
- Remove legacy netx_api/mcp and mcp_server.py; add mcp.all.json;
  sync skills and docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:52:05 +08:00

166 lines
6.9 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.

"""通过真实 stdio MCP 留存本地验收证据;不安装插件、不导入 handler。
使用已有本机 MCP 配置中的凭据,只在子进程内存中传递,证据自动去敏。
示例:python scripts/mcp_acceptance.py ops runNmsDiagnostics '{}'
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
from pathlib import Path
import queue
import re
import subprocess
import sys
import threading
import time
ROOT = Path(__file__).resolve().parents[1]
GROUPS = {
"ops": ("netx", "netx_mcp", "netx-mcp"),
"topology": ("netx-topology", "netx_topology_mcp", "netx-topology-mcp"),
"biz": ("netx-bizmonitor", "netx_bizmonitor_mcp", "netx-bizmonitor-mcp"),
}
SENSITIVE = re.compile(r"password|passwd|secret|token|authorization|cookie|private_key", re.I)
def redact(value):
if isinstance(value, dict):
return {k: "[已去敏]" if SENSITIVE.search(k) else redact(v) for k, v in value.items()}
if isinstance(value, list):
return [redact(x) for x in value]
if isinstance(value, str):
# MCP 的 JSON 位于 content.text 内;递归处理,避免只净化外层。
try:
decoded = json.loads(value)
except (ValueError, TypeError):
return re.sub(r"\bnxt_[\w-]+", "[已去敏]", value)
if isinstance(decoded, (dict, list)):
return json.dumps(redact(decoded), ensure_ascii=False)
return value
def payload(response):
result = response.get("result", {})
content = result.get("content", [])
if content and content[0].get("type") == "text":
try:
return json.loads(content[0]["text"])
except ValueError:
pass
return result or response.get("error", {})
class McpSession:
def __init__(self, group, config, evidence, source_root=ROOT):
source_root = Path(source_root).resolve()
server_id, module, package = GROUPS[group]
cfg = json.loads(Path(config).read_text(encoding="utf-8-sig"))
settings = cfg["mcpServers"][server_id].get("env", {})
env = os.environ.copy()
env.update({k: str(v) for k, v in settings.items()})
# 只允许本机 netx;不能由配置悄悄指向生产服务。
actual_url = (env.get("NETX_API_URL") or env.get("OCLAW_NETX_BASE_URL")
or "http://127.0.0.1:8890").rstrip("/")
if actual_url not in (
"http://127.0.0.1:8890", "http://localhost:8890",
"http://127.0.0.1:8891", "http://localhost:8891"
):
raise ValueError("验收仅允许本机 8890 API 或隔离样例 8891 API")
env["NETX_API_URL"] = actual_url
env.pop("OCLAW_NETX_BASE_URL", None)
env["PYTHONPATH"] = str(source_root / "packages" / package / "src")
env["PYTHONUTF8"] = "1"
self.group, self.evidence, self.seq = group, Path(evidence), 0
self.stderr = []
self.proc = subprocess.Popen(
[sys.executable, "-m", module], cwd=source_root, env=env,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
encoding="utf-8", bufsize=1,
)
self.lines = queue.Queue()
threading.Thread(target=self._read_stdout, daemon=True).start()
threading.Thread(target=self._read_stderr, daemon=True).start()
self.rpc("initialize", {"protocolVersion": "2024-11-05", "capabilities": {},
"clientInfo": {"name": "netx-mcp-acceptance", "version": "1"}})
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
self.proc.stdin.flush()
def _read_stdout(self):
for line in self.proc.stdout:
self.lines.put(line)
def _read_stderr(self):
for line in self.proc.stderr:
self.stderr.append(line)
def rpc(self, method, params=None, timeout=180):
self.seq += 1
request = {"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params or {}}
start = time.monotonic()
self.proc.stdin.write(json.dumps(request, ensure_ascii=False) + "\n")
self.proc.stdin.flush()
deadline = start + timeout
while True:
try:
line = self.lines.get(timeout=max(0.01, deadline - time.monotonic()))
except queue.Empty as exc:
raise TimeoutError(f"{self.group} {method} 超时;stderr={''.join(self.stderr)[-500:]}") from exc
response = json.loads(line)
if response.get("id") == self.seq:
break
if "id" in response:
raise RuntimeError("服务器对通知回包或返回了错误的请求 id")
record = {"at": dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).isoformat(),
"group": self.group, "request": request, "response": response,
"elapsed_ms": round((time.monotonic() - start) * 1000)}
self.evidence.parent.mkdir(parents=True, exist_ok=True)
with self.evidence.open("a", encoding="utf-8") as f:
f.write(json.dumps(redact(record), ensure_ascii=False) + "\n")
return response
def call(self, name, args):
if name == "execManagedNe":
commands = list(args.get("commands") or [])
for target in args.get("targets") or []:
commands.extend(target.get("commands") or [])
for cmd in commands:
if not re.match(r"(?i)^(show|display)\s+", cmd) or re.search(r"[;\r\n`$&<>|]", cmd):
raise ValueError("验收 CLI 只允许单行 show/display")
return self.rpc("tools/call", {"name": name, "arguments": args})
def close(self):
self.proc.stdin.close()
try:
self.proc.wait(timeout=8)
except subprocess.TimeoutExpired:
self.proc.terminate()
self.proc.wait(timeout=5)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("group", choices=GROUPS)
parser.add_argument("tool", help="工具名,或 tools/list")
parser.add_argument("arguments", nargs="?", default="{}")
parser.add_argument("--config", default=str(ROOT / "mcp.all.json"))
parser.add_argument("--source-root", default=str(ROOT), help="被验收包所在 worktree 根目录")
parser.add_argument("--evidence", default=str(ROOT / "artifacts/mcp-evidence.jsonl"))
opts = parser.parse_args()
session = McpSession(opts.group, opts.config, opts.evidence, opts.source_root)
try:
if opts.tool == "tools/list":
response = session.rpc("tools/list")
names = [t["name"] for t in response.get("result", {}).get("tools", [])]
print(json.dumps({"count": len(names), "tools": names}, ensure_ascii=False))
else:
response = session.call(opts.tool, json.loads(opts.arguments))
print(json.dumps(redact(payload(response)), ensure_ascii=False))
finally:
session.close()
if __name__ == "__main__":
main()