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>
This commit is contained in:
oliver 2026-10-16 18:52:05 +08:00
parent d8da7acb7d
commit 9085cac742
63 changed files with 3251 additions and 1092 deletions

View file

@ -0,0 +1,62 @@
# netx-bizmonitor-mcp
独立 Python stdio MCP,只读访问 netx 的割接对比与业务状态 HTTP API。运行依赖仅 `httpx`,不依赖 `netx_api`、数据库驱动或 netxops。采用独立包与现有 topology 包、一组一 skill 的组织方式一致,客户端可按组挂载最小权限,避免扩大 ops 工具域及引入重依赖。
## 配置与启动
安装本包后使用 `python -m netx_bizmonitor_mcp`,或命令 `netx-bizmonitor-mcp`。无需安装时,将本包 `src` 的绝对路径设置为 `PYTHONPATH`。`mcp.json` 是客户端配置示例,需改为实际 Python/src 路径与令牌。
使用凭据文件时,应删除样例的 NETX_API_TOKEN 占位值或设为空;同时为客户端设定正确工作目录,或用 NETX_MCP_TOKEN_FILE 指定绝对路径。
- `NETX_API_URL`:默认 `http://127.0.0.1:8890`,支持 HTTP(S) 和路径前缀;禁止内嵌用户名/密码、查询参数、片段。
- `NETX_API_TOKEN`:最高优先级 Bearer API 令牌;为空时依次读 `NETX_MCP_TOKEN_FILE` 指定的 UTF-8 文件、当前工作目录的 `data/auth/mcp_token`。文件为空、缺失、不可读或编码错误时继续下一候选。后端要求 **`biz-monitor:read`**,`ne:read` 或 `ops:write` 不代替此 scope。无可用令牌时返回 `netx_token_missing`,不发送请求。
- `NETX_LANG`:默认 `zh`;`en` 时附加 `lang=en`。
- Windows 建议 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`;程序也会将 stdio 配为 UTF-8。
令牌读取遵循现有独立 MCP 的凭据文件约定,仅在内存使用,文件内容与路径不回显;响应脱敏覆盖文件令牌,包括请求中途文件轮换前实际使用的令牌。所有业务请求仅 GET;不会触发采集、评估、CLI 执行或设备写入。通过 `GET /v1/auth/me` 探测权限,每次工具调用重新读取,避免缓存权限撤销;探测失败时仍列出工具,由目标 GET 的后端权限检查最终裁决。
## 工具与 HTTP 路由
工具名与 netxops bizMonitor 组相同,去掉 `netx__` 前缀,仅提供以下 8 个工具:
1. `listBizMonitors` → `GET /v1/biz-migration/projects` 与/或 `GET /v1/biz-state/tasks`。
`kind=all|projects|tasks`,`purpose` 只转发任务查询;`status/q` 本地过滤,`limit` 默认 100、上限 500,各列表分别限额。项目压缩为名称、状态、任务 ID、设备摘要、模板名称、高频参数与更新时间;任务保留后端目录字段。counts 区分匹配总数与返回数量。
2. `listBizMonitorBatches` → `GET /v1/biz-migration/projects/{project_id}/batches`、`GET /v1/biz-state/tasks/{task_id}/batches`、`GET /v1/biz-migration/batches/{batch_id}/runs`。
`kind=auto|all|cutover|collect|runs`;auto 根据项目/任务/批次 ID 选择。`all` 需要 project_id 和 task_id;`runs` 需要割接 batch_id。批次 limit 默认 50、上限 200;runs_limit 默认 10、上限 50。include_runs 最多展开前 5 个已筛选割接批次;单个展开失败保留批次并附 runs_error,直接 runs 查询失败则返回工具错误。只保留批次摘要与运行 anomaly/progress,不回传大 summary;counts 为本次返回数量。
3. `getBizMonitorContext` → `GET /v1/biz-migration/monitor-context`。
project_id/task_id 至少一个;传同名查询参数。保留后端定义包的模板、映射、任务和命令。
4. `getBizMonitorBoard` → `GET /v1/biz-migration/batches/{batch_id}/board`。
必填割接 batch_id,可选 run_id;保留看板统计与证据。
5. `listBizMonitorReds` → `GET /v1/biz-migration/projects/{project_id}/red-tickets`。
必填 project_id,可选 status、limit(1..500)。未传 limit 时沿用后端默认 200;保留原始 A/B 键及设备、命令、时间证据。
6. `getBizMonitorDiffs` → `GET /v1/biz-migration/runs/{run_id}/diffs`。
必填 run_id,可选 metric_id/sheet_id/verdict/color/q/limit/offset。**MCP `q` 映射 HTTP `kw`**,修正 TS 将 q 原样传递导致搜索失效的问题。limit 限 1..500,未传时后端默认 100;offset 下限 0。原始 A/B 和 match_* 键保持不变。
7. `getBizCollectBatch` → `GET /v1/biz-state/batches/{batch_id}`。
必填采集 batch_id。后端返回工作簿摘要、命令及 sheet 目录,不含原始 CLI 全文和全量指标行。本包保持后端响应,避免误称其会返回全部指标数据。
8. `getBizCollectCommandRaw` → `GET /v1/biz-state/batches/{batch_id}/commands/{command_id}`。
两个 ID 必填;返回完整 raw_text 和采集证据,按 netxops 不做字符/行数截断。大原文只在明确调用此工具时获取。
路径 ID 均作为单个段编码。数字参数按 netxops 截为整数并限幅;未知字段/类型错误返回可修正的工具错误,不接受过时单数 kind 或旧工具别名。
## 推荐调用链
- 无 ID 时先 `listBizMonitors({"q":"站点名"})`,取得项目 ID。
- `getBizMonitorContext({"project_id":"..."})` 理解模板与 old/new 任务。
- `listBizMonitorBatches({"project_id":"...","include_runs":true})` 找割接批次及运行;也可 `kind=runs,batch_id=...` 单独查运行。
- `getBizMonitorBoard({"batch_id":"...","run_id":"..."})` 看结论,再以 `listBizMonitorReds({"project_id":"..."})` 与 `getBizMonitorDiffs({"run_id":"...","color":"red","limit":100,"offset":0})` 查看证据和分页差异。
- 需要原文时,用上下文中的任务 ID 调 `listBizMonitorBatches({"task_id":"..."})` 找采集批次,`getBizCollectBatch` 找 command_id,再 `getBizCollectCommandRaw` 获取指定命令全文。割接批次 ID 与采集批次 ID 不可混用。
所有返回使用 `{ok,data}` 文本 JSON。参数或上游业务/HTTP 错误使用 MCP `result.isError=true`;JSON-RPC 的解析、请求结构、方法和 tools/call 结构错误分别为 -32700/-32600/-32601/-32602。通知永不回包且不触发业务请求;stdout 只输出协议消息。
凭据字段递归删除,配置令牌、Bearer 与常见 `password=...`/`secret:...` 文本脱敏。HTTP 错误体、异常文本、URL 不回显,避免凭据泄露;业务 evidence、raw A/B、show 命令、时间字段保持。原始设备输出可能含任意格式的敏感业务信息,部署方仍应控制授权采集内容。
## 验证
在 worktree 根目录运行(不安装依赖,不启动项目服务,不连接真实设备):
```powershell
$env:PYTHONPATH = (Resolve-Path packages/netx-bizmonitor-mcp/src).Path
& D:/project/chatgpt/netx/.venv/Scripts/python.exe -m pytest packages/netx-bizmonitor-mcp/tests -q
```
测试使用 HTTP mock;stdio 子进程清空令牌环境变量并在无令牌文件的临时目录运行,因此不发送网络请求。覆盖 8 工具、目录/批次裁剪与展开、q→kw、scope、业务与协议错误、通知、凭据来源优先级、文件错误回退、令牌轮换与脱敏及长原文不截断。

View file

@ -0,0 +1,16 @@
{
"mcpServers": {
"netx-bizmonitor": {
"command": "python",
"args": ["-m", "netx_bizmonitor_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_API_TOKEN": "nxt_replace_with_biz_monitor_read_token",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1",
"PYTHONPATH": "D:/project/chatgpt/netx/packages/netx-bizmonitor-mcp/src"
}
}
}
}

View file

@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "netx-bizmonitor-mcp"
version = "0.1.0"
description = "Read-only stdio MCP for netx business monitoring and cutover evidence"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = ["httpx>=0.27.0"]
[project.scripts]
netx-bizmonitor-mcp = "netx_bizmonitor_mcp.server:main"
[tool.setuptools.packages.find]
where = ["src"]

View file

@ -0,0 +1,3 @@
"""Standalone, read-only netx business-monitor MCP."""
__version__ = "0.1.0"

View file

@ -0,0 +1,3 @@
from .server import main
main()

View file

@ -0,0 +1,100 @@
"""HTTP transport. Never echo credentials, upstream error bodies, or URLs."""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any
from urllib.parse import urlsplit
import httpx
_SECRET_KEYS = {
"password", "passwd", "pwd", "secret", "token", "api_token", "api_key",
"authorization", "access_token", "refresh_token", "private_key", "client_secret",
"ssh_password", "enable_password", "hop_password", "credentials",
}
def api_token() -> str:
"""Use the shared MCP credential convention; never log paths or file contents."""
token = os.getenv("NETX_API_TOKEN", "").strip()
if token:
return token
for candidate in (os.getenv("NETX_MCP_TOKEN_FILE", "").strip(), "data/auth/mcp_token"):
if not candidate:
continue
try:
token = Path(candidate).read_text(encoding="utf-8").strip()
except (OSError, UnicodeError, ValueError):
continue
if token:
return token
return ""
def redact(value: Any, *, tokens: tuple[str, ...] | None = None) -> Any:
"""Remove credential fields while retaining device raw A/B and CLI evidence."""
if tokens is None:
tokens = (api_token(),)
if isinstance(value, dict):
return {k: redact(v, tokens=tokens) for k, v in value.items() if str(k).lower() not in _SECRET_KEYS}
if isinstance(value, list):
return [redact(v, tokens=tokens) for v in value]
if isinstance(value, str):
for token in tokens:
if token:
value = value.replace(token, "[REDACTED]")
value = re.sub(r"(?i)\bBearer\s+[^\s\"']+", "Bearer [REDACTED]", value)
value = re.sub(
r"(?im)(\b(?:password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*)([^\s,;]+)",
r"\1[REDACTED]", value,
)
return value
def http_json(method: str, path: str, *, params: dict[str, Any] | None = None,
timeout: float = 45.0) -> dict[str, Any]:
if method != "GET":
return {"ok": False, "error": "read_only_transport"}
base = os.getenv("NETX_API_URL", "http://127.0.0.1:8890").strip().rstrip("/")
try:
parsed = urlsplit(base)
if (parsed.scheme not in {"http", "https"} or not parsed.hostname
or parsed.username or parsed.password or parsed.query or parsed.fragment):
return {"ok": False, "error": "netx_api_url_invalid"}
except ValueError:
return {"ok": False, "error": "netx_api_url_invalid"}
token = api_token()
if not token:
return {"ok": False, "error": "netx_token_missing",
"hint": "设置 NETX_API_TOKEN、NETX_MCP_TOKEN_FILE,或工作目录 data/auth/mcp_token。"}
query: dict[str, Any] = {}
if os.getenv("NETX_LANG", "zh").strip().lower().startswith("en"):
query["lang"] = "en"
query.update(params or {})
try:
with httpx.Client(timeout=timeout, trust_env=False, follow_redirects=False) as client:
response = client.request("GET", base + path, params=query or None, headers={
"accept": "application/json", "authorization": f"Bearer {token}",
})
if not response.is_success:
return {"ok": False, "error": f"netx_http_{response.status_code}",
"hint": "检查 API 地址、令牌 scope 和资源 ID。"}
data = response.json() if response.content else {}
# Use the actual request token even if the credential file rotates mid-request.
return {"ok": True, "data": redact(data if isinstance(data, dict) else {"raw": data}, tokens=(token,))}
except (ValueError, httpx.HTTPError):
return {"ok": False, "error": "netx_request_failed",
"hint": "检查 API 连通性及 JSON 响应。"}
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
result = redact(result)
out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]}
if result.get("ok") is False or (isinstance(result.get("data"), dict)
and result["data"].get("ok") is False):
out["isError"] = True
return out

View file

@ -0,0 +1,268 @@
"""The eight bizMonitor tools, aligned with netxops and backend HTTP contracts."""
from __future__ import annotations
import math
from typing import Any
from urllib.parse import quote
from .http_client import http_json, mcp_from_handler_result
class _InvalidArgument(ValueError):
"""Only messages constructed from known schema fields; never include values."""
def _str(description: str = "") -> dict[str, Any]:
return {"type": "string", "description": description}
def _int(default: int, maximum: int, minimum: int = 1) -> dict[str, Any]:
return {"type": "integer", "default": default, "minimum": minimum, "maximum": maximum}
def _tool(name: str, description: str, properties: dict[str, Any],
required: tuple[str, ...] = ()) -> dict[str, Any]:
return {"name": name, "description": description, "inputSchema": {
"type": "object", "properties": properties, "required": list(required),
"additionalProperties": False,
}, "annotations": {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}}
HTTP_MCP_TOOLS = [
_tool("listBizMonitors", "入口:列出割接项目和业务状态任务,无需 ID。取得 project_id/task_id 后查上下文或批次。", {
"kind": {"type": "string", "enum": ["all", "projects", "tasks"], "default": "all"},
"purpose": _str("任务用途:portrait / cutover_hf;空为全部"),
"status": _str("本地过滤状态,如 active/paused"), "q": _str("名称、设备、备注或 ID 关键词"),
"limit": _int(100, 500),
}),
_tool("listBizMonitorBatches", "列批次/评估运行 ID:project_id→割接批次,task_id→采集批次,batch_id+kind=runs→评估运行。include_runs 最多展开前 5 个割接批次。", {
"kind": {"type": "string", "enum": ["auto", "all", "cutover", "collect", "runs"], "default": "auto"},
"project_id": _str(), "task_id": _str(), "batch_id": _str("割接批次 ID,非采集批次 ID"),
"include_runs": {"type": "boolean", "default": False}, "status": _str(),
"limit": _int(50, 200), "runs_limit": _int(10, 50),
}),
_tool("getBizMonitorContext", "读取项目、监控/对比模板、端口映射及关联任务命令。project_id/task_id 至少一个;原始 A/B 键保留。", {
"project_id": _str(), "task_id": _str(),
}),
_tool("getBizMonitorBoard", "读取割接批次看板、最新或指定评估运行摘要、sheet 卡片及 verdict 统计。", {
"batch_id": _str("割接批次 ID"), "run_id": _str("可选评估运行 ID"),
}, ("batch_id",)),
_tool("listBizMonitorReds", "读取项目红票及证据(设备、采集时间、show 命令、接口原始/归一/映射键);status 空为全部。", {
"project_id": _str(), "status": _str("open / carried / resolved;空为全部"), "limit": _int(200, 500),
}, ("project_id",)),
_tool("getBizMonitorDiffs", "分页读取评估差异,保留原始 A/B、match_* 键与 show 命令证据。q 转发后端 kw;用 offset/limit 翻页。", {
"run_id": _str(), "metric_id": _str(), "sheet_id": _str(), "verdict": _str(),
"color": _str("如 red"), "q": _str("差异关键词,映射 HTTP kw"),
"limit": _int(100, 500), "offset": _int(0, 9007199254740991, 0),
}, ("run_id",)),
_tool("getBizCollectBatch", "读取采集批次工作簿摘要、commands 和 sheet 目录。后端不返回全量指标行及 CLI 原文;以命令 ID 查原文。", {
"batch_id": _str("业务状态采集批次 ID"),
}, ("batch_id",)),
_tool("getBizCollectCommandRaw", "深挖一个采集命令:返回完整 raw_text、raw_command、设备和采集时间;仅凭据脱敏,不截断原文。", {
"batch_id": _str("采集批次 ID"), "command_id": _str("getBizCollectBatch 返回的命令 ID"),
}, ("batch_id", "command_id")),
]
TOOL_REQUIRED_SCOPE = {t["name"]: "biz-monitor:read" for t in HTTP_MCP_TOOLS}
_SCHEMAS = {t["name"]: t["inputSchema"] for t in HTTP_MCP_TOOLS}
def tools_for_scopes(scopes: list[str] | None) -> list[dict[str, Any]]:
# If /auth/me is unavailable, let the backend enforce authorization per GET.
return list(HTTP_MCP_TOOLS) if scopes is None or "biz-monitor:read" in scopes else []
def _s(args: dict[str, Any], key: str, default: str = "") -> str:
return str(args.get(key, default)).strip()
def _n(args: dict[str, Any], key: str, default: int, low: int, high: int) -> int:
value = args.get(key, default)
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise _InvalidArgument(f"{key} 必须为有限数值")
return max(low, min(high, math.trunc(value)))
def _pick(row: dict[str, Any], keys: str) -> dict[str, Any]:
return {k: row[k] for k in keys.split() if k in row}
def _items(result: dict[str, Any]) -> list[dict[str, Any]]:
rows = result.get("data", {}).get("items", [])
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
def _failed(result: dict[str, Any]) -> bool:
return result.get("ok") is False or (isinstance(result.get("data"), dict)
and result["data"].get("ok") is False)
def _error(code: str, detail: str = "") -> dict[str, Any]:
return {"ok": False, "error": code, **({"detail": detail} if detail else {})}
def _project(row: dict[str, Any]) -> dict[str, Any]:
out = _pick(row, "id name status note monitor_template_id old_task_id new_task_id old_hf_task_id new_hf_task_id hf_interval_sec hf_start_at hf_end_at updated_at")
out["monitor_template_name"] = (row.get("monitor_template") or {}).get("name", "")
for side in ("old", "new"):
task = row.get(f"{side}_task") or {}
name, ip = str(task.get("ne_name") or "").strip(), str(task.get("ne_ip") or "").strip()
out[f"{side}_ne"] = f"{name} ({ip})" if name and ip else name or ip
return out
def _run(row: dict[str, Any]) -> dict[str, Any]:
out = _pick(row, "id batch_id purpose old_batch_id new_batch_id created_at")
summary = row.get("summary") or {}
progress = summary.get("progress") or {}
if "anomaly" in summary:
out["anomaly"] = summary["anomaly"]
for key in ("ok", "total"):
value = progress.get(key, summary.get(f"progress_{key}"))
if value is not None:
out[f"progress_{key}"] = value
return out
def _catalog(args: dict[str, Any]) -> dict[str, Any]:
kind = _s(args, "kind", "all").lower() or "all"
if kind not in {"all", "projects", "tasks"}:
return _error("kind_invalid", "kind must be all|projects|tasks")
limit = _n(args, "limit", 100, 1, 500)
status, query = _s(args, "status").lower(), _s(args, "q").lower()
data: dict[str, Any] = {"kind": kind}
counts = {"projects": 0, "tasks": 0, "projects_returned": 0, "tasks_returned": 0}
for group, path in (("projects", "/v1/biz-migration/projects"), ("tasks", "/v1/biz-state/tasks")):
if kind not in {"all", group}:
continue
params = {"purpose": _s(args, "purpose")} if group == "tasks" and _s(args, "purpose") else {}
result = http_json("GET", path, params=params)
if _failed(result):
return result
rows = [_project(row) for row in _items(result)] if group == "projects" else _items(result)
rows = [row for row in rows if (not status or str(row.get("status", "")).lower() == status)
and (not query or query in " ".join(str(row.get(k) or "").lower() for k in
"id name note status ne_name ne_ip ne_id old_ne new_ne monitor_template_name purpose vendor".split()))]
counts[group] = len(rows)
data[group] = rows[:limit]
counts[f"{group}_returned"] = len(data[group])
data.update(counts=counts, next="将 project_id/task_id 传给 getBizMonitorContext;用 listBizMonitorBatches 获取 batch_id/run_id。")
return {"ok": True, "data": data}
def _batches(args: dict[str, Any]) -> dict[str, Any]:
pid, tid, bid = (_s(args, key) for key in ("project_id", "task_id", "batch_id"))
kind = _s(args, "kind", "auto").lower() or "auto"
include = args.get("include_runs") is True
limit, run_limit = _n(args, "limit", 50, 1, 200), _n(args, "runs_limit", 10, 1, 50)
status = _s(args, "status").lower()
if kind == "auto":
kind = "all" if pid and tid else "cutover" if pid else "collect" if tid else "runs" if bid else ""
if not kind:
return _error("project_id_or_task_id_or_batch_id_required")
if kind not in {"all", "cutover", "collect", "runs"}:
return _error("kind_invalid", "kind must be auto|all|cutover|collect|runs")
cutover, collect = kind in {"all", "cutover"}, kind in {"all", "collect"}
for needed, value, key in ((cutover, pid, "project_id"), (collect, tid, "task_id"), (kind == "runs", bid, "batch_id")):
if needed and not value:
return _error(f"{key}_required")
cb: list[dict[str, Any]] = []
cl: list[dict[str, Any]] = []
runs: list[dict[str, Any]] = []
if cutover:
result = http_json("GET", f"/v1/biz-migration/projects/{quote(pid, safe='')}/batches", params={})
if _failed(result):
return result
cb = [_pick(row, "id project_id batch_label status accept_status accept_run_id started_at ended_at note created_at updated_at")
for row in _items(result) if (not bid or str(row.get("id") or "") == bid)
and (not status or str(row.get("status") or "").lower() == status)][:limit]
if collect:
result = http_json("GET", f"/v1/biz-state/tasks/{quote(tid, safe='')}/batches", params={"limit": limit})
if _failed(result):
return result
cl = [_pick(row, "id task_id status ne_name command_count row_count is_baseline protected message started_at ended_at")
for row in _items(result) if not status or str(row.get("status") or "").lower() == status][:limit]
for row in cl:
row["task_id"] = row.get("task_id") or tid
run_bids = [bid] if kind == "runs" else [str(row.get("id") or "").strip() for row in cb if row.get("id")][:5] if include else []
for run_bid in run_bids:
result = http_json("GET", f"/v1/biz-migration/batches/{quote(run_bid, safe='')}/runs", params={"limit": run_limit})
target = next((row for row in cb if row.get("id") == run_bid), None)
if _failed(result):
if kind == "runs":
return result
if target is not None:
target["runs_error"] = result.get("error") or "runs_fetch_failed"
continue
items = [_run(row) for row in _items(result)][:run_limit]
runs.extend(items)
if target is not None:
target["runs"] = items
data: dict[str, Any] = {"kind": kind, "counts": {"cutover_batches": len(cb), "collect_batches": len(cl), "runs": len(runs)}}
data.update({key: val for key, val in (("project_id", pid), ("task_id", tid), ("batch_id", bid)) if val})
if cutover:
data["cutover_batches"] = cb
if collect:
data["collect_batches"] = cl
if kind == "runs":
data["runs"] = runs
data["next"] = "割接 batch_id→getBizMonitorBoard/listBizMonitorBatches(kind=runs);采集 batch_id→getBizCollectBatch;run_id→getBizMonitorDiffs。"
return {"ok": True, "data": data}
def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
try:
schema = _SCHEMAS.get(name)
if schema is None:
return mcp_from_handler_result(_error("unknown_tool"))
for key, value in args.items():
prop = schema["properties"].get(key)
if prop is None:
raise _InvalidArgument("存在未知字段;允许字段:" + ", ".join(schema["properties"]))
if prop["type"] == "string" and not isinstance(value, str):
raise _InvalidArgument(f"{key} 必须为字符串")
if prop["type"] == "boolean" and not isinstance(value, bool):
raise _InvalidArgument(f"{key} 必须为布尔值")
if name == "listBizMonitors":
result = _catalog(args)
elif name == "listBizMonitorBatches":
result = _batches(args)
else:
pid, tid, bid, rid, cid = (_s(args, key) for key in ("project_id", "task_id", "batch_id", "run_id", "command_id"))
params: dict[str, Any] = {}
if name == "getBizMonitorContext":
if not pid and not tid:
return mcp_from_handler_result(_error("project_id_or_task_id_required"))
path = "/v1/biz-migration/monitor-context"
params = {k: v for k, v in (("project_id", pid), ("task_id", tid)) if v}
else:
missing = [key for key in schema["required"] if not _s(args, key)]
if missing:
code = "batch_id_and_command_id_required" if name == "getBizCollectCommandRaw" else f"{missing[0]}_required"
return mcp_from_handler_result(_error(code))
if name == "getBizMonitorBoard":
path = f"/v1/biz-migration/batches/{quote(bid, safe='')}/board"
if rid:
params["run_id"] = rid
elif name == "listBizMonitorReds":
path = f"/v1/biz-migration/projects/{quote(pid, safe='')}/red-tickets"
if _s(args, "status"):
params["status"] = _s(args, "status")
elif name == "getBizMonitorDiffs":
path = f"/v1/biz-migration/runs/{quote(rid, safe='')}/diffs"
params = {("kw" if k == "q" else k): _s(args, k) for k in ("metric_id", "sheet_id", "verdict", "color", "q") if _s(args, k)}
if "offset" in args:
params["offset"] = _n(args, "offset", 0, 0, 9007199254740991)
elif name == "getBizCollectBatch":
path = f"/v1/biz-state/batches/{quote(bid, safe='')}"
else:
path = f"/v1/biz-state/batches/{quote(bid, safe='')}/commands/{quote(cid, safe='')}"
if "limit" in args:
params["limit"] = _n(args, "limit", 200, 1, 500)
result = http_json("GET", path, params=params)
return mcp_from_handler_result(result)
except _InvalidArgument as exc:
return mcp_from_handler_result(_error("invalid_argument", str(exc)))
except (ValueError, TypeError, OverflowError):
# Avoid echoing user-provided values (which might contain credentials).
return mcp_from_handler_result(_error("invalid_argument", "参数类型或字段不合法;请参照 tools/list 的 inputSchema。"))

View file

@ -0,0 +1,97 @@
"""Newline-delimited UTF-8 JSON-RPC MCP on stdio; stdout is protocol-only."""
from __future__ import annotations
import json
import sys
from typing import Any
from . import __version__
from .http_client import http_json, mcp_from_handler_result
from .http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
def _fetch_scopes() -> list[str] | None:
result = http_json("GET", "/v1/auth/me")
if result.get("ok") is not True or not isinstance(result.get("data"), dict):
return None
data = result["data"]
scopes = data.get("scopes")
if not isinstance(scopes, list) and isinstance(data.get("user"), dict):
scopes = data["user"].get("scopes")
return [str(scope) for scope in scopes] if isinstance(scopes, list) else None
def _result(rid: Any, result: dict[str, Any]) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": rid, "result": result}
def _error(rid: Any, code: int, message: str) -> dict[str, Any]:
return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
def handle_request(request: Any) -> dict[str, Any] | None:
if (not isinstance(request, dict) or request.get("jsonrpc") != "2.0"
or not isinstance(request.get("method"), str)
or ("id" in request and (isinstance(request["id"], bool)
or not isinstance(request["id"], (str, int, type(None)))))):
return _error(None, -32600, "Invalid Request")
# Includes initialize/ping/unknown-method notifications: never reply or execute tools.
if "id" not in request:
return None
rid, method = request["id"], request["method"]
params = request.get("params", {})
if not isinstance(params, dict):
return _error(rid, -32602, "params must be an object")
if method == "initialize":
return _result(rid, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},
"serverInfo": {"name": "netx-bizmonitor-mcp", "version": __version__}})
if method == "ping":
return _result(rid, {})
if method == "tools/list":
return _result(rid, {"tools": tools_for_scopes(_fetch_scopes())})
if method == "tools/call":
name, args = params.get("name"), params.get("arguments", {})
if not isinstance(name, str) or name not in TOOL_REQUIRED_SCOPE:
return _error(rid, -32602, "Unknown tool name")
if not isinstance(args, dict):
return _error(rid, -32602, "arguments must be an object")
# Refresh per call so revocation is not hidden by a cached permission list.
granted = _fetch_scopes()
required = TOOL_REQUIRED_SCOPE[name]
if granted is not None and required not in granted:
return _result(rid, mcp_from_handler_result({"ok": False, "error": "insufficient_scope",
"required_scope": required, "hint": "请管理员授予 API 令牌 biz-monitor:read。"}))
return _result(rid, call_http_tool(name, args))
return _error(rid, -32601, "Method not found")
def run_stdio_loop() -> None:
for line in sys.stdin:
if not line.strip():
continue
try:
request = json.loads(line)
except (ValueError, RecursionError):
response = _error(None, -32700, "Parse error")
else:
try:
response = handle_request(request)
except Exception:
# Never expose traceback, connection URL, headers or exception text.
response = None if isinstance(request, dict) and "id" not in request else _error(
request.get("id") if isinstance(request, dict) else None, -32603, "Internal error")
if response is not None:
sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
sys.stdout.flush()
def main() -> None:
for stream in (sys.stdin, sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
run_stdio_loop()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,6 @@
"""Always import this checkout's standalone package, not the main repository."""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))

View file

@ -0,0 +1,340 @@
from __future__ import annotations
import io
import json
import os
from pathlib import Path
import subprocess
import sys
from unittest.mock import patch
import httpx
import pytest
from netx_bizmonitor_mcp.http_client import api_token, http_json, redact
from netx_bizmonitor_mcp.http_tools import HTTP_MCP_TOOLS, TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
from netx_bizmonitor_mcp.server import _fetch_scopes, handle_request, run_stdio_loop
def payload(result):
return json.loads(result["content"][0]["text"])
def request(method, params=None, rid=1):
return {"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}}
def test_exact_eight_tools_read_only_and_canonical_scope():
assert {t["name"] for t in HTTP_MCP_TOOLS} == {
"listBizMonitors", "listBizMonitorBatches", "getBizMonitorContext", "getBizMonitorBoard",
"listBizMonitorReds", "getBizMonitorDiffs", "getBizCollectBatch", "getBizCollectCommandRaw",
}
assert len(HTTP_MCP_TOOLS) == 8
assert all(t["annotations"]["readOnlyHint"] for t in HTTP_MCP_TOOLS)
assert set(TOOL_REQUIRED_SCOPE.values()) == {"biz-monitor:read"}
assert tools_for_scopes(["ne:read", "ops:write"]) == []
assert len(tools_for_scopes(["biz-monitor:read"])) == 8
assert len(tools_for_scopes(None)) == 8
def test_catalog_filters_slims_projects_and_keeps_totals():
def fake(method, path, *, params):
assert method == "GET"
if path == "/v1/biz-migration/projects":
assert params == {}
return {"ok": True, "data": {"items": [
{"id": f"p{i}", "status": "active", "monitor_template": {"name": "Core"},
"old_task": {"ne_name": "R1", "ne_ip": "10.0.0.1", "commands": "large"}, "large_template": "drop"}
for i in range(3)]}}
assert path == "/v1/biz-state/tasks" and params == {"purpose": "portrait"}
return {"ok": True, "data": {"items": [{"id": "t", "status": "paused", "ne_name": "R1"}]}}
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake):
result = payload(call_http_tool("listBizMonitors", {"q": "r1", "status": "ACTIVE", "purpose": "portrait", "limit": 1}))
data = result["data"]
assert data["counts"] == {"projects": 3, "tasks": 0, "projects_returned": 1, "tasks_returned": 0}
assert data["projects"][0]["old_ne"] == "R1 (10.0.0.1)"
assert "old_task" not in data["projects"][0] and "large_template" not in data["projects"][0]
assert "netx__" not in data["next"]
def test_batch_cutover_expand_runs_and_partial_error():
def fake(method, path, *, params):
if path.endswith("/batches"):
return {"ok": True, "data": {"items": [{"id": "b1", "status": "done", "large": "drop"}, {"id": "b2", "status": "done"}]}}
assert params == {"limit": 3}
if "/b2/" in path:
return {"ok": False, "error": "netx_http_404"}
return {"ok": True, "data": {"items": [{"id": "r1", "summary": {"anomaly": 2, "progress": {"ok": 4, "total": 5}, "large": "drop"}}]}}
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake) as mock:
out = call_http_tool("listBizMonitorBatches", {"project_id": "p/1", "include_runs": True, "runs_limit": 3})
data = payload(out)["data"]
assert "isError" not in out and mock.call_count == 3
assert mock.call_args_list[0].args == ("GET", "/v1/biz-migration/projects/p%2F1/batches")
assert data["cutover_batches"][1]["runs_error"] == "netx_http_404"
assert data["cutover_batches"][0]["runs"] == [{"id": "r1", "anomaly": 2, "progress_ok": 4, "progress_total": 5}]
assert data["counts"]["runs"] == 1 and "runs" not in data
assert "large" not in data["cutover_batches"][0]
def test_batch_collect_and_runs_modes():
with patch("netx_bizmonitor_mcp.http_tools.http_json") as mock:
mock.return_value = {"ok": True, "data": {"items": [{"id": "c1", "status": "done", "raw_text": "drop"}]}}
out = payload(call_http_tool("listBizMonitorBatches", {"task_id": "t", "limit": 20}))
mock.assert_called_once_with("GET", "/v1/biz-state/tasks/t/batches", params={"limit": 20})
assert out["data"]["collect_batches"] == [{"id": "c1", "status": "done", "task_id": "t"}]
mock.reset_mock()
mock.return_value = {"ok": True, "data": {"items": [{"id": "r1", "summary": {"progress_ok": 2}}]}}
out = payload(call_http_tool("listBizMonitorBatches", {"batch_id": "b"}))
mock.assert_called_once_with("GET", "/v1/biz-migration/batches/b/runs", params={"limit": 10})
assert out["data"]["runs"] == [{"id": "r1", "progress_ok": 2}]
def test_batch_all_caps_run_expansion_at_five():
def fake(method, path, *, params):
if path == "/v1/biz-migration/projects/p/batches":
return {"ok": True, "data": {"items": [{"id": f"b{i}"} for i in range(8)]}}
if path == "/v1/biz-state/tasks/t/batches":
return {"ok": True, "data": {"items": [{"id": "c"}]}}
assert path.endswith("/runs")
return {"ok": True, "data": {"items": [{"id": "r"}]}}
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake) as mock:
data = payload(call_http_tool("listBizMonitorBatches", {
"project_id": "p", "task_id": "t", "include_runs": True,
}))["data"]
assert mock.call_count == 7 # two catalogs plus five run lookups
assert data["kind"] == "all"
assert data["counts"] == {"cutover_batches": 8, "collect_batches": 1, "runs": 5}
assert sum("runs" in row for row in data["cutover_batches"]) == 5
@pytest.mark.parametrize("name,args,path,params", [
("getBizMonitorContext", {"project_id": " p/1 ", "task_id": "t"}, "/v1/biz-migration/monitor-context", {"project_id": "p/1", "task_id": "t"}),
("getBizMonitorBoard", {"batch_id": "b/1", "run_id": "r"}, "/v1/biz-migration/batches/b%2F1/board", {"run_id": "r"}),
("listBizMonitorReds", {"project_id": "p", "status": "open", "limit": 700}, "/v1/biz-migration/projects/p/red-tickets", {"status": "open", "limit": 500}),
("getBizMonitorDiffs", {"run_id": "r/1", "metric_id": "m", "sheet_id": "s", "verdict": "changed", "color": "red", "q": "xe-1", "offset": -2, "limit": 10}, "/v1/biz-migration/runs/r%2F1/diffs", {"metric_id": "m", "sheet_id": "s", "verdict": "changed", "color": "red", "kw": "xe-1", "offset": 0, "limit": 10}),
("getBizCollectBatch", {"batch_id": "c/1"}, "/v1/biz-state/batches/c%2F1", {}),
("getBizCollectCommandRaw", {"batch_id": "c", "command_id": "cmd/1"}, "/v1/biz-state/batches/c/commands/cmd%2F1", {}),
])
def test_each_read_tool_forwards_exact_http(name, args, path, params):
evidence = {"old_key": "xe-1", "new_key": "ge-2", "evidence": {"raw_command": "show interface"}}
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": evidence}) as mock:
result = call_http_tool(name, args)
mock.assert_called_once_with("GET", path, params=params)
assert payload(result)["data"] == evidence
def test_diff_default_limit_not_forwarded_and_only_kw_used():
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": {}}) as mock:
call_http_tool("getBizMonitorDiffs", {"run_id": "r", "q": " A B "})
assert mock.call_args.kwargs["params"] == {"kw": "A B"}
def test_raw_not_truncated_and_credentials_removed(monkeypatch):
monkeypatch.setenv("NETX_API_TOKEN", "nxt_do_not_leak")
raw = "show interfaces\n" + "interface xe-1 up\n" * 10000
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": {
"raw_text": raw, "device": {"password": "hidden", "ne_name": "R1"},
"api_token": "hidden", "note": "Bearer abcd password=secret nxt_do_not_leak",
}}):
result = call_http_tool("getBizCollectCommandRaw", {"batch_id": "b", "command_id": "c"})
data = payload(result)["data"]
assert data["raw_text"] == raw and data["device"] == {"ne_name": "R1"}
assert "api_token" not in data
assert "abcd" not in data["note"] and "secret" not in data["note"] and "nxt_do_not_leak" not in data["note"]
@pytest.mark.parametrize("name,args,error", [
("getBizMonitorContext", {}, "project_id_or_task_id_required"),
("getBizMonitorBoard", {}, "batch_id_required"),
("listBizMonitorReds", {}, "project_id_required"),
("getBizMonitorDiffs", {}, "run_id_required"),
("getBizCollectBatch", {}, "batch_id_required"),
("getBizCollectCommandRaw", {"batch_id": "b"}, "batch_id_and_command_id_required"),
("listBizMonitorBatches", {}, "project_id_or_task_id_or_batch_id_required"),
("listBizMonitorBatches", {"kind": "all", "project_id": "p"}, "task_id_required"),
("listBizMonitors", {"kind": "project"}, "kind_invalid"),
("listBizMonitors", {"limit": "two"}, "invalid_argument"),
("getBizMonitorDiffs", {"run_id": "r", "offset": True}, "invalid_argument"),
("getBizMonitorDiffs", {"run_id": "r", "kw": "x"}, "invalid_argument"),
])
def test_bad_arguments_never_make_http_call(name, args, error):
with patch("netx_bizmonitor_mcp.http_tools.http_json") as mock:
result = call_http_tool(name, args)
mock.assert_not_called()
assert result["isError"] is True and payload(result)["error"] == error
def test_argument_error_names_field_without_echoing_value():
result = payload(call_http_tool("listBizMonitors", {"limit": "password=hidden"}))
assert "limit" in result["detail"]
assert "hidden" not in json.dumps(result)
@pytest.mark.parametrize("failure", [
{"ok": False, "error": "netx_http_404"},
{"ok": True, "data": {"ok": False, "error": "not_found"}},
])
def test_upstream_failures_are_tool_errors(failure):
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value=failure):
result = call_http_tool("getBizCollectBatch", {"batch_id": "b"})
assert result["isError"] is True
def test_fetch_scopes_and_server_scope_denial():
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": True, "data": {"scopes": ["ne:read"]}}):
assert _fetch_scopes() == ["ne:read"]
assert handle_request(request("tools/list"))["result"]["tools"] == []
with patch("netx_bizmonitor_mcp.server.call_http_tool") as call:
denied = handle_request(request("tools/call", {"name": "getBizCollectBatch", "arguments": {"batch_id": "b"}}))
call.assert_not_called()
assert "error" not in denied and denied["result"]["isError"]
assert payload(denied["result"])["required_scope"] == "biz-monitor:read"
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": True, "data": {"user": {"scopes": ["biz-monitor:read"]}}}):
assert _fetch_scopes() == ["biz-monitor:read"]
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": False}):
assert _fetch_scopes() is None
def test_protocol_errors_and_notifications():
for obj in ([], "x", {}, {"jsonrpc": "2.0", "id": True, "method": "ping"}):
assert handle_request(obj)["error"]["code"] == -32600
assert handle_request(request("unknown"))["error"]["code"] == -32601
assert handle_request(request("tools/call", {"name": "netx__getBizCollectBatch"}))["error"]["code"] == -32602
assert handle_request(request("tools/call", {"name": "getBizCollectBatch", "arguments": []}))["error"]["code"] == -32602
with patch("netx_bizmonitor_mcp.server.http_json") as mock:
for method in ("initialize", "notifications/initialized", "notifications/cancelled", "ping", "tools/call"):
assert handle_request({"jsonrpc": "2.0", "method": method}) is None
mock.assert_not_called()
stdin, stdout = io.StringIO('{bad\n' + json.dumps(request("ping", rid=2)) + '\n'), io.StringIO()
with patch("sys.stdin", stdin), patch("sys.stdout", stdout):
run_stdio_loop()
lines = [json.loads(line) for line in stdout.getvalue().splitlines()]
assert lines[0]["error"]["code"] == -32700 and lines[1]["id"] == 2
def test_http_auth_lang_redaction_and_error_sanitization(monkeypatch):
monkeypatch.setenv("NETX_API_TOKEN", "nxt_secret")
monkeypatch.setenv("NETX_API_URL", "http://example.test/netx")
monkeypatch.setenv("NETX_LANG", "en")
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
response = httpx.Response(200, json={"id": "b", "password": "secret", "nested": {"api_key": "bad"}})
client.return_value.__enter__.return_value.request.return_value = response
result = http_json("GET", "/v1/biz-state/batches/b", params={"limit": 2})
client.assert_called_once_with(timeout=45.0, trust_env=False, follow_redirects=False)
kwargs = client.return_value.__enter__.return_value.request.call_args.kwargs
assert kwargs["headers"]["authorization"] == "Bearer nxt_secret"
assert kwargs["params"] == {"lang": "en", "limit": 2}
assert result["data"] == {"id": "b", "nested": {}}
client.return_value.__enter__.return_value.request.return_value = httpx.Response(500, text="password=leak nxt_secret")
result = http_json("GET", "/v1/test")
assert result["error"] == "netx_http_500" and "leak" not in json.dumps(result)
client.return_value.__enter__.return_value.request.side_effect = httpx.ConnectError("Bearer leak")
assert "leak" not in json.dumps(http_json("GET", "/v1/test"))
def test_invalid_url_token_and_write_never_open_client(monkeypatch, tmp_path):
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
monkeypatch.delenv("NETX_MCP_TOKEN_FILE", raising=False)
monkeypatch.chdir(tmp_path)
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
monkeypatch.setenv("NETX_API_URL", "https://user:pass@example.test")
assert http_json("GET", "/x")["error"] == "netx_api_url_invalid"
monkeypatch.setenv("NETX_API_URL", "http://example.test")
assert http_json("GET", "/x")["error"] == "netx_token_missing"
assert http_json("POST", "/x")["error"] == "read_only_transport"
client.assert_not_called()
def test_stdio_subprocess_initialize_list_notifications_errors(tmp_path):
env = os.environ.copy()
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src")
env["NETX_API_URL"] = "http://127.0.0.1:1"
env.pop("NETX_API_TOKEN", None) # no network attempted when token is absent
env.pop("NETX_MCP_TOKEN_FILE", None)
messages = [request("initialize"), {"jsonrpc": "2.0", "method": "initialize"},
{"jsonrpc": "2.0", "method": "notifications/initialized"}, request("ping", rid=2),
request("tools/list", rid=3), request("tools/call", {"name": "getBizCollectBatch", "arguments": {}}, rid=4)]
run = subprocess.run([sys.executable, "-m", "netx_bizmonitor_mcp"],
input="\n".join(json.dumps(item) for item in messages) + "\n{bad\n",
text=True, encoding="utf-8", capture_output=True, env=env, cwd=tmp_path, timeout=10)
assert run.returncode == 0 and run.stderr == ""
replies = [json.loads(line) for line in run.stdout.splitlines()]
assert len(replies) == 5
assert replies[0]["result"]["serverInfo"]["name"] == "netx-bizmonitor-mcp"
assert replies[1] == {"jsonrpc": "2.0", "id": 2, "result": {}}
assert len(replies[2]["result"]["tools"]) == 8
assert replies[3]["result"]["isError"] is True
assert replies[4]["error"]["code"] == -32700
def test_token_priority_env_then_explicit_file_then_cwd(monkeypatch, tmp_path):
monkeypatch.chdir(tmp_path)
default = tmp_path / "data" / "auth" / "mcp_token"
default.parent.mkdir(parents=True)
default.write_text(" default-token\n", encoding="utf-8")
explicit = tmp_path / "custom-token"
explicit.write_text(" explicit-token\n", encoding="utf-8")
monkeypatch.setenv("NETX_API_TOKEN", " env-token ")
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(explicit))
assert api_token() == "env-token"
monkeypatch.setenv("NETX_API_TOKEN", " ")
assert api_token() == "explicit-token"
explicit.write_text(" \n", encoding="utf-8")
assert api_token() == "default-token"
monkeypatch.delenv("NETX_MCP_TOKEN_FILE")
assert api_token() == "default-token"
@pytest.mark.parametrize("file_error", ["missing", "directory", "invalid_utf8", "permission"])
def test_token_file_errors_fall_back_without_exposing_contents(monkeypatch, tmp_path, capsys, file_error):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
default = tmp_path / "data" / "auth" / "mcp_token"
default.parent.mkdir(parents=True)
default.write_text("fallback-token", encoding="utf-8")
explicit = tmp_path / "sensitive-file"
if file_error == "directory":
explicit.mkdir()
elif file_error == "invalid_utf8":
explicit.write_bytes(b"secret\xff")
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(explicit))
if file_error == "permission":
original = Path.read_text
def read(path, *args, **kwargs):
if path == explicit:
raise PermissionError("sensitive exception text")
return original(path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", read)
assert api_token() == "fallback-token"
default.unlink()
assert api_token() == ""
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
result = http_json("GET", "/v1/auth/me")
client.assert_not_called()
assert result["error"] == "netx_token_missing"
assert "sensitive-file" not in json.dumps(result) and "sensitive exception" not in json.dumps(result)
captured = capsys.readouterr()
assert captured.out == captured.err == ""
@pytest.mark.parametrize("use_explicit", [True, False])
def test_file_token_auth_and_redaction_survives_rotation(monkeypatch, tmp_path, use_explicit):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
monkeypatch.delenv("NETX_MCP_TOKEN_FILE", raising=False)
monkeypatch.setenv("NETX_API_URL", "http://example.test")
token_file = tmp_path / "custom-token" if use_explicit else tmp_path / "data" / "auth" / "mcp_token"
token_file.parent.mkdir(parents=True, exist_ok=True)
token_file.write_text("nxt_file_secret", encoding="utf-8")
if use_explicit:
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(token_file))
assert redact({"nested": ["raw nxt_file_secret"]}) == {"nested": ["raw [REDACTED]"]}
def response(*args, **kwargs):
assert kwargs["headers"]["authorization"] == "Bearer nxt_file_secret"
token_file.write_text("nxt_rotated", encoding="utf-8")
return httpx.Response(200, json={"raw_text": "prefix nxt_file_secret suffix"})
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
client.return_value.__enter__.return_value.request.side_effect = response
result = http_json("GET", "/v1/biz-state/batches/b/commands/c")
assert result["data"]["raw_text"] == "prefix [REDACTED] suffix"
assert "nxt_file_secret" not in json.dumps(result)

View file

@ -48,14 +48,24 @@ python -m netx_mcp
0.3.1:`getNmsNe` 可直接传 host_name/IP;输出统一 `nms_ne_id` / `source=nms`;参数类型错误返回工具错误而非协议崩溃。
参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。
参数使用 `nms_ne_id` / `nms_ne_ids`,路径端点使用 `from_nms_ne_id` / `to_nms_ne_id`;`getManagedNe` 只接受 `ne_id`。旧 `ume_*`、`managed_ne_id`、`id`、告警 `ne_name` 别名已删除,未知参数会返回工具错误。REST 适配器内部的 `ume_*` 字段不变。
NMS 查询(含 inventory)按当前 HTTP 权限规则需要 `alarms:read`;托管资源、CLI 目标和路径查询需要 `ne:read`;执行与轮询需要 `ne:exec`;SQL 需要 `sql:query`。所有工具返回递归裁剪结构化口令、令牌等凭据,但 CLI 原文输出仍需调用者判断是否敏感。
拓扑画布 / Fabric → skill **topology** / [`netx-topology-mcp`](../netx-topology-mcp)(见 [docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md))。
业务监控 / 割接 → [`netx-bizmonitor-mcp`](../netx-bizmonitor-mcp)。三组统一配置见根 `mcp.all.json`,均不依赖 netxops。
本包的 `execManagedNe` 独立强制只读 show/display;即使后端设备开放 shell,也拒绝配置、命令拼接、重定向和多行脚本。getManagedNe.capability 描述后端能力,不能作为 MCP 脚本许可;Linux/RouterOS 原生 CLI 当前未暴露。异步 ack 返回 `poll_after_ms=5000`;单台核对 terminal/result.ok,批量还须核对 summary.failed=0 和每个 results[].ok。作业 succeeded 仍可能有单台失败,须分别报告,勿整批重跑已成功设备。失败 job 作为工具错误返回,保留原始失败证据。
同 IP 多条纳管记录须按厂商/驱动选 ID。connect_status 为历史连接测试状态,不替代当前执行结果;有正常业务正文才算查询通过,不能把空正文或仅命令头当作成功。后端的 Cisco IOS SSH 驱动使用 CR 换行并等待命令回显和完整提示符;不影响其它厂商或 Telnet 的默认换行。
## Breaking (0.3.0)
模型工具名从 `*Ume*` 改为 `*Nms*`(与 dsh-netxops 对齐)。请更新 skills / 提示词并重启 MCP 宿主。
## 兼容
## 入口
全量 `netx-ops` 开发安装下 `python -m netx_api.mcp` 仍会转到本包;新环境请只装 **netx-mcp**。
只保留独立 **netx-mcp** 包入口:`python -m netx_mcp` / `netx-mcp`。旧 `netx_api.mcp`、`netx_api/mcp_server.py` 和直连数据库模式已删除;配置需迁移到本包。
旧 DB 模式的导入告警查询/聚合/批次/诊断、同步触发和历史告警查询未纳入本包 15 工具,相关 HTTP API 仍保留。

View file

@ -0,0 +1,26 @@
"""MCP 独立只读边界:后端开放 shell 策略也不能扩大模型的执行权限。"""
from __future__ import annotations
import re
_PREFIX = re.compile(r"(?i)^(show|display)[ \t]+\S")
_CONTROL = re.compile(r"[\x00-\x08\x0a-\x1f\x7f-\x9f\u2028\u2029]")
_SHELL = re.compile(r"[;&`$<>]")
_DESTRUCTIVE = re.compile(
r"(?i)\b(reboot|reload|shutdown|erase|format|delete|commit|rollback|redirect|append|tee)\b"
r"|\b(?:configure\s+terminal|conf\s+t|write\s+memory|system-view|undo\s+)"
)
_FILTER = re.compile(r"(?i)^(include|exclude|begin|section|count|match|grep|one-line|no-more)(?:[ \t]|$)")
def validate_readonly_cli(command: str) -> None:
if not isinstance(command, str) or not command.strip():
raise ValueError("MCP CLI requires a nonempty show/display command")
if len(command) > 500 or _CONTROL.search(command) or _SHELL.search(command):
raise ValueError("MCP CLI rejects control characters, shell operators and commands longer than 500 characters")
cmd = command.strip()
if not _PREFIX.match(cmd) or _DESTRUCTIVE.search(cmd):
raise ValueError("MCP CLI allows read-only show/display only; open device policies do not override this boundary")
parts = cmd.split("|")
if any(not _FILTER.match(part.strip()) for part in parts[1:]):
raise ValueError("MCP CLI pipes allow output filters only")

View file

@ -1,7 +1,7 @@
"""MCP tool schemas and HTTP-backed handlers (NMS adapter + managed NE).
Model-facing tool names use generic Nms*; REST paths remain /v1/ume/* for the
current zte-ume provider. Params prefer nms_ne_id with ume_ne_id aliases.
current zte-ume provider. Model-facing params use nms_ne_id exclusively.
"""
from __future__ import annotations
@ -9,6 +9,7 @@ from __future__ import annotations
import os
from typing import Any, Callable
from .cli_guard import validate_readonly_cli
from .http_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id
_EXEC_MAX_COMMANDS_CAP = 50
@ -47,17 +48,16 @@ def _truthy_async_flag(raw: Any) -> bool | None:
def _count_exec_ne_targets(args: dict[str, Any]) -> int:
n = 0
for key in ("ne_ids", "nms_ne_ids", "ume_ne_ids"):
for key in ("ne_ids", "nms_ne_ids"):
val = args.get(key)
if isinstance(val, list):
n = max(n, len([x for x in val if str(x or "").strip()]))
n += len([x for x in val if str(x or "").strip()])
targets = args.get("targets")
if isinstance(targets, list):
n = max(n, len([t for t in targets if isinstance(t, dict)]))
n += len([t for t in targets if isinstance(t, dict)])
if n == 0 and (
str(args.get("ne_id") or "").strip()
or str(args.get("nms_ne_id") or "").strip()
or str(args.get("ume_ne_id") or "").strip()
):
return 1
return int(n)
@ -243,11 +243,8 @@ def _query_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
if str(args.get("is_cleared") or "").strip():
params["is_cleared"] = str(args.get("is_cleared")).strip()
keyword = str(args.get("keyword") or "").strip()
ne_name = str(args.get("ne_name") or "").strip()
if keyword:
params["keyword"] = keyword
elif ne_name:
params["keyword"] = ne_name
if str(args.get("ne_id") or "").strip():
params["ne_id"] = str(args.get("ne_id")).strip()
if str(args.get("host_name") or "").strip():
@ -264,7 +261,7 @@ def _aggregate_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
if group_by:
return _aggregate_ume_alarms_raw(args)
# Default top 50 named NEs — missing host buckets reported separately.
params: dict[str, Any] = {"top_ne": _int_arg(args, "top_ne", 50, 0, 500)}
params: dict[str, Any] = {"top_ne": _int_arg(args, "top_ne", 50, 0, 5000)}
if "exclude_missing_host" in args:
params["exclude_missing_host"] = bool(args.get("exclude_missing_host"))
if _severity(args):
@ -357,6 +354,8 @@ def _query_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
def _aggregate_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
if not str(args.get("group_by") or "").strip():
raise ToolArgError("group_by is required; use listNmsAlarmFields to choose a field")
params: dict[str, Any] = {}
for k in (
"group_by",
@ -442,9 +441,7 @@ def _first_str(*vals: Any) -> str:
def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
ne_id = str(
args.get("ne_id") or args.get("managed_ne_id") or args.get("id") or ""
).strip()
ne_id = str(args.get("ne_id") or "").strip()
if not ne_id:
return {
"ok": False,
@ -473,12 +470,46 @@ def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
return out
def _exec_ack(out: dict[str, Any]) -> dict[str, Any]:
if not out.get("ok"):
return out
data = out.get("data") if isinstance(out.get("data"), dict) else out
if isinstance(data, dict) and data.get("job_id"):
data = dict(data)
data["poll_after_ms"] = 5000
data["hint"] = (
"Execution started; it has not succeeded yet. Continue independent work, then poll "
"getNeExecJob with this job_id after about 5 seconds. Do not resubmit commands while "
"the job is running. Inspect terminal, result.ok, summary.failed and every target result before reporting success."
)
return data if isinstance(data, dict) else {"ok": True, "data": data}
def _exec_result_failed(data: Any) -> bool:
"""Batch ok means the request ran; individual targets may still all fail."""
if not isinstance(data, dict):
return False
if data.get("ok") is False:
return True
summary = data.get("summary")
if isinstance(summary, dict) and isinstance(summary.get("failed"), int) and summary["failed"] > 0:
return True
results = data.get("results")
return isinstance(results, list) and any(isinstance(row, dict) and row.get("ok") is False for row in results)
def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
supplied_commands = list(args.get("commands") or [])
for target in args.get("targets") or []:
supplied_commands.extend(target.get("commands") or [])
for command in supplied_commands:
try:
validate_readonly_cli(command)
except ValueError as exc:
raise ToolArgError(str(exc)) from None
targets_raw = args.get("targets")
ne_ids_raw = args.get("ne_ids")
nms_ne_ids_raw = args.get("nms_ne_ids")
if not isinstance(nms_ne_ids_raw, list) or not nms_ne_ids_raw:
nms_ne_ids_raw = args.get("ume_ne_ids")
shared_cmds_raw = args.get("commands")
shared_commands = (
[str(c).strip() for c in shared_cmds_raw if str(c).strip()]
@ -492,6 +523,13 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
)
want_async = _should_run_exec_async(args)
if multi and (args.get("ne_id") or args.get("nms_ne_id")):
raise ToolArgError("Do not mix single NE identifiers with batch targets")
if multi and _count_exec_ne_targets(args) == 0:
raise ToolArgError("targets_required")
if _count_exec_ne_targets(args) > 20:
raise ToolArgError("too_many_targets (max 20 across targets/ne_ids/nms_ne_ids)")
if multi:
body: dict[str, Any] = {}
if isinstance(targets_raw, list) and targets_raw:
@ -502,13 +540,20 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
row: dict[str, Any] = {}
if str(t.get("ne_id") or "").strip():
row["ne_id"] = str(t.get("ne_id")).strip()
# Wire still uses ume_ne_id on REST; accept nms_ne_id as preferred model param.
nms_id = _first_str(t.get("nms_ne_id"), t.get("ume_ne_id"))
# Wire still uses ume_ne_id on REST.
nms_id = _first_str(t.get("nms_ne_id"))
if bool(row.get("ne_id")) == bool(nms_id):
raise ToolArgError("Each target requires exactly one of ne_id or nms_ne_id")
if nms_id:
row["ume_ne_id"] = nms_id
cmds = t.get("commands")
if isinstance(cmds, list) and cmds:
row["commands"] = [str(c).strip() for c in cmds if str(c).strip()]
effective_commands = row.get("commands", shared_commands)
if not effective_commands:
raise ToolArgError("commands_required for every target")
if len(effective_commands) > exec_max_commands():
raise ToolArgError("too_many_commands for target")
if row:
cleaned_targets.append(row)
body["targets"] = cleaned_targets
@ -516,6 +561,8 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
body["ne_ids"] = [str(x).strip() for x in ne_ids_raw if str(x).strip()]
if isinstance(nms_ne_ids_raw, list) and nms_ne_ids_raw:
body["ume_ne_ids"] = [str(x).strip() for x in nms_ne_ids_raw if str(x).strip()]
if (body.get("ne_ids") or body.get("ume_ne_ids")) and not shared_commands:
raise ToolArgError("commands_required for ne_ids/nms_ne_ids")
if shared_commands:
if len(shared_commands) > exec_max_commands():
return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"}
@ -525,28 +572,25 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
body["concurrency"] = _int_arg(args, "concurrency", 4, 1, 8)
if want_async:
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
if not out.get("ok"):
return out
data = out.get("data") if isinstance(out.get("data"), dict) else out
return data if isinstance(data, dict) else {"ok": True, "data": data}
return _exec_ack(out)
# Wall clock: many NEs × per-cmd timeout; keep below oclaw MCP override.
out = http_post_json("/v1/managed-ne/exec-batch", body, timeout=600.0)
if not out.get("ok"):
return out
data = out.get("data") or {}
if isinstance(data, dict) and data.get("ok") is False:
if _exec_result_failed(data):
return {"ok": False, "data": data, "error": str(data.get("error") or "exec_batch_failed")}
return {"ok": True, "data": data}
ne_id = str(args.get("ne_id") or "").strip()
nms_ne_id = _first_str(args.get("nms_ne_id"), args.get("ume_ne_id"))
nms_ne_id = _first_str(args.get("nms_ne_id"))
if bool(ne_id) == bool(nms_ne_id):
return {
"ok": False,
"error": "exactly_one_of_ne_id_or_nms_ne_id_required",
"error_code": "exactly_one_of_ne_id_or_nms_ne_id_required",
"hint": (
"For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass "
"For one NE pass ne_id OR nms_ne_id. For many NEs pass "
"ne_ids / nms_ne_ids with shared commands, or targets[] with per-NE commands — "
"one call, concurrent on server."
),
@ -564,10 +608,7 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
body["read_timeout_sec"] = _int_arg(args, "read_timeout_sec", 60, 10, 120)
if want_async:
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
if not out.get("ok"):
return out
data = out.get("data") if isinstance(out.get("data"), dict) else out
return data if isinstance(data, dict) else {"ok": True, "data": data}
return _exec_ack(out)
out = http_post_json("/v1/managed-ne/exec", body, timeout=300.0)
if not out.get("ok"):
return out
@ -586,7 +627,16 @@ def _get_ne_exec_job(args: dict[str, Any]) -> dict[str, Any]:
"error_code": "job_id_required",
"hint": "Pass job_id from execManagedNe async ack.",
}
return http_json("GET", f"/v1/managed-ne/exec-jobs/{quote_ne_id(job_id)}", params=None)
out = http_json("GET", f"/v1/managed-ne/exec-jobs/{quote_ne_id(job_id)}", params=None)
data = out.get("data")
if out.get("ok") and isinstance(data, dict):
if data.get("terminal") and (data.get("status") in {"failed", "timeout"}
or _exec_result_failed(data.get("result"))):
return {"ok": False, "error": "ne_exec_job_failed", "data": data}
if not data.get("terminal") and data.get("status") == "running":
data["poll_after_ms"] = 5000
data["hint"] = "Still running. Continue independent work; poll this job_id after about 5 seconds. Do not resubmit."
return out
def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
@ -610,9 +660,9 @@ def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
def _find_topology_paths(args: dict[str, Any]) -> dict[str, Any]:
from_uid = _first_str(args.get("from_nms_ne_id"), args.get("from_ume_ne_id"))
from_uid = _first_str(args.get("from_nms_ne_id"))
from_mid = str(args.get("from_managed_ne_id") or "").strip()
to_uid = _first_str(args.get("to_nms_ne_id"), args.get("to_ume_ne_id"))
to_uid = _first_str(args.get("to_nms_ne_id"))
to_mid = str(args.get("to_managed_ne_id") or "").strip()
if bool(from_uid) == bool(from_mid):
return {"ok": False, "error": "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required"}
@ -664,7 +714,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"is_cleared": {"type": "string", "description": "\"False\" = active only; \"True\" = cleared."},
"ne_id": {"type": "string", "description": "Filter only; do not show UUID to users"},
"host_name": {"type": "string", "description": "Filter by NE host_name"},
"ne_name": {"type": "string", "description": "Legacy alias mapped to keyword"},
"keyword": {
"type": "string",
"description": (
@ -703,9 +752,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"top_ne": {
"type": "integer",
"minimum": 0,
"maximum": 500,
"maximum": 5000,
"default": 50,
"description": "Max NE buckets to return (0 = all, capped at API 5000). Ignored when group_by is set.",
"description": "Max NE buckets to return (0 = all; positive limit up to 5000). Ignored when group_by is set.",
},
"exclude_missing_host": {
"type": "boolean",
@ -883,8 +932,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"name": "getManagedNe",
"description": (
"Get one **managed** NE by managed ne_id (from listManagedNe / listCliTargets source=managed). "
"Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) "
"— read it before complex execManagedNe. "
"Response includes BACKEND capability (device_family, exec_policy_effective, recommended_mode, hints). "
"This MCP independently permits show/display only: backend shell/script capability does not "
"authorize scripts here. Native Linux/RouterOS CLI is not exposed by this MCP. "
"Do NOT pass NMS inventory UUID here — use getNmsNe or execManagedNe(nms_ne_id=...) instead."
),
"inputSchema": {
@ -894,23 +944,21 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"type": "string",
"description": "Managed NE id (not NMS host_name / not NMS ne_id unless they coincide).",
},
"managed_ne_id": {"type": "string", "description": "Alias for ne_id."},
"id": {"type": "string", "description": "Alias for ne_id."},
},
"required": [],
"required": ["ne_id"],
"additionalProperties": False,
},
},
{
"name": "execManagedNe",
"description": (
f"Run CLI via netx (default read-only: show/display/ping/traceroute; "
f"Run single-line read-only show/display CLI via netx ("
f"max {exec_max_commands()} commands per NE, NETX_NE_EXEC_MAX_COMMANDS). "
"Managed NE exec_policy=linux_shell|unrestricted allows shell/script on Linux or "
"MikroTik (routeros/switchos) hosts "
"(pipes/&&/;/quotes/heredoc / RouterOS multiline OK; check getManagedNe / listManagedNe). "
"Single NE: ne_id OR nms_ne_id (+ alias ume_ne_id) + commands. "
"Many NEs (batch-first, server concurrency default 4, max 20): "
"MCP independently allows single-line read-only show/display only, even when the "
"device has an open exec_policy. Shell operators, configuration commands and multiline "
"scripts are rejected before HTTP; only read-only output-filter pipes are allowed. "
"Single NE: ne_id OR nms_ne_id + commands. "
"Many NEs (batch-first, max 20 targets, concurrency default 4, max 8): "
"(1) same CLI on all → ne_ids[]/nms_ne_ids[] + shared commands; "
"(2) different CLI per NE (vendor/role) → ONE targets=["
"{nms_ne_id|ne_id, commands:[…]}, …] — do NOT fall back to one-NE loops. "
@ -918,14 +966,15 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"Default read_timeout_sec=60; on timeout raise to 90–120 — do not blind-retry. "
"Long or multi-NE work: async=true (or auto when ≥4 NEs) returns job_id immediately; "
"poll getNeExecJob. Pass async=false to force sync. "
"Read getManagedNe.capability before choosing show_only vs script_on_device."
"Use getManagedNe.capability to identify the vendor and backend policy; open backend "
"policies never expand this MCP's show/display restriction. For batches inspect "
"summary.failed and every result: job succeeded/result.ok alone does not mean all targets passed."
),
"inputSchema": {
"type": "object",
"properties": {
"ne_id": {"type": "string"},
"nms_ne_id": {"type": "string", "description": "NMS inventory id (preferred)."},
"ume_ne_id": {"type": "string", "description": "Legacy alias of nms_ne_id."},
"ne_ids": {
"type": "array",
"items": {"type": "string"},
@ -938,12 +987,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"maxItems": 20,
"description": "NMS inventory ne_ids for concurrent batch (shared commands).",
},
"ume_ne_ids": {
"type": "array",
"items": {"type": "string"},
"maxItems": 20,
"description": "Legacy alias of nms_ne_ids.",
},
"targets": {
"type": "array",
"maxItems": 20,
@ -952,7 +995,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"properties": {
"ne_id": {"type": "string"},
"nms_ne_id": {"type": "string"},
"ume_ne_id": {"type": "string", "description": "Legacy alias of nms_ne_id."},
"commands": {
"type": "array",
"items": {"type": "string"},
@ -1010,7 +1052,10 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"description": (
"Poll a background execManagedNe job (job_id from async ack). "
"When terminal=true, result holds the exec/exec-batch payload. "
"Do not busy-wait; end the turn and poll later if still running."
"Use poll_after_ms (about 5 seconds); continue independent work while waiting. "
"Terminal failed/timeout is a tool error and retains the original result for diagnosis."
" Batch jobs can finish succeeded with individual failures: inspect result.summary.failed "
"and each result.results[].ok before reporting success."
),
"inputSchema": {
"type": "object",
@ -1033,9 +1078,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"properties": {
"source": {
"type": "string",
"enum": ["managed", "nms", "ume", "all"],
"enum": ["managed", "nms", "all"],
"default": "all",
"description": "nms preferred; ume is a legacy alias mapped to the same inventory.",
"description": "nms selects the configured NMS inventory; managed selects managed NEs.",
},
"keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1, "default": 1},
@ -1050,8 +1095,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"description": (
"Find up to max_paths simple paths between two fabric nodes for troubleshooting "
"(parallel links between the same NEs count as one hop; see edges[].parallel_links). "
"For each endpoint provide exactly one of nms_ne_id (from NMS alarm ne_id; alias "
"from_ume_ne_id) or managed_ne_id — resolved to fabric node internally. Returns "
"For each endpoint provide exactly one of nms_ne_id (from NMS alarm ne_id) or "
"managed_ne_id — resolved to fabric node internally. Returns "
"shortest paths first with compact label + node/edge summary (detail=summary default). "
"Use after critical alarms to correlate neighboring NEs before CLI login."
),
@ -1062,10 +1107,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"type": "string",
"description": "Source NMS ne_id (from alarm ne_id); mutually exclusive with from_managed_ne_id",
},
"from_ume_ne_id": {
"type": "string",
"description": "Legacy alias of from_nms_ne_id",
},
"from_managed_ne_id": {
"type": "string",
"description": "Source managed NE id; mutually exclusive with from_nms_ne_id",
@ -1074,10 +1115,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"type": "string",
"description": "Target NMS ne_id; mutually exclusive with to_managed_ne_id",
},
"to_ume_ne_id": {
"type": "string",
"description": "Legacy alias of to_nms_ne_id",
},
"to_managed_ne_id": {
"type": "string",
"description": "Target managed NE id; mutually exclusive with to_nms_ne_id",
@ -1121,8 +1158,8 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = {
"queryNmsAlarms": "alarms:read",
"aggregateNmsAlarms": "alarms:read",
"runNmsDiagnostics": "alarms:read",
"queryNmsNeInventory": "ne:read",
"getNmsNe": "ne:read",
"queryNmsNeInventory": "alarms:read",
"getNmsNe": "alarms:read",
"queryNmsAlarmsRaw": "alarms:read",
"aggregateNmsAlarmsRaw": "alarms:read",
"listNmsAlarmFields": "alarms:read",
@ -1137,7 +1174,7 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = {
def tools_for_scopes(scopes: list[str] | set[str] | frozenset[str] | None) -> list[dict[str, Any]]:
"""Filter MCP tool list by granted scopes. Empty/None => return all (offline / unauthenticated listing)."""
"""Filter by granted scopes. None advertises all offline; an empty grant advertises none."""
if scopes is None:
return list(HTTP_MCP_TOOLS)
granted = {str(s).strip().lower() for s in scopes if str(s).strip()}
@ -1152,12 +1189,56 @@ def tools_for_scopes(scopes: list[str] | set[str] | frozenset[str] | None) -> li
return out
def _validate_arguments(args: Any, schema: dict[str, Any], path: str = "arguments") -> None:
"""Enforce advertised shapes even when a host skips JSON Schema validation."""
kind = schema.get("type")
expected = {"object": dict, "array": list, "string": str, "boolean": bool, "integer": int}
if kind in expected and (not isinstance(args, expected[kind]) or (kind == "integer" and isinstance(args, bool))):
raise ToolArgError(f"{path} must be {kind}")
if "enum" in schema and args not in schema["enum"]:
raise ToolArgError(f"{path} must be one of {schema['enum']}")
if kind == "object":
props = schema.get("properties", {})
unknown = set(args) - set(props)
if schema.get("additionalProperties") is False and unknown:
raise ToolArgError(f"{path}: unsupported parameter(s): {', '.join(sorted(unknown))}")
for key, value in args.items():
if key in props:
_validate_arguments(value, props[key], f"{path}.{key}")
elif kind == "array":
# Command limits are read at invocation time by the handler (env may change).
if not path.endswith(".commands") and len(args) > schema.get("maxItems", float("inf")):
raise ToolArgError(f"{path} has too many items (max {schema['maxItems']})")
if len(args) < schema.get("minItems", 0):
raise ToolArgError(f"{path} requires at least {schema['minItems']} item(s)")
for i, value in enumerate(args):
_validate_arguments(value, schema.get("items", {}), f"{path}[{i}]")
_SECRET_KEYS = frozenset({
"password", "hop_password", "password_enc", "hop_password_enc", "password_ciphertext",
"hop_password_ciphertext", "credential_secret", "secret_key", "token", "access_token",
"refresh_token", "authorization", "private_key",
})
def _redact_secrets(value: Any) -> Any:
"""Keep capability/has_password flags, but never expose structured credentials to a model."""
if isinstance(value, dict):
return {key: _redact_secrets(item) for key, item in value.items() if str(key).lower() not in _SECRET_KEYS}
if isinstance(value, list):
return [_redact_secrets(item) for item in value]
return value
def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
fn = _HANDLERS.get(str(name or "").strip())
if not fn:
raise ValueError(f"unknown tool: {name}")
try:
schema = next(tool["inputSchema"] for tool in HTTP_MCP_TOOLS if tool["name"] == name)
_validate_arguments(args, schema)
result = fn(dict(args or {}))
except ToolArgError as exc:
result = {"ok": False, "error": "invalid_argument", "error_code": "invalid_argument", "detail": str(exc)}
return mcp_from_handler_result(result)
return mcp_from_handler_result(_redact_secrets(result))

View file

@ -0,0 +1,77 @@
"""无设备/网络副作用地验证 MCP 执行边界与终态失败语义。"""
import json
from unittest.mock import patch
import pytest
from netx_mcp.http_tools import call_http_tool
@pytest.mark.parametrize("command", [
"reboot", "configure terminal", "show version; reboot", "show version && reboot",
"show version | reboot", "show version | redirect flash:config", "show $(reboot)",
"show `reboot`", "display version\nreboot", "show version\u2028reboot",
"show version > config", "show version\x00", "/system reboot",
])
@pytest.mark.parametrize("batch", [False, True])
def test_unsafe_cli_never_reaches_http(command, batch):
args = {"targets": [{"ne_id": "test-only", "commands": [command]}]} if batch else {
"ne_id": "test-only", "commands": [command], "async": True}
with patch("netx_mcp.http_tools.http_post_json") as post:
result = call_http_tool("execManagedNe", args)
assert result.get("isError") is True
post.assert_not_called()
@pytest.mark.parametrize("command", ["show version", "display version", "show bgp summary | one-line"])
def test_readonly_cli_still_executes(command):
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True}}) as post:
result = call_http_tool("execManagedNe", {"ne_id": "test-only", "commands": [command]})
assert not result.get("isError")
assert post.call_args.args[0] == "/v1/managed-ne/exec"
assert post.call_args.args[1]["commands"] == [command]
def test_async_ack_explains_polling_without_forcing_turn_end():
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {
"ok": True, "job_id": "test-job", "status": "running", "hint": "end the turn"}}):
result = call_http_tool("execManagedNe", {"ne_id": "test-only", "commands": ["show version"], "async": True})
data = json.loads(result["content"][0]["text"])
assert data["poll_after_ms"] == 5000 and data["job_id"] == "test-job"
assert "end the turn" not in data["hint"]
def test_failed_exec_job_is_tool_error_and_preserves_evidence():
terminal = {"ok": True, "terminal": True, "status": "failed", "result": {
"ok": False, "detail": "SSH connection failed", "commands": ["display version"]}}
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
result = call_http_tool("getNeExecJob", {"job_id": "test-job"})
assert result.get("isError") is True
data = json.loads(result["content"][0]["text"])
assert data["data"]["result"]["detail"] == "SSH connection failed"
@pytest.mark.parametrize("failed", [1, 2])
@pytest.mark.parametrize("async_job", [False, True])
def test_batch_target_failures_are_tool_errors_even_when_backend_ok(failed, async_job):
result = {"ok": True, "summary": {"total": 2, "ok": 2-failed, "failed": failed},
"results": [{"ok": failed == 1, "output": "version"}, {"ok": False, "detail": "SSH banner timeout"}]}
terminal = {"ok": True, "terminal": True, "status": "succeeded", "result": result}
if async_job:
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
out = call_http_tool("getNeExecJob", {"job_id": "test-job"})
assert json.loads(out["content"][0]["text"])["data"] == terminal
else:
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": result}):
out = call_http_tool("execManagedNe", {"ne_ids": ["m1", "m2"], "commands": ["show version"], "async": False})
assert json.loads(out["content"][0]["text"])["data"] == result
assert out["isError"] is True
@pytest.mark.parametrize("target_ok", [False, True])
def test_batch_checks_individual_results_without_summary(target_ok):
terminal = {"ok": True, "terminal": True, "status": "succeeded", "result": {
"ok": True, "results": [{"ok": target_ok, "output": "version"}]}}
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
out = call_http_tool("getNeExecJob", {"job_id": "test-job"})
assert bool(out.get("isError")) is not target_ok

View file

@ -103,18 +103,6 @@ def test_call_find_topology_paths_defaults_summary_detail() -> None:
assert payload["ok"] is True
def test_call_find_topology_paths_accepts_legacy_ume_params() -> None:
with patch("netx_mcp.http_tools.http_post_json") as mock_post:
mock_post.return_value = {"ok": True, "data": {"path_count": 0, "paths": []}}
call_http_tool(
"findTopologyPaths",
{"from_ume_ne_id": "a", "to_ume_ne_id": "b"},
)
body = mock_post.call_args[0][1]
assert body["from_ume_ne_id"] == "a"
assert body["to_ume_ne_id"] == "b"
def test_call_exec_managed_ne_defaults_read_timeout() -> None:
with patch("netx_mcp.http_tools.http_post_json") as mock_post:
mock_post.return_value = {"ok": True, "data": {"ok": True, "output": "hi"}}
@ -138,16 +126,6 @@ def test_get_managed_ne_requires_id_with_hint() -> None:
assert "hint" in payload
def test_get_managed_ne_accepts_managed_ne_id_alias() -> None:
with patch("netx_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"ne_id": "m1"}}
out = call_http_tool("getManagedNe", {"managed_ne_id": "m1"})
mock_http.assert_called_once()
assert mock_http.call_args[0][1].endswith("/m1")
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
def test_call_get_nms_ne_requires_id() -> None:
out = call_http_tool("getNmsNe", {})
assert out.get("isError") is True
@ -202,7 +180,7 @@ def test_exec_managed_ne_schema_documents_batch() -> None:
props = tool["inputSchema"]["properties"]
assert "ne_ids" in props
assert "nms_ne_ids" in props
assert "ume_ne_ids" in props
assert "ume_ne_ids" not in props
assert "targets" in props
assert "concurrency" in props
assert "Many NEs" in tool["description"] or "ne_ids" in tool["description"]
@ -394,3 +372,194 @@ def test_managed_ne_list_drops_disabled_hop_noise() -> None:
out = call_http_tool("listManagedNe", {"connect_status": "pass"})
row = json.loads(out["content"][0]["text"])["data"]["items"][0]
assert row == {"id": "m1", "hop_enabled": False}
@pytest.mark.parametrize(("name", "args"), [
("queryNmsAlarms", {"ne_name": "old"}),
("getManagedNe", {"managed_ne_id": "old"}),
("getManagedNe", {"id": "old"}),
("execManagedNe", {"ume_ne_id": "old", "commands": ["show version"]}),
("execManagedNe", {"ume_ne_ids": ["old"], "commands": ["show version"]}),
("execManagedNe", {"targets": [{"ume_ne_id": "old", "commands": ["show version"]}]}),
("findTopologyPaths", {"from_ume_ne_id": "old", "to_nms_ne_id": "b"}),
("findTopologyPaths", {"from_nms_ne_id": "a", "to_ume_ne_id": "old"}),
("listCliTargets", {"source": "ume"}),
])
def test_obsolete_aliases_are_rejected_before_http(name: str, args: dict) -> None:
with patch("netx_mcp.http_tools.http_json") as get, patch("netx_mcp.http_tools.http_post_json") as post:
out = call_http_tool(name, args)
get.assert_not_called()
post.assert_not_called()
assert out["isError"] is True
assert json.loads(out["content"][0]["text"])["error"] == "invalid_argument"
def test_all_fifteen_tools_match_backend_scope_policy() -> None:
from netx_api.auth_scopes import required_scope_for_request
from netx_mcp.http_tools import TOOL_REQUIRED_SCOPE
routes = {
"queryNmsAlarms": ("GET", "/v1/ume/alarms"),
"aggregateNmsAlarms": ("GET", "/v1/ume/alarms/aggregate"),
"runNmsDiagnostics": ("GET", "/v1/ume/diagnostics"),
"queryNmsNeInventory": ("GET", "/v1/ume/inventory/ne"),
"getNmsNe": ("GET", "/v1/ume/inventory/ne/id"),
"queryNmsAlarmsRaw": ("GET", "/v1/ume/alarms/raw"),
"aggregateNmsAlarmsRaw": ("GET", "/v1/ume/alarms/aggregate/raw"),
"listNmsAlarmFields": ("GET", "/v1/ume/alarms/fields"),
"sqlQueryNms": ("POST", "/v1/sql/ume_query"),
"listManagedNe": ("GET", "/v1/managed-ne"),
"getManagedNe": ("GET", "/v1/managed-ne/id"),
"execManagedNe": ("POST", "/v1/managed-ne/exec"),
"getNeExecJob": ("GET", "/v1/managed-ne/exec-jobs/id"),
"listCliTargets": ("GET", "/v1/cli/targets"),
"findTopologyPaths": ("POST", "/v1/topology/fabric/paths"),
}
assert set(routes) == {t["name"] for t in HTTP_MCP_TOOLS} == set(TOOL_REQUIRED_SCOPE)
for name, (method, path) in routes.items():
assert TOOL_REQUIRED_SCOPE[name] == required_scope_for_request(method, path)
assert name in {t["name"] for t in tools_for_scopes([TOOL_REQUIRED_SCOPE[name]])}
assert tools_for_scopes([]) == []
@pytest.mark.parametrize(("name", "args", "path", "expected"), [
("aggregateNmsAlarms", {"top_ne": 3000}, "/v1/ume/alarms/aggregate", {"top_ne": 3000}),
("runNmsDiagnostics", {}, "/v1/ume/diagnostics", None),
("queryNmsNeInventory", {"keyword": "edge", "page": 9, "page_size": 900}, "/v1/ume/inventory/ne", {"keyword": "edge", "page": 9, "page_size": 500}),
("listNmsAlarmFields", {}, "/v1/ume/alarms/fields", None),
("getNeExecJob", {"job_id": "job /?"}, "/v1/managed-ne/exec-jobs/job%20%2F%3F", None),
("listCliTargets", {"source": "nms", "keyword": "edge"}, "/v1/cli/targets", {"source": "ume", "keyword": "edge", "page": 1, "page_size": 50}),
])
def test_ops_read_routes_and_backend_limits(name: str, args: dict, path: str, expected: dict | None) -> None:
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": {"items": []}}) as get:
out = call_http_tool(name, args)
get.assert_called_once_with("GET", path, params=expected)
assert not out.get("isError")
def test_raw_aggregation_filters_and_compaction() -> None:
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": {"buckets": [{"key": "edge", "count": 4}], "meta": {"group_by_allowed": ["large"], "time_filter_field": "last_seen_at"}}}) as get:
out = call_http_tool("aggregateNmsAlarmsRaw", {"group_by": "alarm_host_name", "severity": "CRITICAL", "exclude_missing_host": False, "limit": 9999})
assert get.call_args.kwargs["params"] == {"group_by": "alarm_host_name", "severity": "critical", "exclude_missing_host": False, "limit": 2000}
data = json.loads(out["content"][0]["text"])["data"]
assert data["buckets"][0]["count"] == 4
assert data["meta"] == {"time_filter_field": "last_seen_at"}
def test_sql_limits_are_forwarded_and_denial_is_tool_error() -> None:
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": False, "error": "netx_http_403", "detail": "insufficient_scope:sql:query"}) as post:
out = call_http_tool("sqlQueryNms", {"sql": " SELECT ne_id FROM ume_inventory_ne ", "limit": 9999, "statement_timeout_ms": 99999})
post.assert_called_once_with("/v1/sql/ume_query", {"sql": "SELECT ne_id FROM ume_inventory_ne", "limit": 2000, "statement_timeout_ms": 30000}, timeout=60.0)
assert out["isError"] is True
assert "insufficient_scope" in out["content"][0]["text"]
@pytest.mark.parametrize("name,args", [
("listManagedNe", {"keyword": "edge"}),
("getManagedNe", {"ne_id": "m1"}),
("listCliTargets", {"source": "managed"}),
("getNmsNe", {"ne_id": "u1"}),
])
def test_structured_credentials_are_removed_but_capabilities_remain(name: str, args: dict) -> None:
row = {"id": "m1", "password": "secret", "has_password": True, "capability": {"recommended_mode": "show_only", "token": "secret"}, "hop_password_enc": "secret"}
data = {"items": [row]} if name.startswith("list") else row
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": data}):
out = call_http_tool(name, args)
text = out["content"][0]["text"]
assert "secret" not in text
assert "show_only" in text
assert "has_password" in text
@pytest.mark.parametrize("args", [
{"targets": [{"ne_id": "m1", "nms_ne_id": "u1", "commands": ["show version"]}]},
{"targets": [{"ne_id": "m1"}]},
{"targets": [{"ne_id": "m1", "commands": ["show version"] * 6}]},
{"ne_ids": ["m1"]},
{"ne_ids": ["m1"] * 11, "nms_ne_ids": ["u1"] * 10, "commands": ["show version"]},
{"ne_id": "m1", "ne_ids": ["m2"], "commands": ["show version"]},
{"targets": ["m1"], "commands": ["show version"]},
])
def test_invalid_exec_batches_do_not_reach_http(args: dict, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NETX_NE_EXEC_MAX_COMMANDS", "5")
with patch("netx_mcp.http_tools.http_post_json") as post:
out = call_http_tool("execManagedNe", args)
post.assert_not_called()
assert out["isError"] is True
def test_async_auto_counts_all_target_forms_and_poll_preserves_terminal_result(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NETX_NE_EXEC_ASYNC_MIN_NES", "4")
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True, "job_id": "j1", "terminal": False}}) as post:
ack = call_http_tool("execManagedNe", {"ne_ids": ["m1", "m2"], "nms_ne_ids": ["u1", "u2"], "commands": ["show version"], "concurrency": 20})
assert post.call_args.args[0] == "/v1/managed-ne/exec-jobs"
assert post.call_args.args[1]["concurrency"] == 8
assert json.loads(ack["content"][0]["text"])["job_id"] == "j1"
terminal = {"ok": True, "terminal": True, "result": {"ok": True, "results": [{"ok": False, "error": "timeout"}]}}
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
out = call_http_tool("getNeExecJob", {"job_id": "j1"})
assert json.loads(out["content"][0]["text"])["data"] == terminal
assert out["isError"] is True
def test_explicit_sync_overrides_auto_async(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("NETX_NE_EXEC_ASYNC_MIN_NES", "1")
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True}}) as post:
call_http_tool("execManagedNe", {"nms_ne_ids": ["u1", "u2"], "commands": ["show version"], "async": False})
assert post.call_args.args[0] == "/v1/managed-ne/exec-batch"
def test_stdio_scope_denial_never_invokes_handler() -> None:
import io
from netx_mcp.server import run_stdio_loop
request = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "execManagedNe", "arguments": {"ne_id": "m1", "commands": ["show version"]}}}
output = io.StringIO()
with patch("netx_mcp.server.sys.stdin", io.StringIO(json.dumps(request) + "\n")), patch("netx_mcp.server.sys.stdout", output), patch("netx_mcp.server._fetch_scopes", return_value=["ne:read"]), patch("netx_mcp.server.call_http_tool") as handler:
run_stdio_loop()
handler.assert_not_called()
response = json.loads(output.getvalue())
assert response["error"]["code"] == -32001
assert "ne:exec" in response["error"]["message"]
@pytest.mark.parametrize("method", ["GET", "POST"])
@pytest.mark.parametrize("status", [401, 403, 404, 500])
def test_http_errors_preserve_status_and_bound_detail(method: str, status: int) -> None:
import httpx
from netx_mcp.http_client import http_json, http_post_json
transport = httpx.MockTransport(lambda request: httpx.Response(status, text="denied" * 300))
client = httpx.Client(transport=transport)
with patch("netx_mcp.http_client.httpx.Client", return_value=client):
result = http_json("GET", "/v1/ume/alarms") if method == "GET" else http_post_json("/v1/managed-ne/exec", {})
assert result["ok"] is False
assert result["error"] == f"netx_http_{status}"
assert len(result["detail"]) == 800
def test_mixed_exec_targets_translate_nms_ids_and_preserve_per_target_commands() -> None:
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True, "items": [{"ok": True, "password": "secret", "output": "version"}]}}) as post:
out = call_http_tool("execManagedNe", {"targets": [{"ne_id": "m1"}, {"nms_ne_id": "u1", "commands": ["display version"]}], "commands": ["show version"], "async": False})
assert post.call_args.args[0] == "/v1/managed-ne/exec-batch"
assert post.call_args.args[1]["targets"] == [{"ne_id": "m1"}, {"ume_ne_id": "u1", "commands": ["display version"]}]
text = out["content"][0]["text"]
assert "secret" not in text
assert json.loads(text)["data"]["items"][0]["output"] == "version"
def test_async_job_queue_failure_is_not_a_successful_ack() -> None:
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": False, "error": "ne_exec_job_busy"}}):
out = call_http_tool("execManagedNe", {"ne_id": "m1", "commands": ["show version"], "async": True})
assert out["isError"] is True
assert json.loads(out["content"][0]["text"])["error"] == "ne_exec_job_busy"
@pytest.mark.parametrize("args", [{}, {"group_by": ""}])
def test_raw_aggregation_missing_dimension_is_actionable(args: dict) -> None:
with patch("netx_mcp.http_tools.http_json") as get:
out = call_http_tool("aggregateNmsAlarmsRaw", args)
get.assert_not_called()
assert out["isError"] is True
assert "group_by" in out["content"][0]["text"]

View file

@ -8,7 +8,7 @@ MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → netx AP
详细说明 → **[docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md)**
配套 Skill → **[`.cursor/skills/netx-topology`](../../.cursor/skills/netx-topology/SKILL.md)**(画图流水线与硬规则)
配套 Skill → **[`skills/topology/netx-topology`](../../skills/topology/netx-topology/SKILL.md)**(画图流水线与硬规则)
## 目录模型
@ -38,14 +38,35 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
画图请在 env 里设带 `ne:write` 的 `NETX_API_TOKEN`(不要只靠仓库 `mcp_token`)。`getTopologyView` / `projectTopologyNeighbors` 默认 `detail=summary`(坐标抽样 + `links[]` 邻接);`queryTopologyEdges` 默认 adjacency(一对网元一条线),避免整图/端口边占满上下文。
## 工具(14)
## 工具(16)
覆盖 netxops `topology` 清单全部 15 项,另提供 `sinkTopologyDualUnits`。netxops 工具名前缀为 `netx__`,独立 MCP 不带此前缀。
| 类别 | 工具 |
|------|------|
| 树/区域/画布 | `getTopologyTree`, `createTopologyFolder`(唯一建图,返回 view_id), `getTopologyView` |
| 画图 | `addTopologyViewNodes` / `remove…` / `sinkTopologyDualUnits`(分批沉入+默认可先 layout_dual_unit) / `copyTopologyViewNodes`(一键克隆测沙箱) / `update…Positions`(**优先筛选**,API 自选 id;也可 id 列表), `projectTopologyNeighbors` |
| Fabric 只读 | `queryTopologyFabricNodes`(summary\|list\|search), `queryTopologyNeighborhood`, `queryTopologyEdges` |
| Fabric 分类 | `classifyTopologyFabricNodes`(match/tag/patch/unmatched/preview_rules/apply_rules/list_rules) |
| 自动布图 | `layoutTopologyView`(小图 `compact`/`corridor`/`rings`;巨图:`sinkTopologyDualUnits`→`orbit_sweep`→`polish_crossings`→`clear_edge_hits`) |
| 布图验收/规划 | `analyzeTopologyViewLayout`(score;`detail=structure` 定重心;`both`=structure+手拖 sight) |
**安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。
画布节点增删和位置布局只接受 `keyword/role/vendor/link_status` 筛选。按 `region_folder_id/level/level_major` 画图时,先用 `queryTopologyFabricNodes(mode=list)` 分页查询,再将返回的 `id` 作为 `fabric_node_ids` 传入画布工具。`projectTopologyNeighbors(dry_run=true)` 可预览邻居投影而不保存。
## 自动布局与异步任务
1. `layoutTopologyView({view_id, action:"layout", recipe:"compact", mode:"preview"})` 预览;`mode:"apply"` 才保存坐标。
2. 需要后台计算时,调用 `layoutTopologyView({view_id, action:"layout", recipe:"compact", mode:"apply", params:{background:true}})`,记录返回的 `job_id`。布局/局部优化计算支持显式后台;`move_nodes` 和 `align_reference` 按各自流程同步执行。
3. 调用 `layoutTopologyView({action:"job_status", params:{job_id}})`,直到 `status` 为 `done/error/cancelled`。`running/cancelling` 仍需轮询;`stale=true` 仅表示心跳过期,不能视为完成。成功必须检查 `ok=true`、`status=done`、`result.applied=true`。
4. `getTopologyView({view_id, detail:"full"})` 回读 HTTP API 持久化后的坐标。`job_status` 的结果会裁剪大字段,不替代完整画布回读。
5. 需要取消时调用 `layoutTopologyView({action:"job_cancel", params:{job_id}})`;取消为协作式,仍需轮询终态。已经写入的坐标不会因取消自动回滚。
部分大图优化会自动转后台:`polish_crossings/orbit_sweep/clear_edge_hits/compact_bbox/pull_far_chains/layout_dual_unit` 在节点数达到 600 时;`orbit_sweep(until_limit=true)` 的 apply 总是后台。`params.sync=true` 可强制同步。
任务 JSON 默认位于仓库 `data/runtime/layout_jobs`;用绝对路径 `NETX_LAYOUT_JOB_DIR` 可指定共享目录。后台子进程和任务文件能跨 MCP 主进程重启,轮询方必须访问同一目录;这不是 HTTP 后端任务接口,也不支持跨机器自动共享。历史回收仅清理终态,保留运行中的任务。
HTTP 200 内的 `ok=false` 会作为 MCP `isError=true` 返回。分批写入遇错即停;复制/移动/下沉若保存目标坐标失败,会保留源成员并报告已增加的目标成员。最终回读失败返回 `view_readback_failed` 和 `write_completed=true`,此时应先检查画布再重试。多次 HTTP 写入不构成事务,已完成的部分不会自动撤销。
只使用工具 schema 中公开的动作。旧动作别名 `resolve_overlaps/sink_nodes/align_to_reference`、查询模式 `stats/count/find`、分类动作 `find/bulk_tag/assign/preview/apply/rules`、详情别名 `raw/graph/full`(边查询的 full)已移除;规范动作分别见 `layoutTopologyView(catalog=true)` 与各工具 schema。

View file

@ -28,7 +28,10 @@ def _data(out: dict[str, Any]) -> dict[str, Any]:
data = out.get("data")
if isinstance(data, dict):
merged = dict(data)
merged["ok"] = True
# HTTP 200 can carry a failed mutation (for example membership_max_nodes).
merged.setdefault("ok", True)
if merged.get("ok") is False and not merged.get("error"):
merged["error"] = "netx_operation_failed"
return merged
return {"ok": True, "data": data}
@ -233,10 +236,12 @@ def _get_topology_view(args: dict[str, Any]) -> dict[str, Any]:
if not out.get("ok"):
return out
detail = str(args.get("detail") or "summary").strip().lower()
if detail in {"full", "raw", "graph"}:
if detail not in {"summary", "full"}:
return {"ok": False, "error": "detail_invalid", "hint": "detail=summary|full"}
if detail == "full":
out["detail"] = "full"
return out
sample = int(args.get("sample") or 20)
sample = int(args["sample"]) if args.get("sample") is not None else 20
return _summarize_view_graph(out, sample=sample)
@ -359,7 +364,7 @@ def _patch_positions_chunked(view_id: str, positions: list[dict[str, Any]]) -> d
"updated": updated,
"detail": out,
}
updated += int(out.get("updated") or len(chunk))
updated += int(out.get("updated", len(chunk)))
chunks += 1
return {"ok": True, "updated": updated, "chunks": chunks}
@ -469,7 +474,6 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
_public = {
"layout",
"fix_overlaps",
"resolve_overlaps",
"untangle",
"straighten_channels",
"layout_dual_unit",
@ -478,11 +482,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
"compact_bbox",
"pull_far_chains",
"align_reference",
"align_to_reference",
"orbit_sweep",
"level_bands",
"move_nodes",
"sink_nodes",
"job_status",
"job_cancel",
}
@ -503,16 +505,12 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
),
**list_layout_catalog(),
}
if action == "resolve_overlaps":
action = "fix_overlaps"
args = {**args, "action": "fix_overlaps"}
# Bidirectional membership move: view_id=TO, source_view_id=FROM.
if action in {"move_nodes", "sink_nodes"}:
if action == "move_nodes":
return _move_topology_view_nodes(args)
# Reference alignment: view_id=TO, source_view_id|params.reference_view_id=FROM.
if action in {"align_reference", "align_to_reference"}:
if action == "align_reference":
return _align_topology_reference(args)
# Background job poll / cancel (no view_id required).
@ -544,8 +542,8 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
"error": "job_not_found",
"job_id": job_id,
"hint": (
"Unknown job_id. Jobs are in-process on this MCP worker; "
"restart or another instance cannot see them."
"Unknown job_id. Poll from a worker sharing NETX_LAYOUT_JOB_DIR; "
"records survive MCP restarts but may have been pruned."
),
}
status = str(job.get("status") or "unknown")
@ -870,9 +868,7 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
force_sync = _truthy((overrides or {}).get("sync")) or _truthy(
(overrides or {}).get("_force_sync")
)
until_limit = _truthy((overrides or {}).get("until_limit")) or _truthy(
(overrides or {}).get("until_stall")
)
until_limit = _truthy((overrides or {}).get("until_limit"))
heavy_bg = action in {
"polish_crossings",
"orbit_sweep",
@ -885,10 +881,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
bg_n_thr = 80 if (action == "orbit_sweep" and until_limit) else 600
if (
mode == "apply"
and heavy_bg
and not force_sync
and (
len(nodes) >= bg_n_thr
(heavy_bg and len(nodes) >= bg_n_thr)
or _truthy((overrides or {}).get("background"))
or (until_limit and action == "orbit_sweep")
)
@ -912,6 +907,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
tool_args=sync_args,
meta={"node_count": len(nodes), "until_limit": until_limit},
)
started = job_public(job_id)
if started and started.get("status") in {"done", "error", "cancelled"}:
return _layout_topology_view({"action": "job_status", "params": {"job_id": job_id}})
print(
f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}"
+ (" until_limit" if until_limit else ""),
@ -1870,10 +1868,6 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"error": "fabric_nodes_only",
"detail": "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.",
}
if args.get("max_nodes") is not None:
bump = _ensure_view_max_nodes(view_id, int(args.get("max_nodes") or 2000))
if isinstance(bump, dict) and bump.get("ok") is False:
return bump
filters = _filter_fields(args)
fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
if not filters and not fabric_ids:
@ -1883,6 +1877,11 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"detail": "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.",
}
if args.get("max_nodes") is not None:
bump = _ensure_view_max_nodes(view_id, int(args.get("max_nodes") or 2000))
if bump.get("ok") is False:
return bump
layout = str(args.get("layout") or "grid").strip() or "grid"
if filters:
body: dict[str, Any] = {
@ -1909,6 +1908,8 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"return_graph": False,
}
parts.append(_data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body)))
if not parts[-1].get("ok"):
break
return _merge_mutation_summaries(parts)
@ -1935,6 +1936,8 @@ def _remove_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
)
)
)
if not parts[-1].get("ok"):
break
return _merge_mutation_summaries(parts)
@ -2002,6 +2005,8 @@ def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]:
)
)
)
if not parts[-1].get("ok"):
break
return _merge_mutation_summaries(parts)
@ -2009,8 +2014,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
view_id = str(args.get("view_id") or "").strip()
if not view_id:
return {"ok": False, "error": "view_id_required"}
detail = str(args.get("detail") or "summary").strip().lower()
if detail not in {"summary", "full"}:
return {"ok": False, "error": "detail_invalid", "hint": "detail=summary|full"}
body: dict[str, Any] = {}
seeds = args.get("seed_fabric_node_ids") or args.get("fabric_node_ids") or []
seeds = args.get("seed_fabric_node_ids") or []
if isinstance(seeds, list) and seeds:
body["seed_fabric_node_ids"] = [str(x).strip() for x in seeds if str(x).strip()]
mids = args.get("managed_ne_ids") or []
@ -2019,6 +2027,8 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
region_folder_id = str(args.get("region_folder_id") or "").strip()
if region_folder_id:
body["region_folder_id"] = region_folder_id
if "dry_run" in args:
body["dry_run"] = _truthy(args["dry_run"])
# API still returns the full graph; large canvases easily exceed the 60s default.
out = _data(
http_json(
@ -2030,12 +2040,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
)
if not out.get("ok"):
return out
detail = str(args.get("detail") or "summary").strip().lower()
skipped = int(out.get("out_of_region_skipped") or 0)
skipped_sample = out.get("out_of_region_sample") or []
if not isinstance(skipped_sample, list):
skipped_sample = []
if detail in {"full", "raw", "graph"}:
if detail == "full":
out["detail"] = "full"
out["region_folder_id"] = region_folder_id or None
out["out_of_region_skipped"] = skipped
@ -2046,9 +2055,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
f"skipped {skipped} out-of-region neighbors (not added)."
)
return out
summary = _summarize_view_graph(out, sample=int(args.get("sample") or 20))
sample = int(args["sample"]) if args.get("sample") is not None else 20
summary = _summarize_view_graph(out, sample=sample)
summary["view_id"] = view_id
summary["projected"] = True
summary["dry_run"] = bool(body.get("dry_run"))
summary["region_folder_id"] = region_folder_id or None
summary["out_of_region_skipped"] = skipped
summary["out_of_region_sample"] = [
@ -2076,13 +2087,15 @@ def _query_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
mode = "search"
else:
mode = "list"
if mode in {"summary", "stats", "count"}:
if mode not in {"summary", "search", "list"}:
return {"ok": False, "error": "mode_invalid", "hint": "mode=summary|list|search"}
if mode == "summary":
out = _data(http_json("GET", "/v1/topology/fabric/summary"))
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
out["mode"] = "summary"
return out
if mode in {"search", "find"}:
if mode == "search":
needle = q or keyword
if not needle:
return {
@ -2141,11 +2154,11 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
"hint": (
"action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules. "
"Typical: match → tag(level|role, dry_run) → tag; "
"then addTopologyViewNodes by role/level_major/region."
"then queryTopologyFabricNodes by level/region and add returned fabric_node_ids."
),
}
if action in {"match", "find"}:
if action == "match":
pattern = str(args.get("pattern") or args.get("q") or "").strip()
if not pattern:
return {"ok": False, "error": "pattern_required"}
@ -2180,7 +2193,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
)
return out
if action in {"tag", "bulk_tag", "assign"}:
if action == "tag":
level = args.get("level") if "level" in args else Ellipsis
role = args.get("role") if "role" in args else Ellipsis
region = args.get("region_folder_id") if "region_folder_id" in args else Ellipsis
@ -2232,7 +2245,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
else:
out["hint"] = (
"Tagged. Use queryTopologyFabricNodes(role|level_major|region_folder_id) or "
"addTopologyViewNodes filters to place on region canvas."
"pass returned fabric_node_ids to addTopologyViewNodes to place on region canvas."
)
return out
@ -2286,7 +2299,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
page["action"] = "unmatched"
return page
if action in {"preview_rules", "preview"}:
if action == "preview_rules":
out = _data(http_json("POST", "/v1/topology/classify/preview"))
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
@ -2294,7 +2307,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
out["hint"] = "Rule engine dry-run. Re-call action=apply_rules to write."
return out
if action in {"apply_rules", "apply"}:
if action == "apply_rules":
skip_manual = True
if "skip_manual" in args:
skip_manual = _truthy(args.get("skip_manual"))
@ -2310,7 +2323,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
out["action"] = "apply_rules"
return out
if action in {"list_rules", "rules"}:
if action == "list_rules":
out = _data(http_json("GET", "/v1/topology/classify/rules"))
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
@ -2362,7 +2375,9 @@ def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
page_size = min(500, max(1, int(args.get("page_size") or 100)))
node_id = str(args.get("node_id") or "").strip()
detail = str(args.get("detail") or "adjacency").strip().lower()
want_ports = detail in {"ports", "raw", "full"}
if detail not in {"adjacency", "ports"}:
return {"ok": False, "error": "detail_invalid", "hint": "detail=adjacency|ports"}
want_ports = detail == "ports"
params: dict[str, Any] = {
"page": page,
"page_size": page_size,
@ -2374,16 +2389,13 @@ def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
params["status"] = str(args.get("status")).strip()
if str(args.get("source") or "").strip():
src = str(args.get("source")).strip().lower()
if src == "stale":
src = "lldp"
params["source"] = src
if str(args.get("keyword") or "").strip():
params["keyword"] = str(args.get("keyword")).strip()
out = http_json("GET", "/v1/topology/fabric/edges", params=params)
if not isinstance(out, dict) or not out.get("ok"):
return out if isinstance(out, dict) else {"ok": False, "error": "invalid_response"}
data = out.get("data") if isinstance(out.get("data"), dict) else {}
data = _data(http_json("GET", "/v1/topology/fabric/edges", params=params))
if not data.get("ok"):
return data
items = [e for e in (data.get("items") or []) if isinstance(e, dict)]
edge_total = int(data.get("total") or len(items))
links = _collapse_edges_to_links(items, include_names=True)
@ -2528,12 +2540,8 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
units_as_batch_rows,
)
source_view_id = str(
args.get("source_view_id") or args.get("root_view_id") or ""
).strip()
sink_view_id = str(
args.get("sink_view_id") or args.get("target_view_id") or ""
).strip()
source_view_id = str(args.get("source_view_id") or "").strip()
sink_view_id = str(args.get("sink_view_id") or "").strip()
if not source_view_id or not sink_view_id:
return {
"ok": False,
@ -2883,6 +2891,17 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
patch_out: dict[str, Any] = {"ok": True, "updated": 0}
if pos_patch:
patch_out = _patch_positions_chunked(sink_view_id, pos_patch)
if not patch_out.get("ok"):
return {
"ok": False,
"error": patch_out.get("error") or "positions_patch_failed",
"detail": patch_out,
"source_preserved": True,
"added": int(add_out.get("added", 0)),
"batches": batches,
"batch": batch_row,
"hint": "Sink membership may already be added; source nodes were kept. Inspect both views before retrying.",
}
rm_out = _remove_topology_view_nodes(
{"view_id": source_view_id, "fabric_node_ids": move_ids}
@ -2900,6 +2919,16 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
# Refresh counts
src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
snk2 = _data(http_json("GET", f"/v1/topology/views/{sink_view_id}", timeout=120.0))
if not src2.get("ok") or not snk2.get("ok"):
return {
"ok": False,
"error": "view_readback_failed",
"write_completed": True,
"detail": {"source": src2, "sink": snk2},
"batches": batches,
"batch": batch_row,
"hint": "Writes completed but verification failed. Read both views before retrying this batch.",
}
src_ids2, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
snk_ids2, _ = _view_node_id_pos(snk2 if snk2.get("ok") else {})
source_remaining = len(src_ids2) if src2.get("ok") else max(0, source_remaining - len(move_ids))
@ -2908,8 +2937,8 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
batch_row.update(
{
"added": int(add_out.get("added") or len(add_ids)),
"positions_updated": int(patch_out.get("updated") or len(pos_patch)),
"removed_from_source": int(rm_out.get("removed") or len(move_ids)),
"positions_updated": int(patch_out.get("updated", 0)),
"removed_from_source": int(rm_out.get("removed", 0)),
"source_after": source_remaining,
"sink_after": sink_count,
"max_nodes_bump": bump,
@ -2963,19 +2992,8 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
Bidirectional: swap view_id / source_view_id to send nodes back.
Caller supplies fabric_node_ids — no auto CN/dual-unit selection.
"""
dest_view_id = str(
args.get("view_id")
or args.get("sink_view_id")
or args.get("target_view_id")
or args.get("to_view_id")
or ""
).strip()
source_view_id = str(
args.get("source_view_id")
or args.get("from_view_id")
or args.get("root_view_id")
or ""
).strip()
dest_view_id = str(args.get("view_id") or "").strip()
source_view_id = str(args.get("source_view_id") or "").strip()
if not dest_view_id or not source_view_id:
return {
"ok": False,
@ -3010,6 +3028,8 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
return {"ok": False, "error": "fabric_node_ids_empty"}
mode = str(args.get("mode") or "preview").strip().lower() or "preview"
if mode not in {"preview", "apply"}:
return {"ok": False, "error": "mode_invalid", "hint": "mode=preview|apply"}
dry_run = bool(overrides.get("dry_run") if "dry_run" in overrides else args.get("dry_run"))
if mode != "apply":
dry_run = True
@ -3109,7 +3129,7 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
bump = _ensure_view_max_nodes(dest_view_id, min(2000, max(need_cap + 20, 200)))
if isinstance(bump, dict) and bump.get("ok") is False:
return {**bump, **base}
return {**base, **bump}
add_out: dict[str, Any] = {"ok": True, "added": 0, "skipped_existing": len(already)}
if add_ids:
@ -3158,6 +3178,16 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
patch_out: dict[str, Any] = {"ok": True, "updated": 0}
if pos_patch:
patch_out = _patch_positions_chunked(dest_view_id, pos_patch)
if not patch_out.get("ok"):
return {
**base,
"ok": False,
"error": patch_out.get("error") or "positions_patch_failed",
"detail": patch_out,
"added": int(add_out.get("added", 0)),
"source_preserved": True,
"hint": "Destination membership may already be added; source nodes were kept. Inspect both views before retrying.",
}
rm_out: dict[str, Any] = {"ok": True, "removed": 0}
if remove_from_source:
@ -3176,14 +3206,23 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
dst2 = _data(http_json("GET", f"/v1/topology/views/{dest_view_id}", timeout=120.0))
if not src2.get("ok") or not dst2.get("ok"):
return {
**base,
"ok": False,
"error": "view_readback_failed",
"write_completed": True,
"detail": {"source": src2, "destination": dst2},
"hint": "Writes completed but verification failed. Read both views before retrying the move.",
}
src_after, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
return {
**base,
"dry_run": False,
"added": int(add_out.get("added") or len(add_ids)),
"positions_updated": int(patch_out.get("updated") or len(pos_patch)),
"removed_from_source": int(rm_out.get("removed") or (len(move_ids) if remove_from_source else 0)),
"positions_updated": int(patch_out.get("updated", 0)),
"removed_from_source": int(rm_out.get("removed", 0)),
"source_after": len(src_after) if src2.get("ok") else None,
"dest_after": len(dst_after) if dst2.get("ok") else None,
"max_nodes_bump": bump,
@ -3201,12 +3240,8 @@ def _copy_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
Source membership is unchanged. For test sandboxes: clone a known-good
canvas onto a fresh child region without re-adding ids by hand.
"""
source_view_id = str(
args.get("source_view_id") or args.get("from_view_id") or ""
).strip()
target_view_id = str(
args.get("target_view_id") or args.get("to_view_id") or ""
).strip()
source_view_id = str(args.get("source_view_id") or "").strip()
target_view_id = str(args.get("target_view_id") or "").strip()
if not source_view_id or not target_view_id:
return {
"ok": False,
@ -3322,8 +3357,29 @@ def _copy_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
]
if positions:
patch_out = _patch_positions_chunked(target_view_id, positions)
if not patch_out.get("ok"):
return {
"ok": False,
"error": patch_out.get("error") or "positions_patch_failed",
"detail": patch_out,
"source_view_id": source_view_id,
"target_view_id": target_view_id,
"cleared": cleared,
"added": int(add_out.get("added", 0)),
"hint": "Target membership may already be changed; source is unchanged. Inspect target before retrying.",
}
dst2 = _data(http_json("GET", f"/v1/topology/views/{target_view_id}", timeout=120.0))
if not dst2.get("ok"):
return {
"ok": False,
"error": "view_readback_failed",
"write_completed": True,
"source_view_id": source_view_id,
"target_view_id": target_view_id,
"detail": dst2,
"hint": "Writes completed but verification failed. Read target before retrying the copy.",
}
dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
return {
"ok": True,
@ -3779,6 +3835,11 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"skipped peers are counted in out_of_region_skipped."
),
},
"dry_run": {
"type": "boolean",
"default": False,
"description": "Preview projected graph without saving placements.",
},
"detail": {
"type": "string",
"enum": ["summary", "full"],
@ -3797,8 +3858,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"Default: q→search, else list. "
"summary=node/edge counts; list=paged filters "
"(keyword/role/link_status/region_folder_id); "
"search=quick name/IP/id (needs q). Replaces getTopologyFabricSummary / "
"listTopologyFabricNodes / searchTopologyFabricNodes."
"search=quick name/IP/id (needs q)."
),
"inputSchema": {
"type": "object",
@ -3853,7 +3913,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"level: 0=external, 1=core, 2=agg, 3=access; use 1.1 / 2.1 for sub-tiers. "
"action=match → tag(level|role preset, dry_run) → tag. "
"role preset still accepted (maps to *.0). "
"After tagging, addTopologyViewNodes by role/region. No slice maps. "
"After tagging, queryTopologyFabricNodes by region/level, then place returned "
"fabric_node_ids with addTopologyViewNodes (canvas filters do not support region/level). "
"Requires ne:write."
),
"inputSchema": {
@ -4030,8 +4091,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
},
"use_source_dual_portals": {
"type": "boolean",
"default": True,
"description": "Exclude portals from source dual_units (default true).",
"default": False,
"description": "Exclude portals from source dual_units (default false).",
},
"use_sink_dual_portals": {
"type": "boolean",
@ -4156,7 +4217,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"action=layout: recipe=rings|corridor|compact|unstick (small graphs). "
"action=layout_dual_unit: CN/core dual-portal eye (max membership; "
"residual crossings OK unless params.require_zero_cross=true). "
"action=move_nodes (alias sink_nodes): move fabric_node_ids from "
"action=move_nodes: move fabric_node_ids from "
"source_view_id→view_id; park=true for orbit attach; swap views to reverse. "
"Prefer sinkTopologyDualUnits for dual_units batches. "
"action=orbit_sweep: crossing orbit; preview+node_id / apply+pick; "
@ -4170,9 +4231,12 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"action=pull_far_chains: scale far corridors/isolates toward portal mid-point. "
"action=align_reference: SAME-network A/B debug only "
"(shared fabric_node_ids); not a cross-network template. "
"action=fix_overlaps|resolve_overlaps: pull apart overlaps (not on eye sinks). "
"action=fix_overlaps: pull apart overlaps (not on eye sinks). "
"action=untangle / straighten_channels: surgical polish (not on eye sinks). "
"action=job_status|job_cancel: poll/cancel background jobs. "
"params.background=true starts an apply action in a durable subprocess; "
"poll params.job_id until done|error|cancelled, then inspect result. "
"Successful apply persists positions; getTopologyView reads them back. "
"preset: loose|balanced|dense. mode: preview|apply. "
"Workflow: analyze(structure) → sinkTopologyDualUnits(max_units=1) → "
"suggestSinkHubs/move_nodes(park) → until_limit(crossing→total) → "
@ -4192,7 +4256,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"enum": [
"layout",
"fix_overlaps",
"resolve_overlaps",
"layout_dual_unit",
"straighten_channels",
"untangle",
@ -4204,7 +4267,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"orbit_sweep",
"level_bands",
"move_nodes",
"sink_nodes",
"job_status",
"job_cancel",
],
@ -4212,13 +4274,13 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"description": (
"layout=full recipe; layout_dual_unit=dual-portal eye; "
"level_bands=horizontal layer bands; "
"move_nodes|sink_nodes=migrate fabric_node_ids; "
"move_nodes=migrate fabric_node_ids; "
"orbit_sweep=polar sweep; polish_crossings=one-shot cut crossings; "
"clear_edge_hits=eject edge hits (no crossing rise); "
"pull_far_chains=scale far corridors toward portal mid; "
"compact_bbox=gated farthest-K shrink; "
"align_reference=same-network A/B debug only; "
"fix_overlaps|resolve_overlaps; "
"fix_overlaps; "
"untangle/straighten_channels; job_status|job_cancel."
),
},
@ -4281,10 +4343,15 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"max_jump/angle_step/nn_floor; until_limit defaults "
"protect_rigid=portals + objective=crossing; "
"single-node/round default protect_rigid=off. "
"move_nodes|sink_nodes: fabric_node_ids[] (required), "
"move_nodes: fabric_node_ids[] (required), "
"copy_positions (default true), park, remove_from_source "
"(default true), pad/offset_x/offset_y."
),
"properties": {
"job_id": {"type": "string", "description": "Required for job_status/job_cancel; returned by background start."},
"background": {"type": "boolean", "description": "Run apply computation in a durable subprocess and return job_id immediately."},
"sync": {"type": "boolean", "description": "Force inline computation, disabling automatic background execution."},
},
"additionalProperties": True,
},
"catalog": {

View file

@ -89,7 +89,12 @@ def _prune_disk() -> None:
files = [p for p in files if not p.name.endswith(".args.json")]
if len(files) <= _MAX_JOBS:
return
for path in files[: max(0, len(files) - _MAX_JOBS)]:
# Active jobs must remain pollable and cancellable even when history fills up.
removable = [
path for path in files
if (_read_job_disk(path.stem) or {}).get("status") in {"done", "error", "cancelled"}
]
for path in removable[: max(0, len(files) - _MAX_JOBS)]:
try:
path.unlink(missing_ok=True)
path.with_name(path.stem + ".args.json").unlink(missing_ok=True)
@ -400,25 +405,33 @@ def start_job(
subprocess, "DETACHED_PROCESS", 0x00000008
)
proc = subprocess.Popen( # noqa: S603 — controlled argv
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id],
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
close_fds=True,
)
try:
proc = subprocess.Popen( # noqa: S603 — controlled argv
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id],
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
close_fds=True,
)
except OSError as exc:
finish_job(job_id, error=f"worker_start_failed:{exc}")
_args_path(job_id).unlink(missing_ok=True)
# The caller receives a pollable terminal job instead of an orphaned running record.
return job_id
with _LOCK:
job = _read_job_disk(job_id) or job
job["pid"] = proc.pid
job["progress"] = {
**(job.get("progress") or {}),
"phase": "running",
"message": f"worker pid={proc.pid}",
"updated_at": time.time(),
}
job["heartbeat_at"] = time.time()
# A short computation can finish before Popen returns to the parent.
# Keep its terminal progress/result intact.
if job.get("status") in {"running", "cancelling"}:
job["progress"] = {
**(job.get("progress") or {}),
"message": f"worker pid={proc.pid}",
"updated_at": time.time(),
}
job["heartbeat_at"] = time.time()
_JOBS[job_id] = job
_write_job_disk(job)
return job_id

View file

@ -144,7 +144,6 @@ _PARAM_KEYS = (
ACTIONS = (
"layout",
"fix_overlaps",
"resolve_overlaps", # alias of fix_overlaps
"untangle",
"straighten_channels",
"layout_dual_unit",
@ -328,8 +327,6 @@ def run_layout_on_graph(
f"unknown_action:{action_key}; "
f"allowed={','.join(ACTIONS)}"
)
if action_key == "resolve_overlaps":
action_key = "fix_overlaps"
base_params = build_params(preset=preset, overrides=params)
st0 = build_state_from_nodes_edges(nodes, edges)
park_phantom_nodes(st0)
@ -899,14 +896,13 @@ def list_layout_catalog() -> dict[str, Any]:
"actions": {
"layout": "全图配方(骨架);块内 pack,结束时局部解叠",
"fix_overlaps": "只拉开当前重叠点(+1 跳邻居),不重排全图",
"resolve_overlaps": "fix_overlaps 别名",
"straighten_channels": (
"拉直 deg≤2 通道(弦/横/纵);仅全局交叉下降才接受该通道"
"(params: step/min_len)"
),
"layout_dual_unit": (
"双门户单元美化(多走廊→平行 H/V 道;链→拉直);"
"单元内交叉必须为 0 才接受(params: unit_id)"
"默认允许残留交叉;params.require_zero_cross=true 时要求为 0(params: unit_id)"
),
"untangle": (
"贪心挪低度数点降交叉;默认 protect_rigid=portals "
@ -963,7 +959,6 @@ def list_layout_catalog() -> dict[str, Any]:
"默认 remove_from_source;对调两 view 即回迁;"
"copy_positions|park;mode=preview|apply"
),
"sink_nodes": "move_nodes 别名",
},
"recipes": {
"rings": "环+链花瓣;core_bar 时梁优先(多 CN 不塞进 AN 列)",

View file

@ -0,0 +1,8 @@
"""Keep layout-job persistence inside pytest temporary directories."""
import pytest
@pytest.fixture(autouse=True)
def isolated_layout_job_dir(tmp_path, monkeypatch):
monkeypatch.setenv("NETX_LAYOUT_JOB_DIR", str(tmp_path / "layout-jobs"))

View file

@ -16,6 +16,60 @@ from netx_topology_mcp.layout_jobs import (
)
def test_terminal_job_survives_cache_reset_and_reports_failure(tmp_path) -> None:
from netx_topology_mcp import layout_jobs as lj
lj._write_job_disk({"job_id": "persisted", "status": "running", "started_at": time.time()})
lj.finish_job("persisted", result={"ok": False, "error": "backend_write_failed", "applied": False})
lj._JOBS.clear()
out = _layout_topology_view({"action": "job_status", "params": {"job_id": "persisted"}})
assert out["ok"] is False
assert out["status"] == "error"
assert out["error"] == "backend_write_failed"
assert out["result"]["applied"] is False
def test_spawn_failure_is_persisted_as_terminal_error() -> None:
from netx_topology_mcp import layout_jobs as lj
with patch.object(lj.subprocess, "Popen", side_effect=OSError("spawn denied")):
jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"})
lj._JOBS.clear()
out = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}})
assert out["ok"] is False
assert out["status"] == "error"
assert out["error"].startswith("worker_start_failed:")
assert not lj._args_path(jid).exists()
def test_fast_worker_terminal_progress_is_not_overwritten() -> None:
from types import SimpleNamespace
from netx_topology_mcp import layout_jobs as lj
def spawn(argv, **kwargs):
jid = argv[-1]
lj.finish_job(jid, result={"ok": True, "applied": True})
return SimpleNamespace(pid=123)
with patch.object(lj.subprocess, "Popen", side_effect=spawn):
jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"})
out = lj.job_public(jid)
assert out["status"] == "done"
assert out["progress"]["phase"] == "done"
assert out["progress"]["pct"] == 100.0
def test_pruning_preserves_active_jobs() -> None:
from netx_topology_mcp import layout_jobs as lj
lj._write_job_disk({"job_id": "active", "status": "running"})
lj._write_job_disk({"job_id": "completed", "status": "done"})
with patch.object(lj, "_MAX_JOBS", 1):
lj._prune_disk()
assert lj._read_job_disk("active") is not None
assert lj._read_job_disk("completed") is None
def test_start_job_and_poll() -> None:
def runner() -> dict:
report_progress("mid", pct=50.0, message="halfway", step=1, total_steps=2)

View file

@ -10,10 +10,10 @@ def test_move_nodes_in_layout_schema_and_catalog() -> None:
tool = next(t for t in HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")
enum = tool["inputSchema"]["properties"]["action"]["enum"]
assert "move_nodes" in enum
assert "sink_nodes" in enum
assert "sink_nodes" not in enum
cat = list_layout_catalog()
assert "move_nodes" in cat["actions"]
assert "sink_nodes" in cat["actions"]
assert "sink_nodes" not in cat["actions"]
def test_move_nodes_requires_ids_and_views() -> None:

View file

@ -0,0 +1,263 @@
"""HTTP contract and partial-failure regression tests; no backend required."""
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from netx_topology_mcp import http_tools as tools
def payload(name, args):
result = tools.call_http_tool(name, args)
return result, json.loads(result["content"][0]["text"])
@pytest.mark.parametrize("name,args", [
("getTopologyTree", {}),
("getTopologyView", {"view_id": "v"}),
("createTopologyFolder", {"name": "测试"}),
("addTopologyViewNodes", {"view_id": "v", "fabric_node_ids": ["n"]}),
("removeTopologyViewNodes", {"view_id": "v", "fabric_node_ids": ["n"]}),
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
("updateTopologyViewPositions", {"view_id": "v", "layout": "grid"}),
("projectTopologyNeighbors", {"view_id": "v"}),
("queryTopologyFabricNodes", {"mode": "list"}),
("classifyTopologyFabricNodes", {"action": "apply_rules"}),
("queryTopologyNeighborhood", {"node_id": "n"}),
("queryTopologyEdges", {}),
("layoutTopologyView", {"view_id": "v"}),
("suggestSinkHubs", {"source_view_id": "s"}),
("analyzeTopologyViewLayout", {"view_id": "v"}),
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t"}),
])
def test_business_failure_is_mcp_error(name, args):
with patch.object(tools, "http_json", return_value={
"ok": True, "data": {"ok": False, "error": "backend_rejected"},
}):
result, data = payload(name, args)
assert result.get("isError") is True, (name, data)
assert data["ok"] is False
assert data["error"] == "backend_rejected"
@pytest.mark.parametrize("name,args,method", [
("addTopologyViewNodes", {"view_id": "v", "fabric_node_ids": [f"n{i}" for i in range(501)]}, "POST"),
("removeTopologyViewNodes", {"view_id": "v", "fabric_node_ids": [f"n{i}" for i in range(501)]}, "POST"),
("updateTopologyViewPositions", {"view_id": "v", "positions": [
{"fabric_node_id": f"n{i}", "x": i, "y": 0} for i in range(501)
]}, "PATCH"),
])
def test_chunk_failure_stops_later_mutations(name, args, method):
with patch.object(tools, "http_json", side_effect=[
{"ok": True, "data": {"ok": True, "added": 200, "updated": 200, "removed": 200}},
{"ok": True, "data": {"ok": False, "error": "write_failed"}},
]) as call:
result, data = payload(name, args)
assert result.get("isError") is True
assert call.call_count == 2
assert call.call_args.args[0] == method
assert data["error"] == "write_failed"
def graph(node="n"):
return {"ok": True, "nodes": [{"fabric_node_id": node, "x": 1, "y": 2}], "edges": []}
@pytest.mark.parametrize("name,args", [
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
("layoutTopologyView", {"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply", "params": {"fabric_node_ids": ["n"]}}),
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t", "layout_batch": False}),
])
def test_position_failure_preserves_source(name, args):
def http(method, path, **kwargs):
if method == "GET":
return {"ok": True, "data": graph() if path.endswith("/s") else {"nodes": [], "edges": []}}
raise AssertionError(f"Unexpected mutation {method} {path}")
with patch.object(tools, "http_json", side_effect=http), patch.object(
tools, "_ensure_view_max_nodes", return_value={"ok": True}
), patch.object(tools, "_add_topology_view_nodes", return_value={"ok": True, "added": 1}), patch.object(
tools, "_patch_positions_chunked", return_value={"ok": False, "error": "patch_denied", "updated": 0}
), patch.object(tools, "_fabric_bridges_into", return_value=[]), patch.object(
tools, "_remove_topology_view_nodes"
) as remove:
result, data = payload(name, args)
assert result.get("isError") is True, data
assert data["error"] == "patch_denied"
assert data["added"] == 1
remove.assert_not_called()
def test_move_capacity_error_stays_error():
with patch.object(tools, "http_json", return_value={"ok": True, "data": graph()}), patch.object(
tools, "_ensure_view_max_nodes", return_value={"ok": False, "error": "cap_denied"}
), patch.object(tools, "_add_topology_view_nodes") as add:
result, data = payload("layoutTopologyView", {
"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply",
"params": {"fabric_node_ids": ["n"]},
})
assert result.get("isError") is True
assert data["error"] == "cap_denied"
add.assert_not_called()
def test_invalid_add_does_not_change_capacity():
with patch.object(tools, "http_json") as http:
result, _ = payload("addTopologyViewNodes", {"view_id": "v", "max_nodes": 2000})
assert result.get("isError") is True
http.assert_not_called()
@pytest.mark.parametrize("name,args", [
("getTopologyView", {"view_id": "v", "sample": 0}),
("projectTopologyNeighbors", {"view_id": "v", "sample": 0, "dry_run": True}),
])
def test_zero_sample_and_projection_preview(name, args):
with patch.object(tools, "http_json", return_value={"ok": True, "data": graph()}) as http:
result, data = payload(name, args)
assert result.get("isError") is not True
assert data["sample_nodes"] == []
if name == "projectTopologyNeighbors":
assert http.call_args.kwargs["body"]["dry_run"] is True
assert data["dry_run"] is True
def test_invalid_projection_detail_does_not_write():
with patch.object(tools, "http_json") as http:
result, data = payload("projectTopologyNeighbors", {"view_id": "v", "detail": "raw"})
assert result.get("isError") is True
assert data["error"] == "detail_invalid"
http.assert_not_called()
def test_zero_updated_is_not_reported_as_successful_count():
with patch.object(tools, "http_json", return_value={"ok": True, "data": {"ok": True, "updated": 0, "skipped_locked": 1}}):
assert tools._patch_positions_chunked("v", [{"fabric_node_id": "n", "x": 1, "y": 2}])["updated"] == 0
def test_apply_rules_uses_query_flags():
with patch.object(tools, "http_json", return_value={"ok": True, "data": {"updated": 1}}) as http:
payload("classifyTopologyFabricNodes", {"action": "apply_rules", "skip_manual": False, "fill_empty_only": True})
assert http.call_args.args == ("POST", "/v1/topology/classify/apply")
assert http.call_args.kwargs["params"] == {"skip_manual": False, "fill_empty_only": True}
@pytest.mark.parametrize("action", ["resolve_overlaps", "sink_nodes", "align_to_reference", "compose_views", "compose_orbit"])
def test_removed_layout_actions_are_not_advertised_or_called(action):
with patch.object(tools, "http_json") as http:
result, data = payload("layoutTopologyView", {"view_id": "v", "action": action})
assert result.get("isError") is True
assert data["error"] == f"unknown_action:{action}"
assert action not in data["actions"]
schema = next(t for t in tools.HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")["inputSchema"]
assert action not in schema["properties"]["action"]["enum"]
http.assert_not_called()
@pytest.mark.parametrize("name,args", [
("queryTopologyFabricNodes", {"mode": "find"}),
("classifyTopologyFabricNodes", {"action": "apply"}),
("queryTopologyEdges", {"detail": "raw"}),
])
def test_removed_query_aliases_do_not_call_backend(name, args):
with patch.object(tools, "http_json") as http:
result, _ = payload(name, args)
assert result.get("isError") is True
http.assert_not_called()
def test_explicit_background_layout_starts_worker():
canvas = graph()
canvas["nodes"].append({"fabric_node_id": "n2", "x": 100, "y": 200})
with patch.object(tools, "http_json", return_value={"ok": True, "data": canvas}), patch.object(
tools, "start_job", return_value="test-job"
) as start, patch.object(tools, "job_public", return_value=None), patch.object(tools, "run_layout_on_graph") as compute:
result, data = payload("layoutTopologyView", {"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True}})
assert result.get("isError") is not True
assert data["job_id"] == "test-job"
assert data["applied"] is False
assert start.call_args.kwargs["tool_args"]["params"]["_force_sync"] is True
compute.assert_not_called()
def test_background_spawn_error_is_immediately_reported():
from netx_topology_mcp import layout_jobs as jobs
canvas = graph()
canvas["nodes"].append({"fabric_node_id": "n2", "x": 100, "y": 200})
with patch.object(tools, "http_json", return_value={"ok": True, "data": canvas}), patch.object(
jobs.subprocess, "Popen", side_effect=OSError("worker unavailable")
):
result, data = payload("layoutTopologyView", {"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True}})
assert result.get("isError") is True
assert data["status"] == "error"
assert data["error"].startswith("worker_start_failed:")
assert data["job_id"]
def test_background_apply_poll_and_persistent_view_readback():
"""Execute worker orchestration inline against an in-memory HTTP backend."""
from types import SimpleNamespace
from netx_topology_mcp import layout_jobs as jobs
from netx_topology_mcp.layout_job_worker import main as worker_main
canvas = {"nodes": [
{"fabric_node_id": "n", "x": 0, "y": 0},
{"fabric_node_id": "n2", "x": 100, "y": 200},
], "edges": []}
positions = [
{"fabric_node_id": "n", "x": 20, "y": 30},
{"fabric_node_id": "n2", "x": 220, "y": 330},
]
def http(method, path, **kwargs):
if method == "GET":
return {"ok": True, "data": canvas}
assert method == "PATCH" and path == "/v1/topology/views/v/positions"
canvas["nodes"] = kwargs["body"]["positions"]
return {"ok": True, "data": {"ok": True, "updated": 2}}
def spawn(argv, **kwargs):
assert worker_main(["--job-id", argv[-1]]) == 0
return SimpleNamespace(pid=123)
with patch.object(tools, "http_json", side_effect=http), patch.object(
jobs.subprocess, "Popen", side_effect=spawn
), patch.object(tools, "run_layout_on_graph", return_value={"ok": True, "positions": positions, "overlap": {}}):
_, started = payload("layoutTopologyView", {
"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True},
})
jobs._JOBS.clear()
_, polled = payload("layoutTopologyView", {"action": "job_status", "params": {"job_id": started["job_id"]}})
_, view = payload("getTopologyView", {"view_id": "v", "detail": "full"})
assert polled["status"] == "done"
assert polled["result"]["applied"] is True
assert polled["result"]["updated"] == 2
assert view["nodes"] == positions
def test_hub_schema_matches_default_exclusion_behavior():
tool = next(t for t in tools.HTTP_MCP_TOOLS if t["name"] == "suggestSinkHubs")
assert tool["inputSchema"]["properties"]["use_source_dual_portals"]["default"] is False
@pytest.mark.parametrize("name,args", [
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
("layoutTopologyView", {"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply", "params": {"fabric_node_ids": ["n"]}}),
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t", "layout_batch": False}),
])
def test_failed_final_readback_reports_completed_writes(name, args):
with patch.object(tools, "http_json", side_effect=[
{"ok": True, "data": graph()}, {"ok": True, "data": {"nodes": [], "edges": []}},
{"ok": False, "error": "read_timeout"}, {"ok": False, "error": "read_timeout"},
]), patch.object(tools, "_ensure_view_max_nodes", return_value={"ok": True}), patch.object(
tools, "_add_topology_view_nodes", return_value={"ok": True, "added": 1}
), patch.object(tools, "_patch_positions_chunked", return_value={"ok": True, "updated": 1}), patch.object(
tools, "_remove_topology_view_nodes", return_value={"ok": True, "removed": 1}
), patch.object(tools, "_fabric_bridges_into", return_value=[]):
result, data = payload(name, args)
assert result.get("isError") is True
assert data["error"] == "view_readback_failed"
assert data["write_completed"] is True