Add filter-based topology bulk writes and refine API key scopes UX.

Filter add/layout/remove keeps MCP payloads small; tokens default MCP scopes and edit them in a modal with a compact help tip.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-04 07:39:14 +08:00
parent bf0dfd66d8
commit 2d92dde8e5
18 changed files with 1294 additions and 257 deletions

View file

@ -33,7 +33,7 @@ pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx
| 类别 | 工具 |
|------|------|
| 树/画布 | `getTopologyTree`, `listTopologyViews`, `getTopologyView`, `createTopologyView` |
| 画图 | `addTopologyViewNodes`(仅已有 `fabric_node_ids`), `removeTopologyViewNodes`, `updateTopologyViewPositions`, `projectTopologyNeighbors` |
| 画图 | `addTopologyViewNodes` / `remove…` / `update…Positions`(**优先筛选**,API 自选 id;也可 id 列表), `projectTopologyNeighbors` |
| Fabric 只读 | `getTopologyFabricSummary`, `listTopologyFabricNodes`, `searchTopologyFabricNodes`, `queryTopologyNeighborhood`, `queryTopologyEdges` |
**安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。

View file

@ -57,49 +57,152 @@ def _create_topology_view(args: dict[str, Any]) -> dict[str, Any]:
return _data(http_json("POST", "/v1/topology/views", body=body))
_CHUNK = 500
def _filter_fields(args: dict[str, Any]) -> dict[str, str]:
out: dict[str, str] = {}
for key in ("keyword", "role", "vendor", "link_status"):
val = str(args.get(key) or "").strip()
if val:
out[key] = val
return out
def _has_filter(args: dict[str, Any]) -> bool:
return bool(_filter_fields(args))
def _merge_mutation_summaries(parts: list[dict[str, Any]]) -> dict[str, Any]:
if not parts:
return {"ok": False, "error": "empty_batch"}
if len(parts) == 1:
return parts[0]
base = dict(parts[-1])
for key in (
"matched",
"added",
"updated",
"removed",
"skipped_existing",
"skipped_missing",
"skipped_locked",
):
base[key] = sum(int(p.get(key) or 0) for p in parts)
base["truncated"] = any(bool(p.get("truncated")) for p in parts)
# Keep last next_offset / view_node_count / max_nodes from final chunk.
base["ok"] = all(bool(p.get("ok", True)) for p in parts) and not any(
str(p.get("error") or "") for p in parts
)
return base
def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"""Place existing fabric nodes on a view only — never create fabric placeholders."""
"""Place existing fabric nodes on a view — prefer server-side filters over id lists."""
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.",
"detail": "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.",
}
filters = _filter_fields(args)
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))
if not filters and not fabric_ids:
return {
"ok": False,
"error": "filter_or_fabric_node_ids_required",
"detail": "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.",
}
layout = str(args.get("layout") or "grid").strip() or "grid"
if filters:
body: dict[str, Any] = {
"managed_ne_ids": [],
"ume_ne_ids": [],
"fabric_node_ids": [],
"layout": layout,
"limit": min(2000, max(1, int(args.get("limit") or 500))),
"offset": max(0, int(args.get("offset") or 0)),
"return_graph": False,
**filters,
}
return _data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body))
# Explicit ids: chunk to avoid huge payloads.
parts: list[dict[str, Any]] = []
for i in range(0, len(fabric_ids), _CHUNK):
chunk = fabric_ids[i : i + _CHUNK]
body = {
"managed_ne_ids": [],
"ume_ne_ids": [],
"fabric_node_ids": chunk,
"layout": layout,
"return_graph": False,
}
parts.append(_data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body)))
return _merge_mutation_summaries(parts)
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})
)
filters = _filter_fields(args)
ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
if not filters and not ids:
return {"ok": False, "error": "filter_or_fabric_node_ids_required"}
if filters:
body: dict[str, Any] = {"fabric_node_ids": ids, "return_graph": False, **filters}
return _data(http_json("POST", f"/v1/topology/views/{view_id}/nodes/remove", body=body))
parts: list[dict[str, Any]] = []
for i in range(0, len(ids), _CHUNK):
chunk = ids[i : i + _CHUNK]
parts.append(
_data(
http_json(
"POST",
f"/v1/topology/views/{view_id}/nodes/remove",
body={"fabric_node_ids": chunk, "return_graph": False},
)
)
)
return _merge_mutation_summaries(parts)
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"}
layout = str(args.get("layout") or "").strip().lower()
filters = _filter_fields(args)
fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
positions = args.get("positions")
if layout in {"grid", "offset", "stack"}:
body: dict[str, Any] = {
"layout": layout,
"origin_x": float(args.get("origin_x") if args.get("origin_x") is not None else 40),
"origin_y": float(args.get("origin_y") if args.get("origin_y") is not None else 40),
"gap_x": float(args.get("gap_x") if args.get("gap_x") is not None else 180),
"gap_y": float(args.get("gap_y") if args.get("gap_y") is not None else 120),
"cols": int(args.get("cols") or 0),
"dx": float(args.get("dx") or 0),
"dy": float(args.get("dy") or 0),
"fabric_node_ids": fabric_ids,
"return_graph": False,
**filters,
}
return _data(http_json("PATCH", f"/v1/topology/views/{view_id}/positions", body=body))
if not isinstance(positions, list) or not positions:
return {"ok": False, "error": "positions_required"}
return {
"ok": False,
"error": "layout_or_positions_required",
"detail": "Pass layout=grid|offset|stack with optional filters, or positions[].",
}
cleaned: list[dict[str, Any]] = []
for p in positions:
if not isinstance(p, dict):
@ -118,7 +221,19 @@ def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]:
)
if not cleaned:
return {"ok": False, "error": "positions_required"}
return _data(http_json("PATCH", f"/v1/topology/views/{view_id}/positions", body={"positions": cleaned}))
parts: list[dict[str, Any]] = []
for i in range(0, len(cleaned), _CHUNK):
chunk = cleaned[i : i + _CHUNK]
parts.append(
_data(
http_json(
"PATCH",
f"/v1/topology/views/{view_id}/positions",
body={"positions": chunk, "return_graph": False},
)
)
)
return _merge_mutation_summaries(parts)
def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
@ -267,40 +382,79 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
{
"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."
"Bulk-place existing fabric nodes on a view. Prefer server filters "
"(keyword/role/vendor/link_status + limit/offset); API selects ids — do not pull then re-send huge id lists. "
"Returns a summary (added/truncated/next_offset). Canvas hard cap 2000. Never pass managed/UME ids."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"keyword": {"type": "string"},
"role": {"type": "string"},
"vendor": {"type": "string"},
"link_status": {
"type": "string",
"enum": ["linked", "orphaned", "managed", "ume", "both"],
},
"limit": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 500},
"offset": {"type": "integer", "minimum": 0, "default": 0},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}},
"layout": {"type": "string", "enum": ["grid", "keep"], "default": "grid"},
},
"required": ["view_id", "fabric_node_ids"],
"required": ["view_id"],
"additionalProperties": False,
},
},
{
"name": "removeTopologyViewNodes",
"description": "Remove fabric nodes from a view canvas (does not delete fabric inventory).",
"description": (
"Remove placements from a view (not fabric). Prefer filters (keyword/role/vendor/link_status) "
"so the API selects matches; or pass fabric_node_ids. Returns a summary."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1},
"keyword": {"type": "string"},
"role": {"type": "string"},
"vendor": {"type": "string"},
"link_status": {
"type": "string",
"enum": ["linked", "orphaned", "managed", "ume", "both"],
},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}},
},
"required": ["view_id", "fabric_node_ids"],
"required": ["view_id"],
"additionalProperties": False,
},
},
{
"name": "updateTopologyViewPositions",
"description": "Set x/y positions for fabric nodes on a view (draw / rearrange).",
"description": (
"Move nodes on a view. Prefer layout=grid|offset|stack with optional filters "
"(API selects matches and computes coords). Use positions[] only for small manual tweaks. Returns a summary."
),
"inputSchema": {
"type": "object",
"properties": {
"view_id": {"type": "string"},
"layout": {"type": "string", "enum": ["grid", "offset", "stack"]},
"keyword": {"type": "string"},
"role": {"type": "string"},
"vendor": {"type": "string"},
"link_status": {
"type": "string",
"enum": ["linked", "orphaned", "managed", "ume", "both"],
},
"fabric_node_ids": {"type": "array", "items": {"type": "string"}},
"origin_x": {"type": "number", "default": 40},
"origin_y": {"type": "number", "default": 40},
"gap_x": {"type": "number", "default": 180},
"gap_y": {"type": "number", "default": 120},
"cols": {"type": "integer", "minimum": 0, "default": 0},
"dx": {"type": "number", "default": 0},
"dy": {"type": "number", "default": 0},
"positions": {
"type": "array",
"items": {
@ -315,10 +469,9 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"required": ["fabric_node_id"],
"additionalProperties": False,
},
"minItems": 1,
},
},
"required": ["view_id", "positions"],
"required": ["view_id"],
"additionalProperties": False,
},
},

View file

@ -35,7 +35,7 @@ def test_add_nodes_rejects_managed_ume_ids() -> None:
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": []}}
mock_http.return_value = {"ok": True, "data": {"ok": True, "added": 2, "return_graph": False}}
out = call_http_tool(
"addTopologyViewNodes",
{"view_id": "v1", "fabric_node_ids": ["f1", "f2"], "layout": "grid"},
@ -44,10 +44,45 @@ def test_add_nodes_posts_fabric_ids_only() -> None:
assert body["fabric_node_ids"] == ["f1", "f2"]
assert body["managed_ne_ids"] == []
assert body["ume_ne_ids"] == []
assert body["return_graph"] is False
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
def test_add_nodes_filter_posts_without_ids() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {
"ok": True,
"data": {"ok": True, "added": 10, "matched": 40, "next_offset": 10, "truncated": True},
}
out = call_http_tool(
"addTopologyViewNodes",
{"view_id": "v1", "keyword": "BJ-", "limit": 10, "offset": 0},
)
body = mock_http.call_args[1]["body"]
assert body["keyword"] == "BJ-"
assert body["fabric_node_ids"] == []
assert body["return_graph"] is False
payload = json.loads(out["content"][0]["text"])
assert payload["added"] == 10
assert payload["next_offset"] == 10
def test_update_positions_layout_filter() -> None:
with patch("netx_topology_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"ok": True, "updated": 5}}
out = call_http_tool(
"updateTopologyViewPositions",
{"view_id": "v1", "layout": "grid", "keyword": "core", "origin_x": 0, "origin_y": 0},
)
body = mock_http.call_args[1]["body"]
assert body["layout"] == "grid"
assert body["keyword"] == "core"
assert body["return_graph"] is False
payload = json.loads(out["content"][0]["text"])
assert payload["updated"] == 5
def test_create_view_requires_folder() -> None:
out = call_http_tool("createTopologyView", {"name": "map1"})
assert out.get("isError") is True