mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 01:40:51 +08:00
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:
parent
d8da7acb7d
commit
9085cac742
63 changed files with 3251 additions and 1092 deletions
62
packages/netx-bizmonitor-mcp/README.md
Normal file
62
packages/netx-bizmonitor-mcp/README.md
Normal 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、业务与协议错误、通知、凭据来源优先级、文件错误回退、令牌轮换与脱敏及长原文不截断。
|
||||
16
packages/netx-bizmonitor-mcp/mcp.json
Normal file
16
packages/netx-bizmonitor-mcp/mcp.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
packages/netx-bizmonitor-mcp/pyproject.toml
Normal file
18
packages/netx-bizmonitor-mcp/pyproject.toml
Normal 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"]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""Standalone, read-only netx business-monitor MCP."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from .server import main
|
||||
|
||||
main()
|
||||
|
|
@ -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
|
||||
|
|
@ -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。"))
|
||||
|
|
@ -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()
|
||||
6
packages/netx-bizmonitor-mcp/tests/conftest.py
Normal file
6
packages/netx-bizmonitor-mcp/tests/conftest.py
Normal 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"))
|
||||
340
packages/netx-bizmonitor-mcp/tests/test_mcp_bizmonitor.py
Normal file
340
packages/netx-bizmonitor-mcp/tests/test_mcp_bizmonitor.py
Normal 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)
|
||||
|
|
@ -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 仍保留。
|
||||
|
|
|
|||
26
packages/netx-mcp/src/netx_mcp/cli_guard.py
Normal file
26
packages/netx-mcp/src/netx_mcp/cli_guard.py
Normal 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")
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
77
packages/netx-mcp/tests/test_cli_safety.py
Normal file
77
packages/netx-mcp/tests/test_cli_safety.py
Normal 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
|
||||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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。
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 列)",
|
||||
|
|
|
|||
8
packages/netx-topology-mcp/tests/conftest.py
Normal file
8
packages/netx-topology-mcp/tests/conftest.py
Normal 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"))
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
263
packages/netx-topology-mcp/tests/test_topology_contract.py
Normal file
263
packages/netx-topology-mcp/tests/test_topology_contract.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue