mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 05:20:47 +08:00
- 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>
166 lines
6.9 KiB
Python
166 lines
6.9 KiB
Python
"""通过真实 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()
|