Add standalone topology MCP with skill, scopes UI, and opt-in live sync.

Splits canvas/Fabric tools into netx-topology-mcp, documents install and a companion Cursor skill, lets API keys grant ne:write explicitly, and adds optional topology live sync so operators can watch agent drawing without constant polling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-04 00:16:02 +08:00
parent a95b96f648
commit bf0dfd66d8
30 changed files with 2057 additions and 410 deletions

View file

@ -0,0 +1,62 @@
---
name: netx-topology
description: >-
用 netx-topology MCP 查询 Fabric 链路、建拓扑画布并安全摆点(不污染 Fabric)。
触发:画拓扑/拓扑图/拓扑画布、LLDP 邻居/链路、Fabric 网元搜索、createTopologyView /
addTopologyViewNodes、netx-topology、看着 MCP 画图。须先读本 skill 再调 MCP。
user-invocable: true
disable-model-invocation: false
---
# netx 拓扑 MCP
通过 **`netx-topology`** MCP(包 `netx-topology-mcp`)操作 netx 拓扑。与告警/CLI 的 **`netx`** MCP **分开**;画图只用本包工具。
安装与 scopes 真源:仓库 [`docs/MCP_TOPOLOGY.md`](../../../docs/MCP_TOPOLOGY.md)。
## 硬规则
1. **先读后写**:任何建图/摆点前先 `getTopologyTree`;改已有图前先 `getTopologyView`。
2. **只放已有 Fabric 节点**:`addTopologyViewNodes` **仅** `fabric_node_ids`。禁止臆造 id;禁止试图传 `managed_ne_ids` / `ume_ne_ids`(会被拒)。
3. **不污染 Fabric**:本 MCP **不能**手工建链、不能 `populate`、不能删 Fabric / 整图。链路来自已有 LLDP/手工边;邻居用 `projectTopologyNeighbors`。
4. **写权限**:画图工具要 token 含 `ne:write`。若 `tools/list` 没有写工具 → 停下来告诉用户去 **系统 → API Key** 用「MCP + 拓扑写」签发,并配置 `NETX_API_TOKEN` 后 Sync Tools。勿假装已画成功。
5. **无文件夹则停**:`createTopologyView` 需要已有 `folder_id`(region)。树里没有可用 folder 时,请用户先在网页建区域,或改挂到已有 region;**MCP 不能新建 region/folder**。
6. **批量克制**:单次 `addTopologyViewNodes` 控制在合理数量(优先先搜再加);大图用多次调用 + `projectTopologyNeighbors` 扩展。
## 推荐流水线(从零画一张图)
```
1 getTopologyTree → 选 folder_id(region)
2 searchTopologyFabricNodes / listTopologyFabricNodes → 拿到 fabric_node_ids
3 createTopologyView → name + folder_id → 得到 view_id
4 addTopologyViewNodes → view_id + fabric_node_ids(layout=grid)
5 projectTopologyNeighbors → 把已有 LLDP 邻居投影上画布(可重复)
6 (可选)updateTopologyViewPositions → 微调坐标
7 getTopologyView → 向用户确认节点/边数量
```
查链路不画图时:`queryTopologyEdges`(带 `node_id` 看 `peer_count`)或 `queryTopologyNeighborhood`。
## 工具速查
| 目的 | 工具 |
|------|------|
| 树 / region / 已有画布 | `getTopologyTree`, `listTopologyViews` |
| 读一图画布 | `getTopologyView` |
| 新建画布 | `createTopologyView` |
| 摆点 / 移除(仅画布) | `addTopologyViewNodes`, `removeTopologyViewNodes` |
| 摆坐标 | `updateTopologyViewPositions` |
| 投影 LLDP 邻居 | `projectTopologyNeighbors` |
| 搜 Fabric | `searchTopologyFabricNodes`, `listTopologyFabricNodes` |
| 汇总 / 邻接 / 边 | `getTopologyFabricSummary`, `queryTopologyNeighborhood`, `queryTopologyEdges` |
## 对人说清楚
- 网页观看:拓扑页左侧或浏览区开 **「实时同步」**(默认关);**不必先打开某张图**也能看到新建画布。
- 回报时给出:`view_id`、画布名、folder、节点数;写失败则原样报 scope/API 错误。
## 不要做
- 不要用 `netx`(告警 MCP)冒充拓扑写接口。
- 不要为「画上设备」去改 managed-NE / 造假 Fabric。
- 不要在未确认 folder/view 时连环盲写。

View file

@ -161,18 +161,22 @@ API base: `http://127.0.0.1:8890/`
先启动 netx API(§5),再在 **MCP 宿主同机** 安装轻量客户端并配置。 先启动 netx API(§5),再在 **MCP 宿主同机** 安装轻量客户端并配置。
**完整说明(安装、配置、更新、排错)见:[docs/MCP.md](docs/MCP.md)** **完整说明(安装、配置、更新、排错)见:[docs/MCP.md](docs/MCP.md)**
**拓扑画布独立 MCP** 见:[docs/MCP_TOPOLOGY.md](docs/MCP_TOPOLOGY.md)
速查: 速查:
```powershell ```powershell
pip install -e ./packages/netx-mcp pip install -e ./packages/netx-mcp
# 拓扑(可选,单独安装)
pip install -e ./packages/netx-topology-mcp
# 配置见 mcp.json,运行: # 配置见 mcp.json,运行:
python -m netx_mcp python -m netx_mcp
python -m netx_topology_mcp
``` ```
- 客户端配置:[`mcp.json`](mcp.json)(Cursor / oclaw Admin 粘贴同一份) - 客户端配置:[`mcp.json`](mcp.json)(含 `netx` + 可选 `netx-topology`)
- oclaw 可选 payload:[`mcp_install_payload.json`](mcp_install_payload.json) - oclaw payload:[`mcp_install_payload.json`](mcp_install_payload.json) / [`mcp_topology_install_payload.json`](mcp_topology_install_payload.json)
- 子包说明:[`packages/netx-mcp/README.md`](packages/netx-mcp/README.md) - 子包:[`packages/netx-mcp`](packages/netx-mcp/README.md)、[`packages/netx-topology-mcp`](packages/netx-topology-mcp/README.md)
## Useful API endpoints ## Useful API endpoints

View file

@ -113,7 +113,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
1. 完成上文 **§1**(用 **oclaw 同机同一个 `python`** 安装 `netx-mcp`)。 1. 完成上文 **§1**(用 **oclaw 同机同一个 `python`** 安装 `netx-mcp`)。
2. Admin → MCP → 粘贴 `mcp.json` 全文 → 点击 **Install from JSON**(安装状态在下方一行小字)。 2. Admin → MCP → 粘贴 `mcp.json` 全文 → 点击 **Install from JSON**(安装状态在下方一行小字)。
3. **Health** → **Sync Tools**(应看到 **14** 个工具)。 3. **Health** → **Sync Tools**(应看到 **13** 个工具)。
4. 在 **MCP 专家绑定** 中为 ops 专家勾选 `server_id=netx`。 4. 在 **MCP 专家绑定** 中为 ops 专家勾选 `server_id=netx`。
更细的 oclaw 说明(双轨内置工具、锚点注入等)见 oclaw 仓库: 更细的 oclaw 说明(双轨内置工具、锚点注入等)见 oclaw 仓库:
@ -121,6 +121,8 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
可选:oclaw 专用字段展开版 [`mcp_install_payload.json`](../mcp_install_payload.json)(与 `mcp.json` 等价)。 可选:oclaw 专用字段展开版 [`mcp_install_payload.json`](../mcp_install_payload.json)(与 `mcp.json` 等价)。
**拓扑画布 MCP**(独立安装/绑定)见 [`MCP_TOPOLOGY.md`](./MCP_TOPOLOGY.md)(`server_id=netx-topology`,13 个工具)。
--- ---
## 5. 暴露的工具 ## 5. 暴露的工具
@ -131,9 +133,8 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
| UME 网元 | `queryUmeNeInventory`, `getUmeNe` | | UME 网元 | `queryUmeNeInventory`, `getUmeNe` |
| UME 原始/SQL | `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme` | | UME 原始/SQL | `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme` |
| 托管网元 CLI | `listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets` | | 托管网元 CLI | `listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets` |
| 拓扑 Fabric | `queryTopologyEdges`(`node_id` → A 的链路与 `peer_count` 互联网元数) |
物理拓扑仅 LLDP;分页查询,勿默认拉全图。oclaw 中名称带前缀:`mcp__netx__<toolName>`。 拓扑 Fabric / 画布工具已拆到 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**(含原 `queryTopologyEdges`)。oclaw 中名称带前缀:`mcp__netx__<toolName>`。
--- ---

118
docs/MCP_TOPOLOGY.md Normal file
View file

@ -0,0 +1,118 @@
# netx Topology MCP — 安装与更新
与告警/CLI 的 [`netx-mcp`](./MCP.md) **分开**的 stdio MCP,只提供 **拓扑树、画布(views)、Fabric 边/邻接**。Agent 可只装本包,或与 `netx` 并存。
```
MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → /v1/topology/*
```
| 组件 | 说明 |
|------|------|
| **netx API** | 需已启动,拓扑数据在服务端 |
| **netx-topology-mcp** | 轻量 HTTP 客户端,与宿主同机 |
---
## 1. 安装
```powershell
cd D:\project\chatgpt\netx
pip install -e ./packages/netx-topology-mcp
python -c "import netx_topology_mcp; print('ok')"
python -m netx_topology_mcp
```
从 GitHub:
```powershell
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-topology-mcp"
```
环境变量与 [`MCP.md`](./MCP.md) 相同:`NETX_API_URL`、`NETX_API_TOKEN` / `data/auth/mcp_token`、`NETX_LANG`。
---
## 2. Cursor / oclaw 配置
独立服务器 id:`netx-topology`(不要与 `netx` 混在同一个 command 里)。
```json
{
"mcpServers": {
"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"
}
}
}
}
```
样本:[`packages/netx-topology-mcp/mcp.json`](../packages/netx-topology-mcp/mcp.json)。
与告警 MCP 并存时,把两个 server 都放进 `mcpServers` 即可;未勾选/未安装的不会加载工具。
oclaw:Install from JSON → Health → Sync Tools(应看到 **13** 个工具)→ 专家绑定勾选 `server_id=netx-topology`。
配套 Agent Skill(画图流水线 / 安全约束):[`.cursor/skills/netx-topology/SKILL.md`](../.cursor/skills/netx-topology/SKILL.md)。Cursor / oclaw 读 skill 后再调 MCP。
---
## 3. 工具一览
### 读
| 工具 | 作用 |
|------|------|
| `getTopologyTree` | 站点/区域文件夹树 + 下属画布 |
| `listTopologyViews` / `getTopologyView` | 画布列表 / 单图(节点+边+坐标) |
| `getTopologyFabricSummary` | Fabric 汇总 |
| `listTopologyFabricNodes` / `searchTopologyFabricNodes` | 网元搜索 |
| `queryTopologyNeighborhood` | 指定节点邻接 |
| `queryTopologyEdges` | LLDP/手工链路(含 `peer_count`) |
### 写(只动画布,不污染 Fabric)
| 工具 | 作用 |
|------|------|
| `createTopologyView` | 在 folder 下新建画布 |
| `addTopologyViewNodes` | **仅** `fabric_node_ids` 放到画布(拒绝 managed/UME,避免创建 Fabric 占位) |
| `removeTopologyViewNodes` | 从画布移除(不删 Fabric) |
| `updateTopologyViewPositions` | 设置坐标 |
| `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布 |
**刻意不提供:** 手工建链、`populate`(会经 managed 创建 Fabric 占位)、删 Fabric / 删整图。
写操作需要 token 具备 `ne:write`;只读为 `ne:read`。
**权限怎么开:** 网页 **系统 → API Key**(`/api-keys`)创建 Key 时勾选 scopes,或点「MCP + 拓扑写」。默认 bootstrap `data/auth/mcp_token` **没有** `ne:write`,Agent 的 `tools/list` **不会出现**写工具。把新 Key 配到 `NETX_API_TOKEN`(或写进 MCP env)后重启 MCP / Sync Tools。
**前端能否看着画:** 在拓扑页左侧树或右侧浏览区点 **「实时同步」**(默认关闭;**不需要先打开某张图**)。开启后树约每 5 秒、已打开的图约每 3 秒拉取,可看到 MCP 新建区域/画布并往上加点。有未保存本地拖动时不会覆盖你的编辑。
---
## 4. 与 netx-mcp 的关系
| 包 | server_id | 职责 |
|----|-----------|------|
| `netx-mcp` | `netx` | 告警、UME、托管网元 CLI(**13** 工具) |
| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 安全画图(**13** 工具) |
`queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。
---
## 5. 更新
```powershell
cd <netx 仓库>
git pull
pip install -e ./packages/netx-topology-mcp
```
然后重启 MCP 宿主,并 Sync Tools。

View file

@ -9,6 +9,16 @@
"PYTHONIOENCODING": "utf-8", "PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1" "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"
}
} }
} }
} }

View file

@ -0,0 +1,28 @@
{
"source_type": "local",
"source_ref": "netx-topology-mcp",
"server_id": "netx-topology",
"version": "0.1.0",
"entry_command": "python",
"entry_args": ["-m", "netx_topology_mcp"],
"env_schema": {
"NETX_API_URL": {
"type": "string",
"description": "netx REST API base URL (no trailing slash)",
"default": "http://127.0.0.1:8890"
},
"NETX_API_TOKEN": {
"type": "string",
"description": "Optional Bearer token when netx API auth is enabled"
},
"NETX_LANG": {
"type": "string",
"description": "Response language hint: zh or en",
"default": "zh"
}
},
"required_permissions": [],
"risk_level": "medium",
"enabled": true,
"timeout_s": 120
}

View file

@ -36,13 +36,13 @@ python -m netx_mcp
[`mcp.json`](./mcp.json) — `command: python`,`args: ["-m", "netx_mcp"]`,`env` 见文件。 [`mcp.json`](./mcp.json) — `command: python`,`args: ["-m", "netx_mcp"]`,`env` 见文件。
## 工具(14) ## 工具(13)
UME:`queryUmeAlarms`, `aggregateUmeAlarms`, `runUmeDiagnostics`, `queryUmeNeInventory`, `getUmeNe`, `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme` UME:`queryUmeAlarms`, `aggregateUmeAlarms`, `runUmeDiagnostics`, `queryUmeNeInventory`, `getUmeNe`, `queryUmeAlarmsRaw`, `aggregateUmeAlarmsRaw`, `listUmeAlarmFields`, `sqlQueryUme`
托管网元:`listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets` 托管网元:`listManagedNe`, `getManagedNe`, `execManagedNe`, `listCliTargets`
拓扑 Fabric:`queryTopologyEdges`(可按 `node_id` 查 A 与多少网元互联,返回 `peer_count`) 拓扑画布 / Fabric → 请单独安装 [`netx-topology-mcp`](../netx-topology-mcp)(见 [docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md))。
## 兼容 ## 兼容

View file

@ -1,4 +1,4 @@
"""MCP tool schemas and HTTP-backed handlers (UME + managed NE + topology fabric).""" """MCP tool schemas and HTTP-backed handlers (UME + managed NE)."""
from __future__ import annotations from __future__ import annotations
@ -285,61 +285,6 @@ def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
return http_json("GET", "/v1/cli/targets", params=params) return http_json("GET", "/v1/cli/targets", params=params)
def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
"""List fabric edges; with node_id, also summarize unique peer NEs (interconnect count)."""
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
node_id = str(args.get("node_id") or "").strip()
params: dict[str, Any] = {
"page": page,
"page_size": page_size,
"layer": str(args.get("layer") or "physical").strip() or "physical",
}
if node_id:
params["node_id"] = node_id
if str(args.get("status") or "").strip():
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):
return out
items = out.get("items") if isinstance(out.get("items"), list) else []
# When scoping to one NE: unique peers on this page (+ total edges from API).
if node_id and items:
peers: set[str] = set()
peer_labels: list[dict[str, str]] = []
seen_label: set[str] = set()
for e in items:
if not isinstance(e, dict):
continue
a_id = str(e.get("a_node_id") or "")
b_id = str(e.get("b_node_id") or "")
if a_id == node_id:
peer_id, pname, pip = b_id, str(e.get("b_name") or ""), str(e.get("b_ip") or "")
elif b_id == node_id:
peer_id, pname, pip = a_id, str(e.get("a_name") or ""), str(e.get("a_ip") or "")
else:
continue
if not peer_id or peer_id in peers:
continue
peers.add(peer_id)
if peer_id not in seen_label:
seen_label.add(peer_id)
peer_labels.append({"node_id": peer_id, "name": pname, "ip": pip})
out["peer_count"] = len(peers)
out["peers"] = peer_labels
out["edge_total"] = int(out.get("total") or len(items))
# Incomplete if caller didn't fetch all pages.
out["peers_complete"] = int(out.get("total") or 0) <= len(items)
return out
HTTP_MCP_TOOLS: list[dict[str, Any]] = [ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
{ {
"name": "queryUmeAlarms", "name": "queryUmeAlarms",
@ -524,34 +469,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"additionalProperties": False, "additionalProperties": False,
}, },
}, },
{
"name": "queryTopologyEdges",
"description": (
"Query fabric LLDP/manual links. Pass node_id to list edges of NE A and get peer_count "
"(how many distinct NEs A interconnects with). Optional keyword filters by endpoint name/IP; "
"status=active|missing. Raise page_size if peers_complete is false."
),
"inputSchema": {
"type": "object",
"properties": {
"node_id": {
"type": "string",
"description": "Fabric node id of NE A — returns its edges + peer_count/peers summary",
},
"keyword": {
"type": "string",
"description": "Filter edges whose endpoint name/IP contains this text",
},
"layer": {"type": "string", "default": "physical"},
"status": {"type": "string", "enum": ["active", "missing", "stale"]},
"source": {"type": "string", "enum": ["lldp", "manual"]},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100},
},
"required": [],
"additionalProperties": False,
},
},
] ]
_HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
@ -568,7 +485,6 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"getManagedNe": _get_managed_ne, "getManagedNe": _get_managed_ne,
"execManagedNe": _exec_managed_ne, "execManagedNe": _exec_managed_ne,
"listCliTargets": _list_cli_targets, "listCliTargets": _list_cli_targets,
"queryTopologyEdges": _query_topology_edges,
} }
# Minimum scope required to advertise / invoke each tool (matches netx API RBAC). # Minimum scope required to advertise / invoke each tool (matches netx API RBAC).
@ -586,7 +502,6 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = {
"getManagedNe": "ne:read", "getManagedNe": "ne:read",
"execManagedNe": "ne:exec", "execManagedNe": "ne:exec",
"listCliTargets": "ne:read", "listCliTargets": "ne:read",
"queryTopologyEdges": "ne:read",
} }

View file

@ -15,11 +15,12 @@ from netx_mcp.server import _fetch_scopes
def test_http_mcp_tool_list_has_expected_tools() -> None: def test_http_mcp_tool_list_has_expected_tools() -> None:
names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS] names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS]
assert len(names) == 14 assert len(names) == 13
assert "queryUmeAlarms" in names assert "queryUmeAlarms" in names
assert "queryUmeAlarmsRaw" in names assert "queryUmeAlarmsRaw" in names
assert "execManagedNe" in names assert "execManagedNe" in names
assert "listCliTargets" in names assert "listCliTargets" in names
assert "queryTopologyEdges" not in names
exec_tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "execManagedNe") exec_tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "execManagedNe")
assert exec_tool["inputSchema"]["properties"]["commands"]["maxItems"] >= 5 assert exec_tool["inputSchema"]["properties"]["commands"]["maxItems"] >= 5
@ -105,6 +106,11 @@ def test_tools_for_scopes_filters_by_granted() -> None:
def test_stdio_initialize_and_tools_list() -> None: def test_stdio_initialize_and_tools_list() -> None:
import os
env = os.environ.copy()
env["NETX_API_URL"] = "http://127.0.0.1:1"
env.pop("NETX_API_TOKEN", None)
proc = subprocess.Popen( proc = subprocess.Popen(
[sys.executable, "-m", "netx_mcp"], [sys.executable, "-m", "netx_mcp"],
stdin=subprocess.PIPE, stdin=subprocess.PIPE,
@ -113,6 +119,7 @@ def test_stdio_initialize_and_tools_list() -> None:
text=True, text=True,
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
env=env,
) )
assert proc.stdin and proc.stdout assert proc.stdin and proc.stdout
init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n" init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n"
@ -129,7 +136,7 @@ def test_stdio_initialize_and_tools_list() -> None:
list_resp = json.loads(list_line) list_resp = json.loads(list_line)
assert "error" not in list_resp, list_resp assert "error" not in list_resp, list_resp
tools = list_resp["result"]["tools"] tools = list_resp["result"]["tools"]
assert len(tools) == 14 assert len(tools) == 13
proc.terminate() proc.terminate()
proc.wait(timeout=5) proc.wait(timeout=5)

View file

@ -0,0 +1,39 @@
# netx-topology-mcp
独立的 **stdio MCP**,只暴露 netx **拓扑画布 / Fabric** 能力,与告警/CLI 的 [`netx-mcp`](../netx-mcp) 分开安装,方便 Agent 按需启用。
```
MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → netx API /v1/topology/*
```
详细说明 → **[docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md)**
配套 Skill → **[`.cursor/skills/netx-topology`](../../.cursor/skills/netx-topology/SKILL.md)**(画图流水线与硬规则)
## 安装
```powershell
cd D:\project\chatgpt\netx
pip install -e ./packages/netx-topology-mcp
python -c "import netx_topology_mcp; print('ok')"
```
GitHub:
```powershell
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-topology-mcp"
```
## 配置
复制 [`mcp.json`](./mcp.json) 到 Cursor / oclaw(`server_id=netx-topology`),可与 `netx` 同时存在。
## 工具(13)
| 类别 | 工具 |
|------|------|
| 树/画布 | `getTopologyTree`, `listTopologyViews`, `getTopologyView`, `createTopologyView` |
| 画图 | `addTopologyViewNodes`(仅已有 `fabric_node_ids`), `removeTopologyViewNodes`, `updateTopologyViewPositions`, `projectTopologyNeighbors` |
| Fabric 只读 | `getTopologyFabricSummary`, `listTopologyFabricNodes`, `searchTopologyFabricNodes`, `queryTopologyNeighborhood`, `queryTopologyEdges` |
**安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。

View file

@ -0,0 +1,14 @@
{
"mcpServers": {
"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"
}
}
}
}

View file

@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "netx-topology-mcp"
version = "0.1.0"
description = "stdio MCP server for netx topology canvas (views / fabric / draw)"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"httpx>=0.27.0",
]
[project.scripts]
netx-topology-mcp = "netx_topology_mcp.server:main"
[tool.setuptools.packages.find]
where = ["src"]

View file

@ -0,0 +1,3 @@
"""netx topology MCP — canvas / fabric tools for drawing topology maps."""
__version__ = "0.1.0"

View file

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

View file

@ -0,0 +1,94 @@
"""HTTP client for netx REST API (topology MCP)."""
from __future__ import annotations
import json
import os
from typing import Any
import httpx
def api_base_url() -> str:
raw = (
os.getenv("NETX_API_URL")
or os.getenv("OCLAW_NETX_BASE_URL")
or "http://127.0.0.1:8890"
)
return str(raw or "").strip().rstrip("/")
def api_headers() -> dict[str, str]:
h = {"accept": "application/json"}
tok = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip()
if not tok:
candidates = [
os.getenv("NETX_MCP_TOKEN_FILE", "").strip(),
"data/auth/mcp_token",
os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "auth", "mcp_token"),
]
for raw in candidates:
if not raw:
continue
path = os.path.abspath(raw)
try:
if os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
tok = fh.read().strip()
if tok:
break
except Exception:
continue
if tok:
h["authorization"] = f"Bearer {tok}"
return h
def lang_query_params() -> dict[str, str]:
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
if lang.startswith("en"):
return {"lang": "en"}
return {}
def http_json(
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
timeout: float = 60.0,
) -> dict[str, Any]:
url = f"{api_base_url()}{path}"
merged: dict[str, Any] = dict(lang_query_params())
if params:
merged.update(params)
try:
with httpx.Client(timeout=timeout, trust_env=False) as client:
resp = client.request(
method,
url,
params=merged or None,
json=body,
headers=api_headers(),
)
text = resp.text
if not resp.is_success:
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
data = resp.json() if text else {}
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
except Exception as exc:
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]:
out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if is_error:
out["isError"] = True
return out
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
if not result.get("ok"):
return mcp_text_result(result, is_error=True)
return mcp_text_result(result)

View file

@ -0,0 +1,467 @@
"""MCP tool schemas and HTTP handlers for netx topology canvas / fabric."""
from __future__ import annotations
from typing import Any, Callable
from netx_topology_mcp.http_client import http_json, mcp_from_handler_result
def _data(out: dict[str, Any]) -> dict[str, Any]:
"""Return API payload dict from http_json envelope (or error as-is)."""
if not isinstance(out, dict):
return {"ok": False, "error": "invalid_response"}
if not out.get("ok"):
return out
data = out.get("data")
if isinstance(data, dict):
merged = dict(data)
merged["ok"] = True
return merged
return {"ok": True, "data": data}
def _get_topology_tree(_args: dict[str, Any]) -> dict[str, Any]:
return _data(http_json("GET", "/v1/topology/tree"))
def _list_topology_views(_args: dict[str, Any]) -> dict[str, Any]:
return _data(http_json("GET", "/v1/topology/views"))
def _get_topology_view(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"}
return _data(http_json("GET", f"/v1/topology/views/{view_id}"))
def _create_topology_view(args: dict[str, Any]) -> dict[str, Any]:
name = str(args.get("name") or "").strip()
folder_id = str(args.get("folder_id") or "").strip()
if not name:
return {"ok": False, "error": "name_required"}
if not folder_id:
return {"ok": False, "error": "folder_id_required"}
body: dict[str, Any] = {
"name": name,
"folder_id": folder_id,
"remark": str(args.get("remark") or ""),
"kind": str(args.get("kind") or "custom").strip() or "custom",
"role": str(args.get("role") or "core").strip() or "core",
"sort_order": int(args.get("sort_order") or 0),
}
filt = args.get("filter")
if isinstance(filt, dict):
body["filter"] = filt
return _data(http_json("POST", "/v1/topology/views", body=body))
def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"""Place existing fabric nodes on a view only — never create fabric placeholders."""
view_id = str(args.get("view_id") or "").strip()
if not view_id:
return {"ok": False, "error": "view_id_required"}
# Reject inventory-id shortcuts that would call ensure_fabric_node_* on the API.
if args.get("managed_ne_ids") or args.get("ume_ne_ids"):
return {
"ok": False,
"error": "fabric_nodes_only",
"detail": "Only fabric_node_ids are allowed; resolve inventory via search/list first.",
}
fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
if not fabric_ids:
return {"ok": False, "error": "fabric_node_ids_required"}
body: dict[str, Any] = {
"managed_ne_ids": [],
"ume_ne_ids": [],
"fabric_node_ids": fabric_ids,
"layout": str(args.get("layout") or "grid").strip() or "grid",
}
return _data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body))
def _remove_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
view_id = str(args.get("view_id") or "").strip()
ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
if not view_id:
return {"ok": False, "error": "view_id_required"}
if not ids:
return {"ok": False, "error": "fabric_node_ids_required"}
return _data(
http_json("POST", f"/v1/topology/views/{view_id}/nodes/remove", body={"fabric_node_ids": ids})
)
def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]:
view_id = str(args.get("view_id") or "").strip()
positions = args.get("positions")
if not view_id:
return {"ok": False, "error": "view_id_required"}
if not isinstance(positions, list) or not positions:
return {"ok": False, "error": "positions_required"}
cleaned: list[dict[str, Any]] = []
for p in positions:
if not isinstance(p, dict):
continue
fid = str(p.get("fabric_node_id") or "").strip()
if not fid:
continue
cleaned.append(
{
"fabric_node_id": fid,
"x": float(p.get("x") or 0),
"y": float(p.get("y") or 0),
"label": str(p.get("label") or ""),
"locked": bool(p.get("locked") or False),
}
)
if not cleaned:
return {"ok": False, "error": "positions_required"}
return _data(http_json("PATCH", f"/v1/topology/views/{view_id}/positions", body={"positions": cleaned}))
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"}
return _data(http_json("POST", f"/v1/topology/views/{view_id}/project-neighbors", body={}))
def _get_topology_fabric_summary(_args: dict[str, Any]) -> dict[str, Any]:
return _data(http_json("GET", "/v1/topology/fabric/summary"))
def _list_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
params: dict[str, Any] = {"page": page, "page_size": page_size}
if str(args.get("keyword") or "").strip():
params["keyword"] = str(args.get("keyword")).strip()
if str(args.get("role") or "").strip():
params["role"] = str(args.get("role")).strip()
if str(args.get("link_status") or "").strip():
params["link_status"] = str(args.get("link_status")).strip()
return _data(http_json("GET", "/v1/topology/fabric/nodes", params=params))
def _search_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
q = str(args.get("q") or args.get("keyword") or "").strip()
if not q:
return {"ok": False, "error": "q_required"}
params: dict[str, Any] = {
"q": q,
"page": max(1, int(args.get("page") or 1)),
"page_size": min(200, max(1, int(args.get("page_size") or args.get("limit") or 50))),
}
return _data(http_json("GET", "/v1/topology/fabric/nodes/search", params=params))
def _query_topology_neighborhood(args: dict[str, Any]) -> dict[str, Any]:
node_id = str(args.get("node_id") or "").strip()
if not node_id:
return {"ok": False, "error": "node_id_required"}
params: dict[str, Any] = {
"node_id": node_id,
"depth": min(3, max(1, int(args.get("depth") or 1))),
"layer": str(args.get("layer") or "physical").strip() or "physical",
}
return _data(http_json("GET", "/v1/topology/fabric/neighborhood", params=params))
def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
"""List fabric edges; with node_id, also summarize unique peer NEs."""
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 100)))
node_id = str(args.get("node_id") or "").strip()
params: dict[str, Any] = {
"page": page,
"page_size": page_size,
"layer": str(args.get("layer") or "physical").strip() or "physical",
}
if node_id:
params["node_id"] = node_id
if str(args.get("status") or "").strip():
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 {}
items = data.get("items") if isinstance(data.get("items"), list) else []
result: dict[str, Any] = {"ok": True, **data}
if node_id and items:
peers: set[str] = set()
peer_labels: list[dict[str, str]] = []
seen_label: set[str] = set()
for e in items:
if not isinstance(e, dict):
continue
a_id = str(e.get("a_node_id") or "")
b_id = str(e.get("b_node_id") or "")
if a_id == node_id:
peer_id, pname, pip = b_id, str(e.get("b_name") or ""), str(e.get("b_ip") or "")
elif b_id == node_id:
peer_id, pname, pip = a_id, str(e.get("a_name") or ""), str(e.get("a_ip") or "")
else:
continue
if not peer_id or peer_id in peers:
continue
peers.add(peer_id)
if peer_id not in seen_label:
seen_label.add(peer_id)
peer_labels.append({"node_id": peer_id, "name": pname, "ip": pip})
result["peer_count"] = len(peers)
result["peers"] = peer_labels
result["edge_total"] = int(data.get("total") or len(items))
result["peers_complete"] = int(data.get("total") or 0) <= len(items)
return result
HTTP_MCP_TOOLS: list[dict[str, Any]] = [
{
"name": "getTopologyTree",
"description": "Get topology folder tree (sites/regions) with nested views — start here before createTopologyView.",
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"name": "listTopologyViews",
"description": "List topology canvas views (maps).",
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"name": "getTopologyView",
"description": "Get a topology view graph (nodes + edges + positions) by view_id.",
"inputSchema": {
"type": "object",
"properties": {"view_id": {"type": "string"}},
"required": ["view_id"],
"additionalProperties": False,
},
},
{
"name": "createTopologyView",
"description": "Create a topology canvas under a folder (folder_id from getTopologyTree).",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"folder_id": {"type": "string"},
"remark": {"type": "string"},
"kind": {"type": "string", "enum": ["physical", "custom"], "default": "custom"},
"role": {"type": "string", "default": "core"},
"sort_order": {"type": "integer", "default": 0},
"filter": {"type": "object"},
},
"required": ["name", "folder_id"],
"additionalProperties": False,
},
},
{
"name": "addTopologyViewNodes",
"description": (
"Place existing fabric nodes onto a view canvas (layout=grid|keep). "
"Only fabric_node_ids — never creates fabric placeholders from managed/UME ids."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"layout": {"type": "string", "enum": ["grid", "keep"], "default": "grid"},
},
"required": ["view_id", "fabric_node_ids"],
"additionalProperties": False,
},
},
{
"name": "removeTopologyViewNodes",
"description": "Remove fabric nodes from a view canvas (does not delete fabric inventory).",
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
},
"required": ["view_id", "fabric_node_ids"],
"additionalProperties": False,
},
},
{
"name": "updateTopologyViewPositions",
"description": "Set x/y positions for fabric nodes on a view (draw / rearrange).",
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"positions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"fabric_node_id": {"type": "string"},
"x": {"type": "number"},
"y": {"type": "number"},
"label": {"type": "string"},
"locked": {"type": "boolean"},
},
"required": ["fabric_node_id"],
"additionalProperties": False,
},
"minItems": 1,
},
},
"required": ["view_id", "positions"],
"additionalProperties": False,
},
},
{
"name": "projectTopologyNeighbors",
"description": (
"Project existing LLDP fabric neighbors of nodes already on the view onto the canvas. "
"Only places nodes that already exist in fabric."
),
"inputSchema": {
"type": "object",
"properties": {"view_id": {"type": "string"}},
"required": ["view_id"],
"additionalProperties": False,
},
},
{
"name": "getTopologyFabricSummary",
"description": "Fabric inventory summary (node/edge counts).",
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"name": "listTopologyFabricNodes",
"description": "Paged fabric nodes (keyword/role/link_status filters).",
"inputSchema": {
"type": "object",
"properties": {
"keyword": {"type": "string"},
"role": {"type": "string"},
"link_status": {
"type": "string",
"enum": ["linked", "orphaned", "managed", "ume", "both"],
},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "searchTopologyFabricNodes",
"description": "Quick search fabric nodes by name/IP/id.",
"inputSchema": {
"type": "object",
"properties": {
"q": {"type": "string"},
"keyword": {"type": "string", "description": "Alias of q"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 200, "default": 50},
"limit": {"type": "integer", "description": "Alias of page_size"},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "queryTopologyNeighborhood",
"description": "Neighborhood around a fabric node (depth 1–3).",
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string"},
"depth": {"type": "integer", "minimum": 1, "maximum": 3, "default": 1},
"layer": {"type": "string", "default": "physical"},
},
"required": ["node_id"],
"additionalProperties": False,
},
},
{
"name": "queryTopologyEdges",
"description": (
"Query fabric LLDP/manual links. Pass node_id for edges of NE A plus peer_count. "
"Raise page_size if peers_complete is false."
),
"inputSchema": {
"type": "object",
"properties": {
"node_id": {"type": "string"},
"keyword": {"type": "string"},
"layer": {"type": "string", "default": "physical"},
"status": {"type": "string", "enum": ["active", "missing", "stale"]},
"source": {"type": "string", "enum": ["lldp", "manual"]},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 100},
},
"required": [],
"additionalProperties": False,
},
},
]
_HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"getTopologyTree": _get_topology_tree,
"listTopologyViews": _list_topology_views,
"getTopologyView": _get_topology_view,
"createTopologyView": _create_topology_view,
"addTopologyViewNodes": _add_topology_view_nodes,
"removeTopologyViewNodes": _remove_topology_view_nodes,
"updateTopologyViewPositions": _update_topology_view_positions,
"projectTopologyNeighbors": _project_topology_neighbors,
"getTopologyFabricSummary": _get_topology_fabric_summary,
"listTopologyFabricNodes": _list_topology_fabric_nodes,
"searchTopologyFabricNodes": _search_topology_fabric_nodes,
"queryTopologyNeighborhood": _query_topology_neighborhood,
"queryTopologyEdges": _query_topology_edges,
}
TOOL_REQUIRED_SCOPE: dict[str, str] = {
"getTopologyTree": "ne:read",
"listTopologyViews": "ne:read",
"getTopologyView": "ne:read",
"createTopologyView": "ne:write",
"addTopologyViewNodes": "ne:write",
"removeTopologyViewNodes": "ne:write",
"updateTopologyViewPositions": "ne:write",
"projectTopologyNeighbors": "ne:write",
"getTopologyFabricSummary": "ne:read",
"listTopologyFabricNodes": "ne:read",
"searchTopologyFabricNodes": "ne:read",
"queryTopologyNeighborhood": "ne:read",
"queryTopologyEdges": "ne:read",
}
def tools_for_scopes(scopes: list[str] | set[str] | frozenset[str] | None) -> list[dict[str, Any]]:
if scopes is None:
return list(HTTP_MCP_TOOLS)
granted = {str(s).strip().lower() for s in scopes if str(s).strip()}
if not granted:
return []
out: list[dict[str, Any]] = []
for tool in HTTP_MCP_TOOLS:
name = str(tool.get("name") or "")
need = TOOL_REQUIRED_SCOPE.get(name)
if need is None or need in granted:
out.append(tool)
return out
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}")
return mcp_from_handler_result(fn(dict(args or {})))

View file

@ -0,0 +1,122 @@
"""netx topology stdio MCP server (HTTP client to netx REST API).
Environment:
- ``NETX_API_URL``: netx REST base URL (default ``http://127.0.0.1:8890``)
- ``NETX_API_TOKEN``: optional Bearer token
- ``NETX_LANG``: ``zh`` or ``en``
"""
from __future__ import annotations
import json
import sys
from typing import Any
from netx_topology_mcp.http_client import http_json
from netx_topology_mcp.http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
def _ensure_utf8_stdio() -> None:
for stream in (sys.stdin, sys.stdout, sys.stderr):
if stream is None or not hasattr(stream, "reconfigure"):
continue
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
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()
_UNSET = object()
def _fetch_scopes() -> list[str] | None:
try:
envelope = http_json("GET", "/v1/auth/me")
if not isinstance(envelope, dict) or not envelope.get("ok"):
return None
data = envelope.get("data")
if not isinstance(data, dict):
return None
scopes = data.get("scopes")
if isinstance(scopes, list):
return [str(s) for s in scopes]
user = data.get("user")
if isinstance(user, dict) and isinstance(user.get("scopes"), list):
return [str(s) for s in user["scopes"]]
except Exception:
return None
return None
def run_stdio_loop() -> None:
cached_scopes: list[str] | None | object = _UNSET
def scopes() -> list[str] | None:
nonlocal cached_scopes
if cached_scopes is _UNSET:
cached_scopes = _fetch_scopes()
return cached_scopes # type: ignore[return-value]
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-topology-mcp", "version": "0.1.0", "mode": "http"},
},
)
continue
if method == "notifications/initialized":
continue
if method == "tools/list":
_ok(rid, {"tools": tools_for_scopes(scopes())})
continue
if method == "tools/call":
name = str(params.get("name") or "")
need = TOOL_REQUIRED_SCOPE.get(name)
granted = scopes()
if need and granted is not None and need not in {str(s).lower() for s in granted}:
_err(rid, -32001, f"insufficient_scope:{need}")
continue
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
_ok(rid, call_http_tool(name, args))
continue
_err(rid, -32601, f"method not found: {method}")
except Exception as exc:
_err(rid, -32000, str(exc))
def main() -> None:
_ensure_utf8_stdio()
run_stdio_loop()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,146 @@
"""Tests for netx topology HTTP MCP server."""
from __future__ import annotations
import json
import subprocess
import sys
from unittest.mock import patch
from netx_topology_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool, tools_for_scopes
from netx_topology_mcp.server import _fetch_scopes
def test_tool_list_has_draw_and_query_tools() -> None:
names = {str(t.get("name") or "") for t in HTTP_MCP_TOOLS}
assert len(names) == 13
assert "createTopologyView" in names
assert "addTopologyViewNodes" in names
assert "updateTopologyViewPositions" in names
assert "queryTopologyEdges" in names
assert "getTopologyTree" in names
assert "createTopologyManualEdge" not in names
assert "populateTopologyView" not in names
def test_add_nodes_rejects_managed_ume_ids() -> None:
out = call_http_tool(
"addTopologyViewNodes",
{"view_id": "v1", "managed_ne_ids": ["m1"], "fabric_node_ids": ["f1"]},
)
assert out.get("isError") is True
payload = json.loads(out["content"][0]["text"])
assert payload["error"] == "fabric_nodes_only"
def test_add_nodes_posts_fabric_ids_only() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"nodes": []}}
out = call_http_tool(
"addTopologyViewNodes",
{"view_id": "v1", "fabric_node_ids": ["f1", "f2"], "layout": "grid"},
)
body = mock_http.call_args[1]["body"]
assert body["fabric_node_ids"] == ["f1", "f2"]
assert body["managed_ne_ids"] == []
assert body["ume_ne_ids"] == []
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
def test_create_view_requires_folder() -> None:
out = call_http_tool("createTopologyView", {"name": "map1"})
assert out.get("isError") is True
payload = json.loads(out["content"][0]["text"])
assert payload["error"] == "folder_id_required"
def test_create_view_posts_body() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"id": "v1", "name": "map1"}}
out = call_http_tool(
"createTopologyView",
{"name": "map1", "folder_id": "f1", "kind": "custom"},
)
mock_http.assert_called_once()
assert mock_http.call_args[0][0] == "POST"
assert mock_http.call_args[0][1] == "/v1/topology/views"
body = mock_http.call_args[1]["body"]
assert body["name"] == "map1"
assert body["folder_id"] == "f1"
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
def test_query_edges_enriches_peers() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {
"ok": True,
"data": {
"total": 1,
"items": [
{
"a_node_id": "A",
"b_node_id": "B",
"a_name": "ne-a",
"b_name": "ne-b",
"a_ip": "1.1.1.1",
"b_ip": "2.2.2.2",
}
],
},
}
out = call_http_tool("queryTopologyEdges", {"node_id": "A", "page_size": 100})
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
assert payload["peer_count"] == 1
assert payload["peers"][0]["node_id"] == "B"
assert payload["peers_complete"] is True
def test_tools_for_scopes_filters_write() -> None:
read_only = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read"])}
assert "queryTopologyEdges" in read_only
assert "createTopologyView" not in read_only
write = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read", "ne:write"])}
assert "createTopologyView" in write
def test_fetch_scopes_unwraps_envelope() -> None:
with patch("netx_topology_mcp.server.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"scopes": ["ne:read", "ne:write"]}}
assert _fetch_scopes() == ["ne:read", "ne:write"]
def test_stdio_initialize_and_tools_list() -> None:
import os
# Force scopes fetch to fail so tools/list returns the full catalog.
env = os.environ.copy()
env["NETX_API_URL"] = "http://127.0.0.1:1"
env.pop("NETX_API_TOKEN", None)
proc = subprocess.Popen(
[sys.executable, "-m", "netx_topology_mcp"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
env=env,
)
assert proc.stdin and proc.stdout
try:
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n")
proc.stdin.flush()
init_resp = json.loads(proc.stdout.readline())
assert init_resp["result"]["serverInfo"]["name"] == "netx-topology-mcp"
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}) + "\n")
proc.stdin.flush()
list_resp = json.loads(proc.stdout.readline())
tools = list_resp["result"]["tools"]
assert len(tools) == 13
finally:
proc.terminate()
proc.wait(timeout=5)

View file

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

View file

@ -234,6 +234,29 @@ class AuthApiTests(unittest.TestCase):
r = self.client.get("/v1/probe", headers={"Authorization": f"Bearer {api_tok}"}) r = self.client.get("/v1/probe", headers={"Authorization": f"Bearer {api_tok}"})
self.assertEqual(r.status_code, 200) self.assertEqual(r.status_code, 200)
def test_api_token_with_scopes(self) -> None:
token = self._login()
created = self.client.post(
"/v1/api-tokens",
headers={"Authorization": f"Bearer {token}"},
json={
"name": "topo-write",
"expires_in_days": 30,
"scopes": ["ne:read", "ne:write", "alarms:read"],
},
)
self.assertEqual(created.status_code, 200, created.text)
body = created.json()["token"]
self.assertEqual(sorted(body.get("scopes") or []), ["alarms:read", "ne:read", "ne:write"])
api_tok = body["token"]
me = self.client.get("/v1/auth/me", headers={"Authorization": f"Bearer {api_tok}"})
self.assertEqual(me.status_code, 200, me.text)
granted = sorted(me.json().get("scopes") or [])
self.assertIn("ne:write", granted)
self.assertIn("ne:read", granted)
# Token cannot escalate beyond listed scopes (admin owner still capped by token list).
self.assertNotIn("admin:users", granted)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 596 KiB

BIN
web/public/login-space.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View file

@ -477,7 +477,7 @@ const en = {
colIp: "IP", colIp: "IP",
apiKeysTitle: "API Key management", apiKeysTitle: "API Key management",
apiKeysHint: apiKeysHint:
"Create long-lived tokens for MCP/scripts. The secret is shown only once. Admins can issue keys for other users.", "Create long-lived tokens for MCP/scripts. The secret is shown only once. Admins can issue keys for other users. Pick scopes; topology drawing needs ne:write.",
tokenName: "Name", tokenName: "Name",
expiresIn: "Expiry", expiresIn: "Expiry",
expire7d: "7 days", expire7d: "7 days",
@ -501,6 +501,24 @@ const en = {
tokenStatusRevoked: "Revoked", tokenStatusRevoked: "Revoked",
revokeToken: "Revoke", revokeToken: "Revoke",
revokeConfirm: "Revoke this API key?", revokeConfirm: "Revoke this API key?",
scopesTitle: "Scopes",
scopesHint:
"Scopes are stored on the token. Without Inherit, pick at least one. MCP default omits ne:write so write tools stay hidden from the agent.",
scopesInherit: "Inherit all owner scopes",
scopesInheritShort: "Inherit owner",
scopesCol: "Scopes",
scopesRequired: "Select at least one scope, or inherit all owner scopes",
scopesNoneAvailable: "This owner has no grantable scopes",
scopePresetMcp: "MCP default (read + CLI)",
scopePresetTopoWrite: "MCP + topology write",
scopeAlarmsRead: "alarms:read Alarms read",
scopeNeRead: "ne:read NE / topology read",
scopeNeWrite: "ne:write NE / topology write (draw)",
scopeNeExec: "ne:exec Managed NE exec",
scopeWebcrt: "webcrt:session WebCRT",
scopeSql: "sql:query SQL",
scopeAdminUsers: "admin:users User admin",
scopeOpsWrite: "ops:write Ops write",
forceChangeTitle: "Change initial password", forceChangeTitle: "Change initial password",
forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.", forceChangeHint: "Account {{user}} is still using the default password. You must change it before continuing.",
oldPassword: "Current password", oldPassword: "Current password",
@ -1375,6 +1393,9 @@ const en = {
edgeDiscovered: "Discovered", edgeDiscovered: "Discovered",
edgeStale: "Missing", edgeStale: "Missing",
fit: "Fit view", fit: "Fit view",
liveSync: "Live sync",
liveSyncOn: "Syncing…",
liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default. No need to open a map first",
fullscreen: "Fullscreen", fullscreen: "Fullscreen",
exitFullscreen: "Exit fullscreen", exitFullscreen: "Exit fullscreen",
display: "Display", display: "Display",

View file

@ -473,7 +473,7 @@ const zh = {
colStatus: "状态码", colStatus: "状态码",
colIp: "IP", colIp: "IP",
apiKeysTitle: "API Key 管理", apiKeysTitle: "API Key 管理",
apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。", apiKeysHint: "生成长期 Token 供 MCP/脚本调用;明文仅创建时显示一次。管理员可为其他用户签发。可勾选权限;拓扑画图需 ne:write。",
tokenName: "名称", tokenName: "名称",
expiresIn: "有效期", expiresIn: "有效期",
expire7d: "7 天", expire7d: "7 天",
@ -497,6 +497,23 @@ const zh = {
tokenStatusRevoked: "已吊销", tokenStatusRevoked: "已吊销",
revokeToken: "吊销", revokeToken: "吊销",
revokeConfirm: "确定吊销该 API Key?", revokeConfirm: "确定吊销该 API Key?",
scopesTitle: "权限范围",
scopesHint: "勾选后写入 Token;不勾选「继承」时至少选一项。默认 MCP 不含 ne:write,写工具对 Agent 不可见。",
scopesInherit: "继承所属用户全部权限",
scopesInheritShort: "继承用户",
scopesCol: "权限",
scopesRequired: "请至少勾选一项权限,或选择继承用户全部权限",
scopesNoneAvailable: "当前所属用户没有可授予的权限",
scopePresetMcp: "MCP 默认(只读+CLI)",
scopePresetTopoWrite: "MCP + 拓扑写",
scopeAlarmsRead: "alarms:read 告警只读",
scopeNeRead: "ne:read 网元/拓扑只读",
scopeNeWrite: "ne:write 网元/拓扑写入(含画图)",
scopeNeExec: "ne:exec 托管网元执行命令",
scopeWebcrt: "webcrt:session WebCRT",
scopeSql: "sql:query SQL 查询",
scopeAdminUsers: "admin:users 用户管理",
scopeOpsWrite: "ops:write 运维写入",
forceChangeTitle: "请修改初始密码", forceChangeTitle: "请修改初始密码",
forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。", forceChangeHint: "账号 {{user}} 仍在使用默认密码,登录前必须先修改。",
oldPassword: "当前密码", oldPassword: "当前密码",
@ -1369,6 +1386,9 @@ const zh = {
edgeDiscovered: "发现", edgeDiscovered: "发现",
edgeStale: "未发现", edgeStale: "未发现",
fit: "适应画布", fit: "适应画布",
liveSync: "实时同步",
liveSyncOn: "同步中…",
liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新已打开的图(观看 MCP 建图/画拓扑);默认关闭。不需要先打开画布",
fullscreen: "全屏显示", fullscreen: "全屏显示",
exitFullscreen: "退出全屏", exitFullscreen: "退出全屏",
display: "显示", display: "显示",

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,4 @@
import { useMemo, useState, type FormEvent } from "react"; import { useEffect, useMemo, useState, type FormEvent } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
@ -11,6 +11,7 @@ type TokenRow = {
name: string; name: string;
user_id: string; user_id: string;
username: string; username: string;
scopes?: string[];
created_at: string | null; created_at: string | null;
expires_at: string | null; expires_at: string | null;
last_used_at: string | null; last_used_at: string | null;
@ -23,9 +24,35 @@ type UserRow = {
id: string; id: string;
username: string; username: string;
role: string; role: string;
scopes?: string[];
is_active: boolean; is_active: boolean;
}; };
const ALL_SCOPE_KEYS = [
"alarms:read",
"ne:read",
"ne:write",
"ne:exec",
"webcrt:session",
"sql:query",
"admin:users",
"ops:write",
] as const;
const MCP_DEFAULT_SCOPES = ["alarms:read", "ne:read", "ne:exec"] as const;
const MCP_TOPO_WRITE_SCOPES = ["alarms:read", "ne:read", "ne:exec", "ne:write"] as const;
const SCOPE_LABEL_KEYS: Record<(typeof ALL_SCOPE_KEYS)[number], string> = {
"alarms:read": "auth.scopeAlarmsRead",
"ne:read": "auth.scopeNeRead",
"ne:write": "auth.scopeNeWrite",
"ne:exec": "auth.scopeNeExec",
"webcrt:session": "auth.scopeWebcrt",
"sql:query": "auth.scopeSql",
"admin:users": "auth.scopeAdminUsers",
"ops:write": "auth.scopeOpsWrite",
};
const EXPIRY_OPTIONS = [ const EXPIRY_OPTIONS = [
{ value: 7, labelKey: "auth.expire7d" }, { value: 7, labelKey: "auth.expire7d" },
{ value: 30, labelKey: "auth.expire30d" }, { value: 30, labelKey: "auth.expire30d" },
@ -40,14 +67,21 @@ function tokenStatusClass(row: TokenRow): string {
return "pt-list-status--ok"; return "pt-list-status--ok";
} }
function intersectScopes(available: string[], desired: readonly string[]): string[] {
const allow = new Set(available);
return desired.filter((s) => allow.has(s));
}
export function ApiTokensPage() { export function ApiTokensPage() {
const { t } = useI18n(); const { t } = useI18n();
const { ready, user, isAdmin } = useAuth(); const { ready, user, isAdmin, scopes: myScopes } = useAuth();
const { showOk, showError } = useToast(); const { showOk, showError } = useToast();
const qc = useQueryClient(); const qc = useQueryClient();
const [name, setName] = useState("mcp"); const [name, setName] = useState("mcp");
const [expiresInDays, setExpiresInDays] = useState(90); const [expiresInDays, setExpiresInDays] = useState(90);
const [ownerUserId, setOwnerUserId] = useState(""); const [ownerUserId, setOwnerUserId] = useState("");
const [inheritScopes, setInheritScopes] = useState(false);
const [selectedScopes, setSelectedScopes] = useState<string[]>([...MCP_DEFAULT_SCOPES]);
const [createdPlain, setCreatedPlain] = useState(""); const [createdPlain, setCreatedPlain] = useState("");
const tokensQuery = useQuery({ const tokensQuery = useQuery({
@ -62,12 +96,32 @@ export function ApiTokensPage() {
enabled: ready && isAdmin, enabled: ready && isAdmin,
}); });
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
const availableScopes = useMemo(() => {
if (ownerUserId) {
const owner = users.find((u) => u.id === ownerUserId);
return [...(owner?.scopes || [])].sort();
}
return [...(myScopes || [])].sort();
}, [ownerUserId, users, myScopes]);
useEffect(() => {
setSelectedScopes((prev) => {
const next = prev.filter((s) => availableScopes.includes(s));
if (next.length) return next;
return intersectScopes(availableScopes, MCP_DEFAULT_SCOPES);
});
}, [availableScopes]);
const createMut = useMutation({ const createMut = useMutation({
mutationFn: () => mutationFn: () =>
apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", { apiPost<{ token: TokenRow & { token: string } }>("/v1/api-tokens", {
name: name.trim() || "mcp", name: name.trim() || "mcp",
expires_in_days: expiresInDays, expires_in_days: expiresInDays,
user_id: isAdmin && ownerUserId ? ownerUserId : undefined, user_id: isAdmin && ownerUserId ? ownerUserId : undefined,
scopes: inheritScopes ? [] : selectedScopes,
}), }),
onSuccess: async (data) => { onSuccess: async (data) => {
setCreatedPlain(data.token.token); setCreatedPlain(data.token.token);
@ -86,11 +140,12 @@ export function ApiTokensPage() {
onError: (e) => showError(String(e instanceof Error ? e.message : e)), onError: (e) => showError(String(e instanceof Error ? e.message : e)),
}); });
const items = useMemo(() => tokensQuery.data?.items || [], [tokensQuery.data]);
const users = useMemo(() => usersQuery.data?.items || [], [usersQuery.data]);
const onCreate = (e: FormEvent) => { const onCreate = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!inheritScopes && selectedScopes.length === 0) {
showError(t("auth.scopesRequired"));
return;
}
setCreatedPlain(""); setCreatedPlain("");
createMut.mutate(); createMut.mutate();
}; };
@ -104,6 +159,22 @@ export function ApiTokensPage() {
} }
}; };
const toggleScope = (scope: string) => {
setSelectedScopes((prev) =>
prev.includes(scope) ? prev.filter((s) => s !== scope) : [...prev, scope].sort(),
);
};
const applyPreset = (desired: readonly string[]) => {
setInheritScopes(false);
setSelectedScopes(intersectScopes(availableScopes, desired));
};
const formatScopes = (scopes: string[] | undefined) => {
if (!scopes || scopes.length === 0) return t("auth.scopesInheritShort");
return scopes.join(", ");
};
return ( return (
<div className="page-stack system-page"> <div className="page-stack system-page">
<section className="panel"> <section className="panel">
@ -113,43 +184,91 @@ export function ApiTokensPage() {
<p className="panel__hint">{t("auth.apiKeysHint")}</p> <p className="panel__hint">{t("auth.apiKeysHint")}</p>
<div className="pt-list"> <div className="pt-list">
<form className="filter-inline" onSubmit={onCreate}> <form className="token-create" onSubmit={onCreate}>
<input <div className="filter-inline">
placeholder={t("auth.tokenName")} <input
value={name} placeholder={t("auth.tokenName")}
onChange={(e) => setName(e.target.value)} value={name}
required onChange={(e) => setName(e.target.value)}
/> required
<select />
value={expiresInDays}
onChange={(e) => setExpiresInDays(Number(e.target.value))}
aria-label={t("auth.expiresIn")}
>
{EXPIRY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</option>
))}
</select>
{isAdmin ? (
<select <select
value={ownerUserId} value={expiresInDays}
onChange={(e) => setOwnerUserId(e.target.value)} onChange={(e) => setExpiresInDays(Number(e.target.value))}
aria-label={t("auth.tokenOwner")} aria-label={t("auth.expiresIn")}
> >
<option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option> {EXPIRY_OPTIONS.map((opt) => (
{users <option key={opt.value} value={opt.value}>
.filter((u) => u.is_active) {t(opt.labelKey)}
.map((u) => ( </option>
<option key={u.id} value={u.id}> ))}
{u.username} ({u.role})
</option>
))}
</select> </select>
) : null} {isAdmin ? (
<button type="submit" disabled={createMut.isPending}> <select
{t("auth.createToken")} value={ownerUserId}
</button> onChange={(e) => setOwnerUserId(e.target.value)}
aria-label={t("auth.tokenOwner")}
>
<option value="">{t("auth.tokenOwnerSelf", { user: user?.username || "" })}</option>
{users
.filter((u) => u.is_active)
.map((u) => (
<option key={u.id} value={u.id}>
{u.username} ({u.role})
</option>
))}
</select>
) : null}
<button type="submit" disabled={createMut.isPending}>
{t("auth.createToken")}
</button>
</div>
<div className="token-scopes">
<div className="token-scopes__head">
<strong>{t("auth.scopesTitle")}</strong>
<span className="panel__hint" style={{ margin: 0 }}>
{t("auth.scopesHint")}
</span>
</div>
<div className="token-scopes__presets">
<button type="button" className="btn--ghost btn--sm" onClick={() => applyPreset(MCP_DEFAULT_SCOPES)}>
{t("auth.scopePresetMcp")}
</button>
<button
type="button"
className="btn--ghost btn--sm"
onClick={() => applyPreset(MCP_TOPO_WRITE_SCOPES)}
disabled={!availableScopes.includes("ne:write")}
>
{t("auth.scopePresetTopoWrite")}
</button>
</div>
<label className="token-scopes__inherit">
<input
type="checkbox"
checked={inheritScopes}
onChange={(e) => setInheritScopes(e.target.checked)}
/>
{t("auth.scopesInherit")}
</label>
<div className={`token-scopes__grid${inheritScopes ? " is-disabled" : ""}`}>
{ALL_SCOPE_KEYS.filter((s) => availableScopes.includes(s)).map((scope) => (
<label key={scope}>
<input
type="checkbox"
disabled={inheritScopes}
checked={selectedScopes.includes(scope)}
onChange={() => toggleScope(scope)}
/>
{t(SCOPE_LABEL_KEYS[scope])}
</label>
))}
{!availableScopes.length ? (
<span className="muted">{t("auth.scopesNoneAvailable")}</span>
) : null}
</div>
</div>
</form> </form>
{createdPlain ? ( {createdPlain ? (
@ -177,6 +296,7 @@ export function ApiTokensPage() {
<tr> <tr>
<th>{t("auth.tokenName")}</th> <th>{t("auth.tokenName")}</th>
<th>{t("auth.tokenOwner")}</th> <th>{t("auth.tokenOwner")}</th>
<th>{t("auth.scopesCol")}</th>
<th>{t("auth.colTime")}</th> <th>{t("auth.colTime")}</th>
<th>{t("auth.expiresAt")}</th> <th>{t("auth.expiresAt")}</th>
<th>{t("auth.lastUsed")}</th> <th>{t("auth.lastUsed")}</th>
@ -189,6 +309,9 @@ export function ApiTokensPage() {
<tr key={row.id}> <tr key={row.id}>
<td className="pt-list-task-name">{row.name}</td> <td className="pt-list-task-name">{row.name}</td>
<td>{row.username || row.user_id}</td> <td>{row.username || row.user_id}</td>
<td className="token-scopes-cell" title={formatScopes(row.scopes)}>
{formatScopes(row.scopes)}
</td>
<td className="pt-list-time"> <td className="pt-list-time">
{row.created_at ? formatSystemTime(row.created_at) : t("common.empty")} {row.created_at ? formatSystemTime(row.created_at) : t("common.empty")}
</td> </td>

View file

@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { apiPost } from "../services/api"; import { apiPost } from "../services/api";
import { LoginShell } from "./LoginShell";
export function ForceChangePasswordPage() { export function ForceChangePasswordPage() {
const { t } = useI18n(); const { t } = useI18n();
@ -42,26 +43,24 @@ export function ForceChangePasswordPage() {
}; };
return ( return (
<div className="login-page"> <LoginShell>
<div className="login-page__atmosphere" aria-hidden="true">
<span className="login-page__orb login-page__orb--a" />
<span className="login-page__orb login-page__orb--b" />
<span className="login-page__grid" />
</div>
<form className="login-card" onSubmit={(e) => void onSubmit(e)}> <form className="login-card" onSubmit={(e) => void onSubmit(e)}>
<div className="login-card__brand" aria-label="NETX"> <div className="login-card__head">
NETX <h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
<div className="login-card__brand" aria-label="NETX">
NETX
</div>
</div> </div>
<h1 className="login-card__title">{t("auth.forceChangeTitle")}</h1>
<p className="login-card__hint"> <p className="login-card__hint">
{t("auth.forceChangeHint", { user: user?.username || "admin" })} {t("auth.forceChangeHint", { user: user?.username || "admin" })}
</p> </p>
<label className="login-card__label"> <label className="login-card__label">
{t("auth.oldPassword")} <span className="login-card__sr">{t("auth.oldPassword")}</span>
<input <input
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
autoFocus autoFocus
placeholder={t("auth.oldPassword")}
value={oldPassword} value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)} onChange={(e) => setOldPassword(e.target.value)}
disabled={busy} disabled={busy}
@ -69,10 +68,11 @@ export function ForceChangePasswordPage() {
/> />
</label> </label>
<label className="login-card__label"> <label className="login-card__label">
{t("auth.newPassword")} <span className="login-card__sr">{t("auth.newPassword")}</span>
<input <input
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
placeholder={t("auth.newPassword")}
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
disabled={busy} disabled={busy}
@ -81,10 +81,11 @@ export function ForceChangePasswordPage() {
/> />
</label> </label>
<label className="login-card__label"> <label className="login-card__label">
{t("auth.confirmPassword")} <span className="login-card__sr">{t("auth.confirmPassword")}</span>
<input <input
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
placeholder={t("auth.confirmPassword")}
value={confirm} value={confirm}
onChange={(e) => setConfirm(e.target.value)} onChange={(e) => setConfirm(e.target.value)}
disabled={busy} disabled={busy}
@ -92,20 +93,23 @@ export function ForceChangePasswordPage() {
minLength={6} minLength={6}
/> />
</label> </label>
{error ? <div className="login-card__error">{error}</div> : null} {error ? (
<div className="login-card__error" role="alert">
{error}
</div>
) : null}
<button type="submit" className="login-card__submit" disabled={busy}> <button type="submit" className="login-card__submit" disabled={busy}>
{busy ? t("auth.savingPassword") : t("auth.savePassword")} {busy ? t("auth.savingPassword") : t("auth.savePassword")}
</button> </button>
<button <button
type="button" type="button"
className="login-card__submit" className="login-card__submit login-card__submit--ghost"
style={{ background: "#64748b" }}
disabled={busy} disabled={busy}
onClick={() => void logout()} onClick={() => void logout()}
> >
{t("auth.logout")} {t("auth.logout")}
</button> </button>
</form> </form>
</div> </LoginShell>
); );
} }

View file

@ -2,6 +2,7 @@ import { useState, type FormEvent } from "react";
import { Navigate, useSearchParams } from "react-router-dom"; import { Navigate, useSearchParams } from "react-router-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { useI18n } from "../i18n"; import { useI18n } from "../i18n";
import { LoginShell } from "./LoginShell";
export function LoginPage() { export function LoginPage() {
const { t } = useI18n(); const { t } = useI18n();
@ -9,6 +10,7 @@ export function LoginPage() {
const [params] = useSearchParams(); const [params] = useSearchParams();
const [username, setUsername] = useState("admin"); const [username, setUsername] = useState("admin");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -31,36 +33,66 @@ export function LoginPage() {
}; };
return ( return (
<div className="login-page"> <LoginShell>
<div className="login-page__atmosphere" aria-hidden="true">
<span className="login-page__orb login-page__orb--a" />
<span className="login-page__orb login-page__orb--b" />
<span className="login-page__grid" />
</div>
<form className="login-card" onSubmit={(e) => void onSubmit(e)}> <form className="login-card" onSubmit={(e) => void onSubmit(e)}>
<div className="login-card__brand" aria-label="NETX"> <div className="login-card__head">
NETX <h1 className="login-card__title">{t("auth.loginTitle")}</h1>
<div className="login-card__brand" aria-label="NETX">
NETX
</div>
</div> </div>
<h1 className="login-card__title">{t("auth.loginTitle")}</h1>
<label className="login-card__label"> <label className="login-card__label">
{t("auth.username")} <span className="login-card__sr">{t("auth.username")}</span>
<input <input
autoFocus autoFocus
autoComplete="username" autoComplete="username"
placeholder={t("auth.username")}
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
disabled={busy || !ready} disabled={busy || !ready}
/> />
</label> </label>
<label className="login-card__label"> <label className="login-card__label">
{t("auth.password")} <span className="login-card__sr">{t("auth.password")}</span>
<input <span className="login-card__password">
type="password" <input
autoComplete="current-password" type={showPassword ? "text" : "password"}
value={password} autoComplete="current-password"
onChange={(e) => setPassword(e.target.value)} placeholder={t("auth.password")}
disabled={busy || !ready} value={password}
/> onChange={(e) => setPassword(e.target.value)}
disabled={busy || !ready}
/>
<button
type="button"
className="login-card__eye"
tabIndex={-1}
aria-label={showPassword ? "Hide password" : "Show password"}
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M3 3l18 18M10.6 10.6a2.5 2.5 0 0 0 3.5 3.5M9.9 5.1A10 10 0 0 1 12 4.8c5.5 0 9.2 5.3 10.2 6.7a1.2 1.2 0 0 1 0 1.4c-.4.6-1.3 1.8-2.7 3M6.1 6.1C4.2 7.5 2.9 9.3 2 11.1a1.2 1.2 0 0 0 0 1.4C3 13.9 6.7 19.2 12 19.2c1.4 0 2.7-.3 3.9-.8"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M2 12.2C3 10.5 6.7 4.8 12 4.8s9 5.7 10 7.4a1.2 1.2 0 0 1 0 1.2C21 15.1 17.3 20.8 12 20.8S3 15.1 2 13.4a1.2 1.2 0 0 1 0-1.2Z"
stroke="currentColor"
strokeWidth="1.75"
strokeLinejoin="round"
/>
<circle cx="12" cy="12.8" r="3" stroke="currentColor" strokeWidth="1.75" />
</svg>
)}
</button>
</span>
</label> </label>
{error ? ( {error ? (
<div className="login-card__error" role="alert"> <div className="login-card__error" role="alert">
@ -71,6 +103,6 @@ export function LoginPage() {
{busy ? t("auth.loggingIn") : t("auth.login")} {busy ? t("auth.loggingIn") : t("auth.login")}
</button> </button>
</form> </form>
</div> </LoginShell>
); );
} }

View file

@ -0,0 +1,18 @@
import type { ReactNode } from "react";
/** Auth shell modeled after CuteCloud login: space BG + rotating night-earth + centered form. */
export function LoginShell({ children }: { children: ReactNode }) {
return (
<div className="login-page">
<div className="login-page__space" aria-hidden="true">
<div className="login-planet">
<div className="login-planet__railway" />
<div className="login-planet__earth">
<div className="login-planet__textures" />
</div>
</div>
</div>
<div className="login-page__content">{children}</div>
</div>
);
}

View file

@ -597,6 +597,22 @@ function flowToPositions(nodes: Node<NeNodeData>[]) {
})); }));
} }
/** Stable signature so live poll only re-applies when the server graph actually changed. */
function graphFingerprint(graph: TopologyViewGraph): string {
const nodes = [...graph.nodes]
.map(
(n) =>
`${n.fabric_node_id}:${Math.round(Number(n.x) || 0)}:${Math.round(Number(n.y) || 0)}:${n.label || n.name || ""}`,
)
.sort()
.join("|");
const edges = [...graph.edges]
.map((e) => `${e.id}:${e.a_node_id}:${e.b_node_id}:${e.a_port || ""}:${e.b_port || ""}:${e.status || ""}`)
.sort()
.join("|");
return `${nodes}#${edges}#${graph.outside_peers?.length || 0}`;
}
function applyViewGraph( function applyViewGraph(
graph: TopologyViewGraph, graph: TopologyViewGraph,
defaults: EdgeDefaults, defaults: EdgeDefaults,
@ -678,6 +694,8 @@ export function TopologyPage() {
}); });
const [discoverError, setDiscoverError] = useState(""); const [discoverError, setDiscoverError] = useState("");
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);
/** Opt-in poll so MCP / other clients painting the open map can be watched. Off by default. */
const [liveSync, setLiveSync] = useState(false);
const [canvasQuery, setCanvasQuery] = useState(""); const [canvasQuery, setCanvasQuery] = useState("");
const [searchHitIds, setSearchHitIds] = useState<string[]>([]); const [searchHitIds, setSearchHitIds] = useState<string[]>([]);
const [findOpen, setFindOpen] = useState(false); const [findOpen, setFindOpen] = useState(false);
@ -692,6 +710,8 @@ export function TopologyPage() {
const canvasRef = useRef<HTMLDivElement | null>(null); const canvasRef = useRef<HTMLDivElement | null>(null);
const dirtyRef = useRef(false); const dirtyRef = useRef(false);
const appliedMapIdRef = useRef(""); const appliedMapIdRef = useRef("");
const appliedGraphFpRef = useRef("");
const nodesRef = useRef<Node<NeNodeData>[]>([]);
const historyRef = useRef<HistorySnap[]>([]); const historyRef = useRef<HistorySnap[]>([]);
const redoRef = useRef<HistorySnap[]>([]); const redoRef = useRef<HistorySnap[]>([]);
const historyLockRef = useRef(false); const historyLockRef = useRef(false);
@ -699,6 +719,8 @@ export function TopologyPage() {
const canUndo = historyTick >= 0 && historyRef.current.length > 0; const canUndo = historyTick >= 0 && historyRef.current.length > 0;
const canRedo = historyTick >= 0 && redoRef.current.length > 0; const canRedo = historyTick >= 0 && redoRef.current.length > 0;
nodesRef.current = nodes;
const markDirty = useCallback(() => { const markDirty = useCallback(() => {
dirtyRef.current = true; dirtyRef.current = true;
setDirty(true); setDirty(true);
@ -760,6 +782,8 @@ export function TopologyPage() {
const treeQuery = useQuery({ const treeQuery = useQuery({
queryKey: queryKeys.topologyTree, queryKey: queryKeys.topologyTree,
queryFn: fetchTopologyTree, queryFn: fetchTopologyTree,
refetchInterval: liveSync ? 5000 : false,
refetchIntervalInBackground: false,
}); });
useEffect(() => { useEffect(() => {
@ -777,6 +801,8 @@ export function TopologyPage() {
queryKey: queryKeys.topologyGraph(mapId), queryKey: queryKeys.topologyGraph(mapId),
queryFn: () => fetchTopologyGraph(mapId), queryFn: () => fetchTopologyGraph(mapId),
enabled: Boolean(mapId), enabled: Boolean(mapId),
refetchInterval: liveSync && mapId ? 3000 : false,
refetchIntervalInBackground: false,
}); });
useEffect(() => { useEffect(() => {
@ -915,24 +941,41 @@ export function TopologyPage() {
useEffect(() => { useEffect(() => {
appliedMapIdRef.current = ""; appliedMapIdRef.current = "";
appliedGraphFpRef.current = "";
}, [mapId]); }, [mapId]);
useEffect(() => { useEffect(() => {
if (!canvasMode) return; if (!canvasMode) return;
if (!mapId || !graphQuery.data) return; if (!mapId || !graphQuery.data) return;
// Only hydrate React Flow from server when entering a map — never clobber unsaved local positions. const entering = appliedMapIdRef.current !== mapId;
if (appliedMapIdRef.current === mapId) return; // Live poll while on the same map: apply server graph only if no unsaved local edits.
appliedMapIdRef.current = mapId; if (!entering && dirtyRef.current) return;
const fp = graphFingerprint(graphQuery.data);
if (!entering && fp === appliedGraphFpRef.current) return;
const selectedIds = entering
? new Set<string>()
: new Set(nodesRef.current.filter((n) => n.selected).map((n) => n.id));
const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults); const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults);
const nextNodes =
selectedIds.size > 0
? rfNodes.map((n) => (selectedIds.has(n.id) ? { ...n, selected: true } : n))
: rfNodes;
historyLockRef.current = true; historyLockRef.current = true;
setNodes(rfNodes); setNodes(nextNodes);
setEdges(rfEdges); setEdges(rfEdges);
historyRef.current = []; appliedGraphFpRef.current = fp;
redoRef.current = []; if (entering) {
clearDirty(); appliedMapIdRef.current = mapId;
bumpHistory(); historyRef.current = [];
redoRef.current = [];
clearDirty();
bumpHistory();
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
}
historyLockRef.current = false; historyLockRef.current = false;
window.setTimeout(() => rfRef.current?.fitView({ padding: 0.2 }), 50);
}, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory]); }, [canvasMode, mapId, graphQuery.data, edgeDefaults, setNodes, setEdges, clearDirty, bumpHistory]);
useEffect(() => { useEffect(() => {
@ -2119,17 +2162,29 @@ export function TopologyPage() {
<div className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}> <div className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}`}>
<aside className="topo-sidebar" aria-label={t("topology.maps")}> <aside className="topo-sidebar" aria-label={t("topology.maps")}>
{sidebarCollapsed ? ( {sidebarCollapsed ? (
<button <div className="topo-sidebar__rail-wrap">
type="button" <button
className="topo-sidebar__rail" type="button"
title={t("topology.expandSidebar")} className="topo-sidebar__rail"
aria-label={t("topology.expandSidebar")} title={t("topology.expandSidebar")}
onClick={() => setSidebarCollapsed(false)} aria-label={t("topology.expandSidebar")}
> onClick={() => setSidebarCollapsed(false)}
<span className="topo-sidebar__rail-icon" aria-hidden="true"> >
<SidebarFoldIcon expand /> <span className="topo-sidebar__rail-icon" aria-hidden="true">
</span> <SidebarFoldIcon expand />
</button> </span>
</button>
<button
type="button"
className={`topo-sidebar__rail topo-sidebar__rail--live${liveSync ? " is-on" : ""}`}
aria-pressed={liveSync}
title={t("topology.liveSyncHint")}
aria-label={liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
onClick={() => setLiveSync((v) => !v)}
>
<span className="topo-sidebar__rail-live-dot" aria-hidden="true" />
</button>
</div>
) : ( ) : (
<> <>
<div className="topo-sidebar__section"> <div className="topo-sidebar__section">
@ -2167,6 +2222,17 @@ export function TopologyPage() {
<SidebarFoldIcon /> <SidebarFoldIcon />
</button> </button>
</div> </div>
<div className="topo-sidebar__live">
<button
type="button"
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
aria-pressed={liveSync}
title={t("topology.liveSyncHint")}
onClick={() => setLiveSync((v) => !v)}
>
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
</button>
</div>
{treeSearchOpen && treeNeQuery.trim() ? ( {treeSearchOpen && treeNeQuery.trim() ? (
<div className="topo-tree-search__panel" role="listbox"> <div className="topo-tree-search__panel" role="listbox">
{debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? ( {debouncedTreeNeQuery.length < 1 || treeNeSearchQuery.isFetching ? (
@ -2905,6 +2971,15 @@ export function TopologyPage() {
)} )}
</p> </p>
</div> </div>
<button
type="button"
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
aria-pressed={liveSync}
title={t("topology.liveSyncHint")}
onClick={() => setLiveSync((v) => !v)}
>
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
</button>
</div> </div>
{!activeRegion ? ( {!activeRegion ? (
regions.length === 0 ? ( regions.length === 0 ? (