MCP: add bizMonitor server, align ops/topology tools, harden CLI exec

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-16 18:52:05 +08:00
parent d8da7acb7d
commit 9085cac742
63 changed files with 3251 additions and 1092 deletions

View file

@ -169,20 +169,24 @@ Fool-proof Windows delivery via `NetX-Setup-*.exe` (bundled or external Postgres
**完整说明(安装、配置、更新、排错)见:[docs/MCP.md](docs/MCP.md)** **完整说明(安装、配置、更新、排错)见:[docs/MCP.md](docs/MCP.md)**
**拓扑画布独立 MCP** 见:[docs/MCP_TOPOLOGY.md](docs/MCP_TOPOLOGY.md) **拓扑画布独立 MCP** 见:[docs/MCP_TOPOLOGY.md](docs/MCP_TOPOLOGY.md)
**割接 / 业务监控独立 MCP** 见:[docs/MCP_BIZMONITOR.md](docs/MCP_BIZMONITOR.md)。三组一起使用时无需 netxops,完整配置为 [mcp.all.json](mcp.all.json)。
速查: 速查:
```powershell ```powershell
pip install -e ./packages/netx-mcp pip install -e ./packages/netx-mcp
# 拓扑(可选,单独安装) # 拓扑(可选,单独安装)
pip install -e ./packages/netx-topology-mcp pip install -e ./packages/netx-topology-mcp
# 配置见 mcp.json,运行: pip install -e ./packages/netx-bizmonitor-mcp
# 三组配置见 mcp.all.json,分别运行:
python -m netx_mcp python -m netx_mcp
python -m netx_topology_mcp python -m netx_topology_mcp
python -m netx_bizmonitor_mcp
``` ```
- 客户端配置:[`mcp.json`](mcp.json)(含 `netx` + 可选 `netx-topology`) - 客户端配置:[`mcp.json`](mcp.json)(含 `netx` + 可选 `netx-topology`)
- oclaw payload:[`mcp_install_payload.json`](mcp_install_payload.json) / [`mcp_topology_install_payload.json`](mcp_topology_install_payload.json) - oclaw payload:[`mcp_install_payload.json`](mcp_install_payload.json) / [`mcp_topology_install_payload.json`](mcp_topology_install_payload.json)
- 子包:[`packages/netx-mcp`](packages/netx-mcp/README.md)、[`packages/netx-topology-mcp`](packages/netx-topology-mcp/README.md) - 子包:[`packages/netx-mcp`](packages/netx-mcp/README.md)、[`packages/netx-topology-mcp`](packages/netx-topology-mcp/README.md)、[`packages/netx-bizmonitor-mcp`](packages/netx-bizmonitor-mcp/README.md)
## Useful API endpoints ## Useful API endpoints

View file

@ -116,7 +116,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
1. 完成上文 **§1**(用 **oclaw 同机同一个 `python`** 安装 `netx-mcp`)。 1. 完成上文 **§1**(用 **oclaw 同机同一个 `python`** 安装 `netx-mcp`)。
2. Admin → MCP → 粘贴 `mcp.json` 全文 → 点击 **Install from JSON**(安装状态在下方一行小字)。 2. Admin → MCP → 粘贴 `mcp.json` 全文 → 点击 **Install from JSON**(安装状态在下方一行小字)。
3. **Health** → **Sync Tools**(应看到 **14** 个工具)。 3. **Health** → **Sync Tools**(完整 scope 下应看到 **15** 个工具)。
4. 在 **MCP 专家绑定** 中为 ops 专家勾选 `server_id=netx`。 4. 在 **MCP 专家绑定** 中为 ops 专家勾选 `server_id=netx`。
更细的 oclaw 说明(双轨内置工具、锚点注入等)见 oclaw 仓库: 更细的 oclaw 说明(双轨内置工具、锚点注入等)见 oclaw 仓库:
@ -141,9 +141,11 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
| NMS 原始/SQL | `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms` | | NMS 原始/SQL | `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms` |
| common(CLI + 路径) | `listManagedNe`, `getManagedNe`, `execManagedNe`, `getNeExecJob`, `listCliTargets`, `findTopologyPaths` | | common(CLI + 路径) | `listManagedNe`, `getManagedNe`, `execManagedNe`, `getNeExecJob`, `listCliTargets`, `findTopologyPaths` |
参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。拓扑 Fabric / 画布工具在 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**。oclaw 中名称带前缀:`mcp__netx__<toolName>`;DSH:`netx__<toolName>`。 参数使用 `nms_ne_id` / `nms_ne_ids`,旧 `ume_*` 模型参数已删除;`getManagedNe` 只接受 `ne_id`。拓扑 Fabric / 画布工具在 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**。oclaw 中名称带前缀:`mcp__netx__<toolName>`;DSH:`netx__<toolName>`。
**业务监控 / 割接分析**:不走 netx-mcp。DSH **netxops** 宿主工具组 `bizMonitor`(`netx__listBizMonitors` / `netx__getBizMonitorContext` 等)直连 netx REST(`/v1/biz-migration/*`、`/v1/biz-state/*`,需 token scope `biz-monitor:read`)。 **业务监控 / 割接分析**:独立 [`netx-bizmonitor-mcp`](./MCP_BIZMONITOR.md) 提供 8 个工具,需 `biz-monitor:read`;netxops 的 bizMonitor 组使用相同 stem。三组统一配置见根 [`mcp.all.json`](../mcp.all.json),不需要安装 netxops。
**CLI 安全边界**:独立 MCP 强制单行只读 `show` / `display`,可用只读输出过滤;配置、shell 操作符、重定向和多行脚本在 HTTP 前拒绝。后端开放 exec_policy 或 capability 的脚本提示也不能解除这个 MCP 门禁,Linux/RouterOS 原生 CLI 当前未暴露。异步 job_id 仅表示开始,按 poll_after_ms 有界轮询;terminal 后检查 result.ok,批量还须检查 summary.failed 和每条 results[].ok,不能只凭作业 succeeded 宣称全部成功。
--- ---
@ -191,10 +193,10 @@ pip show netx-mcp
| 旧方式 | 新方式 | | 旧方式 | 新方式 |
|--------|--------| |--------|--------|
| `python -m netx_api.mcp` | `python -m netx_mcp`(推荐) | | `python -m netx_api.mcp` | `python -m netx_mcp`(唯一 Python 模块入口) |
| `NETX_MCP_MODE=db` 直连库 | 已废弃;使用 HTTP 模式 | | `NETX_MCP_MODE=db` 直连库 | 已废弃;使用 HTTP 模式 |
根目录 `python -m netx_api.mcp` 仍会委托到 `netx_mcp`,新环境请只装 `netx-mcp` 包。 旧入口 `netx_api.mcp`、`netx_api/mcp_server.py` 和 `NETX_MCP_MODE=db` 已删除。请安装独立 `netx-mcp` 包并迁移配置;不再保留兼容委托。旧 DB 模式独有的导入告警查询/聚合/批次/诊断、同步触发、历史告警查询仍有 HTTP API,但不属于当前 ops 15 工具。
--- ---
@ -233,7 +235,7 @@ $p = Start-Process python -ArgumentList "-m","netx_mcp" -RedirectStandardInput p
| 工具调用连不上 API | 检查 `NETX_API_URL`、防火墙、远端 API 是否启动 | | 工具调用连不上 API | 检查 `NETX_API_URL`、防火墙、远端 API 是否启动 |
| Windows 乱码 / JSON 解析失败 | 配置里保留 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`(见 `mcp.json`) | | Windows 乱码 / JSON 解析失败 | 配置里保留 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`(见 `mcp.json`) |
| oclaw 工具数为 0 | Admin **Health → Sync Tools**;确认 `server_id=netx` 已绑定 ops 专家 | | oclaw 工具数为 0 | Admin **Health → Sync Tools**;确认 `server_id=netx` 已绑定 ops 专家 |
| 仍想用仓库脚本路径 | 未 pip 安装时可临时 `"args": ["D:/.../netx_api/mcp_server.py"]`(开发用) | | 旧脚本路径或模块不存在 | 安装 `packages/netx-mcp`,配置改为 `"args": ["-m", "netx_mcp"]` |
--- ---

41
docs/MCP_BIZMONITOR.md Normal file
View file

@ -0,0 +1,41 @@
# 割接与业务监控 MCP
`netx-bizmonitor-mcp` 独立提供 bizMonitor 的 8 个只读工具。不安装 netxops 也能读取已有项目、批次、看板、红票、差异和采集原文。它只依赖 Python 3.11+ 与 httpx,通过 HTTP 使用正在运行的 netx。
## 三组一起配置
在运行 MCP 客户端的 Python 环境分别安装:
```powershell
python -m pip install -e ./packages/netx-mcp
python -m pip install -e ./packages/netx-topology-mcp
python -m pip install -e ./packages/netx-bizmonitor-mcp
```
将根 [mcp.all.json](../mcp.all.json) 的三个 server 放入客户端 MCP 配置。也可以不安装包,分别将该包的 `src` 绝对路径设为对应 server 的 `PYTHONPATH`。三个模块分别为 `netx_mcp`、`netx_topology_mcp`、`netx_bizmonitor_mcp`;console scripts 属于各独立包,安装 API 本体不会提供一个缺少依赖的 MCP 假入口。
统一设置 `NETX_API_URL`。凭据优先用 `NETX_API_TOKEN`,否则读取 `NETX_MCP_TOKEN_FILE`,再尝试工作目录 `data/auth/mcp_token`。客户端工作目录不确定时,给 `NETX_MCP_TOKEN_FILE` 配置绝对路径。不要把真实令牌提交到仓库。
业务组需 `biz-monitor:read`;ops 需 `alarms:read`、`ne:read`、`ne:exec`,SQL 另需 `sql:query`;topology 读取需 `ne:read`,建图/布局需 `ne:write`。默认 bootstrap token 不含 SQL 和拓扑写权限,因此工具数量会按 scope 缩减;有完整权限时分别列出 **15、16、8** 个工具。
## 入口与证据链
MCP 使用裸名;netxops 使用 `netx__` 加相同名称。
1. `listBizMonitors`:无 ID 的入口,取得 project_id/task_id。
2. `getBizMonitorContext`:读取模板、归一化、端口映射、两侧任务和基线。
3. `listBizMonitorBatches(project_id,include_runs=true)`:取得割接批次与 run_id;也可 `kind=runs,batch_id=...`。
4. `getBizMonitorBoard(batch_id)`:检查完成状态、缺失数据和统计。
5. `listBizMonitorReds(project_id)` / `getBizMonitorDiffs(run_id,color=red)`:逐页取得红项和证据。`q` 自动映射后端 `kw`。
6. `getBizCollectBatch`:采集批次的命令目录;需要时由 `listBizMonitorBatches(task_id)` 找采集批次。
7. `getBizCollectCommandRaw(batch_id,command_id)`:两侧原文取证。这里使用采集 batch_id,不能传割接 batch_id。
`old_key/new_key` 保留设备原始 A/B;`match_*` 和归一化映射用于内部配对。原文按需获取,可能很大;凭据会脱敏,不把内部匹配键冒充设备原始端口。
本组不会创建任务/模板、启动采集、执行 evaluate、关闭红票。没有批次时不能得出“割接通过”。完整行为和参数见 [包 README](../packages/netx-bizmonitor-mcp/README.md) 与 [skill](../skills/biz-monitor/netx-biz-monitor/SKILL.md)。
## 可复现验收
`scripts/mcp_acceptance.py` 启动当前 worktree 的真实 stdio MCP,记录去敏 JSONL;仅允许本机 8890 或隔离样例 8891。工具源目录可用 `--source-root` 显式指定,不会静默导入主仓库的 editable 包。
没有现场割接数据时,可用 `scripts/seed_mcp_acceptance.py` 在 `agent-wt/netx-mcp-lead/artifacts/` 的新 acceptance SQLite 文件生成明确标注的离线样例,真实运行业务评价,再通过本地 API/MCP 验证读取链路。样例不代表现场故障;不要将样例库替换应用库。脚本拒绝覆盖、外部路径、网络连接和监听端口。

View file

@ -60,11 +60,11 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
与告警 MCP 并存时,把两个 server 都放进 `mcpServers` 即可;未勾选/未安装的不会加载工具。 与告警 MCP 并存时,把两个 server 都放进 `mcpServers` 即可;未勾选/未安装的不会加载工具。
oclaw:Install from JSON → Health → Sync Tools(应看到 **14** 个工具,含 `layoutTopologyView` / `sinkTopologyDualUnits` / `queryTopologyFabricNodes`)→ 专家绑定勾选 `server_id=netx-topology`。 oclaw:Install from JSON → Health → Sync Tools(完整 scope 下应看到 **16** 个工具,含 `layoutTopologyView` / `sinkTopologyDualUnits` / `queryTopologyFabricNodes`)→ 专家绑定勾选 `server_id=netx-topology`。
Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topology` 或重载窗口(须重启 stdio 进程)。可选在 env 加 `PYTHONPATH=.../packages/netx-topology-mcp/src` 强制最新源码。 Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topology` 或重载窗口(须重启 stdio 进程)。可选在 env 加 `PYTHONPATH=.../packages/netx-topology-mcp/src` 强制最新源码。
配套 Agent Skill(dual_unit 排水 → 扫角/polish → 手拖):[`.cursor/skills/netx-topology/SKILL.md`](../.cursor/skills/netx-topology/SKILL.md)。Cursor / oclaw 读 skill 后再调 MCP。 配套 Agent Skill(dual_unit 排水 → 扫角/polish → 手拖):[`skills/topology/netx-topology/SKILL.md`](../skills/topology/netx-topology/SKILL.md)。Cursor / oclaw 读 skill 后再调 MCP。
--- ---
@ -97,7 +97,7 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog
| `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 | | `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 |
| `sinkTopologyDualUnits` | 根图 dual_units 分批沉入子区域;默认 `layout_batch`→`layout_dual_unit`→块扫挂载;日常禁 `until_empty` | | `sinkTopologyDualUnits` | 根图 dual_units 分批沉入子区域;默认 `layout_batch`→`layout_dual_unit`→块扫挂载;日常禁 `until_empty` |
| `copyTopologyViewNodes` | 一键克隆画布成员+坐标到另一画布(`clear_target` 可选);源画布不动,测沙箱用 | | `copyTopologyViewNodes` | 一键克隆画布成员+坐标到另一画布(`clear_target` 可选);源画布不动,测沙箱用 |
| `layoutTopologyView` `move_nodes` | 指定 `fabric_node_ids` 从 `source_view_id`→`view_id`(默认同移出源);对调两 view 回迁;别名 `sink_nodes`;`park=true` 扫角停靠 | | `layoutTopologyView` `move_nodes` | 指定 `fabric_node_ids` 从 `source_view_id`→`view_id`(默认同移出源);对调两 view 回迁;`park=true` 扫角停靠 |
| `updateTopologyViewPositions` | **优先** `layout=grid\|offset\|stack` + 筛选,API 自己挪点;`positions[]` 仅少量微调 | | `updateTopologyViewPositions` | **优先** `layout=grid\|offset\|stack` + 筛选,API 自己挪点;`positions[]` 仅少量微调 |
| `layoutTopologyView` | **布图/局部修**:公开 `action=layout\|layout_dual_unit\|orbit_sweep\|clear_edge_hits\|pull_far_chains\|compact_bbox\|polish_crossings\|fix_overlaps\|untangle\|straighten_channels\|level_bands\|move_nodes`;recipe 仅 `compact\|corridor\|rings\|unstick`。眼 sink 主路径:`until_limit(crossing→total)` → `clear_edge_hits` → `pull_far_chains` → `compact_bbox` → 手拖;禁对眼跑 polish/fix_overlaps/untangle/round。stall 且 moved≈0 即算法到头。巨图 apply 可能返回 `job_id` → 轮询 `job_status` / `job_cancel`。Job=**子进程+`data/runtime/layout_jobs` 落盘**。`mode=preview\|apply` | | `layoutTopologyView` | **布图/局部修**:公开 `action=layout\|layout_dual_unit\|orbit_sweep\|clear_edge_hits\|pull_far_chains\|compact_bbox\|polish_crossings\|fix_overlaps\|untangle\|straighten_channels\|level_bands\|move_nodes`;recipe 仅 `compact\|corridor\|rings\|unstick`。眼 sink 主路径:`until_limit(crossing→total)` → `clear_edge_hits` → `pull_far_chains` → `compact_bbox` → 手拖;禁对眼跑 polish/fix_overlaps/untangle/round。stall 且 moved≈0 即算法到头。巨图 apply 可能返回 `job_id` → 轮询 `job_status` / `job_cancel`。Job=**子进程+`data/runtime/layout_jobs` 落盘**。`mode=preview\|apply` |
| `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布;区域画布务必传 `region_folder_id`,读 `out_of_region_skipped` | | `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布;区域画布务必传 `region_folder_id`,读 `out_of_region_skipped` |
@ -120,8 +120,11 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog
| 包 | server_id | 职责 | | 包 | server_id | 职责 |
|----|-----------|------| |----|-----------|------|
| `netx-mcp` | `netx` | 告警、UME、托管网元 CLI(**13** 工具) | | `netx-mcp` | `netx` | NMS 告警、网元和只读 CLI(**15** 工具) |
| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 分类打标 + 安全画图(**15** 工具) | | `netx-topology-mcp` | `netx-topology` | topology 组 15 工具及额外 sinkTopologyDualUnits(共 **16**) |
| `netx-bizmonitor-mcp` | `netx-bizmonitor` | 割接 / 业务状态只读分析(**8** 工具) |
显式后台保存:`layoutTopologyView(view_id=...,action=layout,recipe=compact,mode=apply,params={background:true})`。用 `layoutTopologyView(action=job_status,params={job_id:...})` 轮询,成功后 getTopologyView 回读。任务是 MCP 本地子进程与落盘 JSON,没有 HTTP layout job API;跨目录或机器不能轮询同一 job。失败后可能已有部分写入,先回读,不盲重试。布局保存成功与评分诊断分别判断;评分校准另行安排,本轮不改算法/阈值。
`queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。 `queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。

34
mcp.all.json Normal file
View file

@ -0,0 +1,34 @@
{
"mcpServers": {
"netx": {
"command": "python",
"args": ["-m", "netx_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
}
},
"netx-topology": {
"command": "python",
"args": ["-m", "netx_topology_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
}
},
"netx-bizmonitor": {
"command": "python",
"args": ["-m", "netx_bizmonitor_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
}
}
}
}

View file

@ -7,6 +7,7 @@ from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .auth_deps import AuthContext, require_user from .auth_deps import AuthContext, require_user
from .auth_scopes import has_scope
from .auth_service import write_audit from .auth_service import write_audit
from .db import get_db from .db import get_db
from .device_types import SUPPORTED_VENDORS from .device_types import SUPPORTED_VENDORS
@ -275,6 +276,10 @@ def api_create_exec_job(
uid, uname = _actor(ctx) uid, uname = _actor(ctx)
payload = body.model_dump(exclude_none=True) payload = body.model_dump(exclude_none=True)
if not has_scope(ctx.scopes, "ne:exec"):
raise HTTPException(status_code=403, detail={"error": "insufficient_scope", "required": ["ne:exec"]})
if not uid:
raise HTTPException(status_code=401, detail={"error": "job_owner_required"})
is_batch = bool( is_batch = bool(
payload.get("targets") payload.get("targets")
or payload.get("ne_ids") or payload.get("ne_ids")
@ -330,7 +335,7 @@ def api_create_exec_job(
) )
return out return out
ack = start_ne_exec_job(kind=kind, arguments=payload, runner=_runner) ack = start_ne_exec_job(kind=kind, arguments=payload, owner_user_id=uid, runner=_runner)
write_audit( write_audit(
db, db,
action="ne.exec_job_start", action="ne.exec_job_start",
@ -349,9 +354,16 @@ def api_get_exec_job(
job_id: str, job_id: str,
ctx: Annotated[AuthContext, Depends(require_user)], ctx: Annotated[AuthContext, Depends(require_user)],
): ):
"""Poll a background exec job started via POST /exec-jobs.""" """Poll own jobs; admins (role or admin:users scope) may read any job."""
_ = ctx if not has_scope(ctx.scopes, "ne:exec"):
return get_ne_exec_job(job_id) raise HTTPException(status_code=403, detail={"error": "insufficient_scope", "required": ["ne:exec"]})
uid, _ = _actor(ctx)
is_admin = ctx.user.role == "admin" or has_scope(ctx.scopes, "admin:users")
out = get_ne_exec_job(job_id, user_id=uid, is_admin=is_admin)
if out.get("error_code") in {"job_forbidden", "invalid_job_id", "job_id_required"}:
status = 403 if out["error_code"] == "job_forbidden" else 400
raise HTTPException(status_code=status, detail=out)
return out
@router.post("/connect-test") @router.post("/connect-test")

View file

@ -1,5 +0,0 @@
"""Backward-compatible re-exports; prefer ``pip install netx-mcp`` and ``import netx_mcp``."""
from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool
__all__ = ["HTTP_MCP_TOOLS", "call_http_tool"]

View file

@ -1,4 +0,0 @@
from netx_mcp.server import main
if __name__ == "__main__":
main()

View file

@ -1,573 +0,0 @@
from __future__ import annotations
import json
import sys
from typing import Any
from .db import Base, SessionLocal, engine
from .importer import aggregate_alarms, query_alarms
from .models import AlarmBatch, UmeAlarmCurrent, UmeAlarmHistory, UmeInventoryNE
from .ume_client import UMEClient
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full, sync_topology_full
from .ume_token_store import (
clear_shared_token,
load_shared_token,
release_refresh_lock,
save_shared_token,
try_acquire_refresh_lock,
wait_for_token_update,
)
_UME_CLIENT_SINGLETON = UMEClient(
token_loader=lambda: load_shared_token(),
token_saver=lambda token, exp: save_shared_token(token, exp),
token_clearer=lambda: clear_shared_token(),
lock_acquirer=lambda: try_acquire_refresh_lock(),
lock_releaser=lambda: release_refresh_lock(),
token_waiter=lambda min_exp: wait_for_token_update(min_expires_at_epoch_s=float(min_exp)),
)
def _ok(rid: Any, result: dict[str, Any]) -> None:
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
sys.stdout.flush()
def _err(rid: Any, code: int, message: str) -> None:
sys.stdout.write(
json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False)
+ "\n"
)
sys.stdout.flush()
def _tool_list() -> list[dict[str, Any]]:
return [
{
"name": "queryAlarms",
"description": "Query normalized alarms with filters and pagination.",
"inputSchema": {
"type": "object",
"properties": {
"batch_id": {"type": "string"},
"alarm_code": {"type": "string"},
"severity": {"type": "string"},
"ne_name": {"type": "string"},
"page": {"type": "integer", "minimum": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 200},
},
"additionalProperties": False,
},
},
{
"name": "aggregateAlarms",
"description": "Aggregate alarms by severity_norm, alarm_code or ne_name.",
"inputSchema": {
"type": "object",
"properties": {
"group_by": {
"type": "string",
"enum": ["severity_norm", "alarm_code", "ne_name"],
},
"batch_id": {"type": "string"},
},
"required": ["group_by"],
"additionalProperties": False,
},
},
{
"name": "getImportBatch",
"description": "Get imported batch summary by batch_id.",
"inputSchema": {
"type": "object",
"properties": {"batch_id": {"type": "string"}},
"required": ["batch_id"],
"additionalProperties": False,
},
},
{
"name": "runDiagnostics",
"description": "Generate quick diagnostics summary by batch.",
"inputSchema": {
"type": "object",
"properties": {"batch_id": {"type": "string"}},
"required": ["batch_id"],
"additionalProperties": False,
},
},
{
"name": "umeSync",
"description": "Trigger UME sync for inventory/current/history domains.",
"inputSchema": {
"type": "object",
"properties": {
"domains": {
"type": "array",
"items": {"type": "string", "enum": ["inventory", "alarms_current", "alarms_history", "topology"]},
},
"trigger_mode": {"type": "string", "enum": ["manual", "schedule"]},
},
"additionalProperties": False,
},
},
{
"name": "umeListNE",
"description": "List UME network elements from inventory table.",
"inputSchema": {
"type": "object",
"properties": {
"keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
},
"additionalProperties": False,
},
},
{
"name": "umeGetNE",
"description": "Get UME network element by ne_id.",
"inputSchema": {
"type": "object",
"properties": {"ne_id": {"type": "string"}},
"required": ["ne_id"],
"additionalProperties": False,
},
},
{
"name": "umeListCurrentAlarms",
"description": "List current UME alarms from current table.",
"inputSchema": {
"type": "object",
"properties": {
"severity": {"type": "string"},
"is_cleared": {"type": "string"},
"ne_id": {"type": "string"},
"keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
},
"additionalProperties": False,
},
},
{
"name": "umeListHistoryAlarms",
"description": "List historical UME alarms from history table.",
"inputSchema": {
"type": "object",
"properties": {
"severity": {"type": "string"},
"ne_id": {"type": "string"},
"keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
},
"additionalProperties": False,
},
},
]
def _call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
with SessionLocal() as db:
if name == "queryAlarms":
total, rows = query_alarms(
db,
batch_id=str(args.get("batch_id") or "").strip() or None,
alarm_code=str(args.get("alarm_code") or "").strip() or None,
severity=str(args.get("severity") or "").strip() or None,
ne_name=str(args.get("ne_name") or "").strip() or None,
page=max(1, int(args.get("page") or 1)),
page_size=min(200, max(1, int(args.get("page_size") or 50))),
)
payload = {
"total": total,
"items": [
{
"id": r.id,
"batch_id": r.batch_id,
"alarm_time": r.alarm_time.isoformat(),
"severity_norm": r.severity_norm,
"ne_name": r.ne_name,
"alarm_code": r.alarm_code,
"alarm_name": r.alarm_name,
}
for r in rows
],
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "aggregateAlarms":
group_by = str(args.get("group_by") or "").strip()
rows = aggregate_alarms(
db,
group_by=group_by,
batch_id=str(args.get("batch_id") or "").strip() or None,
)
payload = {"group_by": group_by, "buckets": [{"key": k, "count": v} for k, v in rows]}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "getImportBatch":
batch_id = str(args.get("batch_id") or "").strip()
batch = db.get(AlarmBatch, batch_id)
if not batch:
return {"content": [{"type": "text", "text": json.dumps({"error": "batch_not_found"})}], "isError": True}
payload = {
"batch_id": batch.batch_id,
"total_rows": batch.total_rows,
"success_rows": batch.success_rows,
"failed_rows": batch.failed_rows,
"status": batch.status,
"created_at": batch.created_at.isoformat(),
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "runDiagnostics":
batch_id = str(args.get("batch_id") or "").strip()
sev_rows = aggregate_alarms(db, group_by="severity_norm", batch_id=batch_id)
code_rows = aggregate_alarms(db, group_by="alarm_code", batch_id=batch_id)[:5]
ne_rows = aggregate_alarms(db, group_by="ne_name", batch_id=batch_id)[:5]
sev_map = {k: v for k, v in sev_rows}
findings: list[str] = []
actions: list[str] = []
risk_level = "low"
if int(sev_map.get("critical", 0)) > 0:
findings.append("critical 告警存在,建议优先确认核心网元影响。")
actions.append("优先处理 critical 告警,确认影响面并升级。")
risk_level = "high"
if int(sev_map.get("warning", 0)) > int(sev_map.get("major", 0)):
findings.append("warning 占比较高,疑似阈值型告警风暴。")
actions.append("检查高频 warning 告警码是否集中在单一阈值策略。")
if risk_level != "high":
risk_level = "medium"
if not findings:
findings.append("分布相对均衡,建议按 top 告警码进一步排查。")
actions.append("按 top 告警码和网元继续下钻分析。")
payload = {
"batch_id": batch_id,
"risk_level": risk_level,
"severity_summary": [{"key": k, "count": v} for k, v in sev_rows],
"top_alarm_codes": [{"key": k, "count": v} for k, v in code_rows],
"top_ne": [{"key": k, "count": v} for k, v in ne_rows],
"findings": findings,
"actions": actions,
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "umeSync":
domains_raw = args.get("domains")
domains = []
if isinstance(domains_raw, list):
domains = [str(x).strip() for x in domains_raw if str(x).strip()]
if not domains:
domains = ["inventory", "alarms_current", "alarms_history"]
trigger_mode = str(args.get("trigger_mode") or "manual").strip().lower()
if trigger_mode not in {"manual", "schedule"}:
trigger_mode = "manual"
client = _UME_CLIENT_SINGLETON
results: list[dict[str, Any]] = []
if "inventory" in domains:
j = sync_inventory_full(db, client, trigger_mode=trigger_mode)
results.append(
{
"domain": "inventory",
"status": j.status,
"pulled_count": int(j.pulled_count or 0),
"inserted_count": int(j.inserted_count or 0),
"updated_count": int(j.updated_count or 0),
"error_message": str(j.error_message or ""),
}
)
if "alarms_current" in domains:
j, b = sync_alarms_current(db, client, trigger_mode=trigger_mode)
results.append(
{
"domain": "alarms_current",
"status": j.status,
"batch_id": str(b.batch_id),
"pulled_count": int(j.pulled_count or 0),
"inserted_count": int(j.inserted_count or 0),
"updated_count": int(j.updated_count or 0),
"error_message": str(j.error_message or ""),
}
)
if "alarms_history" in domains:
j, b = sync_alarms_history_full(db, client, trigger_mode=trigger_mode)
results.append(
{
"domain": "alarms_history",
"status": j.status,
"batch_id": str(b.batch_id),
"pulled_count": int(j.pulled_count or 0),
"inserted_count": int(j.inserted_count or 0),
"updated_count": int(j.updated_count or 0),
"error_message": str(j.error_message or ""),
}
)
if "topology" in domains:
j = sync_topology_full(db, client, trigger_mode=trigger_mode)
results.append(
{
"domain": "topology",
"status": j.status,
"pulled_count": int(j.pulled_count or 0),
"inserted_count": int(j.inserted_count or 0),
"updated_count": int(j.updated_count or 0),
"error_message": str(j.error_message or ""),
}
)
return {"content": [{"type": "text", "text": json.dumps({"ok": True, "jobs": results}, ensure_ascii=False)}]}
if name == "umeListNE":
stmt = db.query(UmeInventoryNE)
keyword = str(args.get("keyword") or "").strip()
if keyword:
stmt = stmt.filter(
UmeInventoryNE.ne_id.contains(keyword)
| UmeInventoryNE.ne_name.contains(keyword)
| UmeInventoryNE.user_label.contains(keyword)
| UmeInventoryNE.ip_address.contains(keyword)
)
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
total = int(stmt.count())
rows = stmt.order_by(UmeInventoryNE.ne_id.asc()).offset((page - 1) * page_size).limit(page_size).all()
payload = {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"ne_id": str(x.ne_id or ""),
"ne_name": str(x.ne_name or ""),
"user_label": str(x.user_label or ""),
"ip_address": str(x.ip_address or ""),
"ne_type": str(x.ne_type or ""),
}
for x in rows
],
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "umeGetNE":
ne_id = str(args.get("ne_id") or "").strip()
row = db.get(UmeInventoryNE, ne_id)
if not row:
return {"content": [{"type": "text", "text": json.dumps({"error": "ume_ne_not_found"})}], "isError": True}
payload = {
"ne_id": str(row.ne_id or ""),
"ne_name": str(row.ne_name or ""),
"user_label": str(row.user_label or ""),
"ip_address": str(row.ip_address or ""),
"ne_type": str(row.ne_type or ""),
"vendor": str(row.vendor or ""),
"raw_json": str(row.raw_json or "{}"),
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "umeListCurrentAlarms":
stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin(
UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id
)
severity = str(args.get("severity") or "").strip()
is_cleared = str(args.get("is_cleared") or "").strip()
ne_id = str(args.get("ne_id") or "").strip()
keyword = str(args.get("keyword") or "").strip()
if severity:
stmt = stmt.filter(UmeAlarmCurrent.perceived_severity == severity)
if is_cleared:
stmt = stmt.filter(UmeAlarmCurrent.is_cleared == is_cleared)
if ne_id:
stmt = stmt.filter(UmeAlarmCurrent.ne_id == ne_id)
if keyword:
stmt = stmt.filter(
UmeAlarmCurrent.alarm_key.contains(keyword)
| UmeAlarmCurrent.object_name.contains(keyword)
| UmeAlarmCurrent.host_name.contains(keyword)
| UmeInventoryNE.ne_name.contains(keyword)
| UmeInventoryNE.user_label.contains(keyword)
| UmeInventoryNE.ip_address.contains(keyword)
| UmeInventoryNE.host_name.contains(keyword)
)
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
total = int(stmt.count())
rows = (
stmt.order_by(
UmeAlarmCurrent.time_created.desc(),
UmeAlarmCurrent.last_seen_at.desc(),
UmeAlarmCurrent.alarm_key.desc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
payload = {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"alarm_key": str(alarm.alarm_key or ""),
"ne_id": str(alarm.ne_id or ""),
"host_name": str(alarm.host_name or (ne.host_name if ne else "") or ""),
"ne_name": str((ne.ne_name if ne else "") or ""),
"user_label": str((ne.user_label if ne else "") or ""),
"object_name": str(alarm.object_name or ""),
"event_type": str(alarm.event_type or ""),
"native_probable_cause": str(alarm.native_probable_cause or ""),
"perceived_severity": str(alarm.perceived_severity or ""),
"is_cleared": str(alarm.is_cleared or ""),
"time_created": str(alarm.time_created or ""),
}
for alarm, ne in rows
],
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "umeListHistoryAlarms":
stmt = db.query(UmeAlarmHistory, UmeInventoryNE).outerjoin(
UmeInventoryNE, UmeAlarmHistory.ne_id == UmeInventoryNE.ne_id
)
severity = str(args.get("severity") or "").strip()
ne_id = str(args.get("ne_id") or "").strip()
keyword = str(args.get("keyword") or "").strip()
if severity:
stmt = stmt.filter(UmeAlarmHistory.perceived_severity == severity)
if ne_id:
stmt = stmt.filter(UmeAlarmHistory.ne_id == ne_id)
if keyword:
stmt = stmt.filter(
UmeAlarmHistory.alarm_key.contains(keyword)
| UmeAlarmHistory.object_name.contains(keyword)
| UmeAlarmHistory.host_name.contains(keyword)
| UmeInventoryNE.ne_name.contains(keyword)
| UmeInventoryNE.user_label.contains(keyword)
| UmeInventoryNE.ip_address.contains(keyword)
| UmeInventoryNE.host_name.contains(keyword)
)
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
total = int(stmt.count())
rows = stmt.order_by(UmeAlarmHistory.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
payload = {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"alarm_key": str(alarm.alarm_key or ""),
"ne_id": str(alarm.ne_id or ""),
"host_name": str(alarm.host_name or (ne.host_name if ne else "") or ""),
"ne_name": str((ne.ne_name if ne else "") or ""),
"user_label": str((ne.user_label if ne else "") or ""),
"object_name": str(alarm.object_name or ""),
"event_type": str(alarm.event_type or ""),
"native_probable_cause": str(alarm.native_probable_cause or ""),
"perceived_severity": str(alarm.perceived_severity or ""),
"is_cleared": str(alarm.is_cleared or ""),
"time_created": str(alarm.time_created or ""),
}
for alarm, ne in rows
],
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
raise ValueError(f"unknown tool: {name}")
def _ensure_db_schema() -> None:
try:
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS device_level VARCHAR(64) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS host_name VARCHAR(256) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS location VARCHAR(512) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS ipv6_address VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql(
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS hardware_version VARCHAR(128) DEFAULT ''"
)
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS loopback VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql(
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS consistent_state VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql(
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS interface_version VARCHAR(128) DEFAULT ''"
)
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS mac VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS admin_status VARCHAR(64) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS address_type VARCHAR(64) DEFAULT ''")
conn.exec_driver_sql(
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS connection_status VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql(
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS maintain_status VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS net_mask VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS create_time VARCHAR(64) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS creator VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_alarms_current DROP COLUMN IF EXISTS ne_name")
conn.exec_driver_sql("ALTER TABLE ume_alarms_current DROP COLUMN IF EXISTS user_label")
conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS ne_name")
conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS user_label")
conn.exec_driver_sql("COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_type IS '网元类型'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.user_label IS '用户标签'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.address_type IS '管理地址类型(1:IPv4,2:IPv6)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ip_address IS '网元IPv4地址'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.net_mask IS '管理IPv4掩码(点分十进制)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ipv6_address IS 'IPv6地址'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.admin_status IS '管理状态(0-离线,1-在线)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.connection_status IS '连接状态(0-断链,1-正常)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.consistent_state IS '数据一致性状态(1一致,2不一致,3冲突)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.maintain_status IS '工程状态(0普通,1调测,2新建)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.vendor IS '网元提供商'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.interface_version IS '网元接口版本号'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.hardware_version IS '硬件版本'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.mac IS '设备机架MAC地址'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.loopback IS '业务环回IP(IPv4)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.device_level IS '网元层次'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.host_name IS '主机名称'")
except Exception:
pass
def run_stdio_loop() -> None:
"""Legacy MCP: direct PostgreSQL access (deprecated; use HTTP mode)."""
_ensure_db_schema()
for line in sys.stdin:
raw = line.strip()
if not raw:
continue
try:
req = json.loads(raw)
except Exception:
continue
rid = req.get("id")
method = str(req.get("method") or "")
params = req.get("params") if isinstance(req.get("params"), dict) else {}
try:
if method == "initialize":
_ok(
rid,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "netx-mcp", "version": "0.1.0"},
},
)
continue
if method == "notifications/initialized":
continue
if method == "tools/list":
_ok(rid, {"tools": _tool_list()})
continue
if method == "tools/call":
name = str(params.get("name") or "")
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
_ok(rid, _call_tool(name, args))
continue
_err(rid, -32601, f"method not found: {method}")
except Exception as exc:
_err(rid, -32000, str(exc))
def main() -> None:
run_stdio_loop()
if __name__ == "__main__":
main()

View file

@ -1,3 +0,0 @@
"""Re-export from ``netx-mcp`` package (install: ``pip install -e packages/netx-mcp``)."""
from netx_mcp.http_client import * # noqa: F403

View file

@ -1,3 +0,0 @@
"""Re-export from ``netx-mcp`` package (install: ``pip install -e packages/netx-mcp``)."""
from netx_mcp.http_tools import * # noqa: F403

View file

@ -1,32 +0,0 @@
"""Backward-compatible entry for ``python netx_api/mcp_server.py`` / ``netx-mcp`` script on netx-ops install.
HTTP MCP: delegates to ``netx_mcp`` (``pip install -e packages/netx-mcp``).
Legacy DB mode (``NETX_MCP_MODE=db``): requires full ``netx-ops`` install only.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Script-path launch without pip install netx-mcp: add packages/netx-mcp/src
_repo_root = Path(__file__).resolve().parent.parent
_mcp_src = _repo_root / "packages" / "netx-mcp" / "src"
if _mcp_src.is_dir() and str(_mcp_src) not in sys.path:
sys.path.insert(0, str(_mcp_src))
def main() -> None:
if str(os.getenv("NETX_MCP_MODE") or "http").strip().lower() == "db":
from netx_api.mcp.db_server import run_stdio_loop
run_stdio_loop()
return
from netx_mcp.server import main as mcp_main
mcp_main()
if __name__ == "__main__":
main()

View file

@ -87,21 +87,28 @@ def _collect_on_device(
if conn_holder is not None: if conn_holder is not None:
conn_holder["conn"] = conn conn_holder["conn"] = conn
conn_holder["session_log"] = log_buf conn_holder["session_log"] = log_buf
# Belt-and-suspenders: hop/bastion nested CLIs and missed session_prep. from .ne_netmiko import drain_read_channel, is_cisco_ios_device_type
try:
disable_target_paging(
conn,
vendor=str(creds.get("vendor") or ""),
device_type=str(creds.get("device_type") or ""),
)
except Exception:
_log.debug("collection paging disable failed", exc_info=True)
prompt = str(conn.find_prompt() or "")
# find_prompt / paging-off often leave an extra prompt in the channel;
# drain before the first show or Netmiko returns empty (IOSv).
try:
from .ne_netmiko import drain_read_channel
device_type = str(creds.get("device_type") or "")
# Direct Cisco session_prep already set width/length. A second timing-based
# ``terminal length 0`` plus find_prompt() re-introduces leftover prompts
# and line-buffer races on IOSv. Keep the extra paging pass for hop/other.
need_paging_pass = bool(creds.get("hop_enabled")) or not is_cisco_ios_device_type(
device_type
)
if need_paging_pass:
try:
disable_target_paging(
conn,
vendor=str(creds.get("vendor") or ""),
device_type=device_type,
)
except Exception:
_log.debug("collection paging disable failed", exc_info=True)
# Transcript header only — never find_prompt() here (sends Enter).
base = str(getattr(conn, "base_prompt", "") or "").strip()
prompt = f"{base}#" if base else ""
try:
drain_read_channel(conn) drain_read_channel(conn)
except Exception: except Exception:
_log.debug("collection channel drain failed", exc_info=True) _log.debug("collection channel drain failed", exc_info=True)

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import json import json
import os import os
import re
import threading import threading
import time import time
import uuid import uuid
@ -24,6 +25,8 @@ _INFLIGHT = 0
_DEFAULT_TIMEOUT_S = 900 _DEFAULT_TIMEOUT_S = 900
_DEFAULT_MAX_CONCURRENT = 3 _DEFAULT_MAX_CONCURRENT = 3
_DEFAULT_ASYNC_MIN_NES = 4 _DEFAULT_ASYNC_MIN_NES = 4
_JOB_ID_RE = re.compile(r"[0-9a-f]{32}")
_MAX_JOB_TARGETS = 20 # Same combined limit as ne_exec._normalize_batch_targets.
def _env_int(name: str, default: int, *, min_v: int, max_v: int) -> int: def _env_int(name: str, default: int, *, min_v: int, max_v: int) -> int:
@ -72,7 +75,14 @@ def _jobs_dir() -> Path:
def _job_path(job_id: str) -> Path: def _job_path(job_id: str) -> Path:
return _jobs_dir() / f"{job_id}.json" if not isinstance(job_id, str) or not _JOB_ID_RE.fullmatch(job_id):
raise ValueError("invalid_job_id")
directory = _jobs_dir().resolve()
path = (directory / f"{job_id}.json").resolve()
# Also reject a valid-looking filename symlinked outside the job directory.
if path.parent != directory:
raise ValueError("invalid_job_id")
return path
def _atomic_write(path: Path, payload: dict[str, Any]) -> None: def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
@ -90,13 +100,11 @@ def _read_job(job_id: str) -> dict[str, Any] | None:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
except Exception: except Exception:
return None return None
return data if isinstance(data, dict) else None return data if isinstance(data, dict) and data.get("job_id") == job_id else None
def _write_job(job: dict[str, Any]) -> None: def _write_job(job: dict[str, Any]) -> None:
jid = str(job.get("job_id") or "").strip() jid = job.get("job_id")
if not jid:
return
_atomic_write(_job_path(jid), job) _atomic_write(_job_path(jid), job)
@ -116,17 +124,16 @@ def default_job_timeout_s() -> int:
def count_exec_ne_targets(args: dict[str, Any] | None) -> int: def count_exec_ne_targets(args: dict[str, Any] | None) -> int:
a = args if isinstance(args, dict) else {} a = args if isinstance(args, dict) else {}
n = 0 n = 0
for key in ("ne_ids", "ume_ne_ids", "nms_ne_ids"): for key in ("ne_ids", "ume_ne_ids"):
val = a.get(key) val = a.get(key)
if isinstance(val, list): 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 = a.get("targets") targets = a.get("targets")
if isinstance(targets, list): 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 ( if n == 0 and (
str(a.get("ne_id") or "").strip() str(a.get("ne_id") or "").strip()
or str(a.get("ume_ne_id") or "").strip() or str(a.get("ume_ne_id") or "").strip()
or str(a.get("nms_ne_id") or "").strip()
): ):
return 1 return 1
return int(n) return int(n)
@ -164,11 +171,18 @@ def start_ne_exec_job(
*, *,
kind: str, kind: str,
arguments: dict[str, Any], arguments: dict[str, Any],
owner_user_id: str,
runner: Callable[[], dict[str, Any]], runner: Callable[[], dict[str, Any]],
timeout_s: int | None = None, timeout_s: int | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Start background runner; returns immediate ack with job_id.""" """Start background runner; returns immediate ack with job_id."""
global _INFLIGHT global _INFLIGHT
owner = str(owner_user_id or "").strip()
if not owner:
return {"ok": False, "error_code": "job_owner_required", "error": "job_owner_required"}
ne_count = count_exec_ne_targets(arguments)
if ne_count > _MAX_JOB_TARGETS:
return {"ok": False, "error_code": "too_many_targets", "error": "too_many_targets", "max_targets": _MAX_JOB_TARGETS}
max_c = max_concurrent_jobs() max_c = max_concurrent_jobs()
with _LOCK: with _LOCK:
if _INFLIGHT >= max_c: if _INFLIGHT >= max_c:
@ -189,10 +203,11 @@ def start_ne_exec_job(
now = int(time.time() * 1000) now = int(time.time() * 1000)
job = { job = {
"job_id": job_id, "job_id": job_id,
"owner_user_id": owner,
"status": _STATUS_RUNNING, "status": _STATUS_RUNNING,
"kind": str(kind or "exec"), "kind": str(kind or "exec"),
"arguments": dict(arguments or {}), "arguments": dict(arguments or {}),
"ne_count": count_exec_ne_targets(arguments), "ne_count": ne_count,
"created_at_ms": now, "created_at_ms": now,
"updated_at_ms": now, "updated_at_ms": now,
"timeout_s": timeout, "timeout_s": timeout,
@ -260,13 +275,26 @@ def start_ne_exec_job(
} }
def get_ne_exec_job(job_id: str) -> dict[str, Any]: def get_ne_exec_job(job_id: str, *, user_id: str, is_admin: bool = False) -> dict[str, Any]:
jid = str(job_id or "").strip() """Read only the caller's jobs; explicit admins may inspect any persisted job.
Ownerless legacy records are never granted to ordinary callers.
"""
jid = job_id
if not jid: if not jid:
return {"ok": False, "error_code": "job_id_required", "error": "job_id_required"} return {"ok": False, "error_code": "job_id_required", "error": "job_id_required"}
job = _read_job(jid) try:
job = _read_job(jid)
except ValueError:
return {"ok": False, "error_code": "invalid_job_id", "error": "invalid_job_id"}
requester = str(user_id or "").strip()
if not requester:
return {"ok": False, "error_code": "job_forbidden", "error": "job_forbidden"}
if not job: if not job:
return {"ok": False, "error_code": "job_not_found", "error": "job_not_found", "job_id": jid} return {"ok": False, "error_code": "job_not_found", "error": "job_not_found", "job_id": jid}
owner = str(job.get("owner_user_id") or "").strip()
if not is_admin and (not owner or owner != requester):
return {"ok": False, "error_code": "job_forbidden", "error": "job_forbidden"}
status = str(job.get("status") or "") status = str(job.get("status") or "")
out: dict[str, Any] = { out: dict[str, Any] = {
"ok": True, "ok": True,

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import re
import time import time
from typing import Any from typing import Any
@ -138,6 +139,35 @@ def drain_read_channel(
def _send_command_expect_prompt(conn: Any, cmd: str, *, read_timeout: int) -> str: def _send_command_expect_prompt(conn: Any, cmd: str, *, read_timeout: int) -> str:
device_type = getattr(conn, "device_type", "") device_type = getattr(conn, "device_type", "")
if isinstance(device_type, str) and is_cisco_ios_device_type(device_type):
# IOSv has the same late-prompt race as RouterOS: auto_find_prompt sends
# a newline after our drain and its reply can terminate the next show.
# Use the session's cached hostname, but require a full prompt line so
# "R2 uptime ..." inside show version cannot end the command early.
#
# cmd_verify must stay False: Netmiko hardcodes a 10s echo wait, and
# IOSv often echoes ``show version | include ...`` slower than that
# (live: ~13s) even when the command itself succeeds. Correctness comes
# from drain_read_channel + full-line expect_string, not echo verify.
base_prompt = getattr(conn, "base_prompt", "")
if not isinstance(base_prompt, str) or not base_prompt.strip():
raise ValueError("cisco_base_prompt_missing")
prompt_pattern = rf"(?m:^{re.escape(base_prompt.strip())}[>#][ \t]*$)"
out = str(
conn.send_command(
command_string=cmd,
read_timeout=read_timeout,
auto_find_prompt=False,
expect_string=prompt_pattern,
cmd_verify=False,
)
or ""
)
# Leftover-prompt races still yield a silent empty body with ok=true at
# higher layers. Fail loud here — never re-send the command.
if not out.strip():
raise ValueError("cisco_empty_show_output")
return out
if isinstance(device_type, str) and device_type.lower().startswith("mikrotik"): if isinstance(device_type, str) and device_type.lower().startswith("mikrotik"):
# find_prompt sends an extra Enter *after* our channel drain. RouterOS # find_prompt sends an extra Enter *after* our channel drain. RouterOS
# can repaint that old prompt after send_command starts, ending the read # can repaint that old prompt after send_command starts, ending the read
@ -179,8 +209,10 @@ def send_show_command(conn: Any, command: str, *, read_timeout: int = 120) -> st
Prefer ``send_command`` over ``send_command_timing`` for long config dumps: Prefer ``send_command`` over ``send_command_timing`` for long config dumps:
idle during ``Building configuration...`` truncates timing reads. idle during ``Building configuration...`` truncates timing reads.
``cmd_verify=False``: Netmiko's default echo check often raises Cisco uses ``cmd_verify=False`` with a full-line prompt expect after drain;
``Pattern not detected: 'show\\ lldp\\ ...'`` on IOSv / hop / slow echo paths. Netmiko's echo verify is hardcoded to 10s and false-fails slow IOSv echoes.
Other drivers also use ``cmd_verify=False``: the default echo check often
raises ``Pattern not detected`` on hop / slow echo paths.
""" """
cmd = str(command or "").strip() cmd = str(command or "").strip()
if not cmd: if not cmd:
@ -188,5 +220,9 @@ def send_show_command(conn: Any, command: str, *, read_timeout: int = 120) -> st
# IOSv / hop: find_prompt / paging-off often leave an extra prompt in the # IOSv / hop: find_prompt / paging-off often leave an extra prompt in the
# channel; drain so send_command does not match that leftover immediately. # channel; drain so send_command does not match that leftover immediately.
drain_read_channel(conn) device_type = getattr(conn, "device_type", "")
if isinstance(device_type, str) and is_cisco_ios_device_type(device_type):
drain_read_channel(conn, idle_reads=6, pause_sec=0.08)
else:
drain_read_channel(conn)
return _send_command_expect_prompt(conn, cmd, read_timeout=read_timeout) return _send_command_expect_prompt(conn, cmd, read_timeout=read_timeout)

View file

@ -304,24 +304,23 @@ class _ProgressSessionLog:
def _cisco_ios_collection_driver_class(base_cls: type) -> type: def _cisco_ios_collection_driver_class(base_cls: type) -> type:
"""Cisco IOSv-friendly session prep: avoid cmd_verify on terminal width/length.""" """Cisco IOSv collection prep: establish hostname prompt only (like interactive)."""
class _CiscoIosCollectionSession(base_cls): # type: ignore[misc,valid-type] class _CiscoIosCollectionSession(base_cls): # type: ignore[misc,valid-type]
def session_preparation(self) -> None: def session_preparation(self) -> None:
# Default Netmiko waits for exact echo of "terminal width 511" (ReadTimeout on IOSv). # Stock Netmiko width/paging on IOSv+CR races leftover prompts
self._test_channel_read(pattern=r"[>#]") # (``terexit`` / ``terminal lengexit``) or blocks forever waiting for
try: # setup echo once VTY pressure builds. The interactive WebCRT path
self.set_terminal_width(command="terminal width 511", pattern=r"[>#]") # skips these commands and still returns complete show output; MCP
except Exception: # show/display bodies are short enough that --More-- is not required.
pass # Keep hostname discovery so send_show_command can use a full-line
try: # prompt expect without auto_find_prompt.
self.disable_paging(command="terminal length 0", cmd_verify=False, pattern=r"[>#]") from .ne_netmiko import drain_read_channel
except Exception:
try: initial_pattern = r"(?m:^[^\r\n#>]+[>#][ \t]*$)"
self.send_command_timing("terminal length 0", read_timeout=15) self._test_channel_read(pattern=initial_pattern)
except Exception: self.set_base_prompt(pattern=initial_pattern)
pass drain_read_channel(self, idle_reads=8, pause_sec=0.1)
self.set_base_prompt()
_CiscoIosCollectionSession.__name__ = ( _CiscoIosCollectionSession.__name__ = (
f"CiscoIosCollection{getattr(base_cls, '__name__', 'Netmiko')}" f"CiscoIosCollection{getattr(base_cls, '__name__', 'Netmiko')}"
@ -508,6 +507,13 @@ def _base_connect_kwargs(
"auth_timeout": timeout, "auth_timeout": timeout,
"banner_timeout": timeout, "banner_timeout": timeout,
} }
# IOSv SSH accepts CR as Enter. LF alone can leave command input pending
# until a later write, producing missing echo, empty output or prompt timeouts.
# Keep Telnet's own line ending and other vendors' defaults unchanged.
from .ne_netmiko import is_cisco_ios_device_type
if is_cisco_ios_device_type(device_type) and "telnet" not in device_type.lower():
dev["default_enter"] = "\r"
if session_timeout is not None: if session_timeout is not None:
dev["session_timeout"] = session_timeout dev["session_timeout"] = session_timeout
if keepalive is not None and int(keepalive) > 0: if keepalive is not None and int(keepalive) > 0:

View file

@ -749,6 +749,8 @@ def patch_view_positions(
updated += 1 updated += 1
else: else:
from .topology_region_canvas import add_missing_region_placement, is_region_canvas_node_id from .topology_region_canvas import add_missing_region_placement, is_region_canvas_node_id
from .ume_topology_world import is_ume_canvas_view, is_world_flat_view
from .ume_topology_world_graph import get_level_view_graph
positions = list(body.positions or []) positions = list(body.positions or [])
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP: if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
@ -769,6 +771,24 @@ def patch_view_positions(
.filter(TopoFabricNode.id.in_(check_ids)) .filter(TopoFabricNode.id.in_(check_ids))
.all() .all()
} }
# UME canvases synthesize their members instead of adding them via /nodes.
# Persist placements only for actual members of this canvas; a position
# patch must never add unrelated fabric nodes to an ordinary view.
ume_member_ids: set[str] = set()
if is_ume_canvas_view(view) and any(
str(p.fabric_node_id or "").strip() not in existing for p in positions
):
if is_world_flat_view(view):
ume_member_ids = {
str(row[0])
for row in db.query(TopoFabricNode.id).filter(
TopoFabricNode.id.in_(check_ids),
TopoFabricNode.world_x.isnot(None),
TopoFabricNode.world_y.isnot(None),
).all()
}
else:
ume_member_ids = {node.fabric_node_id for node in get_level_view_graph(db, view).nodes}
for p in positions: for p in positions:
fid = str(p.fabric_node_id or "").strip() fid = str(p.fabric_node_id or "").strip()
if not fid: if not fid:
@ -777,9 +797,17 @@ def patch_view_positions(
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}") raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
row = existing.get(fid) row = existing.get(fid)
if row is None: if row is None:
if fid in ume_member_ids:
row = TopoViewNode(
id=uuid4().hex, view_id=view.id, fabric_node_id=fid,
x=float(p.x), y=float(p.y), label="", locked=False,
created_at=now, updated_at=now,
)
db.add(row)
# Child-region icons may be synthesized without a placement row; persist those. # Child-region icons may be synthesized without a placement row; persist those.
# Fabric NEs must not bypass membership caps / freeze (MCP adds via /nodes). # Fabric NEs must not bypass membership caps / freeze (MCP adds via /nodes).
row = add_missing_region_placement(db, view, fid, x=p.x, y=p.y) else:
row = add_missing_region_placement(db, view, fid, x=p.x, y=p.y)
if row is None: if row is None:
skipped_missing += 1 skipped_missing += 1
continue continue

View file

@ -46,9 +46,6 @@ WORLD_FLAT_DETAIL_CAP = VIEW_GRAPH_NODE_HARD_CAP
# Viewport span (display/world units) at/under which we prefer a full region layout # Viewport span (display/world units) at/under which we prefer a full region layout
# instead of stride-thinning — stops “zoomed in still a peppered blob”. # instead of stride-thinning — stops “zoomed in still a peppered blob”.
WORLD_FLAT_CLOSE_SPAN = 28000.0 WORLD_FLAT_CLOSE_SPAN = 28000.0
# Legacy crush span (pre-LOD). Flat graph is now 1:1 with fabric world_*;
# this constant only detects stale TopoViewNode overrides from that era.
WORLD_FLAT_DISPLAY_SPAN = 6000.0
def apply_persisted_view_positions( def apply_persisted_view_positions(
@ -553,8 +550,6 @@ def get_flat_view_graph(
full_max_x = float(extent_row[1]) full_max_x = float(extent_row[1])
full_min_y = float(extent_row[2]) full_min_y = float(extent_row[2])
full_max_y = float(extent_row[3]) full_max_y = float(extent_row[3])
span_x = max(1.0, full_max_x - full_min_x)
span_y = max(1.0, full_max_y - full_min_y)
# 1:1 with fabric world_* (only origin-shift to ~0). Infinite zoom keeps # 1:1 with fabric world_* (only origin-shift to ~0). Infinite zoom keeps
# each packed region block identical to its UME local relative layout. # each packed region block identical to its UME local relative layout.
scale = 1.0 scale = 1.0
@ -768,25 +763,9 @@ def get_flat_view_graph(
) )
for n in nodes for n in nodes
] ]
# Drag overrides in display space (world - origin). Skip legacy rows saved # Saved placements are authoritative display coordinates. Do not infer an
# under the old ~6000 crush — they would yank nodes to the wrong place. # obsolete coordinate system from their magnitude or reject valid negatives.
override_rows = ( nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).limit(8).all()
)
display_span = max(span_x, span_y)
overrides_ok = False
if override_rows and display_span > 0:
oxs = [abs(float(r.x or 0)) for r in override_rows]
oys = [abs(float(r.y or 0)) for r in override_rows]
peak = max(oxs + oys) if (oxs or oys) else 0.0
legacy_crush = display_span > WORLD_FLAT_DISPLAY_SPAN * 2 and peak <= WORLD_FLAT_DISPLAY_SPAN * 1.5
overrides_ok = (not legacy_crush) and all(
0.0 <= float(r.x or 0) <= display_span * 1.2
and 0.0 <= float(r.y or 0) <= display_span * 1.2
for r in override_rows
)
if overrides_ok:
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
edges_out: list[ViewEdgeOut] = [] edges_out: list[ViewEdgeOut] = []
if include_edges and fids: if include_edges and fids:

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -48,14 +48,24 @@ python -m netx_mcp
0.3.1:`getNmsNe` 可直接传 host_name/IP;输出统一 `nms_ne_id` / `source=nms`;参数类型错误返回工具错误而非协议崩溃。 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))。 拓扑画布 / 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) ## Breaking (0.3.0)
模型工具名从 `*Ume*` 改为 `*Nms*`(与 dsh-netxops 对齐)。请更新 skills / 提示词并重启 MCP 宿主。 模型工具名从 `*Ume*` 改为 `*Nms*`(与 dsh-netxops 对齐)。请更新 skills / 提示词并重启 MCP 宿主。
## 兼容 ## 入口
全量 `netx-ops` 开发安装下 `python -m netx_api.mcp` 仍会转到本包;新环境请只装 **netx-mcp**。 只保留独立 **netx-mcp** 包入口:`python -m netx_mcp` / `netx-mcp`。旧 `netx_api.mcp`、`netx_api/mcp_server.py` 和直连数据库模式已删除;配置需迁移到本包。
旧 DB 模式的导入告警查询/聚合/批次/诊断、同步触发和历史告警查询未纳入本包 15 工具,相关 HTTP API 仍保留。

View file

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

View file

@ -1,7 +1,7 @@
"""MCP tool schemas and HTTP-backed handlers (NMS adapter + managed NE). """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 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 from __future__ import annotations
@ -9,6 +9,7 @@ from __future__ import annotations
import os import os
from typing import Any, Callable 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 from .http_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id
_EXEC_MAX_COMMANDS_CAP = 50 _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: def _count_exec_ne_targets(args: dict[str, Any]) -> int:
n = 0 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) val = args.get(key)
if isinstance(val, list): 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") targets = args.get("targets")
if isinstance(targets, list): 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 ( if n == 0 and (
str(args.get("ne_id") or "").strip() str(args.get("ne_id") or "").strip()
or str(args.get("nms_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 1
return int(n) 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(): if str(args.get("is_cleared") or "").strip():
params["is_cleared"] = str(args.get("is_cleared")).strip() params["is_cleared"] = str(args.get("is_cleared")).strip()
keyword = str(args.get("keyword") or "").strip() keyword = str(args.get("keyword") or "").strip()
ne_name = str(args.get("ne_name") or "").strip()
if keyword: if keyword:
params["keyword"] = keyword params["keyword"] = keyword
elif ne_name:
params["keyword"] = ne_name
if str(args.get("ne_id") or "").strip(): if str(args.get("ne_id") or "").strip():
params["ne_id"] = str(args.get("ne_id")).strip() params["ne_id"] = str(args.get("ne_id")).strip()
if str(args.get("host_name") or "").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: if group_by:
return _aggregate_ume_alarms_raw(args) return _aggregate_ume_alarms_raw(args)
# Default top 50 named NEs — missing host buckets reported separately. # 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: if "exclude_missing_host" in args:
params["exclude_missing_host"] = bool(args.get("exclude_missing_host")) params["exclude_missing_host"] = bool(args.get("exclude_missing_host"))
if _severity(args): 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]: 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] = {} params: dict[str, Any] = {}
for k in ( for k in (
"group_by", "group_by",
@ -442,9 +441,7 @@ def _first_str(*vals: Any) -> str:
def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]: def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
ne_id = str( ne_id = str(args.get("ne_id") or "").strip()
args.get("ne_id") or args.get("managed_ne_id") or args.get("id") or ""
).strip()
if not ne_id: if not ne_id:
return { return {
"ok": False, "ok": False,
@ -473,12 +470,46 @@ def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
return out 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]: 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") targets_raw = args.get("targets")
ne_ids_raw = args.get("ne_ids") ne_ids_raw = args.get("ne_ids")
nms_ne_ids_raw = args.get("nms_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_cmds_raw = args.get("commands")
shared_commands = ( shared_commands = (
[str(c).strip() for c in shared_cmds_raw if str(c).strip()] [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) 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: if multi:
body: dict[str, Any] = {} body: dict[str, Any] = {}
if isinstance(targets_raw, list) and targets_raw: 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] = {} row: dict[str, Any] = {}
if str(t.get("ne_id") or "").strip(): if str(t.get("ne_id") or "").strip():
row["ne_id"] = str(t.get("ne_id")).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. # Wire still uses ume_ne_id on REST.
nms_id = _first_str(t.get("nms_ne_id"), t.get("ume_ne_id")) 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: if nms_id:
row["ume_ne_id"] = nms_id row["ume_ne_id"] = nms_id
cmds = t.get("commands") cmds = t.get("commands")
if isinstance(cmds, list) and cmds: if isinstance(cmds, list) and cmds:
row["commands"] = [str(c).strip() for c in cmds if str(c).strip()] 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: if row:
cleaned_targets.append(row) cleaned_targets.append(row)
body["targets"] = cleaned_targets 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()] 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: 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()] 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 shared_commands:
if len(shared_commands) > exec_max_commands(): if len(shared_commands) > exec_max_commands():
return {"ok": False, "error": "too_many_commands", "error_code": "too_many_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) body["concurrency"] = _int_arg(args, "concurrency", 4, 1, 8)
if want_async: if want_async:
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0) out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
if not out.get("ok"): return _exec_ack(out)
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}
# Wall clock: many NEs × per-cmd timeout; keep below oclaw MCP override. # 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) out = http_post_json("/v1/managed-ne/exec-batch", body, timeout=600.0)
if not out.get("ok"): if not out.get("ok"):
return out return out
data = out.get("data") or {} 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": False, "data": data, "error": str(data.get("error") or "exec_batch_failed")}
return {"ok": True, "data": data} return {"ok": True, "data": data}
ne_id = str(args.get("ne_id") or "").strip() 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): if bool(ne_id) == bool(nms_ne_id):
return { return {
"ok": False, "ok": False,
"error": "exactly_one_of_ne_id_or_nms_ne_id_required", "error": "exactly_one_of_ne_id_or_nms_ne_id_required",
"error_code": "exactly_one_of_ne_id_or_nms_ne_id_required", "error_code": "exactly_one_of_ne_id_or_nms_ne_id_required",
"hint": ( "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 — " "ne_ids / nms_ne_ids with shared commands, or targets[] with per-NE commands — "
"one call, concurrent on server." "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) body["read_timeout_sec"] = _int_arg(args, "read_timeout_sec", 60, 10, 120)
if want_async: if want_async:
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0) out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
if not out.get("ok"): return _exec_ack(out)
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}
out = http_post_json("/v1/managed-ne/exec", body, timeout=300.0) out = http_post_json("/v1/managed-ne/exec", body, timeout=300.0)
if not out.get("ok"): if not out.get("ok"):
return out return out
@ -586,7 +627,16 @@ def _get_ne_exec_job(args: dict[str, Any]) -> dict[str, Any]:
"error_code": "job_id_required", "error_code": "job_id_required",
"hint": "Pass job_id from execManagedNe async ack.", "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]: 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]: 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() 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() to_mid = str(args.get("to_managed_ne_id") or "").strip()
if bool(from_uid) == bool(from_mid): if bool(from_uid) == bool(from_mid):
return {"ok": False, "error": "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required"} 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."}, "is_cleared": {"type": "string", "description": "\"False\" = active only; \"True\" = cleared."},
"ne_id": {"type": "string", "description": "Filter only; do not show UUID to users"}, "ne_id": {"type": "string", "description": "Filter only; do not show UUID to users"},
"host_name": {"type": "string", "description": "Filter by NE host_name"}, "host_name": {"type": "string", "description": "Filter by NE host_name"},
"ne_name": {"type": "string", "description": "Legacy alias mapped to keyword"},
"keyword": { "keyword": {
"type": "string", "type": "string",
"description": ( "description": (
@ -703,9 +752,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"top_ne": { "top_ne": {
"type": "integer", "type": "integer",
"minimum": 0, "minimum": 0,
"maximum": 500, "maximum": 5000,
"default": 50, "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": { "exclude_missing_host": {
"type": "boolean", "type": "boolean",
@ -883,8 +932,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"name": "getManagedNe", "name": "getManagedNe",
"description": ( "description": (
"Get one **managed** NE by managed ne_id (from listManagedNe / listCliTargets source=managed). " "Get one **managed** NE by managed ne_id (from listManagedNe / listCliTargets source=managed). "
"Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) " "Response includes BACKEND capability (device_family, exec_policy_effective, recommended_mode, hints). "
"— read it before complex execManagedNe. " "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." "Do NOT pass NMS inventory UUID here — use getNmsNe or execManagedNe(nms_ne_id=...) instead."
), ),
"inputSchema": { "inputSchema": {
@ -894,23 +944,21 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"type": "string", "type": "string",
"description": "Managed NE id (not NMS host_name / not NMS ne_id unless they coincide).", "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, "additionalProperties": False,
}, },
}, },
{ {
"name": "execManagedNe", "name": "execManagedNe",
"description": ( "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). " 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 " "MCP independently allows single-line read-only show/display only, even when the "
"MikroTik (routeros/switchos) hosts " "device has an open exec_policy. Shell operators, configuration commands and multiline "
"(pipes/&&/;/quotes/heredoc / RouterOS multiline OK; check getManagedNe / listManagedNe). " "scripts are rejected before HTTP; only read-only output-filter pipes are allowed. "
"Single NE: ne_id OR nms_ne_id (+ alias ume_ne_id) + commands. " "Single NE: ne_id OR nms_ne_id + commands. "
"Many NEs (batch-first, server concurrency default 4, max 20): " "Many NEs (batch-first, max 20 targets, concurrency default 4, max 8): "
"(1) same CLI on all → ne_ids[]/nms_ne_ids[] + shared commands; " "(1) same CLI on all → ne_ids[]/nms_ne_ids[] + shared commands; "
"(2) different CLI per NE (vendor/role) → ONE targets=[" "(2) different CLI per NE (vendor/role) → ONE targets=["
"{nms_ne_id|ne_id, commands:[…]}, …] — do NOT fall back to one-NE loops. " "{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. " "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; " "Long or multi-NE work: async=true (or auto when ≥4 NEs) returns job_id immediately; "
"poll getNeExecJob. Pass async=false to force sync. " "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": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
"ne_id": {"type": "string"}, "ne_id": {"type": "string"},
"nms_ne_id": {"type": "string", "description": "NMS inventory id (preferred)."}, "nms_ne_id": {"type": "string", "description": "NMS inventory id (preferred)."},
"ume_ne_id": {"type": "string", "description": "Legacy alias of nms_ne_id."},
"ne_ids": { "ne_ids": {
"type": "array", "type": "array",
"items": {"type": "string"}, "items": {"type": "string"},
@ -938,12 +987,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"maxItems": 20, "maxItems": 20,
"description": "NMS inventory ne_ids for concurrent batch (shared commands).", "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": { "targets": {
"type": "array", "type": "array",
"maxItems": 20, "maxItems": 20,
@ -952,7 +995,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"properties": { "properties": {
"ne_id": {"type": "string"}, "ne_id": {"type": "string"},
"nms_ne_id": {"type": "string"}, "nms_ne_id": {"type": "string"},
"ume_ne_id": {"type": "string", "description": "Legacy alias of nms_ne_id."},
"commands": { "commands": {
"type": "array", "type": "array",
"items": {"type": "string"}, "items": {"type": "string"},
@ -1010,7 +1052,10 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"description": ( "description": (
"Poll a background execManagedNe job (job_id from async ack). " "Poll a background execManagedNe job (job_id from async ack). "
"When terminal=true, result holds the exec/exec-batch payload. " "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": { "inputSchema": {
"type": "object", "type": "object",
@ -1033,9 +1078,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"properties": { "properties": {
"source": { "source": {
"type": "string", "type": "string",
"enum": ["managed", "nms", "ume", "all"], "enum": ["managed", "nms", "all"],
"default": "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"}, "keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1, "default": 1}, "page": {"type": "integer", "minimum": 1, "default": 1},
@ -1050,8 +1095,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"description": ( "description": (
"Find up to max_paths simple paths between two fabric nodes for troubleshooting " "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). " "(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 " "For each endpoint provide exactly one of nms_ne_id (from NMS alarm ne_id) or "
"from_ume_ne_id) or managed_ne_id — resolved to fabric node internally. Returns " "managed_ne_id — resolved to fabric node internally. Returns "
"shortest paths first with compact label + node/edge summary (detail=summary default). " "shortest paths first with compact label + node/edge summary (detail=summary default). "
"Use after critical alarms to correlate neighboring NEs before CLI login." "Use after critical alarms to correlate neighboring NEs before CLI login."
), ),
@ -1062,10 +1107,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"type": "string", "type": "string",
"description": "Source NMS ne_id (from alarm ne_id); mutually exclusive with from_managed_ne_id", "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": { "from_managed_ne_id": {
"type": "string", "type": "string",
"description": "Source managed NE id; mutually exclusive with from_nms_ne_id", "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", "type": "string",
"description": "Target NMS ne_id; mutually exclusive with to_managed_ne_id", "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": { "to_managed_ne_id": {
"type": "string", "type": "string",
"description": "Target managed NE id; mutually exclusive with to_nms_ne_id", "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", "queryNmsAlarms": "alarms:read",
"aggregateNmsAlarms": "alarms:read", "aggregateNmsAlarms": "alarms:read",
"runNmsDiagnostics": "alarms:read", "runNmsDiagnostics": "alarms:read",
"queryNmsNeInventory": "ne:read", "queryNmsNeInventory": "alarms:read",
"getNmsNe": "ne:read", "getNmsNe": "alarms:read",
"queryNmsAlarmsRaw": "alarms:read", "queryNmsAlarmsRaw": "alarms:read",
"aggregateNmsAlarmsRaw": "alarms:read", "aggregateNmsAlarmsRaw": "alarms:read",
"listNmsAlarmFields": "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]]: 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: if scopes is None:
return list(HTTP_MCP_TOOLS) return list(HTTP_MCP_TOOLS)
granted = {str(s).strip().lower() for s in scopes if str(s).strip()} 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 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]: def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
fn = _HANDLERS.get(str(name or "").strip()) fn = _HANDLERS.get(str(name or "").strip())
if not fn: if not fn:
raise ValueError(f"unknown tool: {name}") raise ValueError(f"unknown tool: {name}")
try: try:
schema = next(tool["inputSchema"] for tool in HTTP_MCP_TOOLS if tool["name"] == name)
_validate_arguments(args, schema)
result = fn(dict(args or {})) result = fn(dict(args or {}))
except ToolArgError as exc: except ToolArgError as exc:
result = {"ok": False, "error": "invalid_argument", "error_code": "invalid_argument", "detail": str(exc)} result = {"ok": False, "error": "invalid_argument", "error_code": "invalid_argument", "detail": str(exc)}
return mcp_from_handler_result(result) return mcp_from_handler_result(_redact_secrets(result))

View file

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

View file

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

View file

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

View file

@ -28,7 +28,10 @@ def _data(out: dict[str, Any]) -> dict[str, Any]:
data = out.get("data") data = out.get("data")
if isinstance(data, dict): if isinstance(data, dict):
merged = dict(data) 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 merged
return {"ok": True, "data": data} 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"): if not out.get("ok"):
return out return out
detail = str(args.get("detail") or "summary").strip().lower() 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" out["detail"] = "full"
return out 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) 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, "updated": updated,
"detail": out, "detail": out,
} }
updated += int(out.get("updated") or len(chunk)) updated += int(out.get("updated", len(chunk)))
chunks += 1 chunks += 1
return {"ok": True, "updated": updated, "chunks": chunks} return {"ok": True, "updated": updated, "chunks": chunks}
@ -469,7 +474,6 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
_public = { _public = {
"layout", "layout",
"fix_overlaps", "fix_overlaps",
"resolve_overlaps",
"untangle", "untangle",
"straighten_channels", "straighten_channels",
"layout_dual_unit", "layout_dual_unit",
@ -478,11 +482,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
"compact_bbox", "compact_bbox",
"pull_far_chains", "pull_far_chains",
"align_reference", "align_reference",
"align_to_reference",
"orbit_sweep", "orbit_sweep",
"level_bands", "level_bands",
"move_nodes", "move_nodes",
"sink_nodes",
"job_status", "job_status",
"job_cancel", "job_cancel",
} }
@ -503,16 +505,12 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
), ),
**list_layout_catalog(), **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. # 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) return _move_topology_view_nodes(args)
# Reference alignment: view_id=TO, source_view_id|params.reference_view_id=FROM. # 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) return _align_topology_reference(args)
# Background job poll / cancel (no view_id required). # 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", "error": "job_not_found",
"job_id": job_id, "job_id": job_id,
"hint": ( "hint": (
"Unknown job_id. Jobs are in-process on this MCP worker; " "Unknown job_id. Poll from a worker sharing NETX_LAYOUT_JOB_DIR; "
"restart or another instance cannot see them." "records survive MCP restarts but may have been pruned."
), ),
} }
status = str(job.get("status") or "unknown") 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( force_sync = _truthy((overrides or {}).get("sync")) or _truthy(
(overrides or {}).get("_force_sync") (overrides or {}).get("_force_sync")
) )
until_limit = _truthy((overrides or {}).get("until_limit")) or _truthy( until_limit = _truthy((overrides or {}).get("until_limit"))
(overrides or {}).get("until_stall")
)
heavy_bg = action in { heavy_bg = action in {
"polish_crossings", "polish_crossings",
"orbit_sweep", "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 bg_n_thr = 80 if (action == "orbit_sweep" and until_limit) else 600
if ( if (
mode == "apply" mode == "apply"
and heavy_bg
and not force_sync and not force_sync
and ( and (
len(nodes) >= bg_n_thr (heavy_bg and len(nodes) >= bg_n_thr)
or _truthy((overrides or {}).get("background")) or _truthy((overrides or {}).get("background"))
or (until_limit and action == "orbit_sweep") 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, tool_args=sync_args,
meta={"node_count": len(nodes), "until_limit": until_limit}, 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( print(
f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}" f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}"
+ (" until_limit" if until_limit else ""), + (" 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", "error": "fabric_nodes_only",
"detail": "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.", "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) filters = _filter_fields(args)
fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()] fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
if not filters and not fabric_ids: 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.", "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" layout = str(args.get("layout") or "grid").strip() or "grid"
if filters: if filters:
body: dict[str, Any] = { body: dict[str, Any] = {
@ -1909,6 +1908,8 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"return_graph": False, "return_graph": False,
} }
parts.append(_data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body))) 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) 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) 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) 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() view_id = str(args.get("view_id") or "").strip()
if not view_id: if not view_id:
return {"ok": False, "error": "view_id_required"} 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] = {} 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: if isinstance(seeds, list) and seeds:
body["seed_fabric_node_ids"] = [str(x).strip() for x in seeds if str(x).strip()] body["seed_fabric_node_ids"] = [str(x).strip() for x in seeds if str(x).strip()]
mids = args.get("managed_ne_ids") or [] 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() region_folder_id = str(args.get("region_folder_id") or "").strip()
if region_folder_id: if region_folder_id:
body["region_folder_id"] = 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. # API still returns the full graph; large canvases easily exceed the 60s default.
out = _data( out = _data(
http_json( http_json(
@ -2030,12 +2040,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
) )
if not out.get("ok"): if not out.get("ok"):
return out return out
detail = str(args.get("detail") or "summary").strip().lower()
skipped = int(out.get("out_of_region_skipped") or 0) skipped = int(out.get("out_of_region_skipped") or 0)
skipped_sample = out.get("out_of_region_sample") or [] skipped_sample = out.get("out_of_region_sample") or []
if not isinstance(skipped_sample, list): if not isinstance(skipped_sample, list):
skipped_sample = [] skipped_sample = []
if detail in {"full", "raw", "graph"}: if detail == "full":
out["detail"] = "full" out["detail"] = "full"
out["region_folder_id"] = region_folder_id or None out["region_folder_id"] = region_folder_id or None
out["out_of_region_skipped"] = skipped 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)." f"skipped {skipped} out-of-region neighbors (not added)."
) )
return out 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["view_id"] = view_id
summary["projected"] = True summary["projected"] = True
summary["dry_run"] = bool(body.get("dry_run"))
summary["region_folder_id"] = region_folder_id or None summary["region_folder_id"] = region_folder_id or None
summary["out_of_region_skipped"] = skipped summary["out_of_region_skipped"] = skipped
summary["out_of_region_sample"] = [ summary["out_of_region_sample"] = [
@ -2076,13 +2087,15 @@ def _query_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
mode = "search" mode = "search"
else: else:
mode = "list" 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")) out = _data(http_json("GET", "/v1/topology/fabric/summary"))
if isinstance(out, dict) and out.get("ok") is not False: if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out) out = dict(out)
out["mode"] = "summary" out["mode"] = "summary"
return out return out
if mode in {"search", "find"}: if mode == "search":
needle = q or keyword needle = q or keyword
if not needle: if not needle:
return { return {
@ -2141,11 +2154,11 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
"hint": ( "hint": (
"action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules. " "action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules. "
"Typical: match → tag(level|role, dry_run) → tag; " "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() pattern = str(args.get("pattern") or args.get("q") or "").strip()
if not pattern: if not pattern:
return {"ok": False, "error": "pattern_required"} return {"ok": False, "error": "pattern_required"}
@ -2180,7 +2193,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
) )
return out return out
if action in {"tag", "bulk_tag", "assign"}: if action == "tag":
level = args.get("level") if "level" in args else Ellipsis level = args.get("level") if "level" in args else Ellipsis
role = args.get("role") if "role" 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 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: else:
out["hint"] = ( out["hint"] = (
"Tagged. Use queryTopologyFabricNodes(role|level_major|region_folder_id) or " "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 return out
@ -2286,7 +2299,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
page["action"] = "unmatched" page["action"] = "unmatched"
return page return page
if action in {"preview_rules", "preview"}: if action == "preview_rules":
out = _data(http_json("POST", "/v1/topology/classify/preview")) out = _data(http_json("POST", "/v1/topology/classify/preview"))
if isinstance(out, dict) and out.get("ok") is not False: if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out) 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." out["hint"] = "Rule engine dry-run. Re-call action=apply_rules to write."
return out return out
if action in {"apply_rules", "apply"}: if action == "apply_rules":
skip_manual = True skip_manual = True
if "skip_manual" in args: if "skip_manual" in args:
skip_manual = _truthy(args.get("skip_manual")) 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" out["action"] = "apply_rules"
return out return out
if action in {"list_rules", "rules"}: if action == "list_rules":
out = _data(http_json("GET", "/v1/topology/classify/rules")) out = _data(http_json("GET", "/v1/topology/classify/rules"))
if isinstance(out, dict) and out.get("ok") is not False: if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out) 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))) page_size = min(500, max(1, int(args.get("page_size") or 100)))
node_id = str(args.get("node_id") or "").strip() node_id = str(args.get("node_id") or "").strip()
detail = str(args.get("detail") or "adjacency").strip().lower() 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] = { params: dict[str, Any] = {
"page": page, "page": page,
"page_size": page_size, "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() params["status"] = str(args.get("status")).strip()
if str(args.get("source") or "").strip(): if str(args.get("source") or "").strip():
src = str(args.get("source")).strip().lower() src = str(args.get("source")).strip().lower()
if src == "stale":
src = "lldp"
params["source"] = src params["source"] = src
if str(args.get("keyword") or "").strip(): if str(args.get("keyword") or "").strip():
params["keyword"] = str(args.get("keyword")).strip() params["keyword"] = str(args.get("keyword")).strip()
out = http_json("GET", "/v1/topology/fabric/edges", params=params) data = _data(http_json("GET", "/v1/topology/fabric/edges", params=params))
if not isinstance(out, dict) or not out.get("ok"): if not data.get("ok"):
return out if isinstance(out, dict) else {"ok": False, "error": "invalid_response"} return data
data = out.get("data") if isinstance(out.get("data"), dict) else {}
items = [e for e in (data.get("items") or []) if isinstance(e, dict)] items = [e for e in (data.get("items") or []) if isinstance(e, dict)]
edge_total = int(data.get("total") or len(items)) edge_total = int(data.get("total") or len(items))
links = _collapse_edges_to_links(items, include_names=True) 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, units_as_batch_rows,
) )
source_view_id = str( source_view_id = str(args.get("source_view_id") or "").strip()
args.get("source_view_id") or args.get("root_view_id") or "" sink_view_id = str(args.get("sink_view_id") or "").strip()
).strip()
sink_view_id = str(
args.get("sink_view_id") or args.get("target_view_id") or ""
).strip()
if not source_view_id or not sink_view_id: if not source_view_id or not sink_view_id:
return { return {
"ok": False, "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} patch_out: dict[str, Any] = {"ok": True, "updated": 0}
if pos_patch: if pos_patch:
patch_out = _patch_positions_chunked(sink_view_id, 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( rm_out = _remove_topology_view_nodes(
{"view_id": source_view_id, "fabric_node_ids": move_ids} {"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 # Refresh counts
src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0)) 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)) 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 {}) src_ids2, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
snk_ids2, _ = _view_node_id_pos(snk2 if snk2.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)) 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( batch_row.update(
{ {
"added": int(add_out.get("added") or len(add_ids)), "added": int(add_out.get("added") or len(add_ids)),
"positions_updated": int(patch_out.get("updated") or len(pos_patch)), "positions_updated": int(patch_out.get("updated", 0)),
"removed_from_source": int(rm_out.get("removed") or len(move_ids)), "removed_from_source": int(rm_out.get("removed", 0)),
"source_after": source_remaining, "source_after": source_remaining,
"sink_after": sink_count, "sink_after": sink_count,
"max_nodes_bump": bump, "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. Bidirectional: swap view_id / source_view_id to send nodes back.
Caller supplies fabric_node_ids — no auto CN/dual-unit selection. Caller supplies fabric_node_ids — no auto CN/dual-unit selection.
""" """
dest_view_id = str( dest_view_id = str(args.get("view_id") or "").strip()
args.get("view_id") source_view_id = str(args.get("source_view_id") or "").strip()
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()
if not dest_view_id or not source_view_id: if not dest_view_id or not source_view_id:
return { return {
"ok": False, "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"} return {"ok": False, "error": "fabric_node_ids_empty"}
mode = str(args.get("mode") or "preview").strip().lower() or "preview" 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")) dry_run = bool(overrides.get("dry_run") if "dry_run" in overrides else args.get("dry_run"))
if mode != "apply": if mode != "apply":
dry_run = True 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))) 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: 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)} add_out: dict[str, Any] = {"ok": True, "added": 0, "skipped_existing": len(already)}
if add_ids: 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} patch_out: dict[str, Any] = {"ok": True, "updated": 0}
if pos_patch: if pos_patch:
patch_out = _patch_positions_chunked(dest_view_id, 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} rm_out: dict[str, Any] = {"ok": True, "removed": 0}
if remove_from_source: 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)) 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)) 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 {}) src_after, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {}) dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
return { return {
**base, **base,
"dry_run": False, "dry_run": False,
"added": int(add_out.get("added") or len(add_ids)), "added": int(add_out.get("added") or len(add_ids)),
"positions_updated": int(patch_out.get("updated") or len(pos_patch)), "positions_updated": int(patch_out.get("updated", 0)),
"removed_from_source": int(rm_out.get("removed") or (len(move_ids) if remove_from_source else 0)), "removed_from_source": int(rm_out.get("removed", 0)),
"source_after": len(src_after) if src2.get("ok") else None, "source_after": len(src_after) if src2.get("ok") else None,
"dest_after": len(dst_after) if dst2.get("ok") else None, "dest_after": len(dst_after) if dst2.get("ok") else None,
"max_nodes_bump": bump, "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 Source membership is unchanged. For test sandboxes: clone a known-good
canvas onto a fresh child region without re-adding ids by hand. canvas onto a fresh child region without re-adding ids by hand.
""" """
source_view_id = str( source_view_id = str(args.get("source_view_id") or "").strip()
args.get("source_view_id") or args.get("from_view_id") or "" target_view_id = str(args.get("target_view_id") or "").strip()
).strip()
target_view_id = str(
args.get("target_view_id") or args.get("to_view_id") or ""
).strip()
if not source_view_id or not target_view_id: if not source_view_id or not target_view_id:
return { return {
"ok": False, "ok": False,
@ -3322,8 +3357,29 @@ def _copy_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
] ]
if positions: if positions:
patch_out = _patch_positions_chunked(target_view_id, 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)) 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 {}) dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
return { return {
"ok": True, "ok": True,
@ -3779,6 +3835,11 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"skipped peers are counted in out_of_region_skipped." "skipped peers are counted in out_of_region_skipped."
), ),
}, },
"dry_run": {
"type": "boolean",
"default": False,
"description": "Preview projected graph without saving placements.",
},
"detail": { "detail": {
"type": "string", "type": "string",
"enum": ["summary", "full"], "enum": ["summary", "full"],
@ -3797,8 +3858,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"Default: q→search, else list. " "Default: q→search, else list. "
"summary=node/edge counts; list=paged filters " "summary=node/edge counts; list=paged filters "
"(keyword/role/link_status/region_folder_id); " "(keyword/role/link_status/region_folder_id); "
"search=quick name/IP/id (needs q). Replaces getTopologyFabricSummary / " "search=quick name/IP/id (needs q)."
"listTopologyFabricNodes / searchTopologyFabricNodes."
), ),
"inputSchema": { "inputSchema": {
"type": "object", "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. " "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. " "action=match → tag(level|role preset, dry_run) → tag. "
"role preset still accepted (maps to *.0). " "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." "Requires ne:write."
), ),
"inputSchema": { "inputSchema": {
@ -4030,8 +4091,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
}, },
"use_source_dual_portals": { "use_source_dual_portals": {
"type": "boolean", "type": "boolean",
"default": True, "default": False,
"description": "Exclude portals from source dual_units (default true).", "description": "Exclude portals from source dual_units (default false).",
}, },
"use_sink_dual_portals": { "use_sink_dual_portals": {
"type": "boolean", "type": "boolean",
@ -4156,7 +4217,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"action=layout: recipe=rings|corridor|compact|unstick (small graphs). " "action=layout: recipe=rings|corridor|compact|unstick (small graphs). "
"action=layout_dual_unit: CN/core dual-portal eye (max membership; " "action=layout_dual_unit: CN/core dual-portal eye (max membership; "
"residual crossings OK unless params.require_zero_cross=true). " "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. " "source_view_id→view_id; park=true for orbit attach; swap views to reverse. "
"Prefer sinkTopologyDualUnits for dual_units batches. " "Prefer sinkTopologyDualUnits for dual_units batches. "
"action=orbit_sweep: crossing orbit; preview+node_id / apply+pick; " "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=pull_far_chains: scale far corridors/isolates toward portal mid-point. "
"action=align_reference: SAME-network A/B debug only " "action=align_reference: SAME-network A/B debug only "
"(shared fabric_node_ids); not a cross-network template. " "(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=untangle / straighten_channels: surgical polish (not on eye sinks). "
"action=job_status|job_cancel: poll/cancel background jobs. " "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. " "preset: loose|balanced|dense. mode: preview|apply. "
"Workflow: analyze(structure) → sinkTopologyDualUnits(max_units=1) → " "Workflow: analyze(structure) → sinkTopologyDualUnits(max_units=1) → "
"suggestSinkHubs/move_nodes(park) → until_limit(crossing→total) → " "suggestSinkHubs/move_nodes(park) → until_limit(crossing→total) → "
@ -4192,7 +4256,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"enum": [ "enum": [
"layout", "layout",
"fix_overlaps", "fix_overlaps",
"resolve_overlaps",
"layout_dual_unit", "layout_dual_unit",
"straighten_channels", "straighten_channels",
"untangle", "untangle",
@ -4204,7 +4267,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"orbit_sweep", "orbit_sweep",
"level_bands", "level_bands",
"move_nodes", "move_nodes",
"sink_nodes",
"job_status", "job_status",
"job_cancel", "job_cancel",
], ],
@ -4212,13 +4274,13 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"description": ( "description": (
"layout=full recipe; layout_dual_unit=dual-portal eye; " "layout=full recipe; layout_dual_unit=dual-portal eye; "
"level_bands=horizontal layer bands; " "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; " "orbit_sweep=polar sweep; polish_crossings=one-shot cut crossings; "
"clear_edge_hits=eject edge hits (no crossing rise); " "clear_edge_hits=eject edge hits (no crossing rise); "
"pull_far_chains=scale far corridors toward portal mid; " "pull_far_chains=scale far corridors toward portal mid; "
"compact_bbox=gated farthest-K shrink; " "compact_bbox=gated farthest-K shrink; "
"align_reference=same-network A/B debug only; " "align_reference=same-network A/B debug only; "
"fix_overlaps|resolve_overlaps; " "fix_overlaps; "
"untangle/straighten_channels; job_status|job_cancel." "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 " "max_jump/angle_step/nn_floor; until_limit defaults "
"protect_rigid=portals + objective=crossing; " "protect_rigid=portals + objective=crossing; "
"single-node/round default protect_rigid=off. " "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 " "copy_positions (default true), park, remove_from_source "
"(default true), pad/offset_x/offset_y." "(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, "additionalProperties": True,
}, },
"catalog": { "catalog": {

View file

@ -89,7 +89,12 @@ def _prune_disk() -> None:
files = [p for p in files if not p.name.endswith(".args.json")] files = [p for p in files if not p.name.endswith(".args.json")]
if len(files) <= _MAX_JOBS: if len(files) <= _MAX_JOBS:
return 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: try:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
path.with_name(path.stem + ".args.json").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 subprocess, "DETACHED_PROCESS", 0x00000008
) )
proc = subprocess.Popen( # noqa: S603 — controlled argv try:
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id], proc = subprocess.Popen( # noqa: S603 — controlled argv
env=env, [sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id],
stdin=subprocess.DEVNULL, env=env,
stdout=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
creationflags=creationflags, stderr=subprocess.DEVNULL,
close_fds=True, 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: with _LOCK:
job = _read_job_disk(job_id) or job job = _read_job_disk(job_id) or job
job["pid"] = proc.pid job["pid"] = proc.pid
job["progress"] = { # A short computation can finish before Popen returns to the parent.
**(job.get("progress") or {}), # Keep its terminal progress/result intact.
"phase": "running", if job.get("status") in {"running", "cancelling"}:
"message": f"worker pid={proc.pid}", job["progress"] = {
"updated_at": time.time(), **(job.get("progress") or {}),
} "message": f"worker pid={proc.pid}",
job["heartbeat_at"] = time.time() "updated_at": time.time(),
}
job["heartbeat_at"] = time.time()
_JOBS[job_id] = job _JOBS[job_id] = job
_write_job_disk(job) _write_job_disk(job)
return job_id return job_id

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -34,10 +34,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
mcp = ["netx-mcp @ file:packages/netx-mcp"] mcp = ["netx-mcp @ file:packages/netx-mcp"]
mcp-topology = ["netx-topology-mcp @ file:packages/netx-topology-mcp"] mcp-topology = ["netx-topology-mcp @ file:packages/netx-topology-mcp"]
mcp-bizmonitor = ["netx-bizmonitor-mcp @ file:packages/netx-bizmonitor-mcp"]
[project.scripts]
netx-mcp = "netx_mcp.server:main"
netx-topology-mcp = "netx_topology_mcp.server:main"
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]

166
scripts/mcp_acceptance.py Normal file
View file

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

View file

@ -0,0 +1,264 @@
"""离线生成验收样例/非现场故障;评价结论必须来自真实 netx 业务逻辑。
用法(在 worktree 根目录,使用已有 netx Python 环境):
python scripts/seed_mcp_acceptance.py --database <绝对路径>/artifacts/acceptance.sqlite \
--metadata <绝对路径>/artifacts/acceptance.json
只接受本工人/组长 worktree artifacts 下带 acceptance 的新文件。
不启动服务、不导入应用入口、不使用网络、不连接应用数据库。
"""
from __future__ import annotations
import argparse
from datetime import datetime, timedelta, timezone
import json
import os
from pathlib import Path
import sys
from typing import Any
ROOT = Path(__file__).resolve().parents[1]
ALLOWED_WORKTREES = frozenset({"netx-mcp-bizmonitor-codex", "netx-mcp-lead"})
DECLARATION = "验收样例/非现场故障:离线合成接口采集输入,真实执行 netx 业务评价;无真实设备或凭据。"
PREFIX = "acceptance-"
def artifact_path(raw: str, *, metadata: bool = False) -> Path:
if ROOT.parent.name != "agent-wt" or ROOT.name not in ALLOWED_WORKTREES:
raise ValueError("脚本必须位于允许的 agent-wt worktree 内")
path = Path(raw)
if not path.is_absolute() or "://" in raw:
raise ValueError("必须指定 SQLite 文件绝对路径,不能使用数据库 URL")
path = path.resolve()
allowed = []
for name in ALLOWED_WORKTREES:
worktree = ROOT.parent / name
base = worktree / "artifacts"
# Reparse points/symlinks must not turn the allowlist into an external directory.
if worktree.resolve() != worktree or base.resolve() != base:
continue
allowed.append(base)
if not any(path.is_relative_to(base) for base in allowed):
raise ValueError("仅允许本工人/组长 worktree 的 artifacts 目录")
suffixes = {".json"} if metadata else {".sqlite", ".sqlite3", ".db"}
if "acceptance" not in path.name.lower() or path.suffix.lower() not in suffixes:
raise ValueError("文件名必须含 acceptance,扩展名须为 SQLite 文件或元数据 JSON")
if path.exists():
raise ValueError("目标已存在,拒绝覆盖;请使用新的验收文件名")
return path
def _offline_audit(event: str, _args: tuple[Any, ...]) -> None:
if event in {"socket.connect", "socket.getaddrinfo", "socket.bind"}:
raise RuntimeError("离线样例脚本禁止网络/监听端口")
def seed(database: Path) -> dict[str, Any]:
# Environment must precede imports; explicit sys.path wins over editable main repo.
if any(name == "netx_api" or name.startswith("netx_api.") for name in sys.modules):
raise RuntimeError("必须通过独立 Python 进程运行,不能复用已导入 netx_api 的进程")
url = "sqlite:///" + database.as_posix()
os.environ["NETX_DATABASE_URL"] = url
os.environ["NETX_CREDENTIAL_SECRET_FILE"] = str(database.with_suffix(".unused-secret"))
for setting in ("NE_COLLECT_SCHEDULER_ENABLED", "CONFIG_SYNC_SCHEDULER_ENABLED",
"LLDP_COLLECT_SCHEDULER_ENABLED", "FABRIC_RECONCILE_SCHEDULER_ENABLED",
"PORT_TRAFFIC_SCHEDULER_ENABLED", "BIZ_STATE_SCHEDULER_ENABLED",
"UME_KEEPALIVE_ENABLED", "UME_ALARM_WS_ENABLED",
"UME_SYNC_ALARMS_CURRENT_ENABLED", "UME_SYNC_INVENTORY_AUTO_ENABLED",
"UME_SYNC_TOPOLOGY_AUTO_ENABLED"):
os.environ["NETX_" + setting] = "false"
sys.path.insert(0, str(ROOT))
sys.addaudithook(_offline_audit)
import netx_api
from sqlalchemy.engine import make_url
from netx_api.db import Base, SessionLocal, engine
from netx_api.biz_migration import service as mig
from netx_api.biz_state import service as biz
from netx_api.models import (
BizCompareTemplate, BizMigrationProject, BizMonitorTemplate,
BizPortMapping, BizPortMappingRow, BizStateBatch, BizStateBatchCommand,
BizStateMetricRow, BizStateTask, BizStateTaskItem,
)
if Path(netx_api.__file__).resolve().parent != ROOT / "netx_api":
raise RuntimeError("netx_api 未从当前 worktree 导入,拒绝运行")
if engine.url != make_url(url):
raise RuntimeError("数据库配置未指向隔离 SQLite 文件,拒绝运行")
Base.metadata.create_all(engine)
now = datetime.now(timezone.utc).replace(tzinfo=None)
ids = {
"project_id": PREFIX + "project",
"old_portrait_task_id": PREFIX + "old-portrait",
"new_portrait_task_id": PREFIX + "new-portrait",
"old_current_task_id": PREFIX + "old-current",
"new_current_task_id": PREFIX + "new-current",
}
batch_ids: dict[str, str] = {}
command_ids: dict[str, str] = {}
try:
with SessionLocal() as db:
sheet = {"sheet_id": "interface_brief", "title": "验收样例接口",
"metric_id": "interface_brief", "key_fields": ["interface"],
"iface_fields": ["interface"], "compare_fields": ["admin", "phy", "prot"],
"row_filters": [], "field_rules": []}
db.add(BizCompareTemplate(id=PREFIX + "compare", name="验收样例/非现场故障-接口对比",
metric_id="interface_brief", key_fields=["interface"],
iface_fields=["interface"], compare_fields=["admin", "phy", "prot"],
metrics_json=[sheet], iface_normalize_json=[{"from": "GE", "to": "gei"}]))
db.add(BizMonitorTemplate(id=PREFIX + "monitor", name="验收样例/非现场故障-接口监控",
compare_template_id=PREFIX + "compare",
collect_metric_ids_json=["interface_brief"],
defaults_json={"out_of_expect": "strict"}, sheet_overrides_json=[{
"metric_id": "interface_brief", "sheet_id": "interface_brief",
"status_fields": ["admin", "phy", "prot"],
"down_values": ["down"], "up_values": ["up"],
"success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}],
"anomaly": [],
}]))
db.add(BizPortMapping(id=PREFIX + "mapping", name="验收样例端口映射", note=DECLARATION))
db.add(BizPortMappingRow(id=PREFIX + "mapping-row", mapping_id=PREFIX + "mapping",
before_if="gei-old", after_if="xgei-new"))
for side in ("old", "new"):
for mode in ("portrait", "current"):
tid = ids[f"{side}_{mode}_task_id"]
task = BizStateTask(
id=tid, source="import", ne_id=PREFIX + side + "-offline",
ne_name=f"验收样例-{'旧' if side == 'old' else '新'}侧(非现场)", ne_ip="",
vendor="zte", device_type="router", note=DECLARATION,
purpose="portrait" if mode == "portrait" else "cutover_hf",
status="draft", collect_manual_only=True, collect_running=False,
interval_sec=3600, retention_days=30,
)
db.add(task)
db.add(BizStateTaskItem(id=PREFIX + side + "-" + mode + "-item", task_id=tid,
source_profile_id="zte.interface_brief", kind="catalog",
enabled=False, title="验收样例离线输入;禁用采集"))
bid = PREFIX + side + "-" + mode + "-collect"
cid = PREFIX + side + "-" + mode + "-command"
batch_ids[f"{side}_{mode}"] = bid
command_ids[f"{side}_{mode}"] = cid
at = now - timedelta(minutes=10) if mode == "portrait" else now - timedelta(seconds=2)
interface = "GE-old" if side == "old" else "xgei-new"
state = "up" if side == "old" else "down"
raw = ("# 验收样例/非现场故障;离线合成输出\n"
"show interface brief\nInterface Admin Phy Prot\n"
f"{interface} {state} {state} {state}\n")
db.add(BizStateBatch(id=bid, task_id=tid, source="import", ne_id=task.ne_id,
ne_name=task.ne_name, vendor="zte", status="success",
started_at=at, ended_at=at + timedelta(seconds=1),
row_count=1, command_count=1, message=DECLARATION,
is_baseline=mode == "portrait"))
db.add(BizStateBatchCommand(id=cid, batch_id=bid,
profile_id="zte.interface_brief", parser_id="zte.interface_brief",
metric_id="interface_brief", raw_command="show interface brief",
parse_status="ok", row_count=1, raw_line_count=len(raw.splitlines()),
raw_text=raw, message=DECLARATION, created_at=at))
db.add(BizStateMetricRow(id=PREFIX + side + "-" + mode + "-row", batch_id=bid,
batch_command_id=cid, task_id=tid, ne_id=task.ne_id,
metric_id="interface_brief", seq=0, collected_at=at,
data_json={"interface": interface, "admin": state, "phy": state, "prot": state}))
db.add(BizMigrationProject(
id=ids["project_id"], name="验收样例/非现场故障-旧口仍UP新口DOWN", note=DECLARATION,
old_task_id=ids["old_portrait_task_id"], new_task_id=ids["new_portrait_task_id"],
old_hf_task_id=ids["old_current_task_id"], new_hf_task_id=ids["new_current_task_id"],
old_baseline_batch_id=batch_ids["old_portrait"], new_baseline_batch_id=batch_ids["new_portrait"],
mapping_id=PREFIX + "mapping", monitor_template_id=PREFIX + "monitor",
collect_metric_ids_json=["interface_brief"], status="draft", hf_interval_sec=3600,
))
db.commit()
cutover = mig.create_batch(db, ids["project_id"], {
"batch_label": "验收样例/非现场故障-第一批", "status": "active",
"expect_set": {"ports": ["gei-old"]}, "note": DECLARATION,
})
# This executes real run_evaluate and real red-ticket persistence.
finished = mig.finish_batch(db, cutover["id"], mark_done=False)
run_id = finished["run"]["id"]
diffs = mig.list_run_diffs(db, run_id, color="red", limit=50)
reds = mig.list_red_tickets(db, ids["project_id"])
if diffs["total"] < 1 or reds["open_count"] < 1 or finished["accept_summary"]["passed"]:
raise RuntimeError("真实评价未生成预期红项,样例验证失败")
item = diffs["items"][0]
if item["old_key"] != "GE-old" or item["new_key"] != "xgei-new":
raise RuntimeError("缺少原始 A/B 键,样例验证失败")
evidence = item.get("evidence") or {}
raw_checks: dict[str, Any] = {}
for side in ("old", "new"):
ev = evidence.get(side) or {}
command = ev.get("command") or {}
collect = ev.get("collect") or {}
if command.get("raw_command") != "show interface brief" or not collect.get("collected_at"):
# Some evidence versions name the timestamp at batch granularity.
if command.get("raw_command") != "show interface brief" or not any(collect.get(k) for k in ("started_at", "ended_at", "batch_started_at")):
raise RuntimeError("双侧命令/采集时间证据缺失")
bid = collect["batch_id"]
cid = command.get("id") or command.get("command_id")
if not cid:
raise RuntimeError("证据没有可请求的命令 ID")
raw = biz.get_batch_command(db, bid, cid)
if "验收样例/非现场故障" not in raw["raw_text"]:
raise RuntimeError("命令原文回读失败")
raw_checks[side] = {"batch_id": bid, "command_id": cid, "raw_text": raw["raw_text"],
"collected_at": raw["collected_at"]}
# Read paths behind all eight MCP tools; HTTP/stdio is the lead's next step.
assert mig.list_projects(db) and biz.list_tasks(db)
assert mig.list_batches(db, ids["project_id"]) and biz.list_batches(db, ids["old_current_task_id"])
assert mig.list_runs(db, cutover["id"])
assert mig.get_monitor_context(db, project_id=ids["project_id"])
assert mig.board(db, cutover["id"], run_id=run_id)
assert biz.get_batch(db, batch_ids["new_current"])["commands"]
tasks = db.query(BizStateTask).all()
if not all(t.status == "draft" and t.collect_manual_only and not t.collect_running for t in tasks):
raise RuntimeError("样例任务安全状态检查失败")
db.commit()
return {
"declaration": DECLARATION, "database": str(database), "database_url": url,
"source_root": str(ROOT), "created_at": now.isoformat() + "Z", **ids,
"cutover_batch_id": cutover["id"], "run_id": run_id,
"collect_batch_ids": batch_ids, "command_ids": command_ids,
"expected": {"scenario": "旧侧接口仍UP,新侧映射接口DOWN,割接验收未完成,应产生红项",
"old_key": "GE-old", "new_key": "xgei-new", "minimum_red_count": 1},
"observed": {"red_diff_count": diffs["total"], "open_red_count": reds["open_count"],
"verdict": item["verdict"], "color": item["color"],
"accept_status": finished["batch"]["accept_status"],
"accept_summary": finished["accept_summary"], "raw_evidence": raw_checks},
"safety": {"network_blocked": True, "task_status": "draft", "manual_only": True,
"task_items_enabled": False, "device_credentials": False, "real_device_ip": False},
}
finally:
engine.dispose()
def main() -> int:
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--database", required=True, help="artifacts 内新 SQLite 文件的绝对路径,文件名含 acceptance")
parser.add_argument("--metadata", help="artifacts 内新 JSON 文件的绝对路径,文件名含 acceptance")
args = parser.parse_args()
try:
database = artifact_path(args.database)
metadata = artifact_path(args.metadata, metadata=True) if args.metadata else None
database.parent.mkdir(parents=True, exist_ok=True)
# Exclusive creation prevents overwrite, including races after validation.
with database.open("xb"):
pass
result = seed(database)
text = json.dumps(result, ensure_ascii=False, indent=2)
if metadata:
metadata.parent.mkdir(parents=True, exist_ok=True)
with metadata.open("x", encoding="utf-8") as output:
output.write(text + "\n")
print(text)
return 0
except Exception as exc:
# Known local validation messages are safe; other failures omit internals.
message = str(exc) if isinstance(exc, (ValueError, RuntimeError)) else type(exc).__name__
print(json.dumps({"ok": False, "error": message, "declaration": DECLARATION}, ensure_ascii=False), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -6,7 +6,7 @@
|-------|--------------|----------------------------------------| |-------|--------------|----------------------------------------|
| `ops` | `ops/netx-ops/` | NMS 告警/库存/SQL + managed CLI + `findTopologyPaths` | | `ops` | `ops/netx-ops/` | NMS 告警/库存/SQL + managed CLI + `findTopologyPaths` |
| `topology` | `topology/netx-topology/` | 画布 / Fabric / dual_unit / 布图 | | `topology` | `topology/netx-topology/` | 画布 / Fabric / dual_unit / 布图 |
| `biz-monitor` | `biz-monitor/netx-biz-monitor/` | 割接/业务监控只读分析(**netxops 宿主工具**直连 REST;非 netx-mcp) | | `bizMonitor` | `biz-monitor/netx-biz-monitor/` | 割接/业务监控只读分析(独立 `netx-bizmonitor-mcp` 或 netxops 宿主工具) |
| `biz-state` | `biz-state/netx-biz-state/` | 状态监控:TextFSM / parser / Profile(Cursor Agent;无 DSH 工具) | | `biz-state` | `biz-state/netx-biz-state/` | 状态监控:TextFSM / parser / Profile(Cursor Agent;无 DSH 工具) |
## DSH 怎么用 ## DSH 怎么用
@ -14,8 +14,8 @@
1. 运行时优先:`NETX_SKILLS_ROOT` → 旁路 `../netx/skills` → 包内 `presets/netxops/skills` 1. 运行时优先:`NETX_SKILLS_ROOT` → 旁路 `../netx/skills` → 包内 `presets/netxops/skills`
2. 发 npm 前:`powershell -File netxops/scripts/sync-skills-from-netx.ps1` 2. 发 npm 前:`powershell -File netxops/scripts/sync-skills-from-netx.ps1`
3. Settings → 能力组开关:开哪组就注册哪组的 **tools + 对应 skill**(含 **bizMonitor**) 3. Settings → 能力组开关:开哪组就注册哪组的 **tools + 对应 skill**(含 **bizMonitor**)
4. 其它预设可强制挂:`dsh-netxops/tools-ops|topology|biz-monitor`(旧别名 `tools-nms` / `tools-common` → ops) 4. 其它预设可强制挂:`dsh-netxops/tools-ops|topology|biz-monitor`
MCP / Cursor:skill 根直接指本目录。MCP 包内不镜像 skills。业务监控分析工具只在 **netxops**,不要往 netx-mcp 加。 MCP / Cursor:skill 根直接指本目录,包内不镜像 skills。不装 netxops 时,分别使用 `netx-mcp`(15 工具)、`netx-topology-mcp`(16 工具)和 `netx-bizmonitor-mcp`(8 工具);配置见根 `mcp.all.json`。独立包均通过 HTTP 访问 netx,不直连数据库。
**Cursor Agent**:加 biz_state 监控命令时启用或点名 **netx-biz-state**(`skills/biz-state/netx-biz-state/`)。 **Cursor Agent**:加 biz_state 监控命令时启用或点名 **netx-biz-state**(`skills/biz-state/netx-biz-state/`)。

View file

@ -1,32 +1,30 @@
--- ---
name: netx-biz-monitor name: netx-biz-monitor
description: >- description: >-
Overnight cutover / biz_state monitor analysis via netxops host tools 通过独立 netx-bizmonitor-mcp 或 netxops 分析割接与业务状态。
(netx__listBizMonitors / listBizMonitorBatches / getBizMonitor*). List 无 ID 先查目录和批次,结合看板、变红项、双侧原始 A/B 键和采集命令原文判断故障。
projects/tasks then batches when no id is given; read definitions, board, 只读已有项目与评价结果,不创建模板、触发采集、执行评价或关闭红票。
reds/diffs with evidence (device-raw A/B, show commands), and judge tool
false-positive vs real business fault. Does not create templates/tasks.
--- ---
# netx-biz-monitor(割接 / 业务监控只读分析) # netx-biz-monitor(割接 / 业务监控只读分析)
人配好监控与任务;AI 当晚做归因。 人配好监控与任务;AI 当晚做归因。
工具在 **netxops**(`netx__*`),直连 netx REST,**不是** netx-mcp。 独立 **netx-bizmonitor-mcp** 提供下表裸名;netxops 使用 `netx__` 加同名。两者均访问 netx REST,需 `biz-monitor:read`。安装和三组配置见 [MCP_BIZMONITOR.md](../../../docs/MCP_BIZMONITOR.md)。
## 工具(能力组 `bizMonitor`) ## 工具(能力组 `bizMonitor`)
| 工具 | 用途 | | 工具 | 用途 |
|------|------| |------|------|
| `netx__listBizMonitors` | **入口目录**:列出割接/监控对比项目 + 业务监控任务(**无需** project_id) | | `listBizMonitors` | **入口目录**:列出割接/监控对比项目 + 业务监控任务(**无需** project_id) |
| `netx__listBizMonitorBatches` | **批次目录**:`project_id`→割接批次;`task_id`→采集批次;`kind=runs`→evaluate run | | `listBizMonitorBatches` | **批次目录**:`project_id`→割接批次;`task_id`→采集批次;`batch_id,kind=runs`→evaluate run |
| `netx__getBizMonitorContext` | 任务定义:项目、监控/对比模板、归一化规则、端口映射、关联任务与命令项 | | `getBizMonitorContext` | 任务定义:项目、监控/对比模板、归一化规则、端口映射、关联任务与命令项 |
| `netx__getBizMonitorBoard` | 批次进度、evaluate run、sheet 卡、verdict 统计 | | `getBizMonitorBoard` | 批次进度、evaluate run、sheet 卡、verdict 统计 |
| `netx__listBizMonitorReds` | 红单 + **evidence** | | `listBizMonitorReds` | 红单 + **evidence** |
| `netx__getBizMonitorDiffs` | run 明细(可滤 color=red) | | `getBizMonitorDiffs` | run 明细,`color=red`;`offset/limit` 分页;`q` 为关键词 |
| `netx__getBizCollectBatch` | 某次采集:commands / metrics | | `getBizCollectBatch` | 采集工作簿摘要、commands 和 sheet 目录;不含全部指标行或原文 |
| `netx__getBizCollectCommandRaw` | 单命令全量 show 输出 | | `getBizCollectCommandRaw` | 以采集 batch_id + command_id 获取单命令原文,按需调用,可能很大 |
交叉验证真障时再用 ops:`netx__execManagedNe` / 拓扑路径。 交叉验证真障时再用 ops 的 `execManagedNe` / 拓扑路径。独立 MCP 仅允许单行 `show` / `display`,不执行配置或 shell 脚本。
## 字段约定(必守) ## 字段约定(必守)
@ -41,9 +39,9 @@ description: >-
2. `listBizMonitorBatches(project_id)` 或 `(task_id)` — 拿到 **完整 batch_id**;需要 run 时再 `kind=runs` / `include_runs` 2. `listBizMonitorBatches(project_id)` 或 `(task_id)` — 拿到 **完整 batch_id**;需要 run 时再 `kind=runs` / `include_runs`
3. `getBizMonitorContext(project_id)` — 弄清盯什么 3. `getBizMonitorContext(project_id)` — 弄清盯什么
4. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped 4. `getBizMonitorBoard(batch_id)` — 进度 / missing / skipped
5. `listBizMonitorReds` 或 `getBizMonitorDiffs(color=red)` 5. `listBizMonitorReds` 或 `getBizMonitorDiffs(run_id,color=red,limit=100,offset=0)`;按 total 翻页,不能只看第一页就宣称全部红项。
6. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤? 6. 看 evidence:parse 失败?current_missing?映射 miss?归一化误伤?
7. 需要原文 → `getBizCollectCommandRaw` 7. 从差异 evidence 的 collect/command 取两侧**采集** batch_id/command_id → `getBizCollectCommandRaw`;若证据不全,用对应 task_id 查采集批次,再 getBizCollectBatch 查命令。不要把割接 batch_id 当成采集 batch_id。
8. 像真障 → `execManagedNe` 现场核对 8. 像真障 → `execManagedNe` 现场核对
## Verdict 速查(skill 解释,工具不硬编码) ## Verdict 速查(skill 解释,工具不硬编码)
@ -64,3 +62,5 @@ description: >-
4. 再谈真实业务问题 4. 再谈真实业务问题
配置整改与关红单由人做;本 skill **只读分析**。 配置整改与关红单由人做;本 skill **只读分析**。
目录或批次为空时,明确说明没有可评价的数据;不把空红票当作割接通过。run 尚未完成或存在 current_missing/采集失败时,不给通过结论。工具本身不启动 evaluate;需要人先在 Web 完成定义、采集和评价。`old_key/new_key` 是设备原始端口;例:归一化和映射将 GE-old 匹配到 xgei-new,结论仍分别展示 GE-old 与 xgei-new,不能用内部匹配键替代。

View file

@ -8,7 +8,7 @@ description: netx 运维:告警、网元、只读 CLI。
处理 netx **告警 / 网元清单 / 只读 CLI** 时遵循本手册。 处理 netx **告警 / 网元清单 / 只读 CLI** 时遵循本手册。
画布布图不在本 skill(见 **netx-topology**)。本手册里的「路径」仅指 `findTopologyPaths` 查两端关联。 画布布图不在本 skill(见 **netx-topology**)。本手册里的「路径」仅指 `findTopologyPaths` 查两端关联。
**工具名**:DSH 为 `netx__` + stem(如 `netx__queryNmsAlarms`);MCP 多为同名裸 stem。下文一律写 stem。 **工具名**:DSH 为 `netx__` + stem(如 `netx__queryNmsAlarms`);独立 `netx-mcp` 使用同名裸 stem,共 15 个。下文一律写 stem。模型入参只用 `nms_ne_id/nms_ne_ids`;旧 `ume_*`、getManagedNe 的 `id/managed_ne_id` 别名已删除。
**两套「一批」(勿混):** **两套「一批」(勿混):**
@ -23,7 +23,7 @@ description: netx 运维:告警、网元、只读 CLI。
## 1. 总原则 ## 1. 总原则
1. **先新鲜度,再结论**:`runNmsDiagnostics` 或 `aggregateNmsAlarms` → 读 `meta.last_seen_*`。过旧则按快照:时间窗落在 min~max 内,禁止默认「现在−30 分钟」。 1. **先新鲜度,再结论**:`runNmsDiagnostics` → 读全库 `meta.last_seen_*`。用户问最近一小时则按用户时区查询真实时间窗;若为空且快照过旧,只能说当前数据无法判断。可另按快照 max 前一小时补充历史分析,明确日期,不冒充当前告警。带过滤的 aggregate 元数据只代表过滤后的窗口。
2. **证据优先**:有告警须有级别计数和/或 Top `host_name`;否则写「证据不足」,禁止臆造根因。 2. **证据优先**:有告警须有级别计数和/或 Top `host_name`;否则写「证据不足」,禁止臆造根因。
3. **对用户**:只展示 **host_name**;**严禁**出现任何 UUID/`ne_id`。无 `host_name` 的行当垃圾丢弃(不顶替 label、不提「缺失」)。工具入参仍可用 id,但不得写入对用户可见正文。 3. **对用户**:只展示 **host_name**;**严禁**出现任何 UUID/`ne_id`。无 `host_name` 的行当垃圾丢弃(不顶替 label、不提「缺失」)。工具入参仍可用 id,但不得写入对用户可见正文。
4. **短问**:能 ≤3 次工具调用就闭环的,先聚合/过滤,禁止无过滤翻全库。复杂关联(dying gasp、A<>B CLI)不受 3 次硬顶,但仍须有过滤与目标。 4. **短问**:能 ≤3 次工具调用就闭环的,先聚合/过滤,禁止无过滤翻全库。复杂关联(dying gasp、A<>B CLI)不受 3 次硬顶,但仍须有过滤与目标。
@ -67,7 +67,10 @@ description: netx 运维:告警、网元、只读 CLI。
- 命令相同:`nms_ne_ids` 或 `ne_ids` + 共享 `commands` - 命令相同:`nms_ne_ids` 或 `ne_ids` + 共享 `commands`
- 命令不同(厂商/角色不同):`targets=[{nms_ne_id|ne_id, commands:[…]}, …]`,仍一次调用 - 命令不同(厂商/角色不同):`targets=[{nms_ne_id|ne_id, commands:[…]}, …]`,仍一次调用
- 超时:加大 `read_timeout_sec` 或减条数;禁止同参盲重试 - 超时:加大 `read_timeout_sec` 或减条数;禁止同参盲重试
- 只读:`show` / `display` / `ping` / `traceroute` 及带 `?` 的探索;配置类不做 - 独立 MCP 只允许单行 `show` / `display`,支持只读输出过滤和 `?` 探索;禁止配置、shell、重定向、命令拼接。后端 `linux_shell/unrestricted` 不能解除 MCP 门禁。
- `async=true` 或多台自动异步返回 job_id 仅代表开始;约 5 秒后用 `getNeExecJob` 有界轮询,期间可继续独立工作。单台须 `terminal=true` 且 `result.ok=true`;批量还须核对 `result.summary.failed=0` 和每条 `result.results[].ok=true`。`status=succeeded` / `result.ok=true` 仍可能是部分成功,要分别报告成功和失败目标。连接失败/timeout 不能当作登录成功,也不要重提相同 job 或整批重跑已成功设备。
- `getManagedNe.capability` 是后端设备能力;独立 MCP 仍只允许单行 show/display,后端 Linux/RouterOS 开放策略不会放宽此门禁。NAS/VPS/RouterOS 原生 CLI 当前不能通过这组 MCP 执行;连接测试须明确另用 netx Web/API,不能把 API 登录证据写成 MCP 原生 CLI 执行成功。
- 同一 IP 有多个纳管记录时,结合用户指定厂商与 `vendor/device_type` 选择 ID,不能取第一条 generic 记录就执行。`connect_status` 是上次连接测试状态,exec 成功不会自动刷新;本次登录/查询结论以本次 job 的逐台结果和有效正文为准。空正文或只有命令头不算查询通过。
**✓ 同命令多台** **✓ 同命令多台**
@ -180,7 +183,7 @@ Neighbour / PW / Tunnel 等控制面量多 **不当断纤**,除非用户问的
- **优先复用本会话已成功过的命令**(含同厂其他台验证过的):新台/下一批先试这些,再对失败子集另探;不要一失败就换全员命令。 - **优先复用本会话已成功过的命令**(含同厂其他台验证过的):新台/下一批先试这些,再对失败子集另探;不要一失败就换全员命令。
- 多台一批时:用 `targets` 给失败台换命令,或先一批共同命令 → 只对失败子集再一批探测;不要为了一台把成功台的结果丢掉重来。 - 多台一批时:用 `targets` 给失败台换命令,或先一批共同命令 → 只对失败子集再一批探测;不要为了一台把成功台的结果丢掉重来。
### `?` 规则(`show` / `display` / `ping` 等同一套) ### `?` 规则(`show` / `display`)
1. 报错中的 **`^`** 标出错位置 → **`^` 之前**已正确 → 对该前缀加 `?` 列下一级。 1. 报错中的 **`^`** 标出错位置 → **`^` 之前**已正确 → 对该前缀加 `?` 列下一级。
2. `?` 可紧接在部分单词后(`show optical?`、`display inter?`),不必强制空格再写 `?`。 2. `?` 可紧接在部分单词后(`show optical?`、`display inter?`),不必强制空格再写 `?`。

View file

@ -317,7 +317,7 @@ sinkTopologyDualUnits({
|--------|------| |--------|------|
| `layout` | 小图 / 剩余块:`compact` / `corridor` / `rings` / `unstick` | | `layout` | 小图 / 剩余块:`compact` / `corridor` / `rings` / `unstick` |
| `layout_dual_unit` | **仅**重布已沉的那一只眼;椭圆弧带 + 空心眼 | | `layout_dual_unit` | **仅**重布已沉的那一只眼;椭圆弧带 + 空心眼 |
| `move_nodes` / `sink_nodes` | **后续下沉主路径**;`park` 块扫 | | `move_nodes` | **后续下沉主路径**;`park` 块扫 |
| `orbit_sweep` | **眼后精修主工具**:`until_limit` 或 `node_id`;`objective=crossing\|total` | | `orbit_sweep` | **眼后精修主工具**:`until_limit` 或 `node_id`;`objective=crossing\|total` |
| `clear_edge_hits` | 眼 sink **可**(须 `portal_ids`;交叉不升) | | `clear_edge_hits` | 眼 sink **可**(须 `portal_ids`;交叉不升) |
| `pull_far_chains` | 眼 sink **可**:朝门户中点收远走廊/孤立点 | | `pull_far_chains` | 眼 sink **可**:朝门户中点收远走廊/孤立点 |
@ -376,3 +376,13 @@ sinkTopologyDualUnits({
4. 机器上常残留多份旧 MCP 进程 → 精修前 catalog 若仍缺 action,**杀光再启**;勿用旧进程半截交付,勿改走 HTTP/临时 py。 4. 机器上常残留多份旧 MCP 进程 → 精修前 catalog 若仍缺 action,**杀光再启**;勿用旧进程半截交付,勿改走 HTTP/临时 py。
拓扑页开「实时同步」可看落笔。勿用告警/CLI MCP 写拓扑。 拓扑页开「实时同步」可看落笔。勿用告警/CLI MCP 写拓扑。
## MCP 异步与失败处理
小图先用 `layoutTopologyView(view_id=...,action=layout,recipe=compact,mode=preview)` 看提案,再 `mode=apply,params={background:true}` 保存。`job_id/status=running/applied=false` 表示尚未完成;用同一台机器、同一任务目录的 `layoutTopologyView(action=job_status,params={job_id:...})` 轮询。成功需 `status=done` 且 `result.applied=true`,随后 `getTopologyView(detail=full)` 回读坐标。没有 HTTP layout job 接口。
任务状态在 MCP 的 `NETX_LAYOUT_JOB_DIR`(默认 data/runtime/layout_jobs)落盘,换机器/目录不能读同一 job。`job_cancel` 只请求协作取消,不承诺回滚。复制/移动/下沉是多步 HTTP 操作;遇到错误或 `write_completed=true` 先读两端现状,不盲重试。坐标保存失败时不继续删除源成员。
只读 token 不包含 `ne:write`,将看不到布局/建图工具;preview/job_status 目前也随 layout 工具受该权限限制。修改的是 netx 画布与 Fabric 元数据,不向真实设备下配置。需要演练时新建验收视图,避免覆盖既有图。
布局任务成功和评分模型评价分别记录。评分仅供诊断,需结合图形与业务需求;不能仅凭总分判定布局通过或失败。评分模型的校准留待后续专项,本轮不调整算法或阈值。

3
tests/fixtures/zte_vpnv6/README.md vendored Normal file
View file

@ -0,0 +1,3 @@
# ZTE vpnv6 只读回放样本
这3个小样本于2026-10-15从工作区现有 `D:/project/chatgpt/test/show-zte/` 同名文件原样复制,作为 `test_bgp_peer_ipv4_and_ipv6_wrap` 的仓库内测试输入。内容为 show bgp 的summary及vrf邻居in/out只读输出,不含配置日志或口令。未在本次任务中连接设备采集。保留字节原貌;请勿把它们解释为合成数据或本次现场实测。

13
tests/fixtures/zte_vpnv6/show-bgp-vpnv6 vendored Normal file
View file

@ -0,0 +1,13 @@
MDN-BCP-CN1-ZM8SP#show bgp vpnv6 unicast summary | one-line
08:04:10 Indonesia Sat Sep 19 2026
BGP router ID : 124.195.96.248
Local AS : 4761
All peers : 2
Established : 2
All prefixes : 0
Neighbor Ver As MsgRcvd MsgSend Up/Down State/PfxRcd
FC00:A000:0:61:114::75
4 4761 14476867 2679817 5w4d 0
FC00:A000:0:61:114::76
4 4761 14609910 2680356 5w4d 0
MDN-BCP-CN1-ZM8SP#

View file

@ -0,0 +1,40 @@
MDN-BCP-CN1-ZM8SP#show bgp vpnv6 unicast vrf vpn.giims neighbor in 2407::45:0:0:5:1 | one-line
09:50:02 Indonesia Sat Sep 19 2026
Routes Learned From This Neighbor:
Status codes: * valid, < last valid, i - internal, s - stale
Origin codes: i - IGP, e - EGP, ? - incomplete
Local router ID 124.195.96.248, Local AS 4761, Local port 179
Remote router ID 10.83.48.3, Remote AS 65254, Remote port 54486
Total number of routes: 9
Valid routes : 9
Invalid routes : 0
Network Next Hop Metric LocPrf RtPrf Path
* 2407::45:0:0:2:780/125
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:7c4/128
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:7c5/128
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:800/123
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:844/128
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:845/128
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:880/123
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:8c4/128
2407::45:0:0:5:1
20 65254 ?
* 2407::45:0:0:2:8c5/128
2407::45:0:0:5:1
20 65254 ?
MDN-BCP-CN1-ZM8SP#

View file

@ -0,0 +1,11 @@
MDN-BCP-CN1-ZM8SP#show bgp vpnv6 unicast vrf vpn.giims neighbor out 2407::45:0:0:5:1 | one-line
09:51:15 Indonesia Sat Sep 19 2026
Routes Sent To This Neighbor:
Origin codes: i - IGP, e - EGP, ? - incomplete
Total number of routes: 1
Network Next Hop From Metric LocPrf Path
2407::45:0:0:5:0/127
2407::45:0:0:5:0
4761 ?
MDN-BCP-CN1-ZM8SP#

View file

@ -6,12 +6,56 @@ import unittest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import create_engine, event, inspect
from sqlalchemy.orm import Session
from netx_api.db import Base
from netx_api.biz_state.service import get_batch, list_batch_metric_rows from netx_api.biz_state.service import get_batch, list_batch_metric_rows
from netx_api.models import BizStateBatch, BizStateBatchCommand from netx_api.models import BizStateBatch, BizStateBatchCommand, BizStateMetricRow
class BatchWorkbookApiTests(unittest.TestCase): class BatchWorkbookApiTests(unittest.TestCase):
def _stored_summary(self, batch, commands, metric_counts):
"""Exercise SQL summary projection against an isolated DB, with raw text deferred."""
engine = create_engine("sqlite+pysqlite:///:memory:")
self.addCleanup(engine.dispose)
Base.metadata.create_all(engine)
with Session(engine, expire_on_commit=False) as db:
db.add(batch)
db.add_all(commands)
for metric_id, count in metric_counts:
db.add_all([
BizStateMetricRow(batch_id=batch.id, metric_id=metric_id, seq=i)
for i in range(count)
])
db.commit()
db.expunge_all()
selected_commands = []
column_loads = []
@event.listens_for(db, "loaded_as_persistent")
def check_deferred(session, row):
if isinstance(row, BizStateBatchCommand):
selected_commands.append(row)
self.assertIn("raw_text", inspect(row).unloaded)
@event.listens_for(db, "do_orm_execute")
def track_column_loads(state):
if state.is_column_load:
column_loads.append(state.statement)
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
result = get_batch(db, batch.id)
self.assertEqual(len(selected_commands), len(commands))
self.assertEqual(column_loads, [], "summary must never lazy-load raw CLI text")
for row in selected_commands:
self.assertIn("raw_text", inspect(row).unloaded)
return result
def test_get_batch_summary_has_sheets_not_metrics(self) -> None: def test_get_batch_summary_has_sheets_not_metrics(self) -> None:
batch = BizStateBatch( batch = BizStateBatch(
id="b1", id="b1",
@ -31,35 +75,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
row_count=2, row_count=2,
raw_text="RAW", raw_text="RAW",
) )
db = MagicMock() out = self._stored_summary(batch, [cmd], [("arp", 2)])
db.get.side_effect = lambda model, pk: batch if pk == "b1" else None
cmd_q = MagicMock()
cmd_q.filter.return_value.order_by.return_value.all.return_value = [cmd]
metric_count_q = MagicMock()
metric_count_q.filter.return_value.group_by.return_value.all.return_value = [("arp", 2)]
lldp_count_q = MagicMock()
lldp_count_q.filter.return_value.scalar.return_value = 0
def query(*_args, **_kwargs):
# First call in get_batch after protect: BatchCommand; then metric count; then lldp
# Distinguish by call count
n = query.n
query.n += 1
if n == 0:
return cmd_q
if n == 1:
return metric_count_q
return lldp_count_q
query.n = 0
db.query.side_effect = query
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
out = get_batch(db, "b1")
self.assertIn("sheets", out) self.assertIn("sheets", out)
self.assertNotIn("metrics", out) self.assertNotIn("metrics", out)
@ -93,32 +109,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
raw_text="a\nb\nc\n", raw_text="a\nb\nc\n",
message="", message="",
) )
db = MagicMock() out = self._stored_summary(batch, [cmd], [("arp", 3)])
db.get.side_effect = lambda model, pk: batch if pk == "b1" else None
cmd_q = MagicMock()
cmd_q.filter.return_value.order_by.return_value.all.return_value = [cmd]
metric_count_q = MagicMock()
metric_count_q.filter.return_value.group_by.return_value.all.return_value = [("arp", 3)]
lldp_count_q = MagicMock()
lldp_count_q.filter.return_value.scalar.return_value = 0
def query(*_args, **_kwargs):
n = query.n
query.n += 1
if n == 0:
return cmd_q
if n == 1:
return metric_count_q
return lldp_count_q
query.n = 0
db.query.side_effect = query
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
out = get_batch(db, "b1")
self.assertEqual(out["message"], "stopped") self.assertEqual(out["message"], "stopped")
self.assertEqual(out["commands"][0]["raw_line_count"], 3) self.assertEqual(out["commands"][0]["raw_line_count"], 3)
@ -148,34 +139,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
declared_total=1669101, declared_total=1669101,
message="declared=1669101;parsed=100", message="declared=1669101;parsed=100",
) )
db = MagicMock() out = self._stored_summary(batch, [cmd], [("bgp_route", 100)])
db.get.side_effect = lambda model, pk: batch if pk == "b1" else None
cmd_q = MagicMock()
cmd_q.filter.return_value.order_by.return_value.all.return_value = [cmd]
metric_count_q = MagicMock()
metric_count_q.filter.return_value.group_by.return_value.all.return_value = [
("bgp_route", 100)
]
lldp_count_q = MagicMock()
lldp_count_q.filter.return_value.scalar.return_value = 0
def query(*_args, **_kwargs):
n = query.n
query.n += 1
if n == 0:
return cmd_q
if n == 1:
return metric_count_q
return lldp_count_q
query.n = 0
db.query.side_effect = query
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
out = get_batch(db, "b1")
self.assertEqual(out["commands"][0]["raw_line_count"], 119303) self.assertEqual(out["commands"][0]["raw_line_count"], 119303)
self.assertEqual(out["commands"][0]["declared_total"], 1669101) self.assertEqual(out["commands"][0]["declared_total"], 1669101)
@ -263,34 +227,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
row_count=1, row_count=1,
), ),
] ]
db = MagicMock() out = self._stored_summary(batch, cmds, [("bgp_peer", 3)])
db.get.side_effect = lambda model, pk: batch if pk == "b1" else None
cmd_q = MagicMock()
cmd_q.filter.return_value.order_by.return_value.all.return_value = cmds
metric_count_q = MagicMock()
metric_count_q.filter.return_value.group_by.return_value.all.return_value = [
("bgp_peer", 3)
]
lldp_count_q = MagicMock()
lldp_count_q.filter.return_value.scalar.return_value = 0
def query(*_args, **_kwargs):
n = query.n
query.n += 1
if n == 0:
return cmd_q
if n == 1:
return metric_count_q
return lldp_count_q
query.n = 0
db.query.side_effect = query
with patch(
"netx_api.biz_state.service.batch_protect_info",
return_value={"protected": False, "reasons": []},
):
out = get_batch(db, "b1")
bgp = next(s for s in out["sheets"] if s["metric_id"] == "bgp_peer") bgp = next(s for s in out["sheets"] if s["metric_id"] == "bgp_peer")
self.assertEqual(bgp["title"], "BGP Status Summary") self.assertEqual(bgp["title"], "BGP Status Summary")

View file

@ -188,8 +188,9 @@ class ManagedNeApiTests(unittest.TestCase):
self.assertEqual(listed.json()["total"], 1, listed.text) self.assertEqual(listed.json()["total"], 1, listed.text)
self.client.delete(f"/v1/managed-ne/{ne_id}") self.client.delete(f"/v1/managed-ne/{ne_id}")
def test_create_without_crypto_key(self): @patch("netx_api.ne_service_common.credentials_configured", return_value=False)
settings.credential_secret_key = "" def test_create_without_crypto_key(self, mock_configured):
# Empty settings now auto-provision a key; model an unavailable key explicitly.
r = self.client.post( r = self.client.post(
"/v1/managed-ne", "/v1/managed-ne",
json={ json={
@ -201,6 +202,7 @@ class ManagedNeApiTests(unittest.TestCase):
}, },
) )
self.assertEqual(r.status_code, 503, r.text) self.assertEqual(r.status_code, 503, r.text)
mock_configured.assert_called_once()
def test_delete_ume_sync_route_not_captured_by_ne_id(self): def test_delete_ume_sync_route_not_captured_by_ne_id(self):
"""DELETE /ume-sync must not match DELETE /{ne_id} with ne_id='ume-sync'.""" """DELETE /ume-sync must not match DELETE /{ne_id} with ne_id='ume-sync'."""

View file

@ -100,23 +100,10 @@ def test_stdio_initialize_and_tools_list() -> None:
proc.wait(timeout=5) proc.wait(timeout=5)
def test_legacy_netx_api_mcp_module_still_works() -> None:
proc = subprocess.Popen( def test_only_standalone_mcp_implementation_is_kept() -> None:
[sys.executable, "-m", "netx_api.mcp"], from pathlib import Path
stdin=subprocess.PIPE,
stdout=subprocess.PIPE, root = Path(__file__).resolve().parents[1]
stderr=subprocess.PIPE, assert not (root / "netx_api/mcp_server.py").exists()
text=True, assert not list((root / "netx_api/mcp").glob("*.py"))
encoding="utf-8",
errors="replace",
cwd=str(__import__("pathlib").Path(__file__).resolve().parents[1]),
)
assert proc.stdin and proc.stdout
init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n"
proc.stdin.write(init_req)
proc.stdin.flush()
init_line = proc.stdout.readline()
init_resp = json.loads(init_line)
assert init_resp["result"]["serverInfo"]["mode"] == "http"
proc.terminate()
proc.wait(timeout=5)

View file

@ -73,6 +73,7 @@ class NeExecJobTests(unittest.TestCase):
ack = start_ne_exec_job( ack = start_ne_exec_job(
kind="exec", kind="exec",
arguments={"ne_id": "abc", "commands": ["show version"]}, arguments={"ne_id": "abc", "commands": ["show version"]},
owner_user_id="test-owner",
runner=lambda: {"ok": True, "output": "hi"}, runner=lambda: {"ok": True, "output": "hi"},
) )
self.assertTrue(ack.get("ok")) self.assertTrue(ack.get("ok"))
@ -80,7 +81,7 @@ class NeExecJobTests(unittest.TestCase):
self.assertTrue(jid) self.assertTrue(jid)
done = None done = None
for _ in range(50): for _ in range(50):
polled = get_ne_exec_job(jid) polled = get_ne_exec_job(jid, user_id="test-owner")
if polled.get("terminal"): if polled.get("terminal"):
done = polled done = polled
break break

View file

@ -0,0 +1,241 @@
"""Exec job isolation and path tests: temporary files and in-memory ASGI only."""
from __future__ import annotations
import json
import time
from types import SimpleNamespace
from urllib.parse import quote
from unittest.mock import MagicMock, patch
from fastapi import FastAPI
from fastapi.testclient import TestClient
import pytest
from netx_api import managed_ne_router as routes
from netx_api import ne_exec_jobs as jobs
from netx_api.auth_deps import AuthContext, require_user
from netx_api.db import get_db
JOB_ID = "a" * 32
OTHER_JOB_ID = "b" * 32
@pytest.fixture
def job_dir(tmp_path, monkeypatch):
directory = tmp_path / "jobs"
directory.mkdir()
monkeypatch.setenv("NETX_NE_EXEC_JOB_DIR", str(directory))
return directory
def write_record(directory, *, owner="alice", job_id=JOB_ID):
record = {
"job_id": job_id, "owner_user_id": owner, "status": "succeeded", "ne_count": 1,
"result": {"ok": True, "output": "temporary-test-output"},
}
(directory / f"{job_id}.json").write_text(json.dumps(record), encoding="utf-8")
return record
@pytest.mark.parametrize("job_id", [
"../canary", r"..\canary", r"C:\temp\canary", r"\\server\share\canary",
"/tmp/canary", "a" * 31, "a" * 33, "A" * 32,
"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", " " + JOB_ID, JOB_ID + "\n", "not-a-job",
])
def test_invalid_ids_never_access_job_directory(job_id):
with patch.object(jobs, "_jobs_dir") as directory:
out = jobs.get_ne_exec_job(job_id, user_id="alice")
assert out == {"ok": False, "error_code": "invalid_job_id", "error": "invalid_job_id"}
with pytest.raises(ValueError, match="^invalid_job_id$"):
jobs._job_path(job_id)
directory.assert_not_called()
assert job_id not in json.dumps(out)
def test_canary_outside_job_directory_cannot_be_read(job_dir):
canary = job_dir.parent / "canary.json"
canary.write_text(json.dumps({"status": "succeeded", "result": {"canary": "non-secret"}}), encoding="utf-8")
for job_id in ("../canary", r"..\canary", str(canary.with_suffix(""))):
out = jobs.get_ne_exec_job(job_id, user_id="alice", is_admin=True)
assert out["error"] == "invalid_job_id"
assert "result" not in out
assert str(job_dir.parent) not in json.dumps(out)
def test_valid_id_symlink_cannot_escape_directory(job_dir):
canary = job_dir.parent / "canary.json"
canary.write_text(json.dumps({"job_id": JOB_ID, "owner_user_id": "alice", "status": "succeeded", "result": {"canary": "non-secret"}}), encoding="utf-8")
link = job_dir / f"{JOB_ID}.json"
try:
link.symlink_to(canary)
except (OSError, NotImplementedError):
pytest.skip("Host does not permit temporary symlink creation")
out = jobs.get_ne_exec_job(JOB_ID, user_id="alice", is_admin=True)
assert out["error"] == "invalid_job_id"
with pytest.raises(ValueError, match="^invalid_job_id$"):
jobs._write_job({"job_id": JOB_ID, "owner_user_id": "alice"})
assert json.loads(canary.read_text(encoding="utf-8"))["result"]["canary"] == "non-secret"
def test_resolved_candidate_must_stay_inside_job_directory(job_dir):
"""Exercise containment even on Windows hosts that cannot create symlinks."""
from pathlib import Path
original_resolve = Path.resolve
candidate = job_dir / f"{JOB_ID}.json"
outside = job_dir.parent / "canary.json"
def resolve(path, *args, **kwargs):
return outside if path == candidate else original_resolve(path, *args, **kwargs)
with patch.object(Path, "resolve", resolve):
assert jobs.get_ne_exec_job(JOB_ID, user_id="alice", is_admin=True)["error"] == "invalid_job_id"
def test_persisted_owner_is_required_and_other_user_is_denied(job_dir):
write_record(job_dir)
# Reader loads the on-disk record on each call; no in-memory owner fallback.
assert jobs.get_ne_exec_job(JOB_ID, user_id="alice")["result"]["output"] == "temporary-test-output"
denied = jobs.get_ne_exec_job(JOB_ID, user_id="bob")
assert denied["error"] == "job_forbidden"
assert "result" not in denied and "owner_user_id" not in denied
assert jobs.get_ne_exec_job(JOB_ID, user_id="admin", is_admin=True)["ok"] is True
record = json.loads((job_dir / f"{JOB_ID}.json").read_text(encoding="utf-8"))
record["owner_user_id"] = "bob"
(job_dir / f"{JOB_ID}.json").write_text(json.dumps(record), encoding="utf-8")
assert jobs.get_ne_exec_job(JOB_ID, user_id="alice")["error"] == "job_forbidden"
@pytest.mark.parametrize("owner", [None, "", " "])
def test_ownerless_legacy_records_are_admin_only(job_dir, owner):
write_record(job_dir, owner=owner)
assert jobs.get_ne_exec_job(JOB_ID, user_id="alice")["error"] == "job_forbidden"
assert jobs.get_ne_exec_job(JOB_ID, user_id="admin", is_admin=True)["ok"] is True
assert jobs.get_ne_exec_job(JOB_ID, user_id="", is_admin=True)["error"] == "job_forbidden"
def test_record_cannot_impersonate_another_job_id(job_dir):
write_record(job_dir)
record = json.loads((job_dir / f"{JOB_ID}.json").read_text(encoding="utf-8"))
record["job_id"] = OTHER_JOB_ID
(job_dir / f"{JOB_ID}.json").write_text(json.dumps(record), encoding="utf-8")
assert jobs.get_ne_exec_job(JOB_ID, user_id="alice")["error"] == "job_not_found"
def test_counts_sum_mixed_batch_sources():
args = {"targets": [{"ne_id": "a"}, {"ume_ne_id": "b"}], "ne_ids": ["c", "", None], "ume_ne_ids": ["d", "e"]}
assert jobs.count_exec_ne_targets(args) == 5
with patch.object(jobs, "async_min_ne_count", return_value=4):
assert jobs.should_run_exec_async(args) is True
def test_combined_target_cap_is_enforced_before_scheduling(job_dir):
with patch.object(jobs.threading, "Thread") as thread:
out = jobs.start_ne_exec_job(kind="exec-batch", owner_user_id="alice", arguments={
"ne_ids": [f"n{i}" for i in range(11)], "ume_ne_ids": [f"u{i}" for i in range(10)],
}, runner=lambda: pytest.fail("Oversized batch must not execute"))
assert out["error"] == "too_many_targets"
assert out["max_targets"] == 20
assert list(job_dir.iterdir()) == []
thread.assert_not_called()
def test_twenty_mixed_targets_are_accepted_and_persisted(job_dir):
ack = jobs.start_ne_exec_job(kind="exec-batch", owner_user_id="alice", arguments={
"targets": [{"ne_id": f"t{i}"} for i in range(7)],
"ne_ids": [f"n{i}" for i in range(6)], "ume_ne_ids": [f"u{i}" for i in range(7)],
}, runner=lambda: {"ok": True, "summary": {"total": 20}, "results": []})
assert ack["ok"] is True and ack["ne_count"] == 20
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
done = jobs.get_ne_exec_job(ack["job_id"], user_id="alice")
if done.get("terminal"):
break
time.sleep(0.01)
assert done["status"] == "succeeded"
assert done["ne_count"] == 20
assert done["result"]["summary"]["total"] == 20
def test_job_owner_cannot_be_empty(job_dir):
with patch.object(jobs.threading, "Thread") as thread:
out = jobs.start_ne_exec_job(kind="exec", owner_user_id="", arguments={"ne_id": "n"}, runner=lambda: {})
assert out["error"] == "job_owner_required"
assert list(job_dir.iterdir()) == []
thread.assert_not_called()
@pytest.fixture
def api_client(job_dir, monkeypatch):
app = FastAPI()
app.include_router(routes.router)
auth = [AuthContext(user=SimpleNamespace(id="alice", username="alice", role="user"), auth_via="api_token", scopes=frozenset({"ne:exec"}))]
app.dependency_overrides[require_user] = lambda: auth[0]
app.dependency_overrides[get_db] = lambda: MagicMock()
monkeypatch.setattr(routes, "write_audit", lambda *args, **kwargs: None)
# The fake DB context and exec results must never open a database or device.
monkeypatch.setattr("netx_api.db.SessionLocal", MagicMock())
monkeypatch.setattr(routes, "execute_managed_ne_commands", lambda *args, **kwargs: {"ok": True, "output": "mock-cli"})
monkeypatch.setattr(routes, "execute_managed_ne_commands_batch", lambda *args, **kwargs: {"ok": True, "items": []})
with TestClient(app) as client:
yield client, auth
def test_asgi_owner_other_user_and_admin_access(api_client, job_dir):
client, auth = api_client
write_record(job_dir)
path = f"/v1/managed-ne/exec-jobs/{JOB_ID}"
assert client.get(path).json()["result"]["output"] == "temporary-test-output"
auth[0].user.id = "bob"
assert client.get(path).status_code == 403
auth[0].user.role = "admin"
assert client.get(path).status_code == 200
auth[0].user.role = "user"
auth[0].scopes = frozenset({"ne:exec", "admin:users"})
assert client.get(path).status_code == 200
def test_asgi_scope_required_even_for_owner_and_admin(api_client, job_dir):
client, auth = api_client
write_record(job_dir)
auth[0].user.role = "admin"
auth[0].scopes = frozenset({"ne:read"})
assert client.get(f"/v1/managed-ne/exec-jobs/{JOB_ID}").status_code == 403
with patch.object(routes, "start_ne_exec_job") as start:
response = client.post("/v1/managed-ne/exec-jobs", json={"ne_id": "n", "commands": ["show version"]})
assert response.status_code == 403
start.assert_not_called()
def test_asgi_invalid_windows_path_returns_structured_error(api_client, job_dir):
client, _ = api_client
response = client.get("/v1/managed-ne/exec-jobs/" + quote(r"..\canary", safe=""))
assert response.status_code == 400
assert response.json()["detail"]["error"] == "invalid_job_id"
assert str(job_dir) not in response.text
def test_asgi_create_persists_actor_and_mixed_count(api_client, job_dir):
client, auth = api_client
response = client.post("/v1/managed-ne/exec-jobs", json={
"targets": [{"ne_id": "n1"}], "ne_ids": ["n2"], "ume_ne_ids": ["n3"],
"commands": ["show version"], "owner_user_id": "forged-owner",
})
assert response.status_code == 200
ack = response.json()
assert ack["ok"] is True and ack["ne_count"] == 3
job_id = ack["job_id"]
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
done = client.get(f"/v1/managed-ne/exec-jobs/{job_id}").json()
if done.get("terminal"):
break
time.sleep(0.01)
assert done["status"] == "succeeded"
record = json.loads((job_dir / f"{job_id}.json").read_text(encoding="utf-8"))
assert record["owner_user_id"] == "alice"
assert done["ne_count"] == 3
auth[0].user.id = "forged-owner"
assert client.get(f"/v1/managed-ne/exec-jobs/{job_id}").status_code == 403

View file

@ -5,6 +5,9 @@ from __future__ import annotations
import unittest import unittest
import re import re
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from types import SimpleNamespace
from netmiko.base_connection import BaseConnection
from netx_api.ne_netmiko import drain_read_channel, send_show_command from netx_api.ne_netmiko import drain_read_channel, send_show_command
@ -24,6 +27,85 @@ class DrainReadChannelTests(unittest.TestCase):
class SendShowCommandTests(unittest.TestCase): class SendShowCommandTests(unittest.TestCase):
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_real_netmiko_cisco_reader_uses_full_prompt_after_drain(self, drain: MagicMock) -> None:
conn = BaseConnection(host="127.0.0.1", device_type="cisco_ios", auto_connect=False)
conn.base_prompt = "R2"
# Drain clears leftovers; reader then sees echo + body + full prompt.
chunks = iter(["show version\nCisco IOS Software\nR2 uptime is 15 hours\nR2#", "", ""])
conn.channel = SimpleNamespace(read_channel=lambda: next(chunks, ""))
conn.write_channel = MagicMock()
conn.find_prompt = MagicMock()
out = send_show_command(conn, "show version", read_timeout=2)
self.assertIn("Cisco IOS Software", out)
self.assertIn("R2 uptime is 15 hours", out)
conn.write_channel.assert_called_once()
conn.find_prompt.assert_not_called()
drain.assert_called_once()
self.assertEqual(drain.call_args.kwargs.get("idle_reads"), 6)
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_does_not_probe_prompt_or_match_hostname_inside_output(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios_ssh"
conn.base_prompt = "R2"
body = "Cisco IOS Software\nR2 uptime is 15 hours\nSystem image file is flash:ios.bin"
def delayed_ios_output(**kwargs: object) -> str:
if kwargs.get("auto_find_prompt", True):
return ""
if kwargs.get("cmd_verify", True):
return ""
pattern = str(kwargs.get("expect_string", ""))
if re.search(pattern, "R2 uptime is 15 hours"):
return "Cisco IOS Software"
return body
conn.send_command.side_effect = delayed_ios_output
self.assertEqual(send_show_command(conn, "show version", read_timeout=60), body)
conn.send_command.assert_called_once()
conn.find_prompt.assert_not_called()
conn.send_command_timing.assert_not_called()
self.assertFalse(conn.send_command.call_args.kwargs["cmd_verify"])
drain.assert_called_once()
self.assertEqual(drain.call_args.kwargs.get("idle_reads"), 6)
pattern = conn.send_command.call_args.kwargs["expect_string"]
for prompt in ("R2#", "R2>", "R2# "):
self.assertIsNotNone(re.search(pattern, prompt))
for content in ("R2", "R2 uptime is 15 hours", "R2#show version", "prefix R2#"):
self.assertIsNone(re.search(pattern, content))
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_prompt_hostname_is_literal(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios_telnet"
conn.base_prompt = "edge-1.lab"
conn.send_command.return_value = "interfaces"
self.assertEqual(send_show_command(conn, "show ip interface brief"), "interfaces")
pattern = conn.send_command.call_args.kwargs["expect_string"]
self.assertIsNotNone(re.search(pattern, "edge-1.lab#"))
self.assertIsNone(re.search(pattern, "edge-1Xlab#"))
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_timeout_does_not_resend(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios"
conn.base_prompt = "R2"
conn.send_command.side_effect = TimeoutError("late show output")
with self.assertRaises(TimeoutError):
send_show_command(conn, "show version")
conn.send_command.assert_called_once()
conn.send_command_timing.assert_not_called()
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_missing_cached_prompt_fails_before_sending(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios"
conn.base_prompt = ""
with self.assertRaisesRegex(ValueError, "cisco_base_prompt_missing"):
send_show_command(conn, "show version")
conn.send_command.assert_not_called()
@patch("netx_api.ne_netmiko.drain_read_channel") @patch("netx_api.ne_netmiko.drain_read_channel")
def test_mikrotik_does_not_probe_prompt_after_drain(self, drain: MagicMock) -> None: def test_mikrotik_does_not_probe_prompt_after_drain(self, drain: MagicMock) -> None:
conn = MagicMock() conn = MagicMock()
@ -88,6 +170,17 @@ class SendShowCommandTests(unittest.TestCase):
conn.send_command_timing.assert_not_called() conn.send_command_timing.assert_not_called()
drain.assert_called_once_with(conn) drain.assert_called_once_with(conn)
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_cisco_empty_output_raises_without_resend(self, drain: MagicMock) -> None:
conn = MagicMock()
conn.device_type = "cisco_ios_ssh"
conn.base_prompt = "R2"
conn.send_command.return_value = "\n"
with self.assertRaisesRegex(ValueError, "cisco_empty_show_output"):
send_show_command(conn, "show version", read_timeout=30)
conn.send_command.assert_called_once()
conn.send_command_timing.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -1,9 +1,13 @@
from __future__ import annotations from __future__ import annotations
import unittest import unittest
import re
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from types import SimpleNamespace
from netx_api.ne_session_connect import _collection_driver_class, _zte_collection_driver_class from netmiko.base_connection import BaseConnection
from netx_api.ne_session_connect import _collection_driver_class, _zte_collection_driver_class, _cisco_ios_collection_driver_class, _base_connect_kwargs
from netx_api.ne_session_factory import ( from netx_api.ne_session_factory import (
_build_netmiko_connection, _build_netmiko_connection,
_interactive_driver_class, _interactive_driver_class,
@ -50,6 +54,74 @@ class InteractiveNetmikoTests(unittest.TestCase):
self.assertTrue(direct.call_args.kwargs.get("interactive")) self.assertTrue(direct.call_args.kwargs.get("interactive"))
class CiscoCollectionPrepTests(unittest.TestCase):
def test_only_cisco_ssh_uses_cr_enter_on_the_actual_driver(self):
for device_type in ("cisco_ios", "cisco_ios_ssh", "cisco_ios_telnet", "huawei", "linux_ssh", "mikrotik_routeros"):
with self.subTest(device_type=device_type):
kwargs = _base_connect_kwargs(device_type=device_type, host="127.0.0.1", port=22,
username="fixture", password="fixture", enable_secret="")
if device_type in ("cisco_ios", "cisco_ios_ssh"):
self.assertEqual(kwargs["default_enter"], "\r")
driver = BaseConnection(**kwargs, auto_connect=False)
self.assertEqual(driver.normalize_cmd("show version"), "show version\r")
else:
self.assertNotIn("default_enter", kwargs)
def session(self):
cls = _cisco_ios_collection_driver_class(_netmiko_driver_class("cisco_ios"))
sess = cls.__new__(cls)
sess.base_prompt = "R2.lab"
sess.RETURN = "\r"
sess._test_channel_read = MagicMock() # type: ignore[method-assign]
sess.set_base_prompt = MagicMock() # type: ignore[method-assign]
sess.write_channel = MagicMock() # type: ignore[method-assign]
sess.read_until_pattern = MagicMock() # type: ignore[method-assign]
sess.set_terminal_width = MagicMock() # type: ignore[method-assign]
sess.disable_paging = MagicMock() # type: ignore[method-assign]
sess.send_command_timing = MagicMock() # type: ignore[method-assign]
return sess
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_setup_only_establishes_hostname_prompt(self, drain: MagicMock):
sess = self.session()
order: list[str] = []
drain.side_effect = lambda *_a, **_k: order.append("drain")
sess.set_base_prompt.side_effect = lambda **kw: order.append("prompt")
sess.session_preparation()
self.assertEqual(order, ["prompt", "drain"])
sess.set_base_prompt.assert_called_once()
initial = sess._test_channel_read.call_args.kwargs["pattern"]
self.assertIsNotNone(re.search(initial, "R2.lab#"))
self.assertIsNone(re.search(initial, "R2.lab#terminal length 0"))
channel = SimpleNamespace(read_timeout_override=None, _read_buffer="", read_channel=lambda: "R2.lab#")
self.assertEqual(BaseConnection.read_until_pattern(channel, pattern=initial), "R2.lab#")
sess.set_terminal_width.assert_not_called()
sess.disable_paging.assert_not_called()
sess.write_channel.assert_not_called()
sess.send_command_timing.assert_not_called()
drain.assert_called_once()
self.assertEqual(drain.call_args.kwargs.get("idle_reads"), 8)
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_setup_timeout_stops_before_drain(self, drain: MagicMock):
sess = self.session()
sess.set_base_prompt.side_effect = TimeoutError("late prompt")
with self.assertRaises(TimeoutError):
sess.session_preparation()
drain.assert_not_called()
sess.write_channel.assert_not_called()
sess.send_command_timing.assert_not_called()
@patch("netx_api.ne_netmiko.drain_read_channel")
def test_setup_never_sends_terminal_commands(self, drain: MagicMock):
sess = self.session()
sess.session_preparation()
sess.write_channel.assert_not_called()
sess.set_terminal_width.assert_not_called()
sess.disable_paging.assert_not_called()
drain.assert_called_once_with(sess, idle_reads=8, pause_sec=0.1)
class ZteCollectionPrepTests(unittest.TestCase): class ZteCollectionPrepTests(unittest.TestCase):
def test_collection_driver_selects_zte_wrapper(self) -> None: def test_collection_driver_selects_zte_wrapper(self) -> None:
base = _netmiko_driver_class("zte_zxros_ssh") base = _netmiko_driver_class("zte_zxros_ssh")

View file

@ -10,7 +10,7 @@ from sqlalchemy.orm import sessionmaker
import netx_api.models # noqa: F401 import netx_api.models # noqa: F401
from netx_api.db import Base from netx_api.db import Base
from netx_api.models import TopoFabricNode, TopoFolder, TopoView, UmeInventoryNE, UmeTopoLink, UmeTopoNode from netx_api.models import TopoFabricNode, TopoFolder, TopoView, TopoViewNode, UmeInventoryNE, UmeTopoLink, UmeTopoNode
from netx_api.ume_topology_apply import apply_ume_topology_to_fabric from netx_api.ume_topology_apply import apply_ume_topology_to_fabric
from netx_api.ume_topology_flat_coords import recompute_flat_world_coords from netx_api.ume_topology_flat_coords import recompute_flat_world_coords
from netx_api.ume_topology_world import ensure_ume_world_and_sbn_folders, get_world_view from netx_api.ume_topology_world import ensure_ume_world_and_sbn_folders, get_world_view
@ -217,6 +217,11 @@ class UmeHierarchyTests(unittest.TestCase):
self.assertEqual(moved.x, 777.0) self.assertEqual(moved.x, 777.0)
self.assertEqual(moved.y, 888.0) self.assertEqual(moved.y, 888.0)
placement = self.db.query(TopoViewNode).filter_by(
view_id=world.id, fabric_node_id=region.fabric_node_id,
).one()
self.assertEqual((placement.x, placement.y), (777.0, 888.0))
self.db.expire_all()
g1 = get_level_view_graph(self.db, world) g1 = get_level_view_graph(self.db, world)
again = next(n for n in g1.nodes if n.fabric_node_id == region.fabric_node_id) again = next(n for n in g1.nodes if n.fabric_node_id == region.fabric_node_id)
self.assertEqual(again.x, 777.0) self.assertEqual(again.x, 777.0)
@ -395,6 +400,57 @@ class UmeHierarchyTests(unittest.TestCase):
self.assertEqual(moved.x, ox) self.assertEqual(moved.x, ox)
self.assertEqual(moved.y, oy) self.assertEqual(moved.y, oy)
def test_flat_drag_accepts_negative_and_distant_display_coordinates(self):
from netx_api.topology_schemas import ViewNodeIn, ViewPositionsPatch
from netx_api.topology_views_graph import patch_view_positions
from netx_api.ume_topology_world import get_world_flat_view
self._seed_tree()
apply_ume_topology_to_fabric(self.db)
ensure_ume_world_and_sbn_folders(self.db)
flat = get_world_flat_view(self.db)
initial = get_flat_view_graph(self.db, flat, lod="detail")
target = initial.nodes[0]
patch_view_positions(self.db, flat.id, ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=target.fabric_node_id, x=-20000, y=30000, locked=True)],
return_graph=False,
))
placement = self.db.query(TopoViewNode).filter_by(
view_id=flat.id, fabric_node_id=target.fabric_node_id,
).one()
self.assertEqual((placement.x, placement.y, placement.locked), (-20000, 30000, True))
recompute_flat_world_coords(self.db)
self.db.expire_all()
graph = get_flat_view_graph(self.db, flat, lod="detail")
moved = next(n for n in graph.nodes if n.fabric_node_id == target.fabric_node_id)
self.assertEqual((moved.x, moved.y, moved.locked), (-20000, 30000, True))
# Later dragging cannot move a locked placement without explicit unlock.
result = patch_view_positions(self.db, flat.id, ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=target.fabric_node_id, x=10, y=20)],
return_graph=False,
))
self.assertEqual(result.skipped_locked, 1)
self.assertEqual((placement.x, placement.y), (-20000, 30000))
def test_level_position_patch_does_not_add_members_from_another_level(self):
from netx_api.topology_schemas import ViewNodeIn, ViewPositionsPatch
from netx_api.topology_views_graph import patch_view_positions
self._seed_tree()
apply_ume_topology_to_fabric(self.db)
ensure_ume_world_and_sbn_folders(self.db)
world = get_world_view(self.db)
foreign = self.db.query(TopoFabricNode).filter_by(ume_ne_id="me-a1").one()
result = patch_view_positions(self.db, world.id, ViewPositionsPatch(
positions=[ViewNodeIn(fabric_node_id=foreign.id, x=1, y=2)],
return_graph=False,
))
self.assertEqual(result.updated, 0)
self.assertEqual(result.skipped_missing, 1)
self.assertEqual(self.db.query(TopoViewNode).filter_by(
view_id=world.id, fabric_node_id=foreign.id,
).count(), 0)
def test_create_folder_under_ume_world_remounts_to_drill(self): def test_create_folder_under_ume_world_remounts_to_drill(self):
from netx_api.topology_schemas import TopologyFolderCreate from netx_api.topology_schemas import TopologyFolderCreate
from netx_api.topology_views_tree import create_folder from netx_api.topology_views_tree import create_folder

View file

@ -410,6 +410,9 @@ class WebcrtServiceTests(unittest.TestCase):
"password": "p", "password": "p",
"hop_enabled": True, "hop_enabled": True,
"hop_vendor": "huawei", "hop_vendor": "huawei",
"hop_host": "192.0.2.10",
"hop_username": "hop-user",
"hop_password": "test-only-hop-password",
"ip_address": "10.0.0.3", "ip_address": "10.0.0.3",
"protocol": "ssh", "protocol": "ssh",
}, },
@ -479,6 +482,7 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertTrue( self.assertTrue(
svc._webcrt_creds_ready( svc._webcrt_creds_ready(
{ {
"ip_address": "192.0.2.20",
"username": "ca-oper", "username": "ca-oper",
"password": "", "password": "",
"hop_enabled": True, "hop_enabled": True,
@ -493,6 +497,7 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertFalse( self.assertFalse(
svc._webcrt_creds_ready( svc._webcrt_creds_ready(
{ {
"ip_address": "192.0.2.20",
"username": "ca-oper", "username": "ca-oper",
"password": "", "password": "",
"hop_enabled": True, "hop_enabled": True,
@ -507,6 +512,7 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertFalse( self.assertFalse(
svc._webcrt_creds_ready( svc._webcrt_creds_ready(
{ {
"ip_address": "192.0.2.20",
"username": "u", "username": "u",
"password": "", "password": "",
"protocol": "ssh", "protocol": "ssh",
@ -517,6 +523,7 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertTrue( self.assertTrue(
svc._webcrt_creds_ready( svc._webcrt_creds_ready(
{ {
"ip_address": "192.0.2.20",
"username": "", "username": "",
"password": "", "password": "",
"protocol": "telnet", "protocol": "telnet",

View file

@ -332,8 +332,8 @@ class ResolveAuditCommandsTests(unittest.TestCase):
) )
self.assertEqual(out, [prompt + "show ip interface brief"]) self.assertEqual(out, [prompt + "show ip interface brief"])
def test_zte_real_backspace_redraw_reconstructs_full_command(self) -> None: def test_zte_synthetic_backspace_redraw_reconstructs_full_command(self) -> None:
"""Real ZTE device redraws via backspace runs (not CSI cursor moves). """Synthetic ZTE-style replay; no missing field capture is assumed.
Up-arrow recalls ``show interface brief``, left-arrows move the cursor Up-arrow recalls ``show interface brief``, left-arrows move the cursor
after ``show``, then ``ip`` is inserted. The device redraws using after ``show``, then ``ip`` is inserted. The device redraws using
@ -344,8 +344,6 @@ class ResolveAuditCommandsTests(unittest.TestCase):
(standard terminal behaviour) so the recalled text survives the redraw (standard terminal behaviour) so the recalled text survives the redraw
and the full edited line is reconstructed. and the full edited line is reconstructed.
""" """
import os
from netx_api.webcrt_channel import ( from netx_api.webcrt_channel import (
_live_input_line, _live_input_line,
_stdout_has_inplace_edit, _stdout_has_inplace_edit,
@ -353,12 +351,13 @@ class ResolveAuditCommandsTests(unittest.TestCase):
render_pty_line, render_pty_line,
) )
fixture = os.path.join(os.path.dirname(__file__), "fixtures",
"zte_history_edit_stdout.txt")
with open(fixture, encoding="utf-8") as f:
stdout = f.read()
prompt = "AL5458-ACC-6120HS#" prompt = "AL5458-ACC-6120HS#"
# Recall a command, move left without deleting, then redraw the edited suffix.
# The final command never appears as one contiguous substring in the replay.
recalled = "show interface brief"
stdout = prompt + recalled + ("\x08" * len("interface brief")) + "\x1b[Kip interface brief"
self.assertNotIn("show ip interface brief", stdout)
expected = prompt + "show ip interface brief" expected = prompt + "show ip interface brief"
# render_pty_line must reconstruct the full edited command. # render_pty_line must reconstruct the full edited command.

View file

@ -270,7 +270,7 @@ class ZteExtendedParserTests(unittest.TestCase):
self.assertTrue(all(r["afi"] == "ipv4" for r in vrf), "vrf-scoped vpnv4 → ipv4") self.assertTrue(all(r["afi"] == "ipv4" for r in vrf), "vrf-scoped vpnv4 → ipv4")
# Real wrapped vpnv6 summary + IPv6 neighbor in/out routes # Real wrapped vpnv6 summary + IPv6 neighbor in/out routes
sample_dir = Path(__file__).resolve().parents[2] / "test" / "show-zte" sample_dir = Path(__file__).resolve().parent / "fixtures" / "zte_vpnv6"
v6_sum = (sample_dir / "show-bgp-vpnv6").read_text(encoding="utf-8", errors="replace") v6_sum = (sample_dir / "show-bgp-vpnv6").read_text(encoding="utf-8", errors="replace")
v6_real = normalize_bgp_peer( v6_real = normalize_bgp_peer(
raw_text=v6_sum, command="show bgp vpnv6 unicast summary | one-line" raw_text=v6_sum, command="show bgp vpnv6 unicast summary | one-line"