mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 04:00:47 +08:00
MCP: add bizMonitor server, align ops/topology tools, harden CLI exec
- New packages/netx-bizmonitor-mcp: 8 read-only cutover/biz-state tools mirroring the netxops bizMonitor group. - Align netx-mcp (15) and netx-topology-mcp (16) with backend HTTP contracts; surface partial batch failures as tool errors. - CLI hard gate: single-line show/display only; exec job IDs, path containment, owner and ne:exec checks. - Cisco IOSv collection: stable prompt/echo handling over CR. - Remove legacy netx_api/mcp and mcp_server.py; add mcp.all.json; sync skills and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8da7acb7d
commit
9085cac742
63 changed files with 3251 additions and 1092 deletions
|
|
@ -169,20 +169,24 @@ Fool-proof Windows delivery via `NetX-Setup-*.exe` (bundled or external Postgres
|
|||
**完整说明(安装、配置、更新、排错)见:[docs/MCP.md](docs/MCP.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
|
||||
pip install -e ./packages/netx-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_topology_mcp
|
||||
python -m netx_bizmonitor_mcp
|
||||
```
|
||||
|
||||
- 客户端配置:[`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)
|
||||
- 子包:[`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
|
||||
|
||||
|
|
|
|||
14
docs/MCP.md
14
docs/MCP.md
|
|
@ -116,7 +116,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
|
||||
1. 完成上文 **§1**(用 **oclaw 同机同一个 `python`** 安装 `netx-mcp`)。
|
||||
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`。
|
||||
|
||||
更细的 oclaw 说明(双轨内置工具、锚点注入等)见 oclaw 仓库:
|
||||
|
|
@ -141,9 +141,11 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
| NMS 原始/SQL | `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms` |
|
||||
| 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 模式 |
|
||||
|
||||
根目录 `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 是否启动 |
|
||||
| Windows 乱码 / JSON 解析失败 | 配置里保留 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`(见 `mcp.json`) |
|
||||
| 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
41
docs/MCP_BIZMONITOR.md
Normal 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 验证读取链路。样例不代表现场故障;不要将样例库替换应用库。脚本拒绝覆盖、外部路径、网络连接和监听端口。
|
||||
|
|
@ -60,11 +60,11 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
|
||||
与告警 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` 强制最新源码。
|
||||
|
||||
配套 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),摘要 |
|
||||
| `sinkTopologyDualUnits` | 根图 dual_units 分批沉入子区域;默认 `layout_batch`→`layout_dual_unit`→块扫挂载;日常禁 `until_empty` |
|
||||
| `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[]` 仅少量微调 |
|
||||
| `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` |
|
||||
|
|
@ -120,8 +120,11 @@ Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topolog
|
|||
|
||||
| 包 | server_id | 职责 |
|
||||
|----|-----------|------|
|
||||
| `netx-mcp` | `netx` | 告警、UME、托管网元 CLI(**13** 工具) |
|
||||
| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 分类打标 + 安全画图(**15** 工具) |
|
||||
| `netx-mcp` | `netx` | NMS 告警、网元和只读 CLI(**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` **迁出**到本包,避免重复。
|
||||
|
||||
|
|
|
|||
34
mcp.all.json
Normal file
34
mcp.all.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ from fastapi.responses import Response
|
|||
from sqlalchemy.orm import Session
|
||||
|
||||
from .auth_deps import AuthContext, require_user
|
||||
from .auth_scopes import has_scope
|
||||
from .auth_service import write_audit
|
||||
from .db import get_db
|
||||
from .device_types import SUPPORTED_VENDORS
|
||||
|
|
@ -275,6 +276,10 @@ def api_create_exec_job(
|
|||
|
||||
uid, uname = _actor(ctx)
|
||||
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(
|
||||
payload.get("targets")
|
||||
or payload.get("ne_ids")
|
||||
|
|
@ -330,7 +335,7 @@ def api_create_exec_job(
|
|||
)
|
||||
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(
|
||||
db,
|
||||
action="ne.exec_job_start",
|
||||
|
|
@ -349,9 +354,16 @@ def api_get_exec_job(
|
|||
job_id: str,
|
||||
ctx: Annotated[AuthContext, Depends(require_user)],
|
||||
):
|
||||
"""Poll a background exec job started via POST /exec-jobs."""
|
||||
_ = ctx
|
||||
return get_ne_exec_job(job_id)
|
||||
"""Poll own jobs; admins (role or admin:users scope) may read any job."""
|
||||
if not has_scope(ctx.scopes, "ne:exec"):
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
from netx_mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -87,21 +87,28 @@ def _collect_on_device(
|
|||
if conn_holder is not None:
|
||||
conn_holder["conn"] = conn
|
||||
conn_holder["session_log"] = log_buf
|
||||
# Belt-and-suspenders: hop/bastion nested CLIs and missed session_prep.
|
||||
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
|
||||
from .ne_netmiko import drain_read_channel, is_cisco_ios_device_type
|
||||
|
||||
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)
|
||||
except Exception:
|
||||
_log.debug("collection channel drain failed", exc_info=True)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
|
@ -24,6 +25,8 @@ _INFLIGHT = 0
|
|||
_DEFAULT_TIMEOUT_S = 900
|
||||
_DEFAULT_MAX_CONCURRENT = 3
|
||||
_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:
|
||||
|
|
@ -72,7 +75,14 @@ def _jobs_dir() -> 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:
|
||||
|
|
@ -90,13 +100,11 @@ def _read_job(job_id: str) -> dict[str, Any] | None:
|
|||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
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:
|
||||
jid = str(job.get("job_id") or "").strip()
|
||||
if not jid:
|
||||
return
|
||||
jid = job.get("job_id")
|
||||
_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:
|
||||
a = args if isinstance(args, dict) else {}
|
||||
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)
|
||||
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")
|
||||
if isinstance(targets, list):
|
||||
n = max(n, len([t for t in targets if isinstance(t, dict)]))
|
||||
n += len([t for t in targets if isinstance(t, dict)])
|
||||
if n == 0 and (
|
||||
str(a.get("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 int(n)
|
||||
|
|
@ -164,11 +171,18 @@ def start_ne_exec_job(
|
|||
*,
|
||||
kind: str,
|
||||
arguments: dict[str, Any],
|
||||
owner_user_id: str,
|
||||
runner: Callable[[], dict[str, Any]],
|
||||
timeout_s: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Start background runner; returns immediate ack with job_id."""
|
||||
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()
|
||||
with _LOCK:
|
||||
if _INFLIGHT >= max_c:
|
||||
|
|
@ -189,10 +203,11 @@ def start_ne_exec_job(
|
|||
now = int(time.time() * 1000)
|
||||
job = {
|
||||
"job_id": job_id,
|
||||
"owner_user_id": owner,
|
||||
"status": _STATUS_RUNNING,
|
||||
"kind": str(kind or "exec"),
|
||||
"arguments": dict(arguments or {}),
|
||||
"ne_count": count_exec_ne_targets(arguments),
|
||||
"ne_count": ne_count,
|
||||
"created_at_ms": now,
|
||||
"updated_at_ms": now,
|
||||
"timeout_s": timeout,
|
||||
|
|
@ -260,13 +275,26 @@ def start_ne_exec_job(
|
|||
}
|
||||
|
||||
|
||||
def get_ne_exec_job(job_id: str) -> dict[str, Any]:
|
||||
jid = str(job_id or "").strip()
|
||||
def get_ne_exec_job(job_id: str, *, user_id: str, is_admin: bool = False) -> dict[str, Any]:
|
||||
"""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:
|
||||
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:
|
||||
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 "")
|
||||
out: dict[str, Any] = {
|
||||
"ok": True,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
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:
|
||||
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"):
|
||||
# find_prompt sends an extra Enter *after* our channel drain. RouterOS
|
||||
# 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:
|
||||
idle during ``Building configuration...`` truncates timing reads.
|
||||
|
||||
``cmd_verify=False``: Netmiko's default echo check often raises
|
||||
``Pattern not detected: 'show\\ lldp\\ ...'`` on IOSv / hop / slow echo paths.
|
||||
Cisco uses ``cmd_verify=False`` with a full-line prompt expect after drain;
|
||||
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()
|
||||
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
|
||||
# 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)
|
||||
|
|
|
|||
|
|
@ -304,24 +304,23 @@ class _ProgressSessionLog:
|
|||
|
||||
|
||||
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]
|
||||
def session_preparation(self) -> None:
|
||||
# Default Netmiko waits for exact echo of "terminal width 511" (ReadTimeout on IOSv).
|
||||
self._test_channel_read(pattern=r"[>#]")
|
||||
try:
|
||||
self.set_terminal_width(command="terminal width 511", pattern=r"[>#]")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.disable_paging(command="terminal length 0", cmd_verify=False, pattern=r"[>#]")
|
||||
except Exception:
|
||||
try:
|
||||
self.send_command_timing("terminal length 0", read_timeout=15)
|
||||
except Exception:
|
||||
pass
|
||||
self.set_base_prompt()
|
||||
# Stock Netmiko width/paging on IOSv+CR races leftover prompts
|
||||
# (``terexit`` / ``terminal lengexit``) or blocks forever waiting for
|
||||
# setup echo once VTY pressure builds. The interactive WebCRT path
|
||||
# skips these commands and still returns complete show output; MCP
|
||||
# show/display bodies are short enough that --More-- is not required.
|
||||
# Keep hostname discovery so send_show_command can use a full-line
|
||||
# prompt expect without auto_find_prompt.
|
||||
from .ne_netmiko import drain_read_channel
|
||||
|
||||
initial_pattern = r"(?m:^[^\r\n#>]+[>#][ \t]*$)"
|
||||
self._test_channel_read(pattern=initial_pattern)
|
||||
self.set_base_prompt(pattern=initial_pattern)
|
||||
drain_read_channel(self, idle_reads=8, pause_sec=0.1)
|
||||
|
||||
_CiscoIosCollectionSession.__name__ = (
|
||||
f"CiscoIosCollection{getattr(base_cls, '__name__', 'Netmiko')}"
|
||||
|
|
@ -508,6 +507,13 @@ def _base_connect_kwargs(
|
|||
"auth_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:
|
||||
dev["session_timeout"] = session_timeout
|
||||
if keepalive is not None and int(keepalive) > 0:
|
||||
|
|
|
|||
|
|
@ -749,6 +749,8 @@ def patch_view_positions(
|
|||
updated += 1
|
||||
else:
|
||||
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 [])
|
||||
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
|
||||
|
|
@ -769,6 +771,24 @@ def patch_view_positions(
|
|||
.filter(TopoFabricNode.id.in_(check_ids))
|
||||
.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:
|
||||
fid = str(p.fabric_node_id or "").strip()
|
||||
if not fid:
|
||||
|
|
@ -777,9 +797,17 @@ def patch_view_positions(
|
|||
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
|
||||
row = existing.get(fid)
|
||||
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.
|
||||
# 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:
|
||||
skipped_missing += 1
|
||||
continue
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# instead of stride-thinning — stops “zoomed in still a peppered blob”.
|
||||
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(
|
||||
|
|
@ -553,8 +550,6 @@ def get_flat_view_graph(
|
|||
full_max_x = float(extent_row[1])
|
||||
full_min_y = float(extent_row[2])
|
||||
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
|
||||
# each packed region block identical to its UME local relative layout.
|
||||
scale = 1.0
|
||||
|
|
@ -768,25 +763,9 @@ def get_flat_view_graph(
|
|||
)
|
||||
for n in nodes
|
||||
]
|
||||
# Drag overrides in display space (world - origin). Skip legacy rows saved
|
||||
# under the old ~6000 crush — they would yank nodes to the wrong place.
|
||||
override_rows = (
|
||||
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)
|
||||
# Saved placements are authoritative display coordinates. Do not infer an
|
||||
# obsolete coordinate system from their magnitude or reject valid negatives.
|
||||
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
|
||||
|
||||
edges_out: list[ViewEdgeOut] = []
|
||||
if include_edges and fids:
|
||||
|
|
|
|||
62
packages/netx-bizmonitor-mcp/README.md
Normal file
62
packages/netx-bizmonitor-mcp/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# netx-bizmonitor-mcp
|
||||
|
||||
独立 Python stdio MCP,只读访问 netx 的割接对比与业务状态 HTTP API。运行依赖仅 `httpx`,不依赖 `netx_api`、数据库驱动或 netxops。采用独立包与现有 topology 包、一组一 skill 的组织方式一致,客户端可按组挂载最小权限,避免扩大 ops 工具域及引入重依赖。
|
||||
|
||||
## 配置与启动
|
||||
|
||||
安装本包后使用 `python -m netx_bizmonitor_mcp`,或命令 `netx-bizmonitor-mcp`。无需安装时,将本包 `src` 的绝对路径设置为 `PYTHONPATH`。`mcp.json` 是客户端配置示例,需改为实际 Python/src 路径与令牌。
|
||||
|
||||
使用凭据文件时,应删除样例的 NETX_API_TOKEN 占位值或设为空;同时为客户端设定正确工作目录,或用 NETX_MCP_TOKEN_FILE 指定绝对路径。
|
||||
|
||||
- `NETX_API_URL`:默认 `http://127.0.0.1:8890`,支持 HTTP(S) 和路径前缀;禁止内嵌用户名/密码、查询参数、片段。
|
||||
- `NETX_API_TOKEN`:最高优先级 Bearer API 令牌;为空时依次读 `NETX_MCP_TOKEN_FILE` 指定的 UTF-8 文件、当前工作目录的 `data/auth/mcp_token`。文件为空、缺失、不可读或编码错误时继续下一候选。后端要求 **`biz-monitor:read`**,`ne:read` 或 `ops:write` 不代替此 scope。无可用令牌时返回 `netx_token_missing`,不发送请求。
|
||||
- `NETX_LANG`:默认 `zh`;`en` 时附加 `lang=en`。
|
||||
- Windows 建议 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`;程序也会将 stdio 配为 UTF-8。
|
||||
|
||||
令牌读取遵循现有独立 MCP 的凭据文件约定,仅在内存使用,文件内容与路径不回显;响应脱敏覆盖文件令牌,包括请求中途文件轮换前实际使用的令牌。所有业务请求仅 GET;不会触发采集、评估、CLI 执行或设备写入。通过 `GET /v1/auth/me` 探测权限,每次工具调用重新读取,避免缓存权限撤销;探测失败时仍列出工具,由目标 GET 的后端权限检查最终裁决。
|
||||
|
||||
## 工具与 HTTP 路由
|
||||
|
||||
工具名与 netxops bizMonitor 组相同,去掉 `netx__` 前缀,仅提供以下 8 个工具:
|
||||
|
||||
1. `listBizMonitors` → `GET /v1/biz-migration/projects` 与/或 `GET /v1/biz-state/tasks`。
|
||||
`kind=all|projects|tasks`,`purpose` 只转发任务查询;`status/q` 本地过滤,`limit` 默认 100、上限 500,各列表分别限额。项目压缩为名称、状态、任务 ID、设备摘要、模板名称、高频参数与更新时间;任务保留后端目录字段。counts 区分匹配总数与返回数量。
|
||||
2. `listBizMonitorBatches` → `GET /v1/biz-migration/projects/{project_id}/batches`、`GET /v1/biz-state/tasks/{task_id}/batches`、`GET /v1/biz-migration/batches/{batch_id}/runs`。
|
||||
`kind=auto|all|cutover|collect|runs`;auto 根据项目/任务/批次 ID 选择。`all` 需要 project_id 和 task_id;`runs` 需要割接 batch_id。批次 limit 默认 50、上限 200;runs_limit 默认 10、上限 50。include_runs 最多展开前 5 个已筛选割接批次;单个展开失败保留批次并附 runs_error,直接 runs 查询失败则返回工具错误。只保留批次摘要与运行 anomaly/progress,不回传大 summary;counts 为本次返回数量。
|
||||
3. `getBizMonitorContext` → `GET /v1/biz-migration/monitor-context`。
|
||||
project_id/task_id 至少一个;传同名查询参数。保留后端定义包的模板、映射、任务和命令。
|
||||
4. `getBizMonitorBoard` → `GET /v1/biz-migration/batches/{batch_id}/board`。
|
||||
必填割接 batch_id,可选 run_id;保留看板统计与证据。
|
||||
5. `listBizMonitorReds` → `GET /v1/biz-migration/projects/{project_id}/red-tickets`。
|
||||
必填 project_id,可选 status、limit(1..500)。未传 limit 时沿用后端默认 200;保留原始 A/B 键及设备、命令、时间证据。
|
||||
6. `getBizMonitorDiffs` → `GET /v1/biz-migration/runs/{run_id}/diffs`。
|
||||
必填 run_id,可选 metric_id/sheet_id/verdict/color/q/limit/offset。**MCP `q` 映射 HTTP `kw`**,修正 TS 将 q 原样传递导致搜索失效的问题。limit 限 1..500,未传时后端默认 100;offset 下限 0。原始 A/B 和 match_* 键保持不变。
|
||||
7. `getBizCollectBatch` → `GET /v1/biz-state/batches/{batch_id}`。
|
||||
必填采集 batch_id。后端返回工作簿摘要、命令及 sheet 目录,不含原始 CLI 全文和全量指标行。本包保持后端响应,避免误称其会返回全部指标数据。
|
||||
8. `getBizCollectCommandRaw` → `GET /v1/biz-state/batches/{batch_id}/commands/{command_id}`。
|
||||
两个 ID 必填;返回完整 raw_text 和采集证据,按 netxops 不做字符/行数截断。大原文只在明确调用此工具时获取。
|
||||
|
||||
路径 ID 均作为单个段编码。数字参数按 netxops 截为整数并限幅;未知字段/类型错误返回可修正的工具错误,不接受过时单数 kind 或旧工具别名。
|
||||
|
||||
## 推荐调用链
|
||||
|
||||
- 无 ID 时先 `listBizMonitors({"q":"站点名"})`,取得项目 ID。
|
||||
- `getBizMonitorContext({"project_id":"..."})` 理解模板与 old/new 任务。
|
||||
- `listBizMonitorBatches({"project_id":"...","include_runs":true})` 找割接批次及运行;也可 `kind=runs,batch_id=...` 单独查运行。
|
||||
- `getBizMonitorBoard({"batch_id":"...","run_id":"..."})` 看结论,再以 `listBizMonitorReds({"project_id":"..."})` 与 `getBizMonitorDiffs({"run_id":"...","color":"red","limit":100,"offset":0})` 查看证据和分页差异。
|
||||
- 需要原文时,用上下文中的任务 ID 调 `listBizMonitorBatches({"task_id":"..."})` 找采集批次,`getBizCollectBatch` 找 command_id,再 `getBizCollectCommandRaw` 获取指定命令全文。割接批次 ID 与采集批次 ID 不可混用。
|
||||
|
||||
所有返回使用 `{ok,data}` 文本 JSON。参数或上游业务/HTTP 错误使用 MCP `result.isError=true`;JSON-RPC 的解析、请求结构、方法和 tools/call 结构错误分别为 -32700/-32600/-32601/-32602。通知永不回包且不触发业务请求;stdout 只输出协议消息。
|
||||
|
||||
凭据字段递归删除,配置令牌、Bearer 与常见 `password=...`/`secret:...` 文本脱敏。HTTP 错误体、异常文本、URL 不回显,避免凭据泄露;业务 evidence、raw A/B、show 命令、时间字段保持。原始设备输出可能含任意格式的敏感业务信息,部署方仍应控制授权采集内容。
|
||||
|
||||
## 验证
|
||||
|
||||
在 worktree 根目录运行(不安装依赖,不启动项目服务,不连接真实设备):
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = (Resolve-Path packages/netx-bizmonitor-mcp/src).Path
|
||||
& D:/project/chatgpt/netx/.venv/Scripts/python.exe -m pytest packages/netx-bizmonitor-mcp/tests -q
|
||||
```
|
||||
|
||||
测试使用 HTTP mock;stdio 子进程清空令牌环境变量并在无令牌文件的临时目录运行,因此不发送网络请求。覆盖 8 工具、目录/批次裁剪与展开、q→kw、scope、业务与协议错误、通知、凭据来源优先级、文件错误回退、令牌轮换与脱敏及长原文不截断。
|
||||
16
packages/netx-bizmonitor-mcp/mcp.json
Normal file
16
packages/netx-bizmonitor-mcp/mcp.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"netx-bizmonitor": {
|
||||
"command": "python",
|
||||
"args": ["-m", "netx_bizmonitor_mcp"],
|
||||
"env": {
|
||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
||||
"NETX_API_TOKEN": "nxt_replace_with_biz_monitor_read_token",
|
||||
"NETX_LANG": "zh",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUTF8": "1",
|
||||
"PYTHONPATH": "D:/project/chatgpt/netx/packages/netx-bizmonitor-mcp/src"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
packages/netx-bizmonitor-mcp/pyproject.toml
Normal file
18
packages/netx-bizmonitor-mcp/pyproject.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "netx-bizmonitor-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Read-only stdio MCP for netx business monitoring and cutover evidence"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
dependencies = ["httpx>=0.27.0"]
|
||||
|
||||
[project.scripts]
|
||||
netx-bizmonitor-mcp = "netx_bizmonitor_mcp.server:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""Standalone, read-only netx business-monitor MCP."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from .server import main
|
||||
|
||||
main()
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"""HTTP transport. Never echo credentials, upstream error bodies, or URLs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
_SECRET_KEYS = {
|
||||
"password", "passwd", "pwd", "secret", "token", "api_token", "api_key",
|
||||
"authorization", "access_token", "refresh_token", "private_key", "client_secret",
|
||||
"ssh_password", "enable_password", "hop_password", "credentials",
|
||||
}
|
||||
|
||||
|
||||
def api_token() -> str:
|
||||
"""Use the shared MCP credential convention; never log paths or file contents."""
|
||||
token = os.getenv("NETX_API_TOKEN", "").strip()
|
||||
if token:
|
||||
return token
|
||||
for candidate in (os.getenv("NETX_MCP_TOKEN_FILE", "").strip(), "data/auth/mcp_token"):
|
||||
if not candidate:
|
||||
continue
|
||||
try:
|
||||
token = Path(candidate).read_text(encoding="utf-8").strip()
|
||||
except (OSError, UnicodeError, ValueError):
|
||||
continue
|
||||
if token:
|
||||
return token
|
||||
return ""
|
||||
|
||||
|
||||
def redact(value: Any, *, tokens: tuple[str, ...] | None = None) -> Any:
|
||||
"""Remove credential fields while retaining device raw A/B and CLI evidence."""
|
||||
if tokens is None:
|
||||
tokens = (api_token(),)
|
||||
if isinstance(value, dict):
|
||||
return {k: redact(v, tokens=tokens) for k, v in value.items() if str(k).lower() not in _SECRET_KEYS}
|
||||
if isinstance(value, list):
|
||||
return [redact(v, tokens=tokens) for v in value]
|
||||
if isinstance(value, str):
|
||||
for token in tokens:
|
||||
if token:
|
||||
value = value.replace(token, "[REDACTED]")
|
||||
value = re.sub(r"(?i)\bBearer\s+[^\s\"']+", "Bearer [REDACTED]", value)
|
||||
value = re.sub(
|
||||
r"(?im)(\b(?:password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*)([^\s,;]+)",
|
||||
r"\1[REDACTED]", value,
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def http_json(method: str, path: str, *, params: dict[str, Any] | None = None,
|
||||
timeout: float = 45.0) -> dict[str, Any]:
|
||||
if method != "GET":
|
||||
return {"ok": False, "error": "read_only_transport"}
|
||||
base = os.getenv("NETX_API_URL", "http://127.0.0.1:8890").strip().rstrip("/")
|
||||
try:
|
||||
parsed = urlsplit(base)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.hostname
|
||||
or parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
return {"ok": False, "error": "netx_api_url_invalid"}
|
||||
except ValueError:
|
||||
return {"ok": False, "error": "netx_api_url_invalid"}
|
||||
token = api_token()
|
||||
if not token:
|
||||
return {"ok": False, "error": "netx_token_missing",
|
||||
"hint": "设置 NETX_API_TOKEN、NETX_MCP_TOKEN_FILE,或工作目录 data/auth/mcp_token。"}
|
||||
query: dict[str, Any] = {}
|
||||
if os.getenv("NETX_LANG", "zh").strip().lower().startswith("en"):
|
||||
query["lang"] = "en"
|
||||
query.update(params or {})
|
||||
try:
|
||||
with httpx.Client(timeout=timeout, trust_env=False, follow_redirects=False) as client:
|
||||
response = client.request("GET", base + path, params=query or None, headers={
|
||||
"accept": "application/json", "authorization": f"Bearer {token}",
|
||||
})
|
||||
if not response.is_success:
|
||||
return {"ok": False, "error": f"netx_http_{response.status_code}",
|
||||
"hint": "检查 API 地址、令牌 scope 和资源 ID。"}
|
||||
data = response.json() if response.content else {}
|
||||
# Use the actual request token even if the credential file rotates mid-request.
|
||||
return {"ok": True, "data": redact(data if isinstance(data, dict) else {"raw": data}, tokens=(token,))}
|
||||
except (ValueError, httpx.HTTPError):
|
||||
return {"ok": False, "error": "netx_request_failed",
|
||||
"hint": "检查 API 连通性及 JSON 响应。"}
|
||||
|
||||
|
||||
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
|
||||
result = redact(result)
|
||||
out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]}
|
||||
if result.get("ok") is False or (isinstance(result.get("data"), dict)
|
||||
and result["data"].get("ok") is False):
|
||||
out["isError"] = True
|
||||
return out
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
"""The eight bizMonitor tools, aligned with netxops and backend HTTP contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from .http_client import http_json, mcp_from_handler_result
|
||||
|
||||
|
||||
class _InvalidArgument(ValueError):
|
||||
"""Only messages constructed from known schema fields; never include values."""
|
||||
|
||||
|
||||
def _str(description: str = "") -> dict[str, Any]:
|
||||
return {"type": "string", "description": description}
|
||||
|
||||
|
||||
def _int(default: int, maximum: int, minimum: int = 1) -> dict[str, Any]:
|
||||
return {"type": "integer", "default": default, "minimum": minimum, "maximum": maximum}
|
||||
|
||||
|
||||
def _tool(name: str, description: str, properties: dict[str, Any],
|
||||
required: tuple[str, ...] = ()) -> dict[str, Any]:
|
||||
return {"name": name, "description": description, "inputSchema": {
|
||||
"type": "object", "properties": properties, "required": list(required),
|
||||
"additionalProperties": False,
|
||||
}, "annotations": {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}}
|
||||
|
||||
|
||||
HTTP_MCP_TOOLS = [
|
||||
_tool("listBizMonitors", "入口:列出割接项目和业务状态任务,无需 ID。取得 project_id/task_id 后查上下文或批次。", {
|
||||
"kind": {"type": "string", "enum": ["all", "projects", "tasks"], "default": "all"},
|
||||
"purpose": _str("任务用途:portrait / cutover_hf;空为全部"),
|
||||
"status": _str("本地过滤状态,如 active/paused"), "q": _str("名称、设备、备注或 ID 关键词"),
|
||||
"limit": _int(100, 500),
|
||||
}),
|
||||
_tool("listBizMonitorBatches", "列批次/评估运行 ID:project_id→割接批次,task_id→采集批次,batch_id+kind=runs→评估运行。include_runs 最多展开前 5 个割接批次。", {
|
||||
"kind": {"type": "string", "enum": ["auto", "all", "cutover", "collect", "runs"], "default": "auto"},
|
||||
"project_id": _str(), "task_id": _str(), "batch_id": _str("割接批次 ID,非采集批次 ID"),
|
||||
"include_runs": {"type": "boolean", "default": False}, "status": _str(),
|
||||
"limit": _int(50, 200), "runs_limit": _int(10, 50),
|
||||
}),
|
||||
_tool("getBizMonitorContext", "读取项目、监控/对比模板、端口映射及关联任务命令。project_id/task_id 至少一个;原始 A/B 键保留。", {
|
||||
"project_id": _str(), "task_id": _str(),
|
||||
}),
|
||||
_tool("getBizMonitorBoard", "读取割接批次看板、最新或指定评估运行摘要、sheet 卡片及 verdict 统计。", {
|
||||
"batch_id": _str("割接批次 ID"), "run_id": _str("可选评估运行 ID"),
|
||||
}, ("batch_id",)),
|
||||
_tool("listBizMonitorReds", "读取项目红票及证据(设备、采集时间、show 命令、接口原始/归一/映射键);status 空为全部。", {
|
||||
"project_id": _str(), "status": _str("open / carried / resolved;空为全部"), "limit": _int(200, 500),
|
||||
}, ("project_id",)),
|
||||
_tool("getBizMonitorDiffs", "分页读取评估差异,保留原始 A/B、match_* 键与 show 命令证据。q 转发后端 kw;用 offset/limit 翻页。", {
|
||||
"run_id": _str(), "metric_id": _str(), "sheet_id": _str(), "verdict": _str(),
|
||||
"color": _str("如 red"), "q": _str("差异关键词,映射 HTTP kw"),
|
||||
"limit": _int(100, 500), "offset": _int(0, 9007199254740991, 0),
|
||||
}, ("run_id",)),
|
||||
_tool("getBizCollectBatch", "读取采集批次工作簿摘要、commands 和 sheet 目录。后端不返回全量指标行及 CLI 原文;以命令 ID 查原文。", {
|
||||
"batch_id": _str("业务状态采集批次 ID"),
|
||||
}, ("batch_id",)),
|
||||
_tool("getBizCollectCommandRaw", "深挖一个采集命令:返回完整 raw_text、raw_command、设备和采集时间;仅凭据脱敏,不截断原文。", {
|
||||
"batch_id": _str("采集批次 ID"), "command_id": _str("getBizCollectBatch 返回的命令 ID"),
|
||||
}, ("batch_id", "command_id")),
|
||||
]
|
||||
TOOL_REQUIRED_SCOPE = {t["name"]: "biz-monitor:read" for t in HTTP_MCP_TOOLS}
|
||||
_SCHEMAS = {t["name"]: t["inputSchema"] for t in HTTP_MCP_TOOLS}
|
||||
|
||||
|
||||
def tools_for_scopes(scopes: list[str] | None) -> list[dict[str, Any]]:
|
||||
# If /auth/me is unavailable, let the backend enforce authorization per GET.
|
||||
return list(HTTP_MCP_TOOLS) if scopes is None or "biz-monitor:read" in scopes else []
|
||||
|
||||
|
||||
def _s(args: dict[str, Any], key: str, default: str = "") -> str:
|
||||
return str(args.get(key, default)).strip()
|
||||
|
||||
|
||||
def _n(args: dict[str, Any], key: str, default: int, low: int, high: int) -> int:
|
||||
value = args.get(key, default)
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
|
||||
raise _InvalidArgument(f"{key} 必须为有限数值")
|
||||
return max(low, min(high, math.trunc(value)))
|
||||
|
||||
|
||||
def _pick(row: dict[str, Any], keys: str) -> dict[str, Any]:
|
||||
return {k: row[k] for k in keys.split() if k in row}
|
||||
|
||||
|
||||
def _items(result: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
rows = result.get("data", {}).get("items", [])
|
||||
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
|
||||
|
||||
|
||||
def _failed(result: dict[str, Any]) -> bool:
|
||||
return result.get("ok") is False or (isinstance(result.get("data"), dict)
|
||||
and result["data"].get("ok") is False)
|
||||
|
||||
|
||||
def _error(code: str, detail: str = "") -> dict[str, Any]:
|
||||
return {"ok": False, "error": code, **({"detail": detail} if detail else {})}
|
||||
|
||||
|
||||
def _project(row: dict[str, Any]) -> dict[str, Any]:
|
||||
out = _pick(row, "id name status note monitor_template_id old_task_id new_task_id old_hf_task_id new_hf_task_id hf_interval_sec hf_start_at hf_end_at updated_at")
|
||||
out["monitor_template_name"] = (row.get("monitor_template") or {}).get("name", "")
|
||||
for side in ("old", "new"):
|
||||
task = row.get(f"{side}_task") or {}
|
||||
name, ip = str(task.get("ne_name") or "").strip(), str(task.get("ne_ip") or "").strip()
|
||||
out[f"{side}_ne"] = f"{name} ({ip})" if name and ip else name or ip
|
||||
return out
|
||||
|
||||
|
||||
def _run(row: dict[str, Any]) -> dict[str, Any]:
|
||||
out = _pick(row, "id batch_id purpose old_batch_id new_batch_id created_at")
|
||||
summary = row.get("summary") or {}
|
||||
progress = summary.get("progress") or {}
|
||||
if "anomaly" in summary:
|
||||
out["anomaly"] = summary["anomaly"]
|
||||
for key in ("ok", "total"):
|
||||
value = progress.get(key, summary.get(f"progress_{key}"))
|
||||
if value is not None:
|
||||
out[f"progress_{key}"] = value
|
||||
return out
|
||||
|
||||
|
||||
def _catalog(args: dict[str, Any]) -> dict[str, Any]:
|
||||
kind = _s(args, "kind", "all").lower() or "all"
|
||||
if kind not in {"all", "projects", "tasks"}:
|
||||
return _error("kind_invalid", "kind must be all|projects|tasks")
|
||||
limit = _n(args, "limit", 100, 1, 500)
|
||||
status, query = _s(args, "status").lower(), _s(args, "q").lower()
|
||||
data: dict[str, Any] = {"kind": kind}
|
||||
counts = {"projects": 0, "tasks": 0, "projects_returned": 0, "tasks_returned": 0}
|
||||
for group, path in (("projects", "/v1/biz-migration/projects"), ("tasks", "/v1/biz-state/tasks")):
|
||||
if kind not in {"all", group}:
|
||||
continue
|
||||
params = {"purpose": _s(args, "purpose")} if group == "tasks" and _s(args, "purpose") else {}
|
||||
result = http_json("GET", path, params=params)
|
||||
if _failed(result):
|
||||
return result
|
||||
rows = [_project(row) for row in _items(result)] if group == "projects" else _items(result)
|
||||
rows = [row for row in rows if (not status or str(row.get("status", "")).lower() == status)
|
||||
and (not query or query in " ".join(str(row.get(k) or "").lower() for k in
|
||||
"id name note status ne_name ne_ip ne_id old_ne new_ne monitor_template_name purpose vendor".split()))]
|
||||
counts[group] = len(rows)
|
||||
data[group] = rows[:limit]
|
||||
counts[f"{group}_returned"] = len(data[group])
|
||||
data.update(counts=counts, next="将 project_id/task_id 传给 getBizMonitorContext;用 listBizMonitorBatches 获取 batch_id/run_id。")
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
|
||||
def _batches(args: dict[str, Any]) -> dict[str, Any]:
|
||||
pid, tid, bid = (_s(args, key) for key in ("project_id", "task_id", "batch_id"))
|
||||
kind = _s(args, "kind", "auto").lower() or "auto"
|
||||
include = args.get("include_runs") is True
|
||||
limit, run_limit = _n(args, "limit", 50, 1, 200), _n(args, "runs_limit", 10, 1, 50)
|
||||
status = _s(args, "status").lower()
|
||||
if kind == "auto":
|
||||
kind = "all" if pid and tid else "cutover" if pid else "collect" if tid else "runs" if bid else ""
|
||||
if not kind:
|
||||
return _error("project_id_or_task_id_or_batch_id_required")
|
||||
if kind not in {"all", "cutover", "collect", "runs"}:
|
||||
return _error("kind_invalid", "kind must be auto|all|cutover|collect|runs")
|
||||
cutover, collect = kind in {"all", "cutover"}, kind in {"all", "collect"}
|
||||
for needed, value, key in ((cutover, pid, "project_id"), (collect, tid, "task_id"), (kind == "runs", bid, "batch_id")):
|
||||
if needed and not value:
|
||||
return _error(f"{key}_required")
|
||||
cb: list[dict[str, Any]] = []
|
||||
cl: list[dict[str, Any]] = []
|
||||
runs: list[dict[str, Any]] = []
|
||||
if cutover:
|
||||
result = http_json("GET", f"/v1/biz-migration/projects/{quote(pid, safe='')}/batches", params={})
|
||||
if _failed(result):
|
||||
return result
|
||||
cb = [_pick(row, "id project_id batch_label status accept_status accept_run_id started_at ended_at note created_at updated_at")
|
||||
for row in _items(result) if (not bid or str(row.get("id") or "") == bid)
|
||||
and (not status or str(row.get("status") or "").lower() == status)][:limit]
|
||||
if collect:
|
||||
result = http_json("GET", f"/v1/biz-state/tasks/{quote(tid, safe='')}/batches", params={"limit": limit})
|
||||
if _failed(result):
|
||||
return result
|
||||
cl = [_pick(row, "id task_id status ne_name command_count row_count is_baseline protected message started_at ended_at")
|
||||
for row in _items(result) if not status or str(row.get("status") or "").lower() == status][:limit]
|
||||
for row in cl:
|
||||
row["task_id"] = row.get("task_id") or tid
|
||||
run_bids = [bid] if kind == "runs" else [str(row.get("id") or "").strip() for row in cb if row.get("id")][:5] if include else []
|
||||
for run_bid in run_bids:
|
||||
result = http_json("GET", f"/v1/biz-migration/batches/{quote(run_bid, safe='')}/runs", params={"limit": run_limit})
|
||||
target = next((row for row in cb if row.get("id") == run_bid), None)
|
||||
if _failed(result):
|
||||
if kind == "runs":
|
||||
return result
|
||||
if target is not None:
|
||||
target["runs_error"] = result.get("error") or "runs_fetch_failed"
|
||||
continue
|
||||
items = [_run(row) for row in _items(result)][:run_limit]
|
||||
runs.extend(items)
|
||||
if target is not None:
|
||||
target["runs"] = items
|
||||
data: dict[str, Any] = {"kind": kind, "counts": {"cutover_batches": len(cb), "collect_batches": len(cl), "runs": len(runs)}}
|
||||
data.update({key: val for key, val in (("project_id", pid), ("task_id", tid), ("batch_id", bid)) if val})
|
||||
if cutover:
|
||||
data["cutover_batches"] = cb
|
||||
if collect:
|
||||
data["collect_batches"] = cl
|
||||
if kind == "runs":
|
||||
data["runs"] = runs
|
||||
data["next"] = "割接 batch_id→getBizMonitorBoard/listBizMonitorBatches(kind=runs);采集 batch_id→getBizCollectBatch;run_id→getBizMonitorDiffs。"
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
|
||||
def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
schema = _SCHEMAS.get(name)
|
||||
if schema is None:
|
||||
return mcp_from_handler_result(_error("unknown_tool"))
|
||||
for key, value in args.items():
|
||||
prop = schema["properties"].get(key)
|
||||
if prop is None:
|
||||
raise _InvalidArgument("存在未知字段;允许字段:" + ", ".join(schema["properties"]))
|
||||
if prop["type"] == "string" and not isinstance(value, str):
|
||||
raise _InvalidArgument(f"{key} 必须为字符串")
|
||||
if prop["type"] == "boolean" and not isinstance(value, bool):
|
||||
raise _InvalidArgument(f"{key} 必须为布尔值")
|
||||
if name == "listBizMonitors":
|
||||
result = _catalog(args)
|
||||
elif name == "listBizMonitorBatches":
|
||||
result = _batches(args)
|
||||
else:
|
||||
pid, tid, bid, rid, cid = (_s(args, key) for key in ("project_id", "task_id", "batch_id", "run_id", "command_id"))
|
||||
params: dict[str, Any] = {}
|
||||
if name == "getBizMonitorContext":
|
||||
if not pid and not tid:
|
||||
return mcp_from_handler_result(_error("project_id_or_task_id_required"))
|
||||
path = "/v1/biz-migration/monitor-context"
|
||||
params = {k: v for k, v in (("project_id", pid), ("task_id", tid)) if v}
|
||||
else:
|
||||
missing = [key for key in schema["required"] if not _s(args, key)]
|
||||
if missing:
|
||||
code = "batch_id_and_command_id_required" if name == "getBizCollectCommandRaw" else f"{missing[0]}_required"
|
||||
return mcp_from_handler_result(_error(code))
|
||||
if name == "getBizMonitorBoard":
|
||||
path = f"/v1/biz-migration/batches/{quote(bid, safe='')}/board"
|
||||
if rid:
|
||||
params["run_id"] = rid
|
||||
elif name == "listBizMonitorReds":
|
||||
path = f"/v1/biz-migration/projects/{quote(pid, safe='')}/red-tickets"
|
||||
if _s(args, "status"):
|
||||
params["status"] = _s(args, "status")
|
||||
elif name == "getBizMonitorDiffs":
|
||||
path = f"/v1/biz-migration/runs/{quote(rid, safe='')}/diffs"
|
||||
params = {("kw" if k == "q" else k): _s(args, k) for k in ("metric_id", "sheet_id", "verdict", "color", "q") if _s(args, k)}
|
||||
if "offset" in args:
|
||||
params["offset"] = _n(args, "offset", 0, 0, 9007199254740991)
|
||||
elif name == "getBizCollectBatch":
|
||||
path = f"/v1/biz-state/batches/{quote(bid, safe='')}"
|
||||
else:
|
||||
path = f"/v1/biz-state/batches/{quote(bid, safe='')}/commands/{quote(cid, safe='')}"
|
||||
if "limit" in args:
|
||||
params["limit"] = _n(args, "limit", 200, 1, 500)
|
||||
result = http_json("GET", path, params=params)
|
||||
return mcp_from_handler_result(result)
|
||||
except _InvalidArgument as exc:
|
||||
return mcp_from_handler_result(_error("invalid_argument", str(exc)))
|
||||
except (ValueError, TypeError, OverflowError):
|
||||
# Avoid echoing user-provided values (which might contain credentials).
|
||||
return mcp_from_handler_result(_error("invalid_argument", "参数类型或字段不合法;请参照 tools/list 的 inputSchema。"))
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
"""Newline-delimited UTF-8 JSON-RPC MCP on stdio; stdout is protocol-only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from . import __version__
|
||||
from .http_client import http_json, mcp_from_handler_result
|
||||
from .http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
|
||||
|
||||
|
||||
def _fetch_scopes() -> list[str] | None:
|
||||
result = http_json("GET", "/v1/auth/me")
|
||||
if result.get("ok") is not True or not isinstance(result.get("data"), dict):
|
||||
return None
|
||||
data = result["data"]
|
||||
scopes = data.get("scopes")
|
||||
if not isinstance(scopes, list) and isinstance(data.get("user"), dict):
|
||||
scopes = data["user"].get("scopes")
|
||||
return [str(scope) for scope in scopes] if isinstance(scopes, list) else None
|
||||
|
||||
|
||||
def _result(rid: Any, result: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"jsonrpc": "2.0", "id": rid, "result": result}
|
||||
|
||||
|
||||
def _error(rid: Any, code: int, message: str) -> dict[str, Any]:
|
||||
return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def handle_request(request: Any) -> dict[str, Any] | None:
|
||||
if (not isinstance(request, dict) or request.get("jsonrpc") != "2.0"
|
||||
or not isinstance(request.get("method"), str)
|
||||
or ("id" in request and (isinstance(request["id"], bool)
|
||||
or not isinstance(request["id"], (str, int, type(None)))))):
|
||||
return _error(None, -32600, "Invalid Request")
|
||||
# Includes initialize/ping/unknown-method notifications: never reply or execute tools.
|
||||
if "id" not in request:
|
||||
return None
|
||||
rid, method = request["id"], request["method"]
|
||||
params = request.get("params", {})
|
||||
if not isinstance(params, dict):
|
||||
return _error(rid, -32602, "params must be an object")
|
||||
if method == "initialize":
|
||||
return _result(rid, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "netx-bizmonitor-mcp", "version": __version__}})
|
||||
if method == "ping":
|
||||
return _result(rid, {})
|
||||
if method == "tools/list":
|
||||
return _result(rid, {"tools": tools_for_scopes(_fetch_scopes())})
|
||||
if method == "tools/call":
|
||||
name, args = params.get("name"), params.get("arguments", {})
|
||||
if not isinstance(name, str) or name not in TOOL_REQUIRED_SCOPE:
|
||||
return _error(rid, -32602, "Unknown tool name")
|
||||
if not isinstance(args, dict):
|
||||
return _error(rid, -32602, "arguments must be an object")
|
||||
# Refresh per call so revocation is not hidden by a cached permission list.
|
||||
granted = _fetch_scopes()
|
||||
required = TOOL_REQUIRED_SCOPE[name]
|
||||
if granted is not None and required not in granted:
|
||||
return _result(rid, mcp_from_handler_result({"ok": False, "error": "insufficient_scope",
|
||||
"required_scope": required, "hint": "请管理员授予 API 令牌 biz-monitor:read。"}))
|
||||
return _result(rid, call_http_tool(name, args))
|
||||
return _error(rid, -32601, "Method not found")
|
||||
|
||||
|
||||
def run_stdio_loop() -> None:
|
||||
for line in sys.stdin:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
request = json.loads(line)
|
||||
except (ValueError, RecursionError):
|
||||
response = _error(None, -32700, "Parse error")
|
||||
else:
|
||||
try:
|
||||
response = handle_request(request)
|
||||
except Exception:
|
||||
# Never expose traceback, connection URL, headers or exception text.
|
||||
response = None if isinstance(request, dict) and "id" not in request else _error(
|
||||
request.get("id") if isinstance(request, dict) else None, -32603, "Internal error")
|
||||
if response is not None:
|
||||
sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
run_stdio_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
6
packages/netx-bizmonitor-mcp/tests/conftest.py
Normal file
6
packages/netx-bizmonitor-mcp/tests/conftest.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Always import this checkout's standalone package, not the main repository."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
340
packages/netx-bizmonitor-mcp/tests/test_mcp_bizmonitor.py
Normal file
340
packages/netx-bizmonitor-mcp/tests/test_mcp_bizmonitor.py
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from netx_bizmonitor_mcp.http_client import api_token, http_json, redact
|
||||
from netx_bizmonitor_mcp.http_tools import HTTP_MCP_TOOLS, TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
|
||||
from netx_bizmonitor_mcp.server import _fetch_scopes, handle_request, run_stdio_loop
|
||||
|
||||
|
||||
def payload(result):
|
||||
return json.loads(result["content"][0]["text"])
|
||||
|
||||
|
||||
def request(method, params=None, rid=1):
|
||||
return {"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}}
|
||||
|
||||
|
||||
def test_exact_eight_tools_read_only_and_canonical_scope():
|
||||
assert {t["name"] for t in HTTP_MCP_TOOLS} == {
|
||||
"listBizMonitors", "listBizMonitorBatches", "getBizMonitorContext", "getBizMonitorBoard",
|
||||
"listBizMonitorReds", "getBizMonitorDiffs", "getBizCollectBatch", "getBizCollectCommandRaw",
|
||||
}
|
||||
assert len(HTTP_MCP_TOOLS) == 8
|
||||
assert all(t["annotations"]["readOnlyHint"] for t in HTTP_MCP_TOOLS)
|
||||
assert set(TOOL_REQUIRED_SCOPE.values()) == {"biz-monitor:read"}
|
||||
assert tools_for_scopes(["ne:read", "ops:write"]) == []
|
||||
assert len(tools_for_scopes(["biz-monitor:read"])) == 8
|
||||
assert len(tools_for_scopes(None)) == 8
|
||||
|
||||
|
||||
def test_catalog_filters_slims_projects_and_keeps_totals():
|
||||
def fake(method, path, *, params):
|
||||
assert method == "GET"
|
||||
if path == "/v1/biz-migration/projects":
|
||||
assert params == {}
|
||||
return {"ok": True, "data": {"items": [
|
||||
{"id": f"p{i}", "status": "active", "monitor_template": {"name": "Core"},
|
||||
"old_task": {"ne_name": "R1", "ne_ip": "10.0.0.1", "commands": "large"}, "large_template": "drop"}
|
||||
for i in range(3)]}}
|
||||
assert path == "/v1/biz-state/tasks" and params == {"purpose": "portrait"}
|
||||
return {"ok": True, "data": {"items": [{"id": "t", "status": "paused", "ne_name": "R1"}]}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake):
|
||||
result = payload(call_http_tool("listBizMonitors", {"q": "r1", "status": "ACTIVE", "purpose": "portrait", "limit": 1}))
|
||||
data = result["data"]
|
||||
assert data["counts"] == {"projects": 3, "tasks": 0, "projects_returned": 1, "tasks_returned": 0}
|
||||
assert data["projects"][0]["old_ne"] == "R1 (10.0.0.1)"
|
||||
assert "old_task" not in data["projects"][0] and "large_template" not in data["projects"][0]
|
||||
assert "netx__" not in data["next"]
|
||||
|
||||
|
||||
def test_batch_cutover_expand_runs_and_partial_error():
|
||||
def fake(method, path, *, params):
|
||||
if path.endswith("/batches"):
|
||||
return {"ok": True, "data": {"items": [{"id": "b1", "status": "done", "large": "drop"}, {"id": "b2", "status": "done"}]}}
|
||||
assert params == {"limit": 3}
|
||||
if "/b2/" in path:
|
||||
return {"ok": False, "error": "netx_http_404"}
|
||||
return {"ok": True, "data": {"items": [{"id": "r1", "summary": {"anomaly": 2, "progress": {"ok": 4, "total": 5}, "large": "drop"}}]}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake) as mock:
|
||||
out = call_http_tool("listBizMonitorBatches", {"project_id": "p/1", "include_runs": True, "runs_limit": 3})
|
||||
data = payload(out)["data"]
|
||||
assert "isError" not in out and mock.call_count == 3
|
||||
assert mock.call_args_list[0].args == ("GET", "/v1/biz-migration/projects/p%2F1/batches")
|
||||
assert data["cutover_batches"][1]["runs_error"] == "netx_http_404"
|
||||
assert data["cutover_batches"][0]["runs"] == [{"id": "r1", "anomaly": 2, "progress_ok": 4, "progress_total": 5}]
|
||||
assert data["counts"]["runs"] == 1 and "runs" not in data
|
||||
assert "large" not in data["cutover_batches"][0]
|
||||
|
||||
|
||||
def test_batch_collect_and_runs_modes():
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json") as mock:
|
||||
mock.return_value = {"ok": True, "data": {"items": [{"id": "c1", "status": "done", "raw_text": "drop"}]}}
|
||||
out = payload(call_http_tool("listBizMonitorBatches", {"task_id": "t", "limit": 20}))
|
||||
mock.assert_called_once_with("GET", "/v1/biz-state/tasks/t/batches", params={"limit": 20})
|
||||
assert out["data"]["collect_batches"] == [{"id": "c1", "status": "done", "task_id": "t"}]
|
||||
mock.reset_mock()
|
||||
mock.return_value = {"ok": True, "data": {"items": [{"id": "r1", "summary": {"progress_ok": 2}}]}}
|
||||
out = payload(call_http_tool("listBizMonitorBatches", {"batch_id": "b"}))
|
||||
mock.assert_called_once_with("GET", "/v1/biz-migration/batches/b/runs", params={"limit": 10})
|
||||
assert out["data"]["runs"] == [{"id": "r1", "progress_ok": 2}]
|
||||
|
||||
|
||||
def test_batch_all_caps_run_expansion_at_five():
|
||||
def fake(method, path, *, params):
|
||||
if path == "/v1/biz-migration/projects/p/batches":
|
||||
return {"ok": True, "data": {"items": [{"id": f"b{i}"} for i in range(8)]}}
|
||||
if path == "/v1/biz-state/tasks/t/batches":
|
||||
return {"ok": True, "data": {"items": [{"id": "c"}]}}
|
||||
assert path.endswith("/runs")
|
||||
return {"ok": True, "data": {"items": [{"id": "r"}]}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake) as mock:
|
||||
data = payload(call_http_tool("listBizMonitorBatches", {
|
||||
"project_id": "p", "task_id": "t", "include_runs": True,
|
||||
}))["data"]
|
||||
assert mock.call_count == 7 # two catalogs plus five run lookups
|
||||
assert data["kind"] == "all"
|
||||
assert data["counts"] == {"cutover_batches": 8, "collect_batches": 1, "runs": 5}
|
||||
assert sum("runs" in row for row in data["cutover_batches"]) == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args,path,params", [
|
||||
("getBizMonitorContext", {"project_id": " p/1 ", "task_id": "t"}, "/v1/biz-migration/monitor-context", {"project_id": "p/1", "task_id": "t"}),
|
||||
("getBizMonitorBoard", {"batch_id": "b/1", "run_id": "r"}, "/v1/biz-migration/batches/b%2F1/board", {"run_id": "r"}),
|
||||
("listBizMonitorReds", {"project_id": "p", "status": "open", "limit": 700}, "/v1/biz-migration/projects/p/red-tickets", {"status": "open", "limit": 500}),
|
||||
("getBizMonitorDiffs", {"run_id": "r/1", "metric_id": "m", "sheet_id": "s", "verdict": "changed", "color": "red", "q": "xe-1", "offset": -2, "limit": 10}, "/v1/biz-migration/runs/r%2F1/diffs", {"metric_id": "m", "sheet_id": "s", "verdict": "changed", "color": "red", "kw": "xe-1", "offset": 0, "limit": 10}),
|
||||
("getBizCollectBatch", {"batch_id": "c/1"}, "/v1/biz-state/batches/c%2F1", {}),
|
||||
("getBizCollectCommandRaw", {"batch_id": "c", "command_id": "cmd/1"}, "/v1/biz-state/batches/c/commands/cmd%2F1", {}),
|
||||
])
|
||||
def test_each_read_tool_forwards_exact_http(name, args, path, params):
|
||||
evidence = {"old_key": "xe-1", "new_key": "ge-2", "evidence": {"raw_command": "show interface"}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": evidence}) as mock:
|
||||
result = call_http_tool(name, args)
|
||||
mock.assert_called_once_with("GET", path, params=params)
|
||||
assert payload(result)["data"] == evidence
|
||||
|
||||
|
||||
def test_diff_default_limit_not_forwarded_and_only_kw_used():
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": {}}) as mock:
|
||||
call_http_tool("getBizMonitorDiffs", {"run_id": "r", "q": " A B "})
|
||||
assert mock.call_args.kwargs["params"] == {"kw": "A B"}
|
||||
|
||||
|
||||
def test_raw_not_truncated_and_credentials_removed(monkeypatch):
|
||||
monkeypatch.setenv("NETX_API_TOKEN", "nxt_do_not_leak")
|
||||
raw = "show interfaces\n" + "interface xe-1 up\n" * 10000
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": {
|
||||
"raw_text": raw, "device": {"password": "hidden", "ne_name": "R1"},
|
||||
"api_token": "hidden", "note": "Bearer abcd password=secret nxt_do_not_leak",
|
||||
}}):
|
||||
result = call_http_tool("getBizCollectCommandRaw", {"batch_id": "b", "command_id": "c"})
|
||||
data = payload(result)["data"]
|
||||
assert data["raw_text"] == raw and data["device"] == {"ne_name": "R1"}
|
||||
assert "api_token" not in data
|
||||
assert "abcd" not in data["note"] and "secret" not in data["note"] and "nxt_do_not_leak" not in data["note"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args,error", [
|
||||
("getBizMonitorContext", {}, "project_id_or_task_id_required"),
|
||||
("getBizMonitorBoard", {}, "batch_id_required"),
|
||||
("listBizMonitorReds", {}, "project_id_required"),
|
||||
("getBizMonitorDiffs", {}, "run_id_required"),
|
||||
("getBizCollectBatch", {}, "batch_id_required"),
|
||||
("getBizCollectCommandRaw", {"batch_id": "b"}, "batch_id_and_command_id_required"),
|
||||
("listBizMonitorBatches", {}, "project_id_or_task_id_or_batch_id_required"),
|
||||
("listBizMonitorBatches", {"kind": "all", "project_id": "p"}, "task_id_required"),
|
||||
("listBizMonitors", {"kind": "project"}, "kind_invalid"),
|
||||
("listBizMonitors", {"limit": "two"}, "invalid_argument"),
|
||||
("getBizMonitorDiffs", {"run_id": "r", "offset": True}, "invalid_argument"),
|
||||
("getBizMonitorDiffs", {"run_id": "r", "kw": "x"}, "invalid_argument"),
|
||||
])
|
||||
def test_bad_arguments_never_make_http_call(name, args, error):
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json") as mock:
|
||||
result = call_http_tool(name, args)
|
||||
mock.assert_not_called()
|
||||
assert result["isError"] is True and payload(result)["error"] == error
|
||||
|
||||
|
||||
def test_argument_error_names_field_without_echoing_value():
|
||||
result = payload(call_http_tool("listBizMonitors", {"limit": "password=hidden"}))
|
||||
assert "limit" in result["detail"]
|
||||
assert "hidden" not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [
|
||||
{"ok": False, "error": "netx_http_404"},
|
||||
{"ok": True, "data": {"ok": False, "error": "not_found"}},
|
||||
])
|
||||
def test_upstream_failures_are_tool_errors(failure):
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value=failure):
|
||||
result = call_http_tool("getBizCollectBatch", {"batch_id": "b"})
|
||||
assert result["isError"] is True
|
||||
|
||||
|
||||
def test_fetch_scopes_and_server_scope_denial():
|
||||
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": True, "data": {"scopes": ["ne:read"]}}):
|
||||
assert _fetch_scopes() == ["ne:read"]
|
||||
assert handle_request(request("tools/list"))["result"]["tools"] == []
|
||||
with patch("netx_bizmonitor_mcp.server.call_http_tool") as call:
|
||||
denied = handle_request(request("tools/call", {"name": "getBizCollectBatch", "arguments": {"batch_id": "b"}}))
|
||||
call.assert_not_called()
|
||||
assert "error" not in denied and denied["result"]["isError"]
|
||||
assert payload(denied["result"])["required_scope"] == "biz-monitor:read"
|
||||
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": True, "data": {"user": {"scopes": ["biz-monitor:read"]}}}):
|
||||
assert _fetch_scopes() == ["biz-monitor:read"]
|
||||
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": False}):
|
||||
assert _fetch_scopes() is None
|
||||
|
||||
|
||||
def test_protocol_errors_and_notifications():
|
||||
for obj in ([], "x", {}, {"jsonrpc": "2.0", "id": True, "method": "ping"}):
|
||||
assert handle_request(obj)["error"]["code"] == -32600
|
||||
assert handle_request(request("unknown"))["error"]["code"] == -32601
|
||||
assert handle_request(request("tools/call", {"name": "netx__getBizCollectBatch"}))["error"]["code"] == -32602
|
||||
assert handle_request(request("tools/call", {"name": "getBizCollectBatch", "arguments": []}))["error"]["code"] == -32602
|
||||
with patch("netx_bizmonitor_mcp.server.http_json") as mock:
|
||||
for method in ("initialize", "notifications/initialized", "notifications/cancelled", "ping", "tools/call"):
|
||||
assert handle_request({"jsonrpc": "2.0", "method": method}) is None
|
||||
mock.assert_not_called()
|
||||
stdin, stdout = io.StringIO('{bad\n' + json.dumps(request("ping", rid=2)) + '\n'), io.StringIO()
|
||||
with patch("sys.stdin", stdin), patch("sys.stdout", stdout):
|
||||
run_stdio_loop()
|
||||
lines = [json.loads(line) for line in stdout.getvalue().splitlines()]
|
||||
assert lines[0]["error"]["code"] == -32700 and lines[1]["id"] == 2
|
||||
|
||||
|
||||
def test_http_auth_lang_redaction_and_error_sanitization(monkeypatch):
|
||||
monkeypatch.setenv("NETX_API_TOKEN", "nxt_secret")
|
||||
monkeypatch.setenv("NETX_API_URL", "http://example.test/netx")
|
||||
monkeypatch.setenv("NETX_LANG", "en")
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
response = httpx.Response(200, json={"id": "b", "password": "secret", "nested": {"api_key": "bad"}})
|
||||
client.return_value.__enter__.return_value.request.return_value = response
|
||||
result = http_json("GET", "/v1/biz-state/batches/b", params={"limit": 2})
|
||||
client.assert_called_once_with(timeout=45.0, trust_env=False, follow_redirects=False)
|
||||
kwargs = client.return_value.__enter__.return_value.request.call_args.kwargs
|
||||
assert kwargs["headers"]["authorization"] == "Bearer nxt_secret"
|
||||
assert kwargs["params"] == {"lang": "en", "limit": 2}
|
||||
assert result["data"] == {"id": "b", "nested": {}}
|
||||
client.return_value.__enter__.return_value.request.return_value = httpx.Response(500, text="password=leak nxt_secret")
|
||||
result = http_json("GET", "/v1/test")
|
||||
assert result["error"] == "netx_http_500" and "leak" not in json.dumps(result)
|
||||
client.return_value.__enter__.return_value.request.side_effect = httpx.ConnectError("Bearer leak")
|
||||
assert "leak" not in json.dumps(http_json("GET", "/v1/test"))
|
||||
|
||||
|
||||
def test_invalid_url_token_and_write_never_open_client(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
|
||||
monkeypatch.delenv("NETX_MCP_TOKEN_FILE", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
monkeypatch.setenv("NETX_API_URL", "https://user:pass@example.test")
|
||||
assert http_json("GET", "/x")["error"] == "netx_api_url_invalid"
|
||||
monkeypatch.setenv("NETX_API_URL", "http://example.test")
|
||||
assert http_json("GET", "/x")["error"] == "netx_token_missing"
|
||||
assert http_json("POST", "/x")["error"] == "read_only_transport"
|
||||
client.assert_not_called()
|
||||
|
||||
|
||||
def test_stdio_subprocess_initialize_list_notifications_errors(tmp_path):
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src")
|
||||
env["NETX_API_URL"] = "http://127.0.0.1:1"
|
||||
env.pop("NETX_API_TOKEN", None) # no network attempted when token is absent
|
||||
env.pop("NETX_MCP_TOKEN_FILE", None)
|
||||
messages = [request("initialize"), {"jsonrpc": "2.0", "method": "initialize"},
|
||||
{"jsonrpc": "2.0", "method": "notifications/initialized"}, request("ping", rid=2),
|
||||
request("tools/list", rid=3), request("tools/call", {"name": "getBizCollectBatch", "arguments": {}}, rid=4)]
|
||||
run = subprocess.run([sys.executable, "-m", "netx_bizmonitor_mcp"],
|
||||
input="\n".join(json.dumps(item) for item in messages) + "\n{bad\n",
|
||||
text=True, encoding="utf-8", capture_output=True, env=env, cwd=tmp_path, timeout=10)
|
||||
assert run.returncode == 0 and run.stderr == ""
|
||||
replies = [json.loads(line) for line in run.stdout.splitlines()]
|
||||
assert len(replies) == 5
|
||||
assert replies[0]["result"]["serverInfo"]["name"] == "netx-bizmonitor-mcp"
|
||||
assert replies[1] == {"jsonrpc": "2.0", "id": 2, "result": {}}
|
||||
assert len(replies[2]["result"]["tools"]) == 8
|
||||
assert replies[3]["result"]["isError"] is True
|
||||
assert replies[4]["error"]["code"] == -32700
|
||||
|
||||
|
||||
def test_token_priority_env_then_explicit_file_then_cwd(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
default = tmp_path / "data" / "auth" / "mcp_token"
|
||||
default.parent.mkdir(parents=True)
|
||||
default.write_text(" default-token\n", encoding="utf-8")
|
||||
explicit = tmp_path / "custom-token"
|
||||
explicit.write_text(" explicit-token\n", encoding="utf-8")
|
||||
monkeypatch.setenv("NETX_API_TOKEN", " env-token ")
|
||||
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(explicit))
|
||||
assert api_token() == "env-token"
|
||||
monkeypatch.setenv("NETX_API_TOKEN", " ")
|
||||
assert api_token() == "explicit-token"
|
||||
explicit.write_text(" \n", encoding="utf-8")
|
||||
assert api_token() == "default-token"
|
||||
monkeypatch.delenv("NETX_MCP_TOKEN_FILE")
|
||||
assert api_token() == "default-token"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_error", ["missing", "directory", "invalid_utf8", "permission"])
|
||||
def test_token_file_errors_fall_back_without_exposing_contents(monkeypatch, tmp_path, capsys, file_error):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
|
||||
default = tmp_path / "data" / "auth" / "mcp_token"
|
||||
default.parent.mkdir(parents=True)
|
||||
default.write_text("fallback-token", encoding="utf-8")
|
||||
explicit = tmp_path / "sensitive-file"
|
||||
if file_error == "directory":
|
||||
explicit.mkdir()
|
||||
elif file_error == "invalid_utf8":
|
||||
explicit.write_bytes(b"secret\xff")
|
||||
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(explicit))
|
||||
if file_error == "permission":
|
||||
original = Path.read_text
|
||||
def read(path, *args, **kwargs):
|
||||
if path == explicit:
|
||||
raise PermissionError("sensitive exception text")
|
||||
return original(path, *args, **kwargs)
|
||||
monkeypatch.setattr(Path, "read_text", read)
|
||||
assert api_token() == "fallback-token"
|
||||
default.unlink()
|
||||
assert api_token() == ""
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
result = http_json("GET", "/v1/auth/me")
|
||||
client.assert_not_called()
|
||||
assert result["error"] == "netx_token_missing"
|
||||
assert "sensitive-file" not in json.dumps(result) and "sensitive exception" not in json.dumps(result)
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == captured.err == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_explicit", [True, False])
|
||||
def test_file_token_auth_and_redaction_survives_rotation(monkeypatch, tmp_path, use_explicit):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
|
||||
monkeypatch.delenv("NETX_MCP_TOKEN_FILE", raising=False)
|
||||
monkeypatch.setenv("NETX_API_URL", "http://example.test")
|
||||
token_file = tmp_path / "custom-token" if use_explicit else tmp_path / "data" / "auth" / "mcp_token"
|
||||
token_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_file.write_text("nxt_file_secret", encoding="utf-8")
|
||||
if use_explicit:
|
||||
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(token_file))
|
||||
assert redact({"nested": ["raw nxt_file_secret"]}) == {"nested": ["raw [REDACTED]"]}
|
||||
def response(*args, **kwargs):
|
||||
assert kwargs["headers"]["authorization"] == "Bearer nxt_file_secret"
|
||||
token_file.write_text("nxt_rotated", encoding="utf-8")
|
||||
return httpx.Response(200, json={"raw_text": "prefix nxt_file_secret suffix"})
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
client.return_value.__enter__.return_value.request.side_effect = response
|
||||
result = http_json("GET", "/v1/biz-state/batches/b/commands/c")
|
||||
assert result["data"]["raw_text"] == "prefix [REDACTED] suffix"
|
||||
assert "nxt_file_secret" not in json.dumps(result)
|
||||
|
|
@ -48,14 +48,24 @@ python -m netx_mcp
|
|||
|
||||
0.3.1:`getNmsNe` 可直接传 host_name/IP;输出统一 `nms_ne_id` / `source=nms`;参数类型错误返回工具错误而非协议崩溃。
|
||||
|
||||
参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。
|
||||
参数使用 `nms_ne_id` / `nms_ne_ids`,路径端点使用 `from_nms_ne_id` / `to_nms_ne_id`;`getManagedNe` 只接受 `ne_id`。旧 `ume_*`、`managed_ne_id`、`id`、告警 `ne_name` 别名已删除,未知参数会返回工具错误。REST 适配器内部的 `ume_*` 字段不变。
|
||||
|
||||
NMS 查询(含 inventory)按当前 HTTP 权限规则需要 `alarms:read`;托管资源、CLI 目标和路径查询需要 `ne:read`;执行与轮询需要 `ne:exec`;SQL 需要 `sql:query`。所有工具返回递归裁剪结构化口令、令牌等凭据,但 CLI 原文输出仍需调用者判断是否敏感。
|
||||
|
||||
拓扑画布 / Fabric → skill **topology** / [`netx-topology-mcp`](../netx-topology-mcp)(见 [docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md))。
|
||||
|
||||
业务监控 / 割接 → [`netx-bizmonitor-mcp`](../netx-bizmonitor-mcp)。三组统一配置见根 `mcp.all.json`,均不依赖 netxops。
|
||||
|
||||
本包的 `execManagedNe` 独立强制只读 show/display;即使后端设备开放 shell,也拒绝配置、命令拼接、重定向和多行脚本。getManagedNe.capability 描述后端能力,不能作为 MCP 脚本许可;Linux/RouterOS 原生 CLI 当前未暴露。异步 ack 返回 `poll_after_ms=5000`;单台核对 terminal/result.ok,批量还须核对 summary.failed=0 和每个 results[].ok。作业 succeeded 仍可能有单台失败,须分别报告,勿整批重跑已成功设备。失败 job 作为工具错误返回,保留原始失败证据。
|
||||
|
||||
同 IP 多条纳管记录须按厂商/驱动选 ID。connect_status 为历史连接测试状态,不替代当前执行结果;有正常业务正文才算查询通过,不能把空正文或仅命令头当作成功。后端的 Cisco IOS SSH 驱动使用 CR 换行并等待命令回显和完整提示符;不影响其它厂商或 Telnet 的默认换行。
|
||||
|
||||
## Breaking (0.3.0)
|
||||
|
||||
模型工具名从 `*Ume*` 改为 `*Nms*`(与 dsh-netxops 对齐)。请更新 skills / 提示词并重启 MCP 宿主。
|
||||
|
||||
## 兼容
|
||||
## 入口
|
||||
|
||||
全量 `netx-ops` 开发安装下 `python -m netx_api.mcp` 仍会转到本包;新环境请只装 **netx-mcp**。
|
||||
只保留独立 **netx-mcp** 包入口:`python -m netx_mcp` / `netx-mcp`。旧 `netx_api.mcp`、`netx_api/mcp_server.py` 和直连数据库模式已删除;配置需迁移到本包。
|
||||
|
||||
旧 DB 模式的导入告警查询/聚合/批次/诊断、同步触发和历史告警查询未纳入本包 15 工具,相关 HTTP API 仍保留。
|
||||
|
|
|
|||
26
packages/netx-mcp/src/netx_mcp/cli_guard.py
Normal file
26
packages/netx-mcp/src/netx_mcp/cli_guard.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
"""MCP 独立只读边界:后端开放 shell 策略也不能扩大模型的执行权限。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_PREFIX = re.compile(r"(?i)^(show|display)[ \t]+\S")
|
||||
_CONTROL = re.compile(r"[\x00-\x08\x0a-\x1f\x7f-\x9f\u2028\u2029]")
|
||||
_SHELL = re.compile(r"[;&`$<>]")
|
||||
_DESTRUCTIVE = re.compile(
|
||||
r"(?i)\b(reboot|reload|shutdown|erase|format|delete|commit|rollback|redirect|append|tee)\b"
|
||||
r"|\b(?:configure\s+terminal|conf\s+t|write\s+memory|system-view|undo\s+)"
|
||||
)
|
||||
_FILTER = re.compile(r"(?i)^(include|exclude|begin|section|count|match|grep|one-line|no-more)(?:[ \t]|$)")
|
||||
|
||||
|
||||
def validate_readonly_cli(command: str) -> None:
|
||||
if not isinstance(command, str) or not command.strip():
|
||||
raise ValueError("MCP CLI requires a nonempty show/display command")
|
||||
if len(command) > 500 or _CONTROL.search(command) or _SHELL.search(command):
|
||||
raise ValueError("MCP CLI rejects control characters, shell operators and commands longer than 500 characters")
|
||||
cmd = command.strip()
|
||||
if not _PREFIX.match(cmd) or _DESTRUCTIVE.search(cmd):
|
||||
raise ValueError("MCP CLI allows read-only show/display only; open device policies do not override this boundary")
|
||||
parts = cmd.split("|")
|
||||
if any(not _FILTER.match(part.strip()) for part in parts[1:]):
|
||||
raise ValueError("MCP CLI pipes allow output filters only")
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""MCP tool schemas and HTTP-backed handlers (NMS adapter + managed NE).
|
||||
|
||||
Model-facing tool names use generic Nms*; REST paths remain /v1/ume/* for the
|
||||
current zte-ume provider. Params prefer nms_ne_id with ume_ne_id aliases.
|
||||
current zte-ume provider. Model-facing params use nms_ne_id exclusively.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -9,6 +9,7 @@ from __future__ import annotations
|
|||
import os
|
||||
from typing import Any, Callable
|
||||
|
||||
from .cli_guard import validate_readonly_cli
|
||||
from .http_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id
|
||||
|
||||
_EXEC_MAX_COMMANDS_CAP = 50
|
||||
|
|
@ -47,17 +48,16 @@ def _truthy_async_flag(raw: Any) -> bool | None:
|
|||
|
||||
def _count_exec_ne_targets(args: dict[str, Any]) -> int:
|
||||
n = 0
|
||||
for key in ("ne_ids", "nms_ne_ids", "ume_ne_ids"):
|
||||
for key in ("ne_ids", "nms_ne_ids"):
|
||||
val = args.get(key)
|
||||
if isinstance(val, list):
|
||||
n = max(n, len([x for x in val if str(x or "").strip()]))
|
||||
n += len([x for x in val if str(x or "").strip()])
|
||||
targets = args.get("targets")
|
||||
if isinstance(targets, list):
|
||||
n = max(n, len([t for t in targets if isinstance(t, dict)]))
|
||||
n += len([t for t in targets if isinstance(t, dict)])
|
||||
if n == 0 and (
|
||||
str(args.get("ne_id") or "").strip()
|
||||
or str(args.get("nms_ne_id") or "").strip()
|
||||
or str(args.get("ume_ne_id") or "").strip()
|
||||
):
|
||||
return 1
|
||||
return int(n)
|
||||
|
|
@ -243,11 +243,8 @@ def _query_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
|
|||
if str(args.get("is_cleared") or "").strip():
|
||||
params["is_cleared"] = str(args.get("is_cleared")).strip()
|
||||
keyword = str(args.get("keyword") or "").strip()
|
||||
ne_name = str(args.get("ne_name") or "").strip()
|
||||
if keyword:
|
||||
params["keyword"] = keyword
|
||||
elif ne_name:
|
||||
params["keyword"] = ne_name
|
||||
if str(args.get("ne_id") or "").strip():
|
||||
params["ne_id"] = str(args.get("ne_id")).strip()
|
||||
if str(args.get("host_name") or "").strip():
|
||||
|
|
@ -264,7 +261,7 @@ def _aggregate_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
|
|||
if group_by:
|
||||
return _aggregate_ume_alarms_raw(args)
|
||||
# Default top 50 named NEs — missing host buckets reported separately.
|
||||
params: dict[str, Any] = {"top_ne": _int_arg(args, "top_ne", 50, 0, 500)}
|
||||
params: dict[str, Any] = {"top_ne": _int_arg(args, "top_ne", 50, 0, 5000)}
|
||||
if "exclude_missing_host" in args:
|
||||
params["exclude_missing_host"] = bool(args.get("exclude_missing_host"))
|
||||
if _severity(args):
|
||||
|
|
@ -357,6 +354,8 @@ def _query_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _aggregate_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
|
||||
if not str(args.get("group_by") or "").strip():
|
||||
raise ToolArgError("group_by is required; use listNmsAlarmFields to choose a field")
|
||||
params: dict[str, Any] = {}
|
||||
for k in (
|
||||
"group_by",
|
||||
|
|
@ -442,9 +441,7 @@ def _first_str(*vals: Any) -> str:
|
|||
|
||||
|
||||
def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
||||
ne_id = str(
|
||||
args.get("ne_id") or args.get("managed_ne_id") or args.get("id") or ""
|
||||
).strip()
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
if not ne_id:
|
||||
return {
|
||||
"ok": False,
|
||||
|
|
@ -473,12 +470,46 @@ def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
return out
|
||||
|
||||
|
||||
def _exec_ack(out: dict[str, Any]) -> dict[str, Any]:
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
data = out.get("data") if isinstance(out.get("data"), dict) else out
|
||||
if isinstance(data, dict) and data.get("job_id"):
|
||||
data = dict(data)
|
||||
data["poll_after_ms"] = 5000
|
||||
data["hint"] = (
|
||||
"Execution started; it has not succeeded yet. Continue independent work, then poll "
|
||||
"getNeExecJob with this job_id after about 5 seconds. Do not resubmit commands while "
|
||||
"the job is running. Inspect terminal, result.ok, summary.failed and every target result before reporting success."
|
||||
)
|
||||
return data if isinstance(data, dict) else {"ok": True, "data": data}
|
||||
|
||||
|
||||
def _exec_result_failed(data: Any) -> bool:
|
||||
"""Batch ok means the request ran; individual targets may still all fail."""
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
if data.get("ok") is False:
|
||||
return True
|
||||
summary = data.get("summary")
|
||||
if isinstance(summary, dict) and isinstance(summary.get("failed"), int) and summary["failed"] > 0:
|
||||
return True
|
||||
results = data.get("results")
|
||||
return isinstance(results, list) and any(isinstance(row, dict) and row.get("ok") is False for row in results)
|
||||
|
||||
|
||||
def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
||||
supplied_commands = list(args.get("commands") or [])
|
||||
for target in args.get("targets") or []:
|
||||
supplied_commands.extend(target.get("commands") or [])
|
||||
for command in supplied_commands:
|
||||
try:
|
||||
validate_readonly_cli(command)
|
||||
except ValueError as exc:
|
||||
raise ToolArgError(str(exc)) from None
|
||||
targets_raw = args.get("targets")
|
||||
ne_ids_raw = args.get("ne_ids")
|
||||
nms_ne_ids_raw = args.get("nms_ne_ids")
|
||||
if not isinstance(nms_ne_ids_raw, list) or not nms_ne_ids_raw:
|
||||
nms_ne_ids_raw = args.get("ume_ne_ids")
|
||||
shared_cmds_raw = args.get("commands")
|
||||
shared_commands = (
|
||||
[str(c).strip() for c in shared_cmds_raw if str(c).strip()]
|
||||
|
|
@ -492,6 +523,13 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
want_async = _should_run_exec_async(args)
|
||||
|
||||
if multi and (args.get("ne_id") or args.get("nms_ne_id")):
|
||||
raise ToolArgError("Do not mix single NE identifiers with batch targets")
|
||||
if multi and _count_exec_ne_targets(args) == 0:
|
||||
raise ToolArgError("targets_required")
|
||||
if _count_exec_ne_targets(args) > 20:
|
||||
raise ToolArgError("too_many_targets (max 20 across targets/ne_ids/nms_ne_ids)")
|
||||
|
||||
if multi:
|
||||
body: dict[str, Any] = {}
|
||||
if isinstance(targets_raw, list) and targets_raw:
|
||||
|
|
@ -502,13 +540,20 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
row: dict[str, Any] = {}
|
||||
if str(t.get("ne_id") or "").strip():
|
||||
row["ne_id"] = str(t.get("ne_id")).strip()
|
||||
# Wire still uses ume_ne_id on REST; accept nms_ne_id as preferred model param.
|
||||
nms_id = _first_str(t.get("nms_ne_id"), t.get("ume_ne_id"))
|
||||
# Wire still uses ume_ne_id on REST.
|
||||
nms_id = _first_str(t.get("nms_ne_id"))
|
||||
if bool(row.get("ne_id")) == bool(nms_id):
|
||||
raise ToolArgError("Each target requires exactly one of ne_id or nms_ne_id")
|
||||
if nms_id:
|
||||
row["ume_ne_id"] = nms_id
|
||||
cmds = t.get("commands")
|
||||
if isinstance(cmds, list) and cmds:
|
||||
row["commands"] = [str(c).strip() for c in cmds if str(c).strip()]
|
||||
effective_commands = row.get("commands", shared_commands)
|
||||
if not effective_commands:
|
||||
raise ToolArgError("commands_required for every target")
|
||||
if len(effective_commands) > exec_max_commands():
|
||||
raise ToolArgError("too_many_commands for target")
|
||||
if row:
|
||||
cleaned_targets.append(row)
|
||||
body["targets"] = cleaned_targets
|
||||
|
|
@ -516,6 +561,8 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
body["ne_ids"] = [str(x).strip() for x in ne_ids_raw if str(x).strip()]
|
||||
if isinstance(nms_ne_ids_raw, list) and nms_ne_ids_raw:
|
||||
body["ume_ne_ids"] = [str(x).strip() for x in nms_ne_ids_raw if str(x).strip()]
|
||||
if (body.get("ne_ids") or body.get("ume_ne_ids")) and not shared_commands:
|
||||
raise ToolArgError("commands_required for ne_ids/nms_ne_ids")
|
||||
if shared_commands:
|
||||
if len(shared_commands) > exec_max_commands():
|
||||
return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"}
|
||||
|
|
@ -525,28 +572,25 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
body["concurrency"] = _int_arg(args, "concurrency", 4, 1, 8)
|
||||
if want_async:
|
||||
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
data = out.get("data") if isinstance(out.get("data"), dict) else out
|
||||
return data if isinstance(data, dict) else {"ok": True, "data": data}
|
||||
return _exec_ack(out)
|
||||
# Wall clock: many NEs × per-cmd timeout; keep below oclaw MCP override.
|
||||
out = http_post_json("/v1/managed-ne/exec-batch", body, timeout=600.0)
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
data = out.get("data") or {}
|
||||
if isinstance(data, dict) and data.get("ok") is False:
|
||||
if _exec_result_failed(data):
|
||||
return {"ok": False, "data": data, "error": str(data.get("error") or "exec_batch_failed")}
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
nms_ne_id = _first_str(args.get("nms_ne_id"), args.get("ume_ne_id"))
|
||||
nms_ne_id = _first_str(args.get("nms_ne_id"))
|
||||
if bool(ne_id) == bool(nms_ne_id):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "exactly_one_of_ne_id_or_nms_ne_id_required",
|
||||
"error_code": "exactly_one_of_ne_id_or_nms_ne_id_required",
|
||||
"hint": (
|
||||
"For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass "
|
||||
"For one NE pass ne_id OR nms_ne_id. For many NEs pass "
|
||||
"ne_ids / nms_ne_ids with shared commands, or targets[] with per-NE commands — "
|
||||
"one call, concurrent on server."
|
||||
),
|
||||
|
|
@ -564,10 +608,7 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
body["read_timeout_sec"] = _int_arg(args, "read_timeout_sec", 60, 10, 120)
|
||||
if want_async:
|
||||
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
data = out.get("data") if isinstance(out.get("data"), dict) else out
|
||||
return data if isinstance(data, dict) else {"ok": True, "data": data}
|
||||
return _exec_ack(out)
|
||||
out = http_post_json("/v1/managed-ne/exec", body, timeout=300.0)
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
|
|
@ -586,7 +627,16 @@ def _get_ne_exec_job(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"error_code": "job_id_required",
|
||||
"hint": "Pass job_id from execManagedNe async ack.",
|
||||
}
|
||||
return http_json("GET", f"/v1/managed-ne/exec-jobs/{quote_ne_id(job_id)}", params=None)
|
||||
out = http_json("GET", f"/v1/managed-ne/exec-jobs/{quote_ne_id(job_id)}", params=None)
|
||||
data = out.get("data")
|
||||
if out.get("ok") and isinstance(data, dict):
|
||||
if data.get("terminal") and (data.get("status") in {"failed", "timeout"}
|
||||
or _exec_result_failed(data.get("result"))):
|
||||
return {"ok": False, "error": "ne_exec_job_failed", "data": data}
|
||||
if not data.get("terminal") and data.get("status") == "running":
|
||||
data["poll_after_ms"] = 5000
|
||||
data["hint"] = "Still running. Continue independent work; poll this job_id after about 5 seconds. Do not resubmit."
|
||||
return out
|
||||
|
||||
|
||||
def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -610,9 +660,9 @@ def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _find_topology_paths(args: dict[str, Any]) -> dict[str, Any]:
|
||||
from_uid = _first_str(args.get("from_nms_ne_id"), args.get("from_ume_ne_id"))
|
||||
from_uid = _first_str(args.get("from_nms_ne_id"))
|
||||
from_mid = str(args.get("from_managed_ne_id") or "").strip()
|
||||
to_uid = _first_str(args.get("to_nms_ne_id"), args.get("to_ume_ne_id"))
|
||||
to_uid = _first_str(args.get("to_nms_ne_id"))
|
||||
to_mid = str(args.get("to_managed_ne_id") or "").strip()
|
||||
if bool(from_uid) == bool(from_mid):
|
||||
return {"ok": False, "error": "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required"}
|
||||
|
|
@ -664,7 +714,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"is_cleared": {"type": "string", "description": "\"False\" = active only; \"True\" = cleared."},
|
||||
"ne_id": {"type": "string", "description": "Filter only; do not show UUID to users"},
|
||||
"host_name": {"type": "string", "description": "Filter by NE host_name"},
|
||||
"ne_name": {"type": "string", "description": "Legacy alias mapped to keyword"},
|
||||
"keyword": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
|
|
@ -703,9 +752,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"top_ne": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"maximum": 500,
|
||||
"maximum": 5000,
|
||||
"default": 50,
|
||||
"description": "Max NE buckets to return (0 = all, capped at API 5000). Ignored when group_by is set.",
|
||||
"description": "Max NE buckets to return (0 = all; positive limit up to 5000). Ignored when group_by is set.",
|
||||
},
|
||||
"exclude_missing_host": {
|
||||
"type": "boolean",
|
||||
|
|
@ -883,8 +932,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"name": "getManagedNe",
|
||||
"description": (
|
||||
"Get one **managed** NE by managed ne_id (from listManagedNe / listCliTargets source=managed). "
|
||||
"Response includes capability (device_family, exec_policy_effective, recommended_mode, hints) "
|
||||
"— read it before complex execManagedNe. "
|
||||
"Response includes BACKEND capability (device_family, exec_policy_effective, recommended_mode, hints). "
|
||||
"This MCP independently permits show/display only: backend shell/script capability does not "
|
||||
"authorize scripts here. Native Linux/RouterOS CLI is not exposed by this MCP. "
|
||||
"Do NOT pass NMS inventory UUID here — use getNmsNe or execManagedNe(nms_ne_id=...) instead."
|
||||
),
|
||||
"inputSchema": {
|
||||
|
|
@ -894,23 +944,21 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"type": "string",
|
||||
"description": "Managed NE id (not NMS host_name / not NMS ne_id unless they coincide).",
|
||||
},
|
||||
"managed_ne_id": {"type": "string", "description": "Alias for ne_id."},
|
||||
"id": {"type": "string", "description": "Alias for ne_id."},
|
||||
},
|
||||
"required": [],
|
||||
"required": ["ne_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "execManagedNe",
|
||||
"description": (
|
||||
f"Run CLI via netx (default read-only: show/display/ping/traceroute; "
|
||||
f"Run single-line read-only show/display CLI via netx ("
|
||||
f"max {exec_max_commands()} commands per NE, NETX_NE_EXEC_MAX_COMMANDS). "
|
||||
"Managed NE exec_policy=linux_shell|unrestricted allows shell/script on Linux or "
|
||||
"MikroTik (routeros/switchos) hosts "
|
||||
"(pipes/&&/;/quotes/heredoc / RouterOS multiline OK; check getManagedNe / listManagedNe). "
|
||||
"Single NE: ne_id OR nms_ne_id (+ alias ume_ne_id) + commands. "
|
||||
"Many NEs (batch-first, server concurrency default 4, max 20): "
|
||||
"MCP independently allows single-line read-only show/display only, even when the "
|
||||
"device has an open exec_policy. Shell operators, configuration commands and multiline "
|
||||
"scripts are rejected before HTTP; only read-only output-filter pipes are allowed. "
|
||||
"Single NE: ne_id OR nms_ne_id + commands. "
|
||||
"Many NEs (batch-first, max 20 targets, concurrency default 4, max 8): "
|
||||
"(1) same CLI on all → ne_ids[]/nms_ne_ids[] + shared commands; "
|
||||
"(2) different CLI per NE (vendor/role) → ONE targets=["
|
||||
"{nms_ne_id|ne_id, commands:[…]}, …] — do NOT fall back to one-NE loops. "
|
||||
|
|
@ -918,14 +966,15 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"Default read_timeout_sec=60; on timeout raise to 90–120 — do not blind-retry. "
|
||||
"Long or multi-NE work: async=true (or auto when ≥4 NEs) returns job_id immediately; "
|
||||
"poll getNeExecJob. Pass async=false to force sync. "
|
||||
"Read getManagedNe.capability before choosing show_only vs script_on_device."
|
||||
"Use getManagedNe.capability to identify the vendor and backend policy; open backend "
|
||||
"policies never expand this MCP's show/display restriction. For batches inspect "
|
||||
"summary.failed and every result: job succeeded/result.ok alone does not mean all targets passed."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ne_id": {"type": "string"},
|
||||
"nms_ne_id": {"type": "string", "description": "NMS inventory id (preferred)."},
|
||||
"ume_ne_id": {"type": "string", "description": "Legacy alias of nms_ne_id."},
|
||||
"ne_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
|
|
@ -938,12 +987,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"maxItems": 20,
|
||||
"description": "NMS inventory ne_ids for concurrent batch (shared commands).",
|
||||
},
|
||||
"ume_ne_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"maxItems": 20,
|
||||
"description": "Legacy alias of nms_ne_ids.",
|
||||
},
|
||||
"targets": {
|
||||
"type": "array",
|
||||
"maxItems": 20,
|
||||
|
|
@ -952,7 +995,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"properties": {
|
||||
"ne_id": {"type": "string"},
|
||||
"nms_ne_id": {"type": "string"},
|
||||
"ume_ne_id": {"type": "string", "description": "Legacy alias of nms_ne_id."},
|
||||
"commands": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
|
|
@ -1010,7 +1052,10 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"description": (
|
||||
"Poll a background execManagedNe job (job_id from async ack). "
|
||||
"When terminal=true, result holds the exec/exec-batch payload. "
|
||||
"Do not busy-wait; end the turn and poll later if still running."
|
||||
"Use poll_after_ms (about 5 seconds); continue independent work while waiting. "
|
||||
"Terminal failed/timeout is a tool error and retains the original result for diagnosis."
|
||||
" Batch jobs can finish succeeded with individual failures: inspect result.summary.failed "
|
||||
"and each result.results[].ok before reporting success."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
|
|
@ -1033,9 +1078,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"properties": {
|
||||
"source": {
|
||||
"type": "string",
|
||||
"enum": ["managed", "nms", "ume", "all"],
|
||||
"enum": ["managed", "nms", "all"],
|
||||
"default": "all",
|
||||
"description": "nms preferred; ume is a legacy alias mapped to the same inventory.",
|
||||
"description": "nms selects the configured NMS inventory; managed selects managed NEs.",
|
||||
},
|
||||
"keyword": {"type": "string"},
|
||||
"page": {"type": "integer", "minimum": 1, "default": 1},
|
||||
|
|
@ -1050,8 +1095,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"description": (
|
||||
"Find up to max_paths simple paths between two fabric nodes for troubleshooting "
|
||||
"(parallel links between the same NEs count as one hop; see edges[].parallel_links). "
|
||||
"For each endpoint provide exactly one of nms_ne_id (from NMS alarm ne_id; alias "
|
||||
"from_ume_ne_id) or managed_ne_id — resolved to fabric node internally. Returns "
|
||||
"For each endpoint provide exactly one of nms_ne_id (from NMS alarm ne_id) or "
|
||||
"managed_ne_id — resolved to fabric node internally. Returns "
|
||||
"shortest paths first with compact label + node/edge summary (detail=summary default). "
|
||||
"Use after critical alarms to correlate neighboring NEs before CLI login."
|
||||
),
|
||||
|
|
@ -1062,10 +1107,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"type": "string",
|
||||
"description": "Source NMS ne_id (from alarm ne_id); mutually exclusive with from_managed_ne_id",
|
||||
},
|
||||
"from_ume_ne_id": {
|
||||
"type": "string",
|
||||
"description": "Legacy alias of from_nms_ne_id",
|
||||
},
|
||||
"from_managed_ne_id": {
|
||||
"type": "string",
|
||||
"description": "Source managed NE id; mutually exclusive with from_nms_ne_id",
|
||||
|
|
@ -1074,10 +1115,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"type": "string",
|
||||
"description": "Target NMS ne_id; mutually exclusive with to_managed_ne_id",
|
||||
},
|
||||
"to_ume_ne_id": {
|
||||
"type": "string",
|
||||
"description": "Legacy alias of to_nms_ne_id",
|
||||
},
|
||||
"to_managed_ne_id": {
|
||||
"type": "string",
|
||||
"description": "Target managed NE id; mutually exclusive with to_nms_ne_id",
|
||||
|
|
@ -1121,8 +1158,8 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = {
|
|||
"queryNmsAlarms": "alarms:read",
|
||||
"aggregateNmsAlarms": "alarms:read",
|
||||
"runNmsDiagnostics": "alarms:read",
|
||||
"queryNmsNeInventory": "ne:read",
|
||||
"getNmsNe": "ne:read",
|
||||
"queryNmsNeInventory": "alarms:read",
|
||||
"getNmsNe": "alarms:read",
|
||||
"queryNmsAlarmsRaw": "alarms:read",
|
||||
"aggregateNmsAlarmsRaw": "alarms:read",
|
||||
"listNmsAlarmFields": "alarms:read",
|
||||
|
|
@ -1137,7 +1174,7 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = {
|
|||
|
||||
|
||||
def tools_for_scopes(scopes: list[str] | set[str] | frozenset[str] | None) -> list[dict[str, Any]]:
|
||||
"""Filter MCP tool list by granted scopes. Empty/None => return all (offline / unauthenticated listing)."""
|
||||
"""Filter by granted scopes. None advertises all offline; an empty grant advertises none."""
|
||||
if scopes is None:
|
||||
return list(HTTP_MCP_TOOLS)
|
||||
granted = {str(s).strip().lower() for s in scopes if str(s).strip()}
|
||||
|
|
@ -1152,12 +1189,56 @@ def tools_for_scopes(scopes: list[str] | set[str] | frozenset[str] | None) -> li
|
|||
return out
|
||||
|
||||
|
||||
def _validate_arguments(args: Any, schema: dict[str, Any], path: str = "arguments") -> None:
|
||||
"""Enforce advertised shapes even when a host skips JSON Schema validation."""
|
||||
kind = schema.get("type")
|
||||
expected = {"object": dict, "array": list, "string": str, "boolean": bool, "integer": int}
|
||||
if kind in expected and (not isinstance(args, expected[kind]) or (kind == "integer" and isinstance(args, bool))):
|
||||
raise ToolArgError(f"{path} must be {kind}")
|
||||
if "enum" in schema and args not in schema["enum"]:
|
||||
raise ToolArgError(f"{path} must be one of {schema['enum']}")
|
||||
if kind == "object":
|
||||
props = schema.get("properties", {})
|
||||
unknown = set(args) - set(props)
|
||||
if schema.get("additionalProperties") is False and unknown:
|
||||
raise ToolArgError(f"{path}: unsupported parameter(s): {', '.join(sorted(unknown))}")
|
||||
for key, value in args.items():
|
||||
if key in props:
|
||||
_validate_arguments(value, props[key], f"{path}.{key}")
|
||||
elif kind == "array":
|
||||
# Command limits are read at invocation time by the handler (env may change).
|
||||
if not path.endswith(".commands") and len(args) > schema.get("maxItems", float("inf")):
|
||||
raise ToolArgError(f"{path} has too many items (max {schema['maxItems']})")
|
||||
if len(args) < schema.get("minItems", 0):
|
||||
raise ToolArgError(f"{path} requires at least {schema['minItems']} item(s)")
|
||||
for i, value in enumerate(args):
|
||||
_validate_arguments(value, schema.get("items", {}), f"{path}[{i}]")
|
||||
|
||||
|
||||
_SECRET_KEYS = frozenset({
|
||||
"password", "hop_password", "password_enc", "hop_password_enc", "password_ciphertext",
|
||||
"hop_password_ciphertext", "credential_secret", "secret_key", "token", "access_token",
|
||||
"refresh_token", "authorization", "private_key",
|
||||
})
|
||||
|
||||
|
||||
def _redact_secrets(value: Any) -> Any:
|
||||
"""Keep capability/has_password flags, but never expose structured credentials to a model."""
|
||||
if isinstance(value, dict):
|
||||
return {key: _redact_secrets(item) for key, item in value.items() if str(key).lower() not in _SECRET_KEYS}
|
||||
if isinstance(value, list):
|
||||
return [_redact_secrets(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
||||
fn = _HANDLERS.get(str(name or "").strip())
|
||||
if not fn:
|
||||
raise ValueError(f"unknown tool: {name}")
|
||||
try:
|
||||
schema = next(tool["inputSchema"] for tool in HTTP_MCP_TOOLS if tool["name"] == name)
|
||||
_validate_arguments(args, schema)
|
||||
result = fn(dict(args or {}))
|
||||
except ToolArgError as exc:
|
||||
result = {"ok": False, "error": "invalid_argument", "error_code": "invalid_argument", "detail": str(exc)}
|
||||
return mcp_from_handler_result(result)
|
||||
return mcp_from_handler_result(_redact_secrets(result))
|
||||
|
|
|
|||
77
packages/netx-mcp/tests/test_cli_safety.py
Normal file
77
packages/netx-mcp/tests/test_cli_safety.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""无设备/网络副作用地验证 MCP 执行边界与终态失败语义。"""
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from netx_mcp.http_tools import call_http_tool
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", [
|
||||
"reboot", "configure terminal", "show version; reboot", "show version && reboot",
|
||||
"show version | reboot", "show version | redirect flash:config", "show $(reboot)",
|
||||
"show `reboot`", "display version\nreboot", "show version\u2028reboot",
|
||||
"show version > config", "show version\x00", "/system reboot",
|
||||
])
|
||||
@pytest.mark.parametrize("batch", [False, True])
|
||||
def test_unsafe_cli_never_reaches_http(command, batch):
|
||||
args = {"targets": [{"ne_id": "test-only", "commands": [command]}]} if batch else {
|
||||
"ne_id": "test-only", "commands": [command], "async": True}
|
||||
with patch("netx_mcp.http_tools.http_post_json") as post:
|
||||
result = call_http_tool("execManagedNe", args)
|
||||
assert result.get("isError") is True
|
||||
post.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("command", ["show version", "display version", "show bgp summary | one-line"])
|
||||
def test_readonly_cli_still_executes(command):
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True}}) as post:
|
||||
result = call_http_tool("execManagedNe", {"ne_id": "test-only", "commands": [command]})
|
||||
assert not result.get("isError")
|
||||
assert post.call_args.args[0] == "/v1/managed-ne/exec"
|
||||
assert post.call_args.args[1]["commands"] == [command]
|
||||
|
||||
|
||||
def test_async_ack_explains_polling_without_forcing_turn_end():
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {
|
||||
"ok": True, "job_id": "test-job", "status": "running", "hint": "end the turn"}}):
|
||||
result = call_http_tool("execManagedNe", {"ne_id": "test-only", "commands": ["show version"], "async": True})
|
||||
data = json.loads(result["content"][0]["text"])
|
||||
assert data["poll_after_ms"] == 5000 and data["job_id"] == "test-job"
|
||||
assert "end the turn" not in data["hint"]
|
||||
|
||||
|
||||
def test_failed_exec_job_is_tool_error_and_preserves_evidence():
|
||||
terminal = {"ok": True, "terminal": True, "status": "failed", "result": {
|
||||
"ok": False, "detail": "SSH connection failed", "commands": ["display version"]}}
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
|
||||
result = call_http_tool("getNeExecJob", {"job_id": "test-job"})
|
||||
assert result.get("isError") is True
|
||||
data = json.loads(result["content"][0]["text"])
|
||||
assert data["data"]["result"]["detail"] == "SSH connection failed"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failed", [1, 2])
|
||||
@pytest.mark.parametrize("async_job", [False, True])
|
||||
def test_batch_target_failures_are_tool_errors_even_when_backend_ok(failed, async_job):
|
||||
result = {"ok": True, "summary": {"total": 2, "ok": 2-failed, "failed": failed},
|
||||
"results": [{"ok": failed == 1, "output": "version"}, {"ok": False, "detail": "SSH banner timeout"}]}
|
||||
terminal = {"ok": True, "terminal": True, "status": "succeeded", "result": result}
|
||||
if async_job:
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
|
||||
out = call_http_tool("getNeExecJob", {"job_id": "test-job"})
|
||||
assert json.loads(out["content"][0]["text"])["data"] == terminal
|
||||
else:
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": result}):
|
||||
out = call_http_tool("execManagedNe", {"ne_ids": ["m1", "m2"], "commands": ["show version"], "async": False})
|
||||
assert json.loads(out["content"][0]["text"])["data"] == result
|
||||
assert out["isError"] is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target_ok", [False, True])
|
||||
def test_batch_checks_individual_results_without_summary(target_ok):
|
||||
terminal = {"ok": True, "terminal": True, "status": "succeeded", "result": {
|
||||
"ok": True, "results": [{"ok": target_ok, "output": "version"}]}}
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
|
||||
out = call_http_tool("getNeExecJob", {"job_id": "test-job"})
|
||||
assert bool(out.get("isError")) is not target_ok
|
||||
|
|
@ -103,18 +103,6 @@ def test_call_find_topology_paths_defaults_summary_detail() -> None:
|
|||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_call_find_topology_paths_accepts_legacy_ume_params() -> None:
|
||||
with patch("netx_mcp.http_tools.http_post_json") as mock_post:
|
||||
mock_post.return_value = {"ok": True, "data": {"path_count": 0, "paths": []}}
|
||||
call_http_tool(
|
||||
"findTopologyPaths",
|
||||
{"from_ume_ne_id": "a", "to_ume_ne_id": "b"},
|
||||
)
|
||||
body = mock_post.call_args[0][1]
|
||||
assert body["from_ume_ne_id"] == "a"
|
||||
assert body["to_ume_ne_id"] == "b"
|
||||
|
||||
|
||||
def test_call_exec_managed_ne_defaults_read_timeout() -> None:
|
||||
with patch("netx_mcp.http_tools.http_post_json") as mock_post:
|
||||
mock_post.return_value = {"ok": True, "data": {"ok": True, "output": "hi"}}
|
||||
|
|
@ -138,16 +126,6 @@ def test_get_managed_ne_requires_id_with_hint() -> None:
|
|||
assert "hint" in payload
|
||||
|
||||
|
||||
def test_get_managed_ne_accepts_managed_ne_id_alias() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"ne_id": "m1"}}
|
||||
out = call_http_tool("getManagedNe", {"managed_ne_id": "m1"})
|
||||
mock_http.assert_called_once()
|
||||
assert mock_http.call_args[0][1].endswith("/m1")
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
||||
def test_call_get_nms_ne_requires_id() -> None:
|
||||
out = call_http_tool("getNmsNe", {})
|
||||
assert out.get("isError") is True
|
||||
|
|
@ -202,7 +180,7 @@ def test_exec_managed_ne_schema_documents_batch() -> None:
|
|||
props = tool["inputSchema"]["properties"]
|
||||
assert "ne_ids" in props
|
||||
assert "nms_ne_ids" in props
|
||||
assert "ume_ne_ids" in props
|
||||
assert "ume_ne_ids" not in props
|
||||
assert "targets" in props
|
||||
assert "concurrency" in props
|
||||
assert "Many NEs" in tool["description"] or "ne_ids" in tool["description"]
|
||||
|
|
@ -394,3 +372,194 @@ def test_managed_ne_list_drops_disabled_hop_noise() -> None:
|
|||
out = call_http_tool("listManagedNe", {"connect_status": "pass"})
|
||||
row = json.loads(out["content"][0]["text"])["data"]["items"][0]
|
||||
assert row == {"id": "m1", "hop_enabled": False}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "args"), [
|
||||
("queryNmsAlarms", {"ne_name": "old"}),
|
||||
("getManagedNe", {"managed_ne_id": "old"}),
|
||||
("getManagedNe", {"id": "old"}),
|
||||
("execManagedNe", {"ume_ne_id": "old", "commands": ["show version"]}),
|
||||
("execManagedNe", {"ume_ne_ids": ["old"], "commands": ["show version"]}),
|
||||
("execManagedNe", {"targets": [{"ume_ne_id": "old", "commands": ["show version"]}]}),
|
||||
("findTopologyPaths", {"from_ume_ne_id": "old", "to_nms_ne_id": "b"}),
|
||||
("findTopologyPaths", {"from_nms_ne_id": "a", "to_ume_ne_id": "old"}),
|
||||
("listCliTargets", {"source": "ume"}),
|
||||
])
|
||||
def test_obsolete_aliases_are_rejected_before_http(name: str, args: dict) -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as get, patch("netx_mcp.http_tools.http_post_json") as post:
|
||||
out = call_http_tool(name, args)
|
||||
get.assert_not_called()
|
||||
post.assert_not_called()
|
||||
assert out["isError"] is True
|
||||
assert json.loads(out["content"][0]["text"])["error"] == "invalid_argument"
|
||||
|
||||
|
||||
def test_all_fifteen_tools_match_backend_scope_policy() -> None:
|
||||
from netx_api.auth_scopes import required_scope_for_request
|
||||
from netx_mcp.http_tools import TOOL_REQUIRED_SCOPE
|
||||
|
||||
routes = {
|
||||
"queryNmsAlarms": ("GET", "/v1/ume/alarms"),
|
||||
"aggregateNmsAlarms": ("GET", "/v1/ume/alarms/aggregate"),
|
||||
"runNmsDiagnostics": ("GET", "/v1/ume/diagnostics"),
|
||||
"queryNmsNeInventory": ("GET", "/v1/ume/inventory/ne"),
|
||||
"getNmsNe": ("GET", "/v1/ume/inventory/ne/id"),
|
||||
"queryNmsAlarmsRaw": ("GET", "/v1/ume/alarms/raw"),
|
||||
"aggregateNmsAlarmsRaw": ("GET", "/v1/ume/alarms/aggregate/raw"),
|
||||
"listNmsAlarmFields": ("GET", "/v1/ume/alarms/fields"),
|
||||
"sqlQueryNms": ("POST", "/v1/sql/ume_query"),
|
||||
"listManagedNe": ("GET", "/v1/managed-ne"),
|
||||
"getManagedNe": ("GET", "/v1/managed-ne/id"),
|
||||
"execManagedNe": ("POST", "/v1/managed-ne/exec"),
|
||||
"getNeExecJob": ("GET", "/v1/managed-ne/exec-jobs/id"),
|
||||
"listCliTargets": ("GET", "/v1/cli/targets"),
|
||||
"findTopologyPaths": ("POST", "/v1/topology/fabric/paths"),
|
||||
}
|
||||
assert set(routes) == {t["name"] for t in HTTP_MCP_TOOLS} == set(TOOL_REQUIRED_SCOPE)
|
||||
for name, (method, path) in routes.items():
|
||||
assert TOOL_REQUIRED_SCOPE[name] == required_scope_for_request(method, path)
|
||||
assert name in {t["name"] for t in tools_for_scopes([TOOL_REQUIRED_SCOPE[name]])}
|
||||
assert tools_for_scopes([]) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "args", "path", "expected"), [
|
||||
("aggregateNmsAlarms", {"top_ne": 3000}, "/v1/ume/alarms/aggregate", {"top_ne": 3000}),
|
||||
("runNmsDiagnostics", {}, "/v1/ume/diagnostics", None),
|
||||
("queryNmsNeInventory", {"keyword": "edge", "page": 9, "page_size": 900}, "/v1/ume/inventory/ne", {"keyword": "edge", "page": 9, "page_size": 500}),
|
||||
("listNmsAlarmFields", {}, "/v1/ume/alarms/fields", None),
|
||||
("getNeExecJob", {"job_id": "job /?"}, "/v1/managed-ne/exec-jobs/job%20%2F%3F", None),
|
||||
("listCliTargets", {"source": "nms", "keyword": "edge"}, "/v1/cli/targets", {"source": "ume", "keyword": "edge", "page": 1, "page_size": 50}),
|
||||
])
|
||||
def test_ops_read_routes_and_backend_limits(name: str, args: dict, path: str, expected: dict | None) -> None:
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": {"items": []}}) as get:
|
||||
out = call_http_tool(name, args)
|
||||
get.assert_called_once_with("GET", path, params=expected)
|
||||
assert not out.get("isError")
|
||||
|
||||
|
||||
def test_raw_aggregation_filters_and_compaction() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": {"buckets": [{"key": "edge", "count": 4}], "meta": {"group_by_allowed": ["large"], "time_filter_field": "last_seen_at"}}}) as get:
|
||||
out = call_http_tool("aggregateNmsAlarmsRaw", {"group_by": "alarm_host_name", "severity": "CRITICAL", "exclude_missing_host": False, "limit": 9999})
|
||||
assert get.call_args.kwargs["params"] == {"group_by": "alarm_host_name", "severity": "critical", "exclude_missing_host": False, "limit": 2000}
|
||||
data = json.loads(out["content"][0]["text"])["data"]
|
||||
assert data["buckets"][0]["count"] == 4
|
||||
assert data["meta"] == {"time_filter_field": "last_seen_at"}
|
||||
|
||||
|
||||
def test_sql_limits_are_forwarded_and_denial_is_tool_error() -> None:
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": False, "error": "netx_http_403", "detail": "insufficient_scope:sql:query"}) as post:
|
||||
out = call_http_tool("sqlQueryNms", {"sql": " SELECT ne_id FROM ume_inventory_ne ", "limit": 9999, "statement_timeout_ms": 99999})
|
||||
post.assert_called_once_with("/v1/sql/ume_query", {"sql": "SELECT ne_id FROM ume_inventory_ne", "limit": 2000, "statement_timeout_ms": 30000}, timeout=60.0)
|
||||
assert out["isError"] is True
|
||||
assert "insufficient_scope" in out["content"][0]["text"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("listManagedNe", {"keyword": "edge"}),
|
||||
("getManagedNe", {"ne_id": "m1"}),
|
||||
("listCliTargets", {"source": "managed"}),
|
||||
("getNmsNe", {"ne_id": "u1"}),
|
||||
])
|
||||
def test_structured_credentials_are_removed_but_capabilities_remain(name: str, args: dict) -> None:
|
||||
row = {"id": "m1", "password": "secret", "has_password": True, "capability": {"recommended_mode": "show_only", "token": "secret"}, "hop_password_enc": "secret"}
|
||||
data = {"items": [row]} if name.startswith("list") else row
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": data}):
|
||||
out = call_http_tool(name, args)
|
||||
text = out["content"][0]["text"]
|
||||
assert "secret" not in text
|
||||
assert "show_only" in text
|
||||
assert "has_password" in text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [
|
||||
{"targets": [{"ne_id": "m1", "nms_ne_id": "u1", "commands": ["show version"]}]},
|
||||
{"targets": [{"ne_id": "m1"}]},
|
||||
{"targets": [{"ne_id": "m1", "commands": ["show version"] * 6}]},
|
||||
{"ne_ids": ["m1"]},
|
||||
{"ne_ids": ["m1"] * 11, "nms_ne_ids": ["u1"] * 10, "commands": ["show version"]},
|
||||
{"ne_id": "m1", "ne_ids": ["m2"], "commands": ["show version"]},
|
||||
{"targets": ["m1"], "commands": ["show version"]},
|
||||
])
|
||||
def test_invalid_exec_batches_do_not_reach_http(args: dict, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("NETX_NE_EXEC_MAX_COMMANDS", "5")
|
||||
with patch("netx_mcp.http_tools.http_post_json") as post:
|
||||
out = call_http_tool("execManagedNe", args)
|
||||
post.assert_not_called()
|
||||
assert out["isError"] is True
|
||||
|
||||
|
||||
def test_async_auto_counts_all_target_forms_and_poll_preserves_terminal_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("NETX_NE_EXEC_ASYNC_MIN_NES", "4")
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True, "job_id": "j1", "terminal": False}}) as post:
|
||||
ack = call_http_tool("execManagedNe", {"ne_ids": ["m1", "m2"], "nms_ne_ids": ["u1", "u2"], "commands": ["show version"], "concurrency": 20})
|
||||
assert post.call_args.args[0] == "/v1/managed-ne/exec-jobs"
|
||||
assert post.call_args.args[1]["concurrency"] == 8
|
||||
assert json.loads(ack["content"][0]["text"])["job_id"] == "j1"
|
||||
terminal = {"ok": True, "terminal": True, "result": {"ok": True, "results": [{"ok": False, "error": "timeout"}]}}
|
||||
with patch("netx_mcp.http_tools.http_json", return_value={"ok": True, "data": terminal}):
|
||||
out = call_http_tool("getNeExecJob", {"job_id": "j1"})
|
||||
assert json.loads(out["content"][0]["text"])["data"] == terminal
|
||||
assert out["isError"] is True
|
||||
|
||||
|
||||
def test_explicit_sync_overrides_auto_async(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("NETX_NE_EXEC_ASYNC_MIN_NES", "1")
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True}}) as post:
|
||||
call_http_tool("execManagedNe", {"nms_ne_ids": ["u1", "u2"], "commands": ["show version"], "async": False})
|
||||
assert post.call_args.args[0] == "/v1/managed-ne/exec-batch"
|
||||
|
||||
|
||||
def test_stdio_scope_denial_never_invokes_handler() -> None:
|
||||
import io
|
||||
from netx_mcp.server import run_stdio_loop
|
||||
|
||||
request = {"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "execManagedNe", "arguments": {"ne_id": "m1", "commands": ["show version"]}}}
|
||||
output = io.StringIO()
|
||||
with patch("netx_mcp.server.sys.stdin", io.StringIO(json.dumps(request) + "\n")), patch("netx_mcp.server.sys.stdout", output), patch("netx_mcp.server._fetch_scopes", return_value=["ne:read"]), patch("netx_mcp.server.call_http_tool") as handler:
|
||||
run_stdio_loop()
|
||||
handler.assert_not_called()
|
||||
response = json.loads(output.getvalue())
|
||||
assert response["error"]["code"] == -32001
|
||||
assert "ne:exec" in response["error"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["GET", "POST"])
|
||||
@pytest.mark.parametrize("status", [401, 403, 404, 500])
|
||||
def test_http_errors_preserve_status_and_bound_detail(method: str, status: int) -> None:
|
||||
import httpx
|
||||
from netx_mcp.http_client import http_json, http_post_json
|
||||
|
||||
transport = httpx.MockTransport(lambda request: httpx.Response(status, text="denied" * 300))
|
||||
client = httpx.Client(transport=transport)
|
||||
with patch("netx_mcp.http_client.httpx.Client", return_value=client):
|
||||
result = http_json("GET", "/v1/ume/alarms") if method == "GET" else http_post_json("/v1/managed-ne/exec", {})
|
||||
assert result["ok"] is False
|
||||
assert result["error"] == f"netx_http_{status}"
|
||||
assert len(result["detail"]) == 800
|
||||
|
||||
|
||||
|
||||
def test_mixed_exec_targets_translate_nms_ids_and_preserve_per_target_commands() -> None:
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": True, "items": [{"ok": True, "password": "secret", "output": "version"}]}}) as post:
|
||||
out = call_http_tool("execManagedNe", {"targets": [{"ne_id": "m1"}, {"nms_ne_id": "u1", "commands": ["display version"]}], "commands": ["show version"], "async": False})
|
||||
assert post.call_args.args[0] == "/v1/managed-ne/exec-batch"
|
||||
assert post.call_args.args[1]["targets"] == [{"ne_id": "m1"}, {"ume_ne_id": "u1", "commands": ["display version"]}]
|
||||
text = out["content"][0]["text"]
|
||||
assert "secret" not in text
|
||||
assert json.loads(text)["data"]["items"][0]["output"] == "version"
|
||||
|
||||
|
||||
def test_async_job_queue_failure_is_not_a_successful_ack() -> None:
|
||||
with patch("netx_mcp.http_tools.http_post_json", return_value={"ok": True, "data": {"ok": False, "error": "ne_exec_job_busy"}}):
|
||||
out = call_http_tool("execManagedNe", {"ne_id": "m1", "commands": ["show version"], "async": True})
|
||||
assert out["isError"] is True
|
||||
assert json.loads(out["content"][0]["text"])["error"] == "ne_exec_job_busy"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("args", [{}, {"group_by": ""}])
|
||||
def test_raw_aggregation_missing_dimension_is_actionable(args: dict) -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as get:
|
||||
out = call_http_tool("aggregateNmsAlarmsRaw", args)
|
||||
get.assert_not_called()
|
||||
assert out["isError"] is True
|
||||
assert "group_by" in out["content"][0]["text"]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → netx AP
|
|||
|
||||
详细说明 → **[docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md)**
|
||||
|
||||
配套 Skill → **[`.cursor/skills/netx-topology`](../../.cursor/skills/netx-topology/SKILL.md)**(画图流水线与硬规则)
|
||||
配套 Skill → **[`skills/topology/netx-topology`](../../skills/topology/netx-topology/SKILL.md)**(画图流水线与硬规则)
|
||||
|
||||
## 目录模型
|
||||
|
||||
|
|
@ -38,14 +38,35 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
|
|||
|
||||
画图请在 env 里设带 `ne:write` 的 `NETX_API_TOKEN`(不要只靠仓库 `mcp_token`)。`getTopologyView` / `projectTopologyNeighbors` 默认 `detail=summary`(坐标抽样 + `links[]` 邻接);`queryTopologyEdges` 默认 adjacency(一对网元一条线),避免整图/端口边占满上下文。
|
||||
|
||||
## 工具(14)
|
||||
## 工具(16)
|
||||
|
||||
覆盖 netxops `topology` 清单全部 15 项,另提供 `sinkTopologyDualUnits`。netxops 工具名前缀为 `netx__`,独立 MCP 不带此前缀。
|
||||
|
||||
| 类别 | 工具 |
|
||||
|------|------|
|
||||
| 树/区域/画布 | `getTopologyTree`, `createTopologyFolder`(唯一建图,返回 view_id), `getTopologyView` |
|
||||
| 画图 | `addTopologyViewNodes` / `remove…` / `sinkTopologyDualUnits`(分批沉入+默认可先 layout_dual_unit) / `copyTopologyViewNodes`(一键克隆测沙箱) / `update…Positions`(**优先筛选**,API 自选 id;也可 id 列表), `projectTopologyNeighbors` |
|
||||
| Fabric 只读 | `queryTopologyFabricNodes`(summary\|list\|search), `queryTopologyNeighborhood`, `queryTopologyEdges` |
|
||||
| Fabric 分类 | `classifyTopologyFabricNodes`(match/tag/patch/unmatched/preview_rules/apply_rules/list_rules) |
|
||||
| 自动布图 | `layoutTopologyView`(小图 `compact`/`corridor`/`rings`;巨图:`sinkTopologyDualUnits`→`orbit_sweep`→`polish_crossings`→`clear_edge_hits`) |
|
||||
| 布图验收/规划 | `analyzeTopologyViewLayout`(score;`detail=structure` 定重心;`both`=structure+手拖 sight) |
|
||||
|
||||
**安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。
|
||||
|
||||
画布节点增删和位置布局只接受 `keyword/role/vendor/link_status` 筛选。按 `region_folder_id/level/level_major` 画图时,先用 `queryTopologyFabricNodes(mode=list)` 分页查询,再将返回的 `id` 作为 `fabric_node_ids` 传入画布工具。`projectTopologyNeighbors(dry_run=true)` 可预览邻居投影而不保存。
|
||||
|
||||
## 自动布局与异步任务
|
||||
|
||||
1. `layoutTopologyView({view_id, action:"layout", recipe:"compact", mode:"preview"})` 预览;`mode:"apply"` 才保存坐标。
|
||||
2. 需要后台计算时,调用 `layoutTopologyView({view_id, action:"layout", recipe:"compact", mode:"apply", params:{background:true}})`,记录返回的 `job_id`。布局/局部优化计算支持显式后台;`move_nodes` 和 `align_reference` 按各自流程同步执行。
|
||||
3. 调用 `layoutTopologyView({action:"job_status", params:{job_id}})`,直到 `status` 为 `done/error/cancelled`。`running/cancelling` 仍需轮询;`stale=true` 仅表示心跳过期,不能视为完成。成功必须检查 `ok=true`、`status=done`、`result.applied=true`。
|
||||
4. `getTopologyView({view_id, detail:"full"})` 回读 HTTP API 持久化后的坐标。`job_status` 的结果会裁剪大字段,不替代完整画布回读。
|
||||
5. 需要取消时调用 `layoutTopologyView({action:"job_cancel", params:{job_id}})`;取消为协作式,仍需轮询终态。已经写入的坐标不会因取消自动回滚。
|
||||
|
||||
部分大图优化会自动转后台:`polish_crossings/orbit_sweep/clear_edge_hits/compact_bbox/pull_far_chains/layout_dual_unit` 在节点数达到 600 时;`orbit_sweep(until_limit=true)` 的 apply 总是后台。`params.sync=true` 可强制同步。
|
||||
|
||||
任务 JSON 默认位于仓库 `data/runtime/layout_jobs`;用绝对路径 `NETX_LAYOUT_JOB_DIR` 可指定共享目录。后台子进程和任务文件能跨 MCP 主进程重启,轮询方必须访问同一目录;这不是 HTTP 后端任务接口,也不支持跨机器自动共享。历史回收仅清理终态,保留运行中的任务。
|
||||
|
||||
HTTP 200 内的 `ok=false` 会作为 MCP `isError=true` 返回。分批写入遇错即停;复制/移动/下沉若保存目标坐标失败,会保留源成员并报告已增加的目标成员。最终回读失败返回 `view_readback_failed` 和 `write_completed=true`,此时应先检查画布再重试。多次 HTTP 写入不构成事务,已完成的部分不会自动撤销。
|
||||
|
||||
只使用工具 schema 中公开的动作。旧动作别名 `resolve_overlaps/sink_nodes/align_to_reference`、查询模式 `stats/count/find`、分类动作 `find/bulk_tag/assign/preview/apply/rules`、详情别名 `raw/graph/full`(边查询的 full)已移除;规范动作分别见 `layoutTopologyView(catalog=true)` 与各工具 schema。
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ def _data(out: dict[str, Any]) -> dict[str, Any]:
|
|||
data = out.get("data")
|
||||
if isinstance(data, dict):
|
||||
merged = dict(data)
|
||||
merged["ok"] = True
|
||||
# HTTP 200 can carry a failed mutation (for example membership_max_nodes).
|
||||
merged.setdefault("ok", True)
|
||||
if merged.get("ok") is False and not merged.get("error"):
|
||||
merged["error"] = "netx_operation_failed"
|
||||
return merged
|
||||
return {"ok": True, "data": data}
|
||||
|
||||
|
|
@ -233,10 +236,12 @@ def _get_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
if not out.get("ok"):
|
||||
return out
|
||||
detail = str(args.get("detail") or "summary").strip().lower()
|
||||
if detail in {"full", "raw", "graph"}:
|
||||
if detail not in {"summary", "full"}:
|
||||
return {"ok": False, "error": "detail_invalid", "hint": "detail=summary|full"}
|
||||
if detail == "full":
|
||||
out["detail"] = "full"
|
||||
return out
|
||||
sample = int(args.get("sample") or 20)
|
||||
sample = int(args["sample"]) if args.get("sample") is not None else 20
|
||||
return _summarize_view_graph(out, sample=sample)
|
||||
|
||||
|
||||
|
|
@ -359,7 +364,7 @@ def _patch_positions_chunked(view_id: str, positions: list[dict[str, Any]]) -> d
|
|||
"updated": updated,
|
||||
"detail": out,
|
||||
}
|
||||
updated += int(out.get("updated") or len(chunk))
|
||||
updated += int(out.get("updated", len(chunk)))
|
||||
chunks += 1
|
||||
return {"ok": True, "updated": updated, "chunks": chunks}
|
||||
|
||||
|
|
@ -469,7 +474,6 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
_public = {
|
||||
"layout",
|
||||
"fix_overlaps",
|
||||
"resolve_overlaps",
|
||||
"untangle",
|
||||
"straighten_channels",
|
||||
"layout_dual_unit",
|
||||
|
|
@ -478,11 +482,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"compact_bbox",
|
||||
"pull_far_chains",
|
||||
"align_reference",
|
||||
"align_to_reference",
|
||||
"orbit_sweep",
|
||||
"level_bands",
|
||||
"move_nodes",
|
||||
"sink_nodes",
|
||||
"job_status",
|
||||
"job_cancel",
|
||||
}
|
||||
|
|
@ -503,16 +505,12 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
),
|
||||
**list_layout_catalog(),
|
||||
}
|
||||
if action == "resolve_overlaps":
|
||||
action = "fix_overlaps"
|
||||
args = {**args, "action": "fix_overlaps"}
|
||||
|
||||
# Bidirectional membership move: view_id=TO, source_view_id=FROM.
|
||||
if action in {"move_nodes", "sink_nodes"}:
|
||||
if action == "move_nodes":
|
||||
return _move_topology_view_nodes(args)
|
||||
|
||||
# Reference alignment: view_id=TO, source_view_id|params.reference_view_id=FROM.
|
||||
if action in {"align_reference", "align_to_reference"}:
|
||||
if action == "align_reference":
|
||||
return _align_topology_reference(args)
|
||||
|
||||
# Background job poll / cancel (no view_id required).
|
||||
|
|
@ -544,8 +542,8 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"error": "job_not_found",
|
||||
"job_id": job_id,
|
||||
"hint": (
|
||||
"Unknown job_id. Jobs are in-process on this MCP worker; "
|
||||
"restart or another instance cannot see them."
|
||||
"Unknown job_id. Poll from a worker sharing NETX_LAYOUT_JOB_DIR; "
|
||||
"records survive MCP restarts but may have been pruned."
|
||||
),
|
||||
}
|
||||
status = str(job.get("status") or "unknown")
|
||||
|
|
@ -870,9 +868,7 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
force_sync = _truthy((overrides or {}).get("sync")) or _truthy(
|
||||
(overrides or {}).get("_force_sync")
|
||||
)
|
||||
until_limit = _truthy((overrides or {}).get("until_limit")) or _truthy(
|
||||
(overrides or {}).get("until_stall")
|
||||
)
|
||||
until_limit = _truthy((overrides or {}).get("until_limit"))
|
||||
heavy_bg = action in {
|
||||
"polish_crossings",
|
||||
"orbit_sweep",
|
||||
|
|
@ -885,10 +881,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
bg_n_thr = 80 if (action == "orbit_sweep" and until_limit) else 600
|
||||
if (
|
||||
mode == "apply"
|
||||
and heavy_bg
|
||||
and not force_sync
|
||||
and (
|
||||
len(nodes) >= bg_n_thr
|
||||
(heavy_bg and len(nodes) >= bg_n_thr)
|
||||
or _truthy((overrides or {}).get("background"))
|
||||
or (until_limit and action == "orbit_sweep")
|
||||
)
|
||||
|
|
@ -912,6 +907,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
tool_args=sync_args,
|
||||
meta={"node_count": len(nodes), "until_limit": until_limit},
|
||||
)
|
||||
started = job_public(job_id)
|
||||
if started and started.get("status") in {"done", "error", "cancelled"}:
|
||||
return _layout_topology_view({"action": "job_status", "params": {"job_id": job_id}})
|
||||
print(
|
||||
f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}"
|
||||
+ (" until_limit" if until_limit else ""),
|
||||
|
|
@ -1870,10 +1868,6 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"error": "fabric_nodes_only",
|
||||
"detail": "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.",
|
||||
}
|
||||
if args.get("max_nodes") is not None:
|
||||
bump = _ensure_view_max_nodes(view_id, int(args.get("max_nodes") or 2000))
|
||||
if isinstance(bump, dict) and bump.get("ok") is False:
|
||||
return bump
|
||||
filters = _filter_fields(args)
|
||||
fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
|
||||
if not filters and not fabric_ids:
|
||||
|
|
@ -1883,6 +1877,11 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"detail": "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.",
|
||||
}
|
||||
|
||||
if args.get("max_nodes") is not None:
|
||||
bump = _ensure_view_max_nodes(view_id, int(args.get("max_nodes") or 2000))
|
||||
if bump.get("ok") is False:
|
||||
return bump
|
||||
|
||||
layout = str(args.get("layout") or "grid").strip() or "grid"
|
||||
if filters:
|
||||
body: dict[str, Any] = {
|
||||
|
|
@ -1909,6 +1908,8 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"return_graph": False,
|
||||
}
|
||||
parts.append(_data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body)))
|
||||
if not parts[-1].get("ok"):
|
||||
break
|
||||
return _merge_mutation_summaries(parts)
|
||||
|
||||
|
||||
|
|
@ -1935,6 +1936,8 @@ def _remove_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
)
|
||||
)
|
||||
if not parts[-1].get("ok"):
|
||||
break
|
||||
return _merge_mutation_summaries(parts)
|
||||
|
||||
|
||||
|
|
@ -2002,6 +2005,8 @@ def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
)
|
||||
)
|
||||
if not parts[-1].get("ok"):
|
||||
break
|
||||
return _merge_mutation_summaries(parts)
|
||||
|
||||
|
||||
|
|
@ -2009,8 +2014,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
|
|||
view_id = str(args.get("view_id") or "").strip()
|
||||
if not view_id:
|
||||
return {"ok": False, "error": "view_id_required"}
|
||||
detail = str(args.get("detail") or "summary").strip().lower()
|
||||
if detail not in {"summary", "full"}:
|
||||
return {"ok": False, "error": "detail_invalid", "hint": "detail=summary|full"}
|
||||
body: dict[str, Any] = {}
|
||||
seeds = args.get("seed_fabric_node_ids") or args.get("fabric_node_ids") or []
|
||||
seeds = args.get("seed_fabric_node_ids") or []
|
||||
if isinstance(seeds, list) and seeds:
|
||||
body["seed_fabric_node_ids"] = [str(x).strip() for x in seeds if str(x).strip()]
|
||||
mids = args.get("managed_ne_ids") or []
|
||||
|
|
@ -2019,6 +2027,8 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
|
|||
region_folder_id = str(args.get("region_folder_id") or "").strip()
|
||||
if region_folder_id:
|
||||
body["region_folder_id"] = region_folder_id
|
||||
if "dry_run" in args:
|
||||
body["dry_run"] = _truthy(args["dry_run"])
|
||||
# API still returns the full graph; large canvases easily exceed the 60s default.
|
||||
out = _data(
|
||||
http_json(
|
||||
|
|
@ -2030,12 +2040,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
if not out.get("ok"):
|
||||
return out
|
||||
detail = str(args.get("detail") or "summary").strip().lower()
|
||||
skipped = int(out.get("out_of_region_skipped") or 0)
|
||||
skipped_sample = out.get("out_of_region_sample") or []
|
||||
if not isinstance(skipped_sample, list):
|
||||
skipped_sample = []
|
||||
if detail in {"full", "raw", "graph"}:
|
||||
if detail == "full":
|
||||
out["detail"] = "full"
|
||||
out["region_folder_id"] = region_folder_id or None
|
||||
out["out_of_region_skipped"] = skipped
|
||||
|
|
@ -2046,9 +2055,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
|
|||
f"skipped {skipped} out-of-region neighbors (not added)."
|
||||
)
|
||||
return out
|
||||
summary = _summarize_view_graph(out, sample=int(args.get("sample") or 20))
|
||||
sample = int(args["sample"]) if args.get("sample") is not None else 20
|
||||
summary = _summarize_view_graph(out, sample=sample)
|
||||
summary["view_id"] = view_id
|
||||
summary["projected"] = True
|
||||
summary["dry_run"] = bool(body.get("dry_run"))
|
||||
summary["region_folder_id"] = region_folder_id or None
|
||||
summary["out_of_region_skipped"] = skipped
|
||||
summary["out_of_region_sample"] = [
|
||||
|
|
@ -2076,13 +2087,15 @@ def _query_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
mode = "search"
|
||||
else:
|
||||
mode = "list"
|
||||
if mode in {"summary", "stats", "count"}:
|
||||
if mode not in {"summary", "search", "list"}:
|
||||
return {"ok": False, "error": "mode_invalid", "hint": "mode=summary|list|search"}
|
||||
if mode == "summary":
|
||||
out = _data(http_json("GET", "/v1/topology/fabric/summary"))
|
||||
if isinstance(out, dict) and out.get("ok") is not False:
|
||||
out = dict(out)
|
||||
out["mode"] = "summary"
|
||||
return out
|
||||
if mode in {"search", "find"}:
|
||||
if mode == "search":
|
||||
needle = q or keyword
|
||||
if not needle:
|
||||
return {
|
||||
|
|
@ -2141,11 +2154,11 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"hint": (
|
||||
"action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules. "
|
||||
"Typical: match → tag(level|role, dry_run) → tag; "
|
||||
"then addTopologyViewNodes by role/level_major/region."
|
||||
"then queryTopologyFabricNodes by level/region and add returned fabric_node_ids."
|
||||
),
|
||||
}
|
||||
|
||||
if action in {"match", "find"}:
|
||||
if action == "match":
|
||||
pattern = str(args.get("pattern") or args.get("q") or "").strip()
|
||||
if not pattern:
|
||||
return {"ok": False, "error": "pattern_required"}
|
||||
|
|
@ -2180,7 +2193,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
)
|
||||
return out
|
||||
|
||||
if action in {"tag", "bulk_tag", "assign"}:
|
||||
if action == "tag":
|
||||
level = args.get("level") if "level" in args else Ellipsis
|
||||
role = args.get("role") if "role" in args else Ellipsis
|
||||
region = args.get("region_folder_id") if "region_folder_id" in args else Ellipsis
|
||||
|
|
@ -2232,7 +2245,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
else:
|
||||
out["hint"] = (
|
||||
"Tagged. Use queryTopologyFabricNodes(role|level_major|region_folder_id) or "
|
||||
"addTopologyViewNodes filters to place on region canvas."
|
||||
"pass returned fabric_node_ids to addTopologyViewNodes to place on region canvas."
|
||||
)
|
||||
return out
|
||||
|
||||
|
|
@ -2286,7 +2299,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
page["action"] = "unmatched"
|
||||
return page
|
||||
|
||||
if action in {"preview_rules", "preview"}:
|
||||
if action == "preview_rules":
|
||||
out = _data(http_json("POST", "/v1/topology/classify/preview"))
|
||||
if isinstance(out, dict) and out.get("ok") is not False:
|
||||
out = dict(out)
|
||||
|
|
@ -2294,7 +2307,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
out["hint"] = "Rule engine dry-run. Re-call action=apply_rules to write."
|
||||
return out
|
||||
|
||||
if action in {"apply_rules", "apply"}:
|
||||
if action == "apply_rules":
|
||||
skip_manual = True
|
||||
if "skip_manual" in args:
|
||||
skip_manual = _truthy(args.get("skip_manual"))
|
||||
|
|
@ -2310,7 +2323,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
out["action"] = "apply_rules"
|
||||
return out
|
||||
|
||||
if action in {"list_rules", "rules"}:
|
||||
if action == "list_rules":
|
||||
out = _data(http_json("GET", "/v1/topology/classify/rules"))
|
||||
if isinstance(out, dict) and out.get("ok") is not False:
|
||||
out = dict(out)
|
||||
|
|
@ -2362,7 +2375,9 @@ def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
|
|||
page_size = min(500, max(1, int(args.get("page_size") or 100)))
|
||||
node_id = str(args.get("node_id") or "").strip()
|
||||
detail = str(args.get("detail") or "adjacency").strip().lower()
|
||||
want_ports = detail in {"ports", "raw", "full"}
|
||||
if detail not in {"adjacency", "ports"}:
|
||||
return {"ok": False, "error": "detail_invalid", "hint": "detail=adjacency|ports"}
|
||||
want_ports = detail == "ports"
|
||||
params: dict[str, Any] = {
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
|
|
@ -2374,16 +2389,13 @@ def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
|
|||
params["status"] = str(args.get("status")).strip()
|
||||
if str(args.get("source") or "").strip():
|
||||
src = str(args.get("source")).strip().lower()
|
||||
if src == "stale":
|
||||
src = "lldp"
|
||||
params["source"] = src
|
||||
if str(args.get("keyword") or "").strip():
|
||||
params["keyword"] = str(args.get("keyword")).strip()
|
||||
|
||||
out = http_json("GET", "/v1/topology/fabric/edges", params=params)
|
||||
if not isinstance(out, dict) or not out.get("ok"):
|
||||
return out if isinstance(out, dict) else {"ok": False, "error": "invalid_response"}
|
||||
data = out.get("data") if isinstance(out.get("data"), dict) else {}
|
||||
data = _data(http_json("GET", "/v1/topology/fabric/edges", params=params))
|
||||
if not data.get("ok"):
|
||||
return data
|
||||
items = [e for e in (data.get("items") or []) if isinstance(e, dict)]
|
||||
edge_total = int(data.get("total") or len(items))
|
||||
links = _collapse_edges_to_links(items, include_names=True)
|
||||
|
|
@ -2528,12 +2540,8 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
|
|||
units_as_batch_rows,
|
||||
)
|
||||
|
||||
source_view_id = str(
|
||||
args.get("source_view_id") or args.get("root_view_id") or ""
|
||||
).strip()
|
||||
sink_view_id = str(
|
||||
args.get("sink_view_id") or args.get("target_view_id") or ""
|
||||
).strip()
|
||||
source_view_id = str(args.get("source_view_id") or "").strip()
|
||||
sink_view_id = str(args.get("sink_view_id") or "").strip()
|
||||
if not source_view_id or not sink_view_id:
|
||||
return {
|
||||
"ok": False,
|
||||
|
|
@ -2883,6 +2891,17 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
|
|||
patch_out: dict[str, Any] = {"ok": True, "updated": 0}
|
||||
if pos_patch:
|
||||
patch_out = _patch_positions_chunked(sink_view_id, pos_patch)
|
||||
if not patch_out.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": patch_out.get("error") or "positions_patch_failed",
|
||||
"detail": patch_out,
|
||||
"source_preserved": True,
|
||||
"added": int(add_out.get("added", 0)),
|
||||
"batches": batches,
|
||||
"batch": batch_row,
|
||||
"hint": "Sink membership may already be added; source nodes were kept. Inspect both views before retrying.",
|
||||
}
|
||||
|
||||
rm_out = _remove_topology_view_nodes(
|
||||
{"view_id": source_view_id, "fabric_node_ids": move_ids}
|
||||
|
|
@ -2900,6 +2919,16 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
|
|||
# Refresh counts
|
||||
src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
|
||||
snk2 = _data(http_json("GET", f"/v1/topology/views/{sink_view_id}", timeout=120.0))
|
||||
if not src2.get("ok") or not snk2.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "view_readback_failed",
|
||||
"write_completed": True,
|
||||
"detail": {"source": src2, "sink": snk2},
|
||||
"batches": batches,
|
||||
"batch": batch_row,
|
||||
"hint": "Writes completed but verification failed. Read both views before retrying this batch.",
|
||||
}
|
||||
src_ids2, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
|
||||
snk_ids2, _ = _view_node_id_pos(snk2 if snk2.get("ok") else {})
|
||||
source_remaining = len(src_ids2) if src2.get("ok") else max(0, source_remaining - len(move_ids))
|
||||
|
|
@ -2908,8 +2937,8 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
|
|||
batch_row.update(
|
||||
{
|
||||
"added": int(add_out.get("added") or len(add_ids)),
|
||||
"positions_updated": int(patch_out.get("updated") or len(pos_patch)),
|
||||
"removed_from_source": int(rm_out.get("removed") or len(move_ids)),
|
||||
"positions_updated": int(patch_out.get("updated", 0)),
|
||||
"removed_from_source": int(rm_out.get("removed", 0)),
|
||||
"source_after": source_remaining,
|
||||
"sink_after": sink_count,
|
||||
"max_nodes_bump": bump,
|
||||
|
|
@ -2963,19 +2992,8 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
Bidirectional: swap view_id / source_view_id to send nodes back.
|
||||
Caller supplies fabric_node_ids — no auto CN/dual-unit selection.
|
||||
"""
|
||||
dest_view_id = str(
|
||||
args.get("view_id")
|
||||
or args.get("sink_view_id")
|
||||
or args.get("target_view_id")
|
||||
or args.get("to_view_id")
|
||||
or ""
|
||||
).strip()
|
||||
source_view_id = str(
|
||||
args.get("source_view_id")
|
||||
or args.get("from_view_id")
|
||||
or args.get("root_view_id")
|
||||
or ""
|
||||
).strip()
|
||||
dest_view_id = str(args.get("view_id") or "").strip()
|
||||
source_view_id = str(args.get("source_view_id") or "").strip()
|
||||
if not dest_view_id or not source_view_id:
|
||||
return {
|
||||
"ok": False,
|
||||
|
|
@ -3010,6 +3028,8 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
return {"ok": False, "error": "fabric_node_ids_empty"}
|
||||
|
||||
mode = str(args.get("mode") or "preview").strip().lower() or "preview"
|
||||
if mode not in {"preview", "apply"}:
|
||||
return {"ok": False, "error": "mode_invalid", "hint": "mode=preview|apply"}
|
||||
dry_run = bool(overrides.get("dry_run") if "dry_run" in overrides else args.get("dry_run"))
|
||||
if mode != "apply":
|
||||
dry_run = True
|
||||
|
|
@ -3109,7 +3129,7 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
bump = _ensure_view_max_nodes(dest_view_id, min(2000, max(need_cap + 20, 200)))
|
||||
if isinstance(bump, dict) and bump.get("ok") is False:
|
||||
return {**bump, **base}
|
||||
return {**base, **bump}
|
||||
|
||||
add_out: dict[str, Any] = {"ok": True, "added": 0, "skipped_existing": len(already)}
|
||||
if add_ids:
|
||||
|
|
@ -3158,6 +3178,16 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
patch_out: dict[str, Any] = {"ok": True, "updated": 0}
|
||||
if pos_patch:
|
||||
patch_out = _patch_positions_chunked(dest_view_id, pos_patch)
|
||||
if not patch_out.get("ok"):
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"error": patch_out.get("error") or "positions_patch_failed",
|
||||
"detail": patch_out,
|
||||
"added": int(add_out.get("added", 0)),
|
||||
"source_preserved": True,
|
||||
"hint": "Destination membership may already be added; source nodes were kept. Inspect both views before retrying.",
|
||||
}
|
||||
|
||||
rm_out: dict[str, Any] = {"ok": True, "removed": 0}
|
||||
if remove_from_source:
|
||||
|
|
@ -3176,14 +3206,23 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
|
||||
dst2 = _data(http_json("GET", f"/v1/topology/views/{dest_view_id}", timeout=120.0))
|
||||
if not src2.get("ok") or not dst2.get("ok"):
|
||||
return {
|
||||
**base,
|
||||
"ok": False,
|
||||
"error": "view_readback_failed",
|
||||
"write_completed": True,
|
||||
"detail": {"source": src2, "destination": dst2},
|
||||
"hint": "Writes completed but verification failed. Read both views before retrying the move.",
|
||||
}
|
||||
src_after, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
|
||||
dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
|
||||
return {
|
||||
**base,
|
||||
"dry_run": False,
|
||||
"added": int(add_out.get("added") or len(add_ids)),
|
||||
"positions_updated": int(patch_out.get("updated") or len(pos_patch)),
|
||||
"removed_from_source": int(rm_out.get("removed") or (len(move_ids) if remove_from_source else 0)),
|
||||
"positions_updated": int(patch_out.get("updated", 0)),
|
||||
"removed_from_source": int(rm_out.get("removed", 0)),
|
||||
"source_after": len(src_after) if src2.get("ok") else None,
|
||||
"dest_after": len(dst_after) if dst2.get("ok") else None,
|
||||
"max_nodes_bump": bump,
|
||||
|
|
@ -3201,12 +3240,8 @@ def _copy_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
Source membership is unchanged. For test sandboxes: clone a known-good
|
||||
canvas onto a fresh child region without re-adding ids by hand.
|
||||
"""
|
||||
source_view_id = str(
|
||||
args.get("source_view_id") or args.get("from_view_id") or ""
|
||||
).strip()
|
||||
target_view_id = str(
|
||||
args.get("target_view_id") or args.get("to_view_id") or ""
|
||||
).strip()
|
||||
source_view_id = str(args.get("source_view_id") or "").strip()
|
||||
target_view_id = str(args.get("target_view_id") or "").strip()
|
||||
if not source_view_id or not target_view_id:
|
||||
return {
|
||||
"ok": False,
|
||||
|
|
@ -3322,8 +3357,29 @@ def _copy_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
|||
]
|
||||
if positions:
|
||||
patch_out = _patch_positions_chunked(target_view_id, positions)
|
||||
if not patch_out.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": patch_out.get("error") or "positions_patch_failed",
|
||||
"detail": patch_out,
|
||||
"source_view_id": source_view_id,
|
||||
"target_view_id": target_view_id,
|
||||
"cleared": cleared,
|
||||
"added": int(add_out.get("added", 0)),
|
||||
"hint": "Target membership may already be changed; source is unchanged. Inspect target before retrying.",
|
||||
}
|
||||
|
||||
dst2 = _data(http_json("GET", f"/v1/topology/views/{target_view_id}", timeout=120.0))
|
||||
if not dst2.get("ok"):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "view_readback_failed",
|
||||
"write_completed": True,
|
||||
"source_view_id": source_view_id,
|
||||
"target_view_id": target_view_id,
|
||||
"detail": dst2,
|
||||
"hint": "Writes completed but verification failed. Read target before retrying the copy.",
|
||||
}
|
||||
dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
|
||||
return {
|
||||
"ok": True,
|
||||
|
|
@ -3779,6 +3835,11 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"skipped peers are counted in out_of_region_skipped."
|
||||
),
|
||||
},
|
||||
"dry_run": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Preview projected graph without saving placements.",
|
||||
},
|
||||
"detail": {
|
||||
"type": "string",
|
||||
"enum": ["summary", "full"],
|
||||
|
|
@ -3797,8 +3858,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"Default: q→search, else list. "
|
||||
"summary=node/edge counts; list=paged filters "
|
||||
"(keyword/role/link_status/region_folder_id); "
|
||||
"search=quick name/IP/id (needs q). Replaces getTopologyFabricSummary / "
|
||||
"listTopologyFabricNodes / searchTopologyFabricNodes."
|
||||
"search=quick name/IP/id (needs q)."
|
||||
),
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
|
|
@ -3853,7 +3913,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"level: 0=external, 1=core, 2=agg, 3=access; use 1.1 / 2.1 for sub-tiers. "
|
||||
"action=match → tag(level|role preset, dry_run) → tag. "
|
||||
"role preset still accepted (maps to *.0). "
|
||||
"After tagging, addTopologyViewNodes by role/region. No slice maps. "
|
||||
"After tagging, queryTopologyFabricNodes by region/level, then place returned "
|
||||
"fabric_node_ids with addTopologyViewNodes (canvas filters do not support region/level). "
|
||||
"Requires ne:write."
|
||||
),
|
||||
"inputSchema": {
|
||||
|
|
@ -4030,8 +4091,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
"use_source_dual_portals": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Exclude portals from source dual_units (default true).",
|
||||
"default": False,
|
||||
"description": "Exclude portals from source dual_units (default false).",
|
||||
},
|
||||
"use_sink_dual_portals": {
|
||||
"type": "boolean",
|
||||
|
|
@ -4156,7 +4217,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"action=layout: recipe=rings|corridor|compact|unstick (small graphs). "
|
||||
"action=layout_dual_unit: CN/core dual-portal eye (max membership; "
|
||||
"residual crossings OK unless params.require_zero_cross=true). "
|
||||
"action=move_nodes (alias sink_nodes): move fabric_node_ids from "
|
||||
"action=move_nodes: move fabric_node_ids from "
|
||||
"source_view_id→view_id; park=true for orbit attach; swap views to reverse. "
|
||||
"Prefer sinkTopologyDualUnits for dual_units batches. "
|
||||
"action=orbit_sweep: crossing orbit; preview+node_id / apply+pick; "
|
||||
|
|
@ -4170,9 +4231,12 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"action=pull_far_chains: scale far corridors/isolates toward portal mid-point. "
|
||||
"action=align_reference: SAME-network A/B debug only "
|
||||
"(shared fabric_node_ids); not a cross-network template. "
|
||||
"action=fix_overlaps|resolve_overlaps: pull apart overlaps (not on eye sinks). "
|
||||
"action=fix_overlaps: pull apart overlaps (not on eye sinks). "
|
||||
"action=untangle / straighten_channels: surgical polish (not on eye sinks). "
|
||||
"action=job_status|job_cancel: poll/cancel background jobs. "
|
||||
"params.background=true starts an apply action in a durable subprocess; "
|
||||
"poll params.job_id until done|error|cancelled, then inspect result. "
|
||||
"Successful apply persists positions; getTopologyView reads them back. "
|
||||
"preset: loose|balanced|dense. mode: preview|apply. "
|
||||
"Workflow: analyze(structure) → sinkTopologyDualUnits(max_units=1) → "
|
||||
"suggestSinkHubs/move_nodes(park) → until_limit(crossing→total) → "
|
||||
|
|
@ -4192,7 +4256,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"enum": [
|
||||
"layout",
|
||||
"fix_overlaps",
|
||||
"resolve_overlaps",
|
||||
"layout_dual_unit",
|
||||
"straighten_channels",
|
||||
"untangle",
|
||||
|
|
@ -4204,7 +4267,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"orbit_sweep",
|
||||
"level_bands",
|
||||
"move_nodes",
|
||||
"sink_nodes",
|
||||
"job_status",
|
||||
"job_cancel",
|
||||
],
|
||||
|
|
@ -4212,13 +4274,13 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"description": (
|
||||
"layout=full recipe; layout_dual_unit=dual-portal eye; "
|
||||
"level_bands=horizontal layer bands; "
|
||||
"move_nodes|sink_nodes=migrate fabric_node_ids; "
|
||||
"move_nodes=migrate fabric_node_ids; "
|
||||
"orbit_sweep=polar sweep; polish_crossings=one-shot cut crossings; "
|
||||
"clear_edge_hits=eject edge hits (no crossing rise); "
|
||||
"pull_far_chains=scale far corridors toward portal mid; "
|
||||
"compact_bbox=gated farthest-K shrink; "
|
||||
"align_reference=same-network A/B debug only; "
|
||||
"fix_overlaps|resolve_overlaps; "
|
||||
"fix_overlaps; "
|
||||
"untangle/straighten_channels; job_status|job_cancel."
|
||||
),
|
||||
},
|
||||
|
|
@ -4281,10 +4343,15 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"max_jump/angle_step/nn_floor; until_limit defaults "
|
||||
"protect_rigid=portals + objective=crossing; "
|
||||
"single-node/round default protect_rigid=off. "
|
||||
"move_nodes|sink_nodes: fabric_node_ids[] (required), "
|
||||
"move_nodes: fabric_node_ids[] (required), "
|
||||
"copy_positions (default true), park, remove_from_source "
|
||||
"(default true), pad/offset_x/offset_y."
|
||||
),
|
||||
"properties": {
|
||||
"job_id": {"type": "string", "description": "Required for job_status/job_cancel; returned by background start."},
|
||||
"background": {"type": "boolean", "description": "Run apply computation in a durable subprocess and return job_id immediately."},
|
||||
"sync": {"type": "boolean", "description": "Force inline computation, disabling automatic background execution."},
|
||||
},
|
||||
"additionalProperties": True,
|
||||
},
|
||||
"catalog": {
|
||||
|
|
|
|||
|
|
@ -89,7 +89,12 @@ def _prune_disk() -> None:
|
|||
files = [p for p in files if not p.name.endswith(".args.json")]
|
||||
if len(files) <= _MAX_JOBS:
|
||||
return
|
||||
for path in files[: max(0, len(files) - _MAX_JOBS)]:
|
||||
# Active jobs must remain pollable and cancellable even when history fills up.
|
||||
removable = [
|
||||
path for path in files
|
||||
if (_read_job_disk(path.stem) or {}).get("status") in {"done", "error", "cancelled"}
|
||||
]
|
||||
for path in removable[: max(0, len(files) - _MAX_JOBS)]:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
path.with_name(path.stem + ".args.json").unlink(missing_ok=True)
|
||||
|
|
@ -400,25 +405,33 @@ def start_job(
|
|||
subprocess, "DETACHED_PROCESS", 0x00000008
|
||||
)
|
||||
|
||||
proc = subprocess.Popen( # noqa: S603 — controlled argv
|
||||
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id],
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
close_fds=True,
|
||||
)
|
||||
try:
|
||||
proc = subprocess.Popen( # noqa: S603 — controlled argv
|
||||
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id],
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
creationflags=creationflags,
|
||||
close_fds=True,
|
||||
)
|
||||
except OSError as exc:
|
||||
finish_job(job_id, error=f"worker_start_failed:{exc}")
|
||||
_args_path(job_id).unlink(missing_ok=True)
|
||||
# The caller receives a pollable terminal job instead of an orphaned running record.
|
||||
return job_id
|
||||
with _LOCK:
|
||||
job = _read_job_disk(job_id) or job
|
||||
job["pid"] = proc.pid
|
||||
job["progress"] = {
|
||||
**(job.get("progress") or {}),
|
||||
"phase": "running",
|
||||
"message": f"worker pid={proc.pid}",
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
job["heartbeat_at"] = time.time()
|
||||
# A short computation can finish before Popen returns to the parent.
|
||||
# Keep its terminal progress/result intact.
|
||||
if job.get("status") in {"running", "cancelling"}:
|
||||
job["progress"] = {
|
||||
**(job.get("progress") or {}),
|
||||
"message": f"worker pid={proc.pid}",
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
job["heartbeat_at"] = time.time()
|
||||
_JOBS[job_id] = job
|
||||
_write_job_disk(job)
|
||||
return job_id
|
||||
|
|
|
|||
|
|
@ -144,7 +144,6 @@ _PARAM_KEYS = (
|
|||
ACTIONS = (
|
||||
"layout",
|
||||
"fix_overlaps",
|
||||
"resolve_overlaps", # alias of fix_overlaps
|
||||
"untangle",
|
||||
"straighten_channels",
|
||||
"layout_dual_unit",
|
||||
|
|
@ -328,8 +327,6 @@ def run_layout_on_graph(
|
|||
f"unknown_action:{action_key}; "
|
||||
f"allowed={','.join(ACTIONS)}"
|
||||
)
|
||||
if action_key == "resolve_overlaps":
|
||||
action_key = "fix_overlaps"
|
||||
base_params = build_params(preset=preset, overrides=params)
|
||||
st0 = build_state_from_nodes_edges(nodes, edges)
|
||||
park_phantom_nodes(st0)
|
||||
|
|
@ -899,14 +896,13 @@ def list_layout_catalog() -> dict[str, Any]:
|
|||
"actions": {
|
||||
"layout": "全图配方(骨架);块内 pack,结束时局部解叠",
|
||||
"fix_overlaps": "只拉开当前重叠点(+1 跳邻居),不重排全图",
|
||||
"resolve_overlaps": "fix_overlaps 别名",
|
||||
"straighten_channels": (
|
||||
"拉直 deg≤2 通道(弦/横/纵);仅全局交叉下降才接受该通道"
|
||||
"(params: step/min_len)"
|
||||
),
|
||||
"layout_dual_unit": (
|
||||
"双门户单元美化(多走廊→平行 H/V 道;链→拉直);"
|
||||
"单元内交叉必须为 0 才接受(params: unit_id)"
|
||||
"默认允许残留交叉;params.require_zero_cross=true 时要求为 0(params: unit_id)"
|
||||
),
|
||||
"untangle": (
|
||||
"贪心挪低度数点降交叉;默认 protect_rigid=portals "
|
||||
|
|
@ -963,7 +959,6 @@ def list_layout_catalog() -> dict[str, Any]:
|
|||
"默认 remove_from_source;对调两 view 即回迁;"
|
||||
"copy_positions|park;mode=preview|apply"
|
||||
),
|
||||
"sink_nodes": "move_nodes 别名",
|
||||
},
|
||||
"recipes": {
|
||||
"rings": "环+链花瓣;core_bar 时梁优先(多 CN 不塞进 AN 列)",
|
||||
|
|
|
|||
8
packages/netx-topology-mcp/tests/conftest.py
Normal file
8
packages/netx-topology-mcp/tests/conftest.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""Keep layout-job persistence inside pytest temporary directories."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_layout_job_dir(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("NETX_LAYOUT_JOB_DIR", str(tmp_path / "layout-jobs"))
|
||||
|
|
@ -16,6 +16,60 @@ from netx_topology_mcp.layout_jobs import (
|
|||
)
|
||||
|
||||
|
||||
def test_terminal_job_survives_cache_reset_and_reports_failure(tmp_path) -> None:
|
||||
from netx_topology_mcp import layout_jobs as lj
|
||||
|
||||
lj._write_job_disk({"job_id": "persisted", "status": "running", "started_at": time.time()})
|
||||
lj.finish_job("persisted", result={"ok": False, "error": "backend_write_failed", "applied": False})
|
||||
lj._JOBS.clear()
|
||||
out = _layout_topology_view({"action": "job_status", "params": {"job_id": "persisted"}})
|
||||
assert out["ok"] is False
|
||||
assert out["status"] == "error"
|
||||
assert out["error"] == "backend_write_failed"
|
||||
assert out["result"]["applied"] is False
|
||||
|
||||
|
||||
def test_spawn_failure_is_persisted_as_terminal_error() -> None:
|
||||
from netx_topology_mcp import layout_jobs as lj
|
||||
|
||||
with patch.object(lj.subprocess, "Popen", side_effect=OSError("spawn denied")):
|
||||
jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"})
|
||||
lj._JOBS.clear()
|
||||
out = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}})
|
||||
assert out["ok"] is False
|
||||
assert out["status"] == "error"
|
||||
assert out["error"].startswith("worker_start_failed:")
|
||||
assert not lj._args_path(jid).exists()
|
||||
|
||||
|
||||
def test_fast_worker_terminal_progress_is_not_overwritten() -> None:
|
||||
from types import SimpleNamespace
|
||||
from netx_topology_mcp import layout_jobs as lj
|
||||
|
||||
def spawn(argv, **kwargs):
|
||||
jid = argv[-1]
|
||||
lj.finish_job(jid, result={"ok": True, "applied": True})
|
||||
return SimpleNamespace(pid=123)
|
||||
|
||||
with patch.object(lj.subprocess, "Popen", side_effect=spawn):
|
||||
jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"})
|
||||
out = lj.job_public(jid)
|
||||
assert out["status"] == "done"
|
||||
assert out["progress"]["phase"] == "done"
|
||||
assert out["progress"]["pct"] == 100.0
|
||||
|
||||
|
||||
def test_pruning_preserves_active_jobs() -> None:
|
||||
from netx_topology_mcp import layout_jobs as lj
|
||||
|
||||
lj._write_job_disk({"job_id": "active", "status": "running"})
|
||||
lj._write_job_disk({"job_id": "completed", "status": "done"})
|
||||
with patch.object(lj, "_MAX_JOBS", 1):
|
||||
lj._prune_disk()
|
||||
assert lj._read_job_disk("active") is not None
|
||||
assert lj._read_job_disk("completed") is None
|
||||
|
||||
|
||||
def test_start_job_and_poll() -> None:
|
||||
def runner() -> dict:
|
||||
report_progress("mid", pct=50.0, message="halfway", step=1, total_steps=2)
|
||||
|
|
|
|||
|
|
@ -10,10 +10,10 @@ def test_move_nodes_in_layout_schema_and_catalog() -> None:
|
|||
tool = next(t for t in HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")
|
||||
enum = tool["inputSchema"]["properties"]["action"]["enum"]
|
||||
assert "move_nodes" in enum
|
||||
assert "sink_nodes" in enum
|
||||
assert "sink_nodes" not in enum
|
||||
cat = list_layout_catalog()
|
||||
assert "move_nodes" in cat["actions"]
|
||||
assert "sink_nodes" in cat["actions"]
|
||||
assert "sink_nodes" not in cat["actions"]
|
||||
|
||||
|
||||
def test_move_nodes_requires_ids_and_views() -> None:
|
||||
|
|
|
|||
263
packages/netx-topology-mcp/tests/test_topology_contract.py
Normal file
263
packages/netx-topology-mcp/tests/test_topology_contract.py
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
"""HTTP contract and partial-failure regression tests; no backend required."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from netx_topology_mcp import http_tools as tools
|
||||
|
||||
|
||||
def payload(name, args):
|
||||
result = tools.call_http_tool(name, args)
|
||||
return result, json.loads(result["content"][0]["text"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("getTopologyTree", {}),
|
||||
("getTopologyView", {"view_id": "v"}),
|
||||
("createTopologyFolder", {"name": "测试"}),
|
||||
("addTopologyViewNodes", {"view_id": "v", "fabric_node_ids": ["n"]}),
|
||||
("removeTopologyViewNodes", {"view_id": "v", "fabric_node_ids": ["n"]}),
|
||||
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
|
||||
("updateTopologyViewPositions", {"view_id": "v", "layout": "grid"}),
|
||||
("projectTopologyNeighbors", {"view_id": "v"}),
|
||||
("queryTopologyFabricNodes", {"mode": "list"}),
|
||||
("classifyTopologyFabricNodes", {"action": "apply_rules"}),
|
||||
("queryTopologyNeighborhood", {"node_id": "n"}),
|
||||
("queryTopologyEdges", {}),
|
||||
("layoutTopologyView", {"view_id": "v"}),
|
||||
("suggestSinkHubs", {"source_view_id": "s"}),
|
||||
("analyzeTopologyViewLayout", {"view_id": "v"}),
|
||||
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t"}),
|
||||
])
|
||||
def test_business_failure_is_mcp_error(name, args):
|
||||
with patch.object(tools, "http_json", return_value={
|
||||
"ok": True, "data": {"ok": False, "error": "backend_rejected"},
|
||||
}):
|
||||
result, data = payload(name, args)
|
||||
assert result.get("isError") is True, (name, data)
|
||||
assert data["ok"] is False
|
||||
assert data["error"] == "backend_rejected"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args,method", [
|
||||
("addTopologyViewNodes", {"view_id": "v", "fabric_node_ids": [f"n{i}" for i in range(501)]}, "POST"),
|
||||
("removeTopologyViewNodes", {"view_id": "v", "fabric_node_ids": [f"n{i}" for i in range(501)]}, "POST"),
|
||||
("updateTopologyViewPositions", {"view_id": "v", "positions": [
|
||||
{"fabric_node_id": f"n{i}", "x": i, "y": 0} for i in range(501)
|
||||
]}, "PATCH"),
|
||||
])
|
||||
def test_chunk_failure_stops_later_mutations(name, args, method):
|
||||
with patch.object(tools, "http_json", side_effect=[
|
||||
{"ok": True, "data": {"ok": True, "added": 200, "updated": 200, "removed": 200}},
|
||||
{"ok": True, "data": {"ok": False, "error": "write_failed"}},
|
||||
]) as call:
|
||||
result, data = payload(name, args)
|
||||
assert result.get("isError") is True
|
||||
assert call.call_count == 2
|
||||
assert call.call_args.args[0] == method
|
||||
assert data["error"] == "write_failed"
|
||||
|
||||
|
||||
def graph(node="n"):
|
||||
return {"ok": True, "nodes": [{"fabric_node_id": node, "x": 1, "y": 2}], "edges": []}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
|
||||
("layoutTopologyView", {"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply", "params": {"fabric_node_ids": ["n"]}}),
|
||||
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t", "layout_batch": False}),
|
||||
])
|
||||
def test_position_failure_preserves_source(name, args):
|
||||
def http(method, path, **kwargs):
|
||||
if method == "GET":
|
||||
return {"ok": True, "data": graph() if path.endswith("/s") else {"nodes": [], "edges": []}}
|
||||
raise AssertionError(f"Unexpected mutation {method} {path}")
|
||||
|
||||
with patch.object(tools, "http_json", side_effect=http), patch.object(
|
||||
tools, "_ensure_view_max_nodes", return_value={"ok": True}
|
||||
), patch.object(tools, "_add_topology_view_nodes", return_value={"ok": True, "added": 1}), patch.object(
|
||||
tools, "_patch_positions_chunked", return_value={"ok": False, "error": "patch_denied", "updated": 0}
|
||||
), patch.object(tools, "_fabric_bridges_into", return_value=[]), patch.object(
|
||||
tools, "_remove_topology_view_nodes"
|
||||
) as remove:
|
||||
result, data = payload(name, args)
|
||||
assert result.get("isError") is True, data
|
||||
assert data["error"] == "patch_denied"
|
||||
assert data["added"] == 1
|
||||
remove.assert_not_called()
|
||||
|
||||
|
||||
def test_move_capacity_error_stays_error():
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": graph()}), patch.object(
|
||||
tools, "_ensure_view_max_nodes", return_value={"ok": False, "error": "cap_denied"}
|
||||
), patch.object(tools, "_add_topology_view_nodes") as add:
|
||||
result, data = payload("layoutTopologyView", {
|
||||
"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply",
|
||||
"params": {"fabric_node_ids": ["n"]},
|
||||
})
|
||||
assert result.get("isError") is True
|
||||
assert data["error"] == "cap_denied"
|
||||
add.assert_not_called()
|
||||
|
||||
|
||||
def test_invalid_add_does_not_change_capacity():
|
||||
with patch.object(tools, "http_json") as http:
|
||||
result, _ = payload("addTopologyViewNodes", {"view_id": "v", "max_nodes": 2000})
|
||||
assert result.get("isError") is True
|
||||
http.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("getTopologyView", {"view_id": "v", "sample": 0}),
|
||||
("projectTopologyNeighbors", {"view_id": "v", "sample": 0, "dry_run": True}),
|
||||
])
|
||||
def test_zero_sample_and_projection_preview(name, args):
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": graph()}) as http:
|
||||
result, data = payload(name, args)
|
||||
assert result.get("isError") is not True
|
||||
assert data["sample_nodes"] == []
|
||||
if name == "projectTopologyNeighbors":
|
||||
assert http.call_args.kwargs["body"]["dry_run"] is True
|
||||
assert data["dry_run"] is True
|
||||
|
||||
|
||||
def test_invalid_projection_detail_does_not_write():
|
||||
with patch.object(tools, "http_json") as http:
|
||||
result, data = payload("projectTopologyNeighbors", {"view_id": "v", "detail": "raw"})
|
||||
assert result.get("isError") is True
|
||||
assert data["error"] == "detail_invalid"
|
||||
http.assert_not_called()
|
||||
|
||||
|
||||
def test_zero_updated_is_not_reported_as_successful_count():
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": {"ok": True, "updated": 0, "skipped_locked": 1}}):
|
||||
assert tools._patch_positions_chunked("v", [{"fabric_node_id": "n", "x": 1, "y": 2}])["updated"] == 0
|
||||
|
||||
|
||||
def test_apply_rules_uses_query_flags():
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": {"updated": 1}}) as http:
|
||||
payload("classifyTopologyFabricNodes", {"action": "apply_rules", "skip_manual": False, "fill_empty_only": True})
|
||||
assert http.call_args.args == ("POST", "/v1/topology/classify/apply")
|
||||
assert http.call_args.kwargs["params"] == {"skip_manual": False, "fill_empty_only": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("action", ["resolve_overlaps", "sink_nodes", "align_to_reference", "compose_views", "compose_orbit"])
|
||||
def test_removed_layout_actions_are_not_advertised_or_called(action):
|
||||
with patch.object(tools, "http_json") as http:
|
||||
result, data = payload("layoutTopologyView", {"view_id": "v", "action": action})
|
||||
assert result.get("isError") is True
|
||||
assert data["error"] == f"unknown_action:{action}"
|
||||
assert action not in data["actions"]
|
||||
schema = next(t for t in tools.HTTP_MCP_TOOLS if t["name"] == "layoutTopologyView")["inputSchema"]
|
||||
assert action not in schema["properties"]["action"]["enum"]
|
||||
http.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("queryTopologyFabricNodes", {"mode": "find"}),
|
||||
("classifyTopologyFabricNodes", {"action": "apply"}),
|
||||
("queryTopologyEdges", {"detail": "raw"}),
|
||||
])
|
||||
def test_removed_query_aliases_do_not_call_backend(name, args):
|
||||
with patch.object(tools, "http_json") as http:
|
||||
result, _ = payload(name, args)
|
||||
assert result.get("isError") is True
|
||||
http.assert_not_called()
|
||||
|
||||
|
||||
def test_explicit_background_layout_starts_worker():
|
||||
canvas = graph()
|
||||
canvas["nodes"].append({"fabric_node_id": "n2", "x": 100, "y": 200})
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": canvas}), patch.object(
|
||||
tools, "start_job", return_value="test-job"
|
||||
) as start, patch.object(tools, "job_public", return_value=None), patch.object(tools, "run_layout_on_graph") as compute:
|
||||
result, data = payload("layoutTopologyView", {"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True}})
|
||||
assert result.get("isError") is not True
|
||||
assert data["job_id"] == "test-job"
|
||||
assert data["applied"] is False
|
||||
assert start.call_args.kwargs["tool_args"]["params"]["_force_sync"] is True
|
||||
compute.assert_not_called()
|
||||
|
||||
|
||||
def test_background_spawn_error_is_immediately_reported():
|
||||
from netx_topology_mcp import layout_jobs as jobs
|
||||
canvas = graph()
|
||||
canvas["nodes"].append({"fabric_node_id": "n2", "x": 100, "y": 200})
|
||||
with patch.object(tools, "http_json", return_value={"ok": True, "data": canvas}), patch.object(
|
||||
jobs.subprocess, "Popen", side_effect=OSError("worker unavailable")
|
||||
):
|
||||
result, data = payload("layoutTopologyView", {"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True}})
|
||||
assert result.get("isError") is True
|
||||
assert data["status"] == "error"
|
||||
assert data["error"].startswith("worker_start_failed:")
|
||||
assert data["job_id"]
|
||||
|
||||
|
||||
def test_background_apply_poll_and_persistent_view_readback():
|
||||
"""Execute worker orchestration inline against an in-memory HTTP backend."""
|
||||
from types import SimpleNamespace
|
||||
from netx_topology_mcp import layout_jobs as jobs
|
||||
from netx_topology_mcp.layout_job_worker import main as worker_main
|
||||
|
||||
canvas = {"nodes": [
|
||||
{"fabric_node_id": "n", "x": 0, "y": 0},
|
||||
{"fabric_node_id": "n2", "x": 100, "y": 200},
|
||||
], "edges": []}
|
||||
positions = [
|
||||
{"fabric_node_id": "n", "x": 20, "y": 30},
|
||||
{"fabric_node_id": "n2", "x": 220, "y": 330},
|
||||
]
|
||||
|
||||
def http(method, path, **kwargs):
|
||||
if method == "GET":
|
||||
return {"ok": True, "data": canvas}
|
||||
assert method == "PATCH" and path == "/v1/topology/views/v/positions"
|
||||
canvas["nodes"] = kwargs["body"]["positions"]
|
||||
return {"ok": True, "data": {"ok": True, "updated": 2}}
|
||||
|
||||
def spawn(argv, **kwargs):
|
||||
assert worker_main(["--job-id", argv[-1]]) == 0
|
||||
return SimpleNamespace(pid=123)
|
||||
|
||||
with patch.object(tools, "http_json", side_effect=http), patch.object(
|
||||
jobs.subprocess, "Popen", side_effect=spawn
|
||||
), patch.object(tools, "run_layout_on_graph", return_value={"ok": True, "positions": positions, "overlap": {}}):
|
||||
_, started = payload("layoutTopologyView", {
|
||||
"view_id": "v", "action": "layout", "mode": "apply", "params": {"background": True},
|
||||
})
|
||||
jobs._JOBS.clear()
|
||||
_, polled = payload("layoutTopologyView", {"action": "job_status", "params": {"job_id": started["job_id"]}})
|
||||
_, view = payload("getTopologyView", {"view_id": "v", "detail": "full"})
|
||||
assert polled["status"] == "done"
|
||||
assert polled["result"]["applied"] is True
|
||||
assert polled["result"]["updated"] == 2
|
||||
assert view["nodes"] == positions
|
||||
|
||||
|
||||
def test_hub_schema_matches_default_exclusion_behavior():
|
||||
tool = next(t for t in tools.HTTP_MCP_TOOLS if t["name"] == "suggestSinkHubs")
|
||||
assert tool["inputSchema"]["properties"]["use_source_dual_portals"]["default"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args", [
|
||||
("copyTopologyViewNodes", {"source_view_id": "s", "target_view_id": "t"}),
|
||||
("layoutTopologyView", {"view_id": "t", "source_view_id": "s", "action": "move_nodes", "mode": "apply", "params": {"fabric_node_ids": ["n"]}}),
|
||||
("sinkTopologyDualUnits", {"source_view_id": "s", "sink_view_id": "t", "layout_batch": False}),
|
||||
])
|
||||
def test_failed_final_readback_reports_completed_writes(name, args):
|
||||
with patch.object(tools, "http_json", side_effect=[
|
||||
{"ok": True, "data": graph()}, {"ok": True, "data": {"nodes": [], "edges": []}},
|
||||
{"ok": False, "error": "read_timeout"}, {"ok": False, "error": "read_timeout"},
|
||||
]), patch.object(tools, "_ensure_view_max_nodes", return_value={"ok": True}), patch.object(
|
||||
tools, "_add_topology_view_nodes", return_value={"ok": True, "added": 1}
|
||||
), patch.object(tools, "_patch_positions_chunked", return_value={"ok": True, "updated": 1}), patch.object(
|
||||
tools, "_remove_topology_view_nodes", return_value={"ok": True, "removed": 1}
|
||||
), patch.object(tools, "_fabric_bridges_into", return_value=[]):
|
||||
result, data = payload(name, args)
|
||||
assert result.get("isError") is True
|
||||
assert data["error"] == "view_readback_failed"
|
||||
assert data["write_completed"] is True
|
||||
|
|
@ -34,10 +34,7 @@ dependencies = [
|
|||
[project.optional-dependencies]
|
||||
mcp = ["netx-mcp @ file:packages/netx-mcp"]
|
||||
mcp-topology = ["netx-topology-mcp @ file:packages/netx-topology-mcp"]
|
||||
|
||||
[project.scripts]
|
||||
netx-mcp = "netx_mcp.server:main"
|
||||
netx-topology-mcp = "netx_topology_mcp.server:main"
|
||||
mcp-bizmonitor = ["netx-bizmonitor-mcp @ file:packages/netx-bizmonitor-mcp"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
|
|
|
|||
166
scripts/mcp_acceptance.py
Normal file
166
scripts/mcp_acceptance.py
Normal 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()
|
||||
264
scripts/seed_mcp_acceptance.py
Normal file
264
scripts/seed_mcp_acceptance.py
Normal 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())
|
||||
|
|
@ -6,7 +6,7 @@
|
|||
|-------|--------------|----------------------------------------|
|
||||
| `ops` | `ops/netx-ops/` | NMS 告警/库存/SQL + managed CLI + `findTopologyPaths` |
|
||||
| `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 工具) |
|
||||
|
||||
## DSH 怎么用
|
||||
|
|
@ -14,8 +14,8 @@
|
|||
1. 运行时优先:`NETX_SKILLS_ROOT` → 旁路 `../netx/skills` → 包内 `presets/netxops/skills`
|
||||
2. 发 npm 前:`powershell -File netxops/scripts/sync-skills-from-netx.ps1`
|
||||
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/`)。
|
||||
|
|
|
|||
|
|
@ -1,32 +1,30 @@
|
|||
---
|
||||
name: netx-biz-monitor
|
||||
description: >-
|
||||
Overnight cutover / biz_state monitor analysis via netxops host tools
|
||||
(netx__listBizMonitors / listBizMonitorBatches / getBizMonitor*). List
|
||||
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-bizmonitor-mcp 或 netxops 分析割接与业务状态。
|
||||
无 ID 先查目录和批次,结合看板、变红项、双侧原始 A/B 键和采集命令原文判断故障。
|
||||
只读已有项目与评价结果,不创建模板、触发采集、执行评价或关闭红票。
|
||||
---
|
||||
|
||||
# netx-biz-monitor(割接 / 业务监控只读分析)
|
||||
|
||||
人配好监控与任务;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`)
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| `netx__listBizMonitors` | **入口目录**:列出割接/监控对比项目 + 业务监控任务(**无需** project_id) |
|
||||
| `netx__listBizMonitorBatches` | **批次目录**:`project_id`→割接批次;`task_id`→采集批次;`kind=runs`→evaluate run |
|
||||
| `netx__getBizMonitorContext` | 任务定义:项目、监控/对比模板、归一化规则、端口映射、关联任务与命令项 |
|
||||
| `netx__getBizMonitorBoard` | 批次进度、evaluate run、sheet 卡、verdict 统计 |
|
||||
| `netx__listBizMonitorReds` | 红单 + **evidence** |
|
||||
| `netx__getBizMonitorDiffs` | run 明细(可滤 color=red) |
|
||||
| `netx__getBizCollectBatch` | 某次采集:commands / metrics |
|
||||
| `netx__getBizCollectCommandRaw` | 单命令全量 show 输出 |
|
||||
| `listBizMonitors` | **入口目录**:列出割接/监控对比项目 + 业务监控任务(**无需** project_id) |
|
||||
| `listBizMonitorBatches` | **批次目录**:`project_id`→割接批次;`task_id`→采集批次;`batch_id,kind=runs`→evaluate run |
|
||||
| `getBizMonitorContext` | 任务定义:项目、监控/对比模板、归一化规则、端口映射、关联任务与命令项 |
|
||||
| `getBizMonitorBoard` | 批次进度、evaluate run、sheet 卡、verdict 统计 |
|
||||
| `listBizMonitorReds` | 红单 + **evidence** |
|
||||
| `getBizMonitorDiffs` | run 明细,`color=red`;`offset/limit` 分页;`q` 为关键词 |
|
||||
| `getBizCollectBatch` | 采集工作簿摘要、commands 和 sheet 目录;不含全部指标行或原文 |
|
||||
| `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`
|
||||
3. `getBizMonitorContext(project_id)` — 弄清盯什么
|
||||
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?归一化误伤?
|
||||
7. 需要原文 → `getBizCollectCommandRaw`
|
||||
7. 从差异 evidence 的 collect/command 取两侧**采集** batch_id/command_id → `getBizCollectCommandRaw`;若证据不全,用对应 task_id 查采集批次,再 getBizCollectBatch 查命令。不要把割接 batch_id 当成采集 batch_id。
|
||||
8. 像真障 → `execManagedNe` 现场核对
|
||||
|
||||
## Verdict 速查(skill 解释,工具不硬编码)
|
||||
|
|
@ -64,3 +62,5 @@ description: >-
|
|||
4. 再谈真实业务问题
|
||||
|
||||
配置整改与关红单由人做;本 skill **只读分析**。
|
||||
|
||||
目录或批次为空时,明确说明没有可评价的数据;不把空红票当作割接通过。run 尚未完成或存在 current_missing/采集失败时,不给通过结论。工具本身不启动 evaluate;需要人先在 Web 完成定义、采集和评价。`old_key/new_key` 是设备原始端口;例:归一化和映射将 GE-old 匹配到 xgei-new,结论仍分别展示 GE-old 与 xgei-new,不能用内部匹配键替代。
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ description: netx 运维:告警、网元、只读 CLI。
|
|||
处理 netx **告警 / 网元清单 / 只读 CLI** 时遵循本手册。
|
||||
画布布图不在本 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. **先新鲜度,再结论**:`runNmsDiagnostics` 或 `aggregateNmsAlarms` → 读 `meta.last_seen_*`。过旧则按快照:时间窗落在 min~max 内,禁止默认「现在−30 分钟」。
|
||||
1. **先新鲜度,再结论**:`runNmsDiagnostics` → 读全库 `meta.last_seen_*`。用户问最近一小时则按用户时区查询真实时间窗;若为空且快照过旧,只能说当前数据无法判断。可另按快照 max 前一小时补充历史分析,明确日期,不冒充当前告警。带过滤的 aggregate 元数据只代表过滤后的窗口。
|
||||
2. **证据优先**:有告警须有级别计数和/或 Top `host_name`;否则写「证据不足」,禁止臆造根因。
|
||||
3. **对用户**:只展示 **host_name**;**严禁**出现任何 UUID/`ne_id`。无 `host_name` 的行当垃圾丢弃(不顶替 label、不提「缺失」)。工具入参仍可用 id,但不得写入对用户可见正文。
|
||||
4. **短问**:能 ≤3 次工具调用就闭环的,先聚合/过滤,禁止无过滤翻全库。复杂关联(dying gasp、A<>B CLI)不受 3 次硬顶,但仍须有过滤与目标。
|
||||
|
|
@ -67,7 +67,10 @@ description: netx 运维:告警、网元、只读 CLI。
|
|||
- 命令相同:`nms_ne_ids` 或 `ne_ids` + 共享 `commands`
|
||||
- 命令不同(厂商/角色不同):`targets=[{nms_ne_id|ne_id, commands:[…]}, …]`,仍一次调用
|
||||
- 超时:加大 `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` 给失败台换命令,或先一批共同命令 → 只对失败子集再一批探测;不要为了一台把成功台的结果丢掉重来。
|
||||
|
||||
### `?` 规则(`show` / `display` / `ping` 等同一套)
|
||||
### `?` 规则(`show` / `display`)
|
||||
|
||||
1. 报错中的 **`^`** 标出错位置 → **`^` 之前**已正确 → 对该前缀加 `?` 列下一级。
|
||||
2. `?` 可紧接在部分单词后(`show optical?`、`display inter?`),不必强制空格再写 `?`。
|
||||
|
|
|
|||
|
|
@ -317,7 +317,7 @@ sinkTopologyDualUnits({
|
|||
|--------|------|
|
||||
| `layout` | 小图 / 剩余块:`compact` / `corridor` / `rings` / `unstick` |
|
||||
| `layout_dual_unit` | **仅**重布已沉的那一只眼;椭圆弧带 + 空心眼 |
|
||||
| `move_nodes` / `sink_nodes` | **后续下沉主路径**;`park` 块扫 |
|
||||
| `move_nodes` | **后续下沉主路径**;`park` 块扫 |
|
||||
| `orbit_sweep` | **眼后精修主工具**:`until_limit` 或 `node_id`;`objective=crossing\|total` |
|
||||
| `clear_edge_hits` | 眼 sink **可**(须 `portal_ids`;交叉不升) |
|
||||
| `pull_far_chains` | 眼 sink **可**:朝门户中点收远走廊/孤立点 |
|
||||
|
|
@ -376,3 +376,13 @@ sinkTopologyDualUnits({
|
|||
4. 机器上常残留多份旧 MCP 进程 → 精修前 catalog 若仍缺 action,**杀光再启**;勿用旧进程半截交付,勿改走 HTTP/临时 py。
|
||||
|
||||
拓扑页开「实时同步」可看落笔。勿用告警/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
3
tests/fixtures/zte_vpnv6/README.md
vendored
Normal 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
13
tests/fixtures/zte_vpnv6/show-bgp-vpnv6
vendored
Normal 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#
|
||||
40
tests/fixtures/zte_vpnv6/show-bgp-vpnv6-neighbor-router-in-vrf
vendored
Normal file
40
tests/fixtures/zte_vpnv6/show-bgp-vpnv6-neighbor-router-in-vrf
vendored
Normal 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#
|
||||
11
tests/fixtures/zte_vpnv6/show-bgp-vpnv6-neighbor-router-out-vrf
vendored
Normal file
11
tests/fixtures/zte_vpnv6/show-bgp-vpnv6-neighbor-router-out-vrf
vendored
Normal 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#
|
||||
|
|
@ -6,12 +6,56 @@ import unittest
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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.models import BizStateBatch, BizStateBatchCommand
|
||||
from netx_api.models import BizStateBatch, BizStateBatchCommand, BizStateMetricRow
|
||||
|
||||
|
||||
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:
|
||||
batch = BizStateBatch(
|
||||
id="b1",
|
||||
|
|
@ -31,35 +75,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
|
|||
row_count=2,
|
||||
raw_text="RAW",
|
||||
)
|
||||
db = MagicMock()
|
||||
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")
|
||||
out = self._stored_summary(batch, [cmd], [("arp", 2)])
|
||||
|
||||
self.assertIn("sheets", out)
|
||||
self.assertNotIn("metrics", out)
|
||||
|
|
@ -93,32 +109,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
|
|||
raw_text="a\nb\nc\n",
|
||||
message="",
|
||||
)
|
||||
db = MagicMock()
|
||||
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")
|
||||
out = self._stored_summary(batch, [cmd], [("arp", 3)])
|
||||
|
||||
self.assertEqual(out["message"], "stopped")
|
||||
self.assertEqual(out["commands"][0]["raw_line_count"], 3)
|
||||
|
|
@ -148,34 +139,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
|
|||
declared_total=1669101,
|
||||
message="declared=1669101;parsed=100",
|
||||
)
|
||||
db = MagicMock()
|
||||
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")
|
||||
out = self._stored_summary(batch, [cmd], [("bgp_route", 100)])
|
||||
|
||||
self.assertEqual(out["commands"][0]["raw_line_count"], 119303)
|
||||
self.assertEqual(out["commands"][0]["declared_total"], 1669101)
|
||||
|
|
@ -263,34 +227,7 @@ class BatchWorkbookApiTests(unittest.TestCase):
|
|||
row_count=1,
|
||||
),
|
||||
]
|
||||
db = MagicMock()
|
||||
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")
|
||||
out = self._stored_summary(batch, cmds, [("bgp_peer", 3)])
|
||||
|
||||
bgp = next(s for s in out["sheets"] if s["metric_id"] == "bgp_peer")
|
||||
self.assertEqual(bgp["title"], "BGP Status Summary")
|
||||
|
|
|
|||
|
|
@ -188,8 +188,9 @@ class ManagedNeApiTests(unittest.TestCase):
|
|||
self.assertEqual(listed.json()["total"], 1, listed.text)
|
||||
self.client.delete(f"/v1/managed-ne/{ne_id}")
|
||||
|
||||
def test_create_without_crypto_key(self):
|
||||
settings.credential_secret_key = ""
|
||||
@patch("netx_api.ne_service_common.credentials_configured", return_value=False)
|
||||
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(
|
||||
"/v1/managed-ne",
|
||||
json={
|
||||
|
|
@ -201,6 +202,7 @@ class ManagedNeApiTests(unittest.TestCase):
|
|||
},
|
||||
)
|
||||
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):
|
||||
"""DELETE /ume-sync must not match DELETE /{ne_id} with ne_id='ume-sync'."""
|
||||
|
|
|
|||
|
|
@ -100,23 +100,10 @@ def test_stdio_initialize_and_tools_list() -> None:
|
|||
proc.wait(timeout=5)
|
||||
|
||||
|
||||
def test_legacy_netx_api_mcp_module_still_works() -> None:
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "-m", "netx_api.mcp"],
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
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)
|
||||
|
||||
def test_only_standalone_mcp_implementation_is_kept() -> None:
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
assert not (root / "netx_api/mcp_server.py").exists()
|
||||
assert not list((root / "netx_api/mcp").glob("*.py"))
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ class NeExecJobTests(unittest.TestCase):
|
|||
ack = start_ne_exec_job(
|
||||
kind="exec",
|
||||
arguments={"ne_id": "abc", "commands": ["show version"]},
|
||||
owner_user_id="test-owner",
|
||||
runner=lambda: {"ok": True, "output": "hi"},
|
||||
)
|
||||
self.assertTrue(ack.get("ok"))
|
||||
|
|
@ -80,7 +81,7 @@ class NeExecJobTests(unittest.TestCase):
|
|||
self.assertTrue(jid)
|
||||
done = None
|
||||
for _ in range(50):
|
||||
polled = get_ne_exec_job(jid)
|
||||
polled = get_ne_exec_job(jid, user_id="test-owner")
|
||||
if polled.get("terminal"):
|
||||
done = polled
|
||||
break
|
||||
|
|
|
|||
241
tests/test_ne_exec_job_security.py
Normal file
241
tests/test_ne_exec_job_security.py
Normal 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
|
||||
|
|
@ -5,6 +5,9 @@ from __future__ import annotations
|
|||
import unittest
|
||||
import re
|
||||
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
|
||||
|
||||
|
|
@ -24,6 +27,85 @@ class DrainReadChannelTests(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")
|
||||
def test_mikrotik_does_not_probe_prompt_after_drain(self, drain: MagicMock) -> None:
|
||||
conn = MagicMock()
|
||||
|
|
@ -88,6 +170,17 @@ class SendShowCommandTests(unittest.TestCase):
|
|||
conn.send_command_timing.assert_not_called()
|
||||
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__":
|
||||
unittest.main()
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
import re
|
||||
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 (
|
||||
_build_netmiko_connection,
|
||||
_interactive_driver_class,
|
||||
|
|
@ -50,6 +54,74 @@ class InteractiveNetmikoTests(unittest.TestCase):
|
|||
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):
|
||||
def test_collection_driver_selects_zte_wrapper(self) -> None:
|
||||
base = _netmiko_driver_class("zte_zxros_ssh")
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ from sqlalchemy.orm import sessionmaker
|
|||
|
||||
import netx_api.models # noqa: F401
|
||||
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_flat_coords import recompute_flat_world_coords
|
||||
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.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)
|
||||
again = next(n for n in g1.nodes if n.fabric_node_id == region.fabric_node_id)
|
||||
self.assertEqual(again.x, 777.0)
|
||||
|
|
@ -395,6 +400,57 @@ class UmeHierarchyTests(unittest.TestCase):
|
|||
self.assertEqual(moved.x, ox)
|
||||
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):
|
||||
from netx_api.topology_schemas import TopologyFolderCreate
|
||||
from netx_api.topology_views_tree import create_folder
|
||||
|
|
|
|||
|
|
@ -410,6 +410,9 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
"password": "p",
|
||||
"hop_enabled": True,
|
||||
"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",
|
||||
"protocol": "ssh",
|
||||
},
|
||||
|
|
@ -479,6 +482,7 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
self.assertTrue(
|
||||
svc._webcrt_creds_ready(
|
||||
{
|
||||
"ip_address": "192.0.2.20",
|
||||
"username": "ca-oper",
|
||||
"password": "",
|
||||
"hop_enabled": True,
|
||||
|
|
@ -493,6 +497,7 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
self.assertFalse(
|
||||
svc._webcrt_creds_ready(
|
||||
{
|
||||
"ip_address": "192.0.2.20",
|
||||
"username": "ca-oper",
|
||||
"password": "",
|
||||
"hop_enabled": True,
|
||||
|
|
@ -507,6 +512,7 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
self.assertFalse(
|
||||
svc._webcrt_creds_ready(
|
||||
{
|
||||
"ip_address": "192.0.2.20",
|
||||
"username": "u",
|
||||
"password": "",
|
||||
"protocol": "ssh",
|
||||
|
|
@ -517,6 +523,7 @@ class WebcrtServiceTests(unittest.TestCase):
|
|||
self.assertTrue(
|
||||
svc._webcrt_creds_ready(
|
||||
{
|
||||
"ip_address": "192.0.2.20",
|
||||
"username": "",
|
||||
"password": "",
|
||||
"protocol": "telnet",
|
||||
|
|
|
|||
|
|
@ -332,8 +332,8 @@ class ResolveAuditCommandsTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(out, [prompt + "show ip interface brief"])
|
||||
|
||||
def test_zte_real_backspace_redraw_reconstructs_full_command(self) -> None:
|
||||
"""Real ZTE device redraws via backspace runs (not CSI cursor moves).
|
||||
def test_zte_synthetic_backspace_redraw_reconstructs_full_command(self) -> None:
|
||||
"""Synthetic ZTE-style replay; no missing field capture is assumed.
|
||||
|
||||
Up-arrow recalls ``show interface brief``, left-arrows move the cursor
|
||||
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
|
||||
and the full edited line is reconstructed.
|
||||
"""
|
||||
import os
|
||||
|
||||
from netx_api.webcrt_channel import (
|
||||
_live_input_line,
|
||||
_stdout_has_inplace_edit,
|
||||
|
|
@ -353,12 +351,13 @@ class ResolveAuditCommandsTests(unittest.TestCase):
|
|||
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#"
|
||||
# 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"
|
||||
|
||||
# render_pty_line must reconstruct the full edited command.
|
||||
|
|
|
|||
|
|
@ -270,7 +270,7 @@ class ZteExtendedParserTests(unittest.TestCase):
|
|||
self.assertTrue(all(r["afi"] == "ipv4" for r in vrf), "vrf-scoped vpnv4 → ipv4")
|
||||
|
||||
# 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_real = normalize_bgp_peer(
|
||||
raw_text=v6_sum, command="show bgp vpnv6 unicast summary | one-line"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue