diff --git a/.cursor/skills/netx-topology/SKILL.md b/.cursor/skills/netx-topology/SKILL.md index aa86844..6f77761 100644 --- a/.cursor/skills/netx-topology/SKILL.md +++ b/.cursor/skills/netx-topology/SKILL.md @@ -19,14 +19,14 @@ disable-model-invocation: false 3. **只动已有 Fabric**:禁止 `managed_ne_ids` / `ume_ne_ids`;禁止臆造 fabric id。 4. **不污染 Fabric**:不能手工建链、不能 populate、不能删 Fabric / 整图。邻居用 `projectTopologyNeighbors`。 5. **写权限**:需要 `ne:write`。tools/list 没有写工具 → 停,让用户用「MCP + 拓扑写」签发 Token。 -6. **无 folder 则停**:`createTopologyView` 需要已有 `folder_id`;MCP 不能新建 region。 +6. **无区域则先建**:`createTopologyView` 需要 `folder_id`;没有合适区域时用 `createTopologyFolder`(API 会自动建默认 physical 图)。 7. **单画布硬顶 2000**:满了 `truncated` / 触顶 → 新建另一张画布继续;全网五万设备靠多画布切片。 ## 推荐流水线(从零画一张图) ``` -1 getTopologyTree → folder_id -2 createTopologyView → view_id +1 getTopologyTree → 看有无区域;没有则 createTopologyFolder → folder_id +2 createTopologyView(或用区域自带的 physical 图)→ view_id 3 addTopologyViewNodes(keyword=…, limit) → 看 added / next_offset,循环 offset 直到无更多或满 cap 4 projectTopologyNeighbors → 可选 5 updateTopologyViewPositions(layout=grid|offset|stack, keyword=…) → API 自己筛并摆 @@ -39,7 +39,7 @@ disable-model-invocation: false | 目的 | 工具 | |------|------| -| 树 / 画布 | `getTopologyTree`, `listTopologyViews`, `getTopologyView`, `createTopologyView` | +| 树 / 区域 / 画布 | `getTopologyTree`, `createTopologyFolder`, `listTopologyViews`, `getTopologyView`, `createTopologyView` | | 筛选批量加 | `addTopologyViewNodes`(filters + limit/offset) | | 筛选批量挪 | `updateTopologyViewPositions`(layout + filters) | | 筛选批量删 | `removeTopologyViewNodes`(filters) | @@ -49,7 +49,7 @@ disable-model-invocation: false ## 对人说清楚 - 网页观看:拓扑页开 **「实时同步」**(默认关)。 -- 回报:`view_id`、画布名、`added`/`updated`/`removed`、是否 `truncated`/`next_offset`。 +- 回报:`folder_id` / `view_id`、画布名、`added`/`updated`/`removed`、是否 `truncated`/`next_offset`。 ## 不要做 diff --git a/docs/MCP_TOPOLOGY.md b/docs/MCP_TOPOLOGY.md index 3b9ea05..3717c41 100644 --- a/docs/MCP_TOPOLOGY.md +++ b/docs/MCP_TOPOLOGY.md @@ -57,7 +57,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx 与告警 MCP 并存时,把两个 server 都放进 `mcpServers` 即可;未勾选/未安装的不会加载工具。 -oclaw:Install from JSON → Health → Sync Tools(应看到 **13** 个工具)→ 专家绑定勾选 `server_id=netx-topology`。 +oclaw:Install from JSON → Health → Sync Tools(应看到 **14** 个工具)→ 专家绑定勾选 `server_id=netx-topology`。 配套 Agent Skill(画图流水线 / 安全约束):[`.cursor/skills/netx-topology/SKILL.md`](../.cursor/skills/netx-topology/SKILL.md)。Cursor / oclaw 读 skill 后再调 MCP。 @@ -80,6 +80,7 @@ oclaw:Install from JSON → Health → Sync Tools(应看到 **13** 个工具 | 工具 | 作用 | |------|------| +| `createTopologyFolder` | 新建**区域**文件夹(挂在根下);返回 `id` 供建画布;并自动创建默认 physical 图 | | `createTopologyView` | 在 folder 下新建画布 | | `addTopologyViewNodes` | **优先**传 `keyword`/`role`/`vendor`/`link_status` + `limit`/`offset`,由 API 筛选落点;也可 `fabric_node_ids`。拒绝 managed/UME。返回摘要。 | | `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 | @@ -103,7 +104,7 @@ oclaw:Install from JSON → Health → Sync Tools(应看到 **13** 个工具 | 包 | server_id | 职责 | |----|-----------|------| | `netx-mcp` | `netx` | 告警、UME、托管网元 CLI(**13** 工具) | -| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 安全画图(**13** 工具) | +| `netx-topology-mcp` | `netx-topology` | 拓扑画布 / Fabric 只读 + 安全画图(**14** 工具) | `queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。 diff --git a/packages/netx-topology-mcp/README.md b/packages/netx-topology-mcp/README.md index 13d13cb..d1c745c 100644 --- a/packages/netx-topology-mcp/README.md +++ b/packages/netx-topology-mcp/README.md @@ -28,11 +28,11 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx 复制 [`mcp.json`](./mcp.json) 到 Cursor / oclaw(`server_id=netx-topology`),可与 `netx` 同时存在。 -## 工具(13) +## 工具(14) | 类别 | 工具 | |------|------| -| 树/画布 | `getTopologyTree`, `listTopologyViews`, `getTopologyView`, `createTopologyView` | +| 树/区域/画布 | `getTopologyTree`, `createTopologyFolder`, `listTopologyViews`, `getTopologyView`, `createTopologyView` | | 画图 | `addTopologyViewNodes` / `remove…` / `update…Positions`(**优先筛选**,API 自选 id;也可 id 列表), `projectTopologyNeighbors` | | Fabric 只读 | `getTopologyFabricSummary`, `listTopologyFabricNodes`, `searchTopologyFabricNodes`, `queryTopologyNeighborhood`, `queryTopologyEdges` | diff --git a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py index a4684f9..bcc4d8b 100644 --- a/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py +++ b/packages/netx-topology-mcp/src/netx_topology_mcp/http_tools.py @@ -57,6 +57,22 @@ def _create_topology_view(args: dict[str, Any]) -> dict[str, Any]: return _data(http_json("POST", "/v1/topology/views", body=body)) +def _create_topology_folder(args: dict[str, Any]) -> dict[str, Any]: + """Create a region folder under the topology root (API kind=region only).""" + name = str(args.get("name") or "").strip() + if not name: + return {"ok": False, "error": "name_required"} + body: dict[str, Any] = { + "name": name, + "kind": "region", + "sort_order": int(args.get("sort_order") or 0), + } + parent_id = str(args.get("parent_id") or "").strip() + if parent_id: + body["parent_id"] = parent_id + return _data(http_json("POST", "/v1/topology/folders", body=body)) + + _CHUNK = 500 @@ -343,7 +359,7 @@ def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]: HTTP_MCP_TOOLS: list[dict[str, Any]] = [ { "name": "getTopologyTree", - "description": "Get topology folder tree (sites/regions) with nested views — start here before createTopologyView.", + "description": "Get topology folder tree (sites/regions) with nested views — start here; create region with createTopologyFolder if missing.", "inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False}, }, { @@ -361,9 +377,29 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [ "additionalProperties": False, }, }, + { + "name": "createTopologyFolder", + "description": ( + "Create a region folder under the topology root. Returns folder id for createTopologyView. " + "Also auto-creates a default physical map under the region. Requires ne:write." + ), + "inputSchema": { + "type": "object", + "properties": { + "name": {"type": "string", "description": "Region display name"}, + "parent_id": { + "type": "string", + "description": "Optional; must be topology root id (default: root)", + }, + "sort_order": {"type": "integer", "default": 0}, + }, + "required": ["name"], + "additionalProperties": False, + }, + }, { "name": "createTopologyView", - "description": "Create a topology canvas under a folder (folder_id from getTopologyTree).", + "description": "Create a topology canvas under a folder (folder_id from getTopologyTree or createTopologyFolder).", "inputSchema": { "type": "object", "properties": { @@ -569,6 +605,7 @@ _HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = { "getTopologyTree": _get_topology_tree, "listTopologyViews": _list_topology_views, "getTopologyView": _get_topology_view, + "createTopologyFolder": _create_topology_folder, "createTopologyView": _create_topology_view, "addTopologyViewNodes": _add_topology_view_nodes, "removeTopologyViewNodes": _remove_topology_view_nodes, @@ -585,6 +622,7 @@ TOOL_REQUIRED_SCOPE: dict[str, str] = { "getTopologyTree": "ne:read", "listTopologyViews": "ne:read", "getTopologyView": "ne:read", + "createTopologyFolder": "ne:write", "createTopologyView": "ne:write", "addTopologyViewNodes": "ne:write", "removeTopologyViewNodes": "ne:write", diff --git a/packages/netx-topology-mcp/tests/test_mcp_topology.py b/packages/netx-topology-mcp/tests/test_mcp_topology.py index 1263f6e..33498f2 100644 --- a/packages/netx-topology-mcp/tests/test_mcp_topology.py +++ b/packages/netx-topology-mcp/tests/test_mcp_topology.py @@ -13,7 +13,8 @@ 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 len(names) == 14 + assert "createTopologyFolder" in names assert "createTopologyView" in names assert "addTopologyViewNodes" in names assert "updateTopologyViewPositions" in names @@ -23,6 +24,29 @@ def test_tool_list_has_draw_and_query_tools() -> None: assert "populateTopologyView" not in names +def test_create_folder_requires_name() -> None: + out = call_http_tool("createTopologyFolder", {}) + assert out.get("isError") is True + payload = json.loads(out["content"][0]["text"]) + assert payload["error"] == "name_required" + + +def test_create_folder_posts_region() -> None: + with patch("netx_topology_mcp.http_tools.http_json") as mock_http: + mock_http.return_value = {"ok": True, "data": {"id": "f1", "name": "华北", "kind": "region"}} + out = call_http_tool("createTopologyFolder", {"name": "华北", "sort_order": 1}) + mock_http.assert_called_once() + assert mock_http.call_args[0][0] == "POST" + assert mock_http.call_args[0][1] == "/v1/topology/folders" + body = mock_http.call_args[1]["body"] + assert body["name"] == "华北" + assert body["kind"] == "region" + assert body["sort_order"] == 1 + payload = json.loads(out["content"][0]["text"]) + assert payload["ok"] is True + assert payload["id"] == "f1" + + def test_add_nodes_rejects_managed_ume_ids() -> None: out = call_http_tool( "addTopologyViewNodes", @@ -137,8 +161,10 @@ 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 + assert "createTopologyFolder" not in read_only write = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read", "ne:write"])} assert "createTopologyView" in write + assert "createTopologyFolder" in write def test_fetch_scopes_unwraps_envelope() -> None: @@ -175,7 +201,7 @@ def test_stdio_initialize_and_tools_list() -> None: proc.stdin.flush() list_resp = json.loads(proc.stdout.readline()) tools = list_resp["result"]["tools"] - assert len(tools) == 13 + assert len(tools) == 14 finally: proc.terminate() proc.wait(timeout=5)