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

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

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

View file

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

View file

@ -28,7 +28,10 @@ def _data(out: dict[str, Any]) -> dict[str, Any]:
data = out.get("data")
if isinstance(data, dict):
merged = dict(data)
merged["ok"] = True
# HTTP 200 can carry a failed mutation (for example membership_max_nodes).
merged.setdefault("ok", True)
if merged.get("ok") is False and not merged.get("error"):
merged["error"] = "netx_operation_failed"
return merged
return {"ok": True, "data": data}
@ -233,10 +236,12 @@ def _get_topology_view(args: dict[str, Any]) -> dict[str, Any]:
if not out.get("ok"):
return out
detail = str(args.get("detail") or "summary").strip().lower()
if detail in {"full", "raw", "graph"}:
if detail not in {"summary", "full"}:
return {"ok": False, "error": "detail_invalid", "hint": "detail=summary|full"}
if detail == "full":
out["detail"] = "full"
return out
sample = int(args.get("sample") or 20)
sample = int(args["sample"]) if args.get("sample") is not None else 20
return _summarize_view_graph(out, sample=sample)
@ -359,7 +364,7 @@ def _patch_positions_chunked(view_id: str, positions: list[dict[str, Any]]) -> d
"updated": updated,
"detail": out,
}
updated += int(out.get("updated") or len(chunk))
updated += int(out.get("updated", len(chunk)))
chunks += 1
return {"ok": True, "updated": updated, "chunks": chunks}
@ -469,7 +474,6 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
_public = {
"layout",
"fix_overlaps",
"resolve_overlaps",
"untangle",
"straighten_channels",
"layout_dual_unit",
@ -478,11 +482,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
"compact_bbox",
"pull_far_chains",
"align_reference",
"align_to_reference",
"orbit_sweep",
"level_bands",
"move_nodes",
"sink_nodes",
"job_status",
"job_cancel",
}
@ -503,16 +505,12 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
),
**list_layout_catalog(),
}
if action == "resolve_overlaps":
action = "fix_overlaps"
args = {**args, "action": "fix_overlaps"}
# Bidirectional membership move: view_id=TO, source_view_id=FROM.
if action in {"move_nodes", "sink_nodes"}:
if action == "move_nodes":
return _move_topology_view_nodes(args)
# Reference alignment: view_id=TO, source_view_id|params.reference_view_id=FROM.
if action in {"align_reference", "align_to_reference"}:
if action == "align_reference":
return _align_topology_reference(args)
# Background job poll / cancel (no view_id required).
@ -544,8 +542,8 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
"error": "job_not_found",
"job_id": job_id,
"hint": (
"Unknown job_id. Jobs are in-process on this MCP worker; "
"restart or another instance cannot see them."
"Unknown job_id. Poll from a worker sharing NETX_LAYOUT_JOB_DIR; "
"records survive MCP restarts but may have been pruned."
),
}
status = str(job.get("status") or "unknown")
@ -870,9 +868,7 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
force_sync = _truthy((overrides or {}).get("sync")) or _truthy(
(overrides or {}).get("_force_sync")
)
until_limit = _truthy((overrides or {}).get("until_limit")) or _truthy(
(overrides or {}).get("until_stall")
)
until_limit = _truthy((overrides or {}).get("until_limit"))
heavy_bg = action in {
"polish_crossings",
"orbit_sweep",
@ -885,10 +881,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
bg_n_thr = 80 if (action == "orbit_sweep" and until_limit) else 600
if (
mode == "apply"
and heavy_bg
and not force_sync
and (
len(nodes) >= bg_n_thr
(heavy_bg and len(nodes) >= bg_n_thr)
or _truthy((overrides or {}).get("background"))
or (until_limit and action == "orbit_sweep")
)
@ -912,6 +907,9 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
tool_args=sync_args,
meta={"node_count": len(nodes), "until_limit": until_limit},
)
started = job_public(job_id)
if started and started.get("status") in {"done", "error", "cancelled"}:
return _layout_topology_view({"action": "job_status", "params": {"job_id": job_id}})
print(
f"[netx-topology] {action} background job_id={job_id} n={len(nodes)}"
+ (" until_limit" if until_limit else ""),
@ -1870,10 +1868,6 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"error": "fabric_nodes_only",
"detail": "Use keyword/role/vendor/link_status or fabric_node_ids; never managed/UME ids.",
}
if args.get("max_nodes") is not None:
bump = _ensure_view_max_nodes(view_id, int(args.get("max_nodes") or 2000))
if isinstance(bump, dict) and bump.get("ok") is False:
return bump
filters = _filter_fields(args)
fabric_ids = [str(x) for x in (args.get("fabric_node_ids") or []) if str(x).strip()]
if not filters and not fabric_ids:
@ -1883,6 +1877,11 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"detail": "Pass keyword/role/vendor/link_status (preferred) or fabric_node_ids.",
}
if args.get("max_nodes") is not None:
bump = _ensure_view_max_nodes(view_id, int(args.get("max_nodes") or 2000))
if bump.get("ok") is False:
return bump
layout = str(args.get("layout") or "grid").strip() or "grid"
if filters:
body: dict[str, Any] = {
@ -1909,6 +1908,8 @@ def _add_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
"return_graph": False,
}
parts.append(_data(http_json("POST", f"/v1/topology/views/{view_id}/nodes", body=body)))
if not parts[-1].get("ok"):
break
return _merge_mutation_summaries(parts)
@ -1935,6 +1936,8 @@ def _remove_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
)
)
)
if not parts[-1].get("ok"):
break
return _merge_mutation_summaries(parts)
@ -2002,6 +2005,8 @@ def _update_topology_view_positions(args: dict[str, Any]) -> dict[str, Any]:
)
)
)
if not parts[-1].get("ok"):
break
return _merge_mutation_summaries(parts)
@ -2009,8 +2014,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
view_id = str(args.get("view_id") or "").strip()
if not view_id:
return {"ok": False, "error": "view_id_required"}
detail = str(args.get("detail") or "summary").strip().lower()
if detail not in {"summary", "full"}:
return {"ok": False, "error": "detail_invalid", "hint": "detail=summary|full"}
body: dict[str, Any] = {}
seeds = args.get("seed_fabric_node_ids") or args.get("fabric_node_ids") or []
seeds = args.get("seed_fabric_node_ids") or []
if isinstance(seeds, list) and seeds:
body["seed_fabric_node_ids"] = [str(x).strip() for x in seeds if str(x).strip()]
mids = args.get("managed_ne_ids") or []
@ -2019,6 +2027,8 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
region_folder_id = str(args.get("region_folder_id") or "").strip()
if region_folder_id:
body["region_folder_id"] = region_folder_id
if "dry_run" in args:
body["dry_run"] = _truthy(args["dry_run"])
# API still returns the full graph; large canvases easily exceed the 60s default.
out = _data(
http_json(
@ -2030,12 +2040,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
)
if not out.get("ok"):
return out
detail = str(args.get("detail") or "summary").strip().lower()
skipped = int(out.get("out_of_region_skipped") or 0)
skipped_sample = out.get("out_of_region_sample") or []
if not isinstance(skipped_sample, list):
skipped_sample = []
if detail in {"full", "raw", "graph"}:
if detail == "full":
out["detail"] = "full"
out["region_folder_id"] = region_folder_id or None
out["out_of_region_skipped"] = skipped
@ -2046,9 +2055,11 @@ def _project_topology_neighbors(args: dict[str, Any]) -> dict[str, Any]:
f"skipped {skipped} out-of-region neighbors (not added)."
)
return out
summary = _summarize_view_graph(out, sample=int(args.get("sample") or 20))
sample = int(args["sample"]) if args.get("sample") is not None else 20
summary = _summarize_view_graph(out, sample=sample)
summary["view_id"] = view_id
summary["projected"] = True
summary["dry_run"] = bool(body.get("dry_run"))
summary["region_folder_id"] = region_folder_id or None
summary["out_of_region_skipped"] = skipped
summary["out_of_region_sample"] = [
@ -2076,13 +2087,15 @@ def _query_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
mode = "search"
else:
mode = "list"
if mode in {"summary", "stats", "count"}:
if mode not in {"summary", "search", "list"}:
return {"ok": False, "error": "mode_invalid", "hint": "mode=summary|list|search"}
if mode == "summary":
out = _data(http_json("GET", "/v1/topology/fabric/summary"))
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
out["mode"] = "summary"
return out
if mode in {"search", "find"}:
if mode == "search":
needle = q or keyword
if not needle:
return {
@ -2141,11 +2154,11 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
"hint": (
"action=match|tag|patch|unmatched|preview_rules|apply_rules|list_rules. "
"Typical: match → tag(level|role, dry_run) → tag; "
"then addTopologyViewNodes by role/level_major/region."
"then queryTopologyFabricNodes by level/region and add returned fabric_node_ids."
),
}
if action in {"match", "find"}:
if action == "match":
pattern = str(args.get("pattern") or args.get("q") or "").strip()
if not pattern:
return {"ok": False, "error": "pattern_required"}
@ -2180,7 +2193,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
)
return out
if action in {"tag", "bulk_tag", "assign"}:
if action == "tag":
level = args.get("level") if "level" in args else Ellipsis
role = args.get("role") if "role" in args else Ellipsis
region = args.get("region_folder_id") if "region_folder_id" in args else Ellipsis
@ -2232,7 +2245,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
else:
out["hint"] = (
"Tagged. Use queryTopologyFabricNodes(role|level_major|region_folder_id) or "
"addTopologyViewNodes filters to place on region canvas."
"pass returned fabric_node_ids to addTopologyViewNodes to place on region canvas."
)
return out
@ -2286,7 +2299,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
page["action"] = "unmatched"
return page
if action in {"preview_rules", "preview"}:
if action == "preview_rules":
out = _data(http_json("POST", "/v1/topology/classify/preview"))
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
@ -2294,7 +2307,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
out["hint"] = "Rule engine dry-run. Re-call action=apply_rules to write."
return out
if action in {"apply_rules", "apply"}:
if action == "apply_rules":
skip_manual = True
if "skip_manual" in args:
skip_manual = _truthy(args.get("skip_manual"))
@ -2310,7 +2323,7 @@ def _classify_topology_fabric_nodes(args: dict[str, Any]) -> dict[str, Any]:
out["action"] = "apply_rules"
return out
if action in {"list_rules", "rules"}:
if action == "list_rules":
out = _data(http_json("GET", "/v1/topology/classify/rules"))
if isinstance(out, dict) and out.get("ok") is not False:
out = dict(out)
@ -2362,7 +2375,9 @@ def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
page_size = min(500, max(1, int(args.get("page_size") or 100)))
node_id = str(args.get("node_id") or "").strip()
detail = str(args.get("detail") or "adjacency").strip().lower()
want_ports = detail in {"ports", "raw", "full"}
if detail not in {"adjacency", "ports"}:
return {"ok": False, "error": "detail_invalid", "hint": "detail=adjacency|ports"}
want_ports = detail == "ports"
params: dict[str, Any] = {
"page": page,
"page_size": page_size,
@ -2374,16 +2389,13 @@ def _query_topology_edges(args: dict[str, Any]) -> dict[str, Any]:
params["status"] = str(args.get("status")).strip()
if str(args.get("source") or "").strip():
src = str(args.get("source")).strip().lower()
if src == "stale":
src = "lldp"
params["source"] = src
if str(args.get("keyword") or "").strip():
params["keyword"] = str(args.get("keyword")).strip()
out = http_json("GET", "/v1/topology/fabric/edges", params=params)
if not isinstance(out, dict) or not out.get("ok"):
return out if isinstance(out, dict) else {"ok": False, "error": "invalid_response"}
data = out.get("data") if isinstance(out.get("data"), dict) else {}
data = _data(http_json("GET", "/v1/topology/fabric/edges", params=params))
if not data.get("ok"):
return data
items = [e for e in (data.get("items") or []) if isinstance(e, dict)]
edge_total = int(data.get("total") or len(items))
links = _collapse_edges_to_links(items, include_names=True)
@ -2528,12 +2540,8 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
units_as_batch_rows,
)
source_view_id = str(
args.get("source_view_id") or args.get("root_view_id") or ""
).strip()
sink_view_id = str(
args.get("sink_view_id") or args.get("target_view_id") or ""
).strip()
source_view_id = str(args.get("source_view_id") or "").strip()
sink_view_id = str(args.get("sink_view_id") or "").strip()
if not source_view_id or not sink_view_id:
return {
"ok": False,
@ -2883,6 +2891,17 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
patch_out: dict[str, Any] = {"ok": True, "updated": 0}
if pos_patch:
patch_out = _patch_positions_chunked(sink_view_id, pos_patch)
if not patch_out.get("ok"):
return {
"ok": False,
"error": patch_out.get("error") or "positions_patch_failed",
"detail": patch_out,
"source_preserved": True,
"added": int(add_out.get("added", 0)),
"batches": batches,
"batch": batch_row,
"hint": "Sink membership may already be added; source nodes were kept. Inspect both views before retrying.",
}
rm_out = _remove_topology_view_nodes(
{"view_id": source_view_id, "fabric_node_ids": move_ids}
@ -2900,6 +2919,16 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
# Refresh counts
src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
snk2 = _data(http_json("GET", f"/v1/topology/views/{sink_view_id}", timeout=120.0))
if not src2.get("ok") or not snk2.get("ok"):
return {
"ok": False,
"error": "view_readback_failed",
"write_completed": True,
"detail": {"source": src2, "sink": snk2},
"batches": batches,
"batch": batch_row,
"hint": "Writes completed but verification failed. Read both views before retrying this batch.",
}
src_ids2, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
snk_ids2, _ = _view_node_id_pos(snk2 if snk2.get("ok") else {})
source_remaining = len(src_ids2) if src2.get("ok") else max(0, source_remaining - len(move_ids))
@ -2908,8 +2937,8 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
batch_row.update(
{
"added": int(add_out.get("added") or len(add_ids)),
"positions_updated": int(patch_out.get("updated") or len(pos_patch)),
"removed_from_source": int(rm_out.get("removed") or len(move_ids)),
"positions_updated": int(patch_out.get("updated", 0)),
"removed_from_source": int(rm_out.get("removed", 0)),
"source_after": source_remaining,
"sink_after": sink_count,
"max_nodes_bump": bump,
@ -2963,19 +2992,8 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
Bidirectional: swap view_id / source_view_id to send nodes back.
Caller supplies fabric_node_ids — no auto CN/dual-unit selection.
"""
dest_view_id = str(
args.get("view_id")
or args.get("sink_view_id")
or args.get("target_view_id")
or args.get("to_view_id")
or ""
).strip()
source_view_id = str(
args.get("source_view_id")
or args.get("from_view_id")
or args.get("root_view_id")
or ""
).strip()
dest_view_id = str(args.get("view_id") or "").strip()
source_view_id = str(args.get("source_view_id") or "").strip()
if not dest_view_id or not source_view_id:
return {
"ok": False,
@ -3010,6 +3028,8 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
return {"ok": False, "error": "fabric_node_ids_empty"}
mode = str(args.get("mode") or "preview").strip().lower() or "preview"
if mode not in {"preview", "apply"}:
return {"ok": False, "error": "mode_invalid", "hint": "mode=preview|apply"}
dry_run = bool(overrides.get("dry_run") if "dry_run" in overrides else args.get("dry_run"))
if mode != "apply":
dry_run = True
@ -3109,7 +3129,7 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
bump = _ensure_view_max_nodes(dest_view_id, min(2000, max(need_cap + 20, 200)))
if isinstance(bump, dict) and bump.get("ok") is False:
return {**bump, **base}
return {**base, **bump}
add_out: dict[str, Any] = {"ok": True, "added": 0, "skipped_existing": len(already)}
if add_ids:
@ -3158,6 +3178,16 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
patch_out: dict[str, Any] = {"ok": True, "updated": 0}
if pos_patch:
patch_out = _patch_positions_chunked(dest_view_id, pos_patch)
if not patch_out.get("ok"):
return {
**base,
"ok": False,
"error": patch_out.get("error") or "positions_patch_failed",
"detail": patch_out,
"added": int(add_out.get("added", 0)),
"source_preserved": True,
"hint": "Destination membership may already be added; source nodes were kept. Inspect both views before retrying.",
}
rm_out: dict[str, Any] = {"ok": True, "removed": 0}
if remove_from_source:
@ -3176,14 +3206,23 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
src2 = _data(http_json("GET", f"/v1/topology/views/{source_view_id}", timeout=120.0))
dst2 = _data(http_json("GET", f"/v1/topology/views/{dest_view_id}", timeout=120.0))
if not src2.get("ok") or not dst2.get("ok"):
return {
**base,
"ok": False,
"error": "view_readback_failed",
"write_completed": True,
"detail": {"source": src2, "destination": dst2},
"hint": "Writes completed but verification failed. Read both views before retrying the move.",
}
src_after, _ = _view_node_id_pos(src2 if src2.get("ok") else {})
dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
return {
**base,
"dry_run": False,
"added": int(add_out.get("added") or len(add_ids)),
"positions_updated": int(patch_out.get("updated") or len(pos_patch)),
"removed_from_source": int(rm_out.get("removed") or (len(move_ids) if remove_from_source else 0)),
"positions_updated": int(patch_out.get("updated", 0)),
"removed_from_source": int(rm_out.get("removed", 0)),
"source_after": len(src_after) if src2.get("ok") else None,
"dest_after": len(dst_after) if dst2.get("ok") else None,
"max_nodes_bump": bump,
@ -3201,12 +3240,8 @@ def _copy_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
Source membership is unchanged. For test sandboxes: clone a known-good
canvas onto a fresh child region without re-adding ids by hand.
"""
source_view_id = str(
args.get("source_view_id") or args.get("from_view_id") or ""
).strip()
target_view_id = str(
args.get("target_view_id") or args.get("to_view_id") or ""
).strip()
source_view_id = str(args.get("source_view_id") or "").strip()
target_view_id = str(args.get("target_view_id") or "").strip()
if not source_view_id or not target_view_id:
return {
"ok": False,
@ -3322,8 +3357,29 @@ def _copy_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
]
if positions:
patch_out = _patch_positions_chunked(target_view_id, positions)
if not patch_out.get("ok"):
return {
"ok": False,
"error": patch_out.get("error") or "positions_patch_failed",
"detail": patch_out,
"source_view_id": source_view_id,
"target_view_id": target_view_id,
"cleared": cleared,
"added": int(add_out.get("added", 0)),
"hint": "Target membership may already be changed; source is unchanged. Inspect target before retrying.",
}
dst2 = _data(http_json("GET", f"/v1/topology/views/{target_view_id}", timeout=120.0))
if not dst2.get("ok"):
return {
"ok": False,
"error": "view_readback_failed",
"write_completed": True,
"source_view_id": source_view_id,
"target_view_id": target_view_id,
"detail": dst2,
"hint": "Writes completed but verification failed. Read target before retrying the copy.",
}
dst_after, _ = _view_node_id_pos(dst2 if dst2.get("ok") else {})
return {
"ok": True,
@ -3779,6 +3835,11 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"skipped peers are counted in out_of_region_skipped."
),
},
"dry_run": {
"type": "boolean",
"default": False,
"description": "Preview projected graph without saving placements.",
},
"detail": {
"type": "string",
"enum": ["summary", "full"],
@ -3797,8 +3858,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"Default: q→search, else list. "
"summary=node/edge counts; list=paged filters "
"(keyword/role/link_status/region_folder_id); "
"search=quick name/IP/id (needs q). Replaces getTopologyFabricSummary / "
"listTopologyFabricNodes / searchTopologyFabricNodes."
"search=quick name/IP/id (needs q)."
),
"inputSchema": {
"type": "object",
@ -3853,7 +3913,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"level: 0=external, 1=core, 2=agg, 3=access; use 1.1 / 2.1 for sub-tiers. "
"action=match → tag(level|role preset, dry_run) → tag. "
"role preset still accepted (maps to *.0). "
"After tagging, addTopologyViewNodes by role/region. No slice maps. "
"After tagging, queryTopologyFabricNodes by region/level, then place returned "
"fabric_node_ids with addTopologyViewNodes (canvas filters do not support region/level). "
"Requires ne:write."
),
"inputSchema": {
@ -4030,8 +4091,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
},
"use_source_dual_portals": {
"type": "boolean",
"default": True,
"description": "Exclude portals from source dual_units (default true).",
"default": False,
"description": "Exclude portals from source dual_units (default false).",
},
"use_sink_dual_portals": {
"type": "boolean",
@ -4156,7 +4217,7 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"action=layout: recipe=rings|corridor|compact|unstick (small graphs). "
"action=layout_dual_unit: CN/core dual-portal eye (max membership; "
"residual crossings OK unless params.require_zero_cross=true). "
"action=move_nodes (alias sink_nodes): move fabric_node_ids from "
"action=move_nodes: move fabric_node_ids from "
"source_view_id→view_id; park=true for orbit attach; swap views to reverse. "
"Prefer sinkTopologyDualUnits for dual_units batches. "
"action=orbit_sweep: crossing orbit; preview+node_id / apply+pick; "
@ -4170,9 +4231,12 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"action=pull_far_chains: scale far corridors/isolates toward portal mid-point. "
"action=align_reference: SAME-network A/B debug only "
"(shared fabric_node_ids); not a cross-network template. "
"action=fix_overlaps|resolve_overlaps: pull apart overlaps (not on eye sinks). "
"action=fix_overlaps: pull apart overlaps (not on eye sinks). "
"action=untangle / straighten_channels: surgical polish (not on eye sinks). "
"action=job_status|job_cancel: poll/cancel background jobs. "
"params.background=true starts an apply action in a durable subprocess; "
"poll params.job_id until done|error|cancelled, then inspect result. "
"Successful apply persists positions; getTopologyView reads them back. "
"preset: loose|balanced|dense. mode: preview|apply. "
"Workflow: analyze(structure) → sinkTopologyDualUnits(max_units=1) → "
"suggestSinkHubs/move_nodes(park) → until_limit(crossing→total) → "
@ -4192,7 +4256,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"enum": [
"layout",
"fix_overlaps",
"resolve_overlaps",
"layout_dual_unit",
"straighten_channels",
"untangle",
@ -4204,7 +4267,6 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"orbit_sweep",
"level_bands",
"move_nodes",
"sink_nodes",
"job_status",
"job_cancel",
],
@ -4212,13 +4274,13 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"description": (
"layout=full recipe; layout_dual_unit=dual-portal eye; "
"level_bands=horizontal layer bands; "
"move_nodes|sink_nodes=migrate fabric_node_ids; "
"move_nodes=migrate fabric_node_ids; "
"orbit_sweep=polar sweep; polish_crossings=one-shot cut crossings; "
"clear_edge_hits=eject edge hits (no crossing rise); "
"pull_far_chains=scale far corridors toward portal mid; "
"compact_bbox=gated farthest-K shrink; "
"align_reference=same-network A/B debug only; "
"fix_overlaps|resolve_overlaps; "
"fix_overlaps; "
"untangle/straighten_channels; job_status|job_cancel."
),
},
@ -4281,10 +4343,15 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"max_jump/angle_step/nn_floor; until_limit defaults "
"protect_rigid=portals + objective=crossing; "
"single-node/round default protect_rigid=off. "
"move_nodes|sink_nodes: fabric_node_ids[] (required), "
"move_nodes: fabric_node_ids[] (required), "
"copy_positions (default true), park, remove_from_source "
"(default true), pad/offset_x/offset_y."
),
"properties": {
"job_id": {"type": "string", "description": "Required for job_status/job_cancel; returned by background start."},
"background": {"type": "boolean", "description": "Run apply computation in a durable subprocess and return job_id immediately."},
"sync": {"type": "boolean", "description": "Force inline computation, disabling automatic background execution."},
},
"additionalProperties": True,
},
"catalog": {

View file

@ -89,7 +89,12 @@ def _prune_disk() -> None:
files = [p for p in files if not p.name.endswith(".args.json")]
if len(files) <= _MAX_JOBS:
return
for path in files[: max(0, len(files) - _MAX_JOBS)]:
# Active jobs must remain pollable and cancellable even when history fills up.
removable = [
path for path in files
if (_read_job_disk(path.stem) or {}).get("status") in {"done", "error", "cancelled"}
]
for path in removable[: max(0, len(files) - _MAX_JOBS)]:
try:
path.unlink(missing_ok=True)
path.with_name(path.stem + ".args.json").unlink(missing_ok=True)
@ -400,25 +405,33 @@ def start_job(
subprocess, "DETACHED_PROCESS", 0x00000008
)
proc = subprocess.Popen( # noqa: S603 — controlled argv
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id],
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
close_fds=True,
)
try:
proc = subprocess.Popen( # noqa: S603 — controlled argv
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id],
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creationflags,
close_fds=True,
)
except OSError as exc:
finish_job(job_id, error=f"worker_start_failed:{exc}")
_args_path(job_id).unlink(missing_ok=True)
# The caller receives a pollable terminal job instead of an orphaned running record.
return job_id
with _LOCK:
job = _read_job_disk(job_id) or job
job["pid"] = proc.pid
job["progress"] = {
**(job.get("progress") or {}),
"phase": "running",
"message": f"worker pid={proc.pid}",
"updated_at": time.time(),
}
job["heartbeat_at"] = time.time()
# A short computation can finish before Popen returns to the parent.
# Keep its terminal progress/result intact.
if job.get("status") in {"running", "cancelling"}:
job["progress"] = {
**(job.get("progress") or {}),
"message": f"worker pid={proc.pid}",
"updated_at": time.time(),
}
job["heartbeat_at"] = time.time()
_JOBS[job_id] = job
_write_job_disk(job)
return job_id

View file

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

View file

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

View file

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

View file

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

View file

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