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

@ -7,6 +7,7 @@ from fastapi.responses import Response
from sqlalchemy.orm import Session
from .auth_deps import AuthContext, require_user
from .auth_scopes import has_scope
from .auth_service import write_audit
from .db import get_db
from .device_types import SUPPORTED_VENDORS
@ -275,6 +276,10 @@ def api_create_exec_job(
uid, uname = _actor(ctx)
payload = body.model_dump(exclude_none=True)
if not has_scope(ctx.scopes, "ne:exec"):
raise HTTPException(status_code=403, detail={"error": "insufficient_scope", "required": ["ne:exec"]})
if not uid:
raise HTTPException(status_code=401, detail={"error": "job_owner_required"})
is_batch = bool(
payload.get("targets")
or payload.get("ne_ids")
@ -330,7 +335,7 @@ def api_create_exec_job(
)
return out
ack = start_ne_exec_job(kind=kind, arguments=payload, runner=_runner)
ack = start_ne_exec_job(kind=kind, arguments=payload, owner_user_id=uid, runner=_runner)
write_audit(
db,
action="ne.exec_job_start",
@ -349,9 +354,16 @@ def api_get_exec_job(
job_id: str,
ctx: Annotated[AuthContext, Depends(require_user)],
):
"""Poll a background exec job started via POST /exec-jobs."""
_ = ctx
return get_ne_exec_job(job_id)
"""Poll own jobs; admins (role or admin:users scope) may read any job."""
if not has_scope(ctx.scopes, "ne:exec"):
raise HTTPException(status_code=403, detail={"error": "insufficient_scope", "required": ["ne:exec"]})
uid, _ = _actor(ctx)
is_admin = ctx.user.role == "admin" or has_scope(ctx.scopes, "admin:users")
out = get_ne_exec_job(job_id, user_id=uid, is_admin=is_admin)
if out.get("error_code") in {"job_forbidden", "invalid_job_id", "job_id_required"}:
status = 403 if out["error_code"] == "job_forbidden" else 400
raise HTTPException(status_code=status, detail=out)
return out
@router.post("/connect-test")

View file

@ -1,5 +0,0 @@
"""Backward-compatible re-exports; prefer ``pip install netx-mcp`` and ``import netx_mcp``."""
from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool
__all__ = ["HTTP_MCP_TOOLS", "call_http_tool"]

View file

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

View file

@ -1,573 +0,0 @@
from __future__ import annotations
import json
import sys
from typing import Any
from .db import Base, SessionLocal, engine
from .importer import aggregate_alarms, query_alarms
from .models import AlarmBatch, UmeAlarmCurrent, UmeAlarmHistory, UmeInventoryNE
from .ume_client import UMEClient
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full, sync_topology_full
from .ume_token_store import (
clear_shared_token,
load_shared_token,
release_refresh_lock,
save_shared_token,
try_acquire_refresh_lock,
wait_for_token_update,
)
_UME_CLIENT_SINGLETON = UMEClient(
token_loader=lambda: load_shared_token(),
token_saver=lambda token, exp: save_shared_token(token, exp),
token_clearer=lambda: clear_shared_token(),
lock_acquirer=lambda: try_acquire_refresh_lock(),
lock_releaser=lambda: release_refresh_lock(),
token_waiter=lambda min_exp: wait_for_token_update(min_expires_at_epoch_s=float(min_exp)),
)
def _ok(rid: Any, result: dict[str, Any]) -> None:
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
sys.stdout.flush()
def _err(rid: Any, code: int, message: str) -> None:
sys.stdout.write(
json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False)
+ "\n"
)
sys.stdout.flush()
def _tool_list() -> list[dict[str, Any]]:
return [
{
"name": "queryAlarms",
"description": "Query normalized alarms with filters and pagination.",
"inputSchema": {
"type": "object",
"properties": {
"batch_id": {"type": "string"},
"alarm_code": {"type": "string"},
"severity": {"type": "string"},
"ne_name": {"type": "string"},
"page": {"type": "integer", "minimum": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 200},
},
"additionalProperties": False,
},
},
{
"name": "aggregateAlarms",
"description": "Aggregate alarms by severity_norm, alarm_code or ne_name.",
"inputSchema": {
"type": "object",
"properties": {
"group_by": {
"type": "string",
"enum": ["severity_norm", "alarm_code", "ne_name"],
},
"batch_id": {"type": "string"},
},
"required": ["group_by"],
"additionalProperties": False,
},
},
{
"name": "getImportBatch",
"description": "Get imported batch summary by batch_id.",
"inputSchema": {
"type": "object",
"properties": {"batch_id": {"type": "string"}},
"required": ["batch_id"],
"additionalProperties": False,
},
},
{
"name": "runDiagnostics",
"description": "Generate quick diagnostics summary by batch.",
"inputSchema": {
"type": "object",
"properties": {"batch_id": {"type": "string"}},
"required": ["batch_id"],
"additionalProperties": False,
},
},
{
"name": "umeSync",
"description": "Trigger UME sync for inventory/current/history domains.",
"inputSchema": {
"type": "object",
"properties": {
"domains": {
"type": "array",
"items": {"type": "string", "enum": ["inventory", "alarms_current", "alarms_history", "topology"]},
},
"trigger_mode": {"type": "string", "enum": ["manual", "schedule"]},
},
"additionalProperties": False,
},
},
{
"name": "umeListNE",
"description": "List UME network elements from inventory table.",
"inputSchema": {
"type": "object",
"properties": {
"keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
},
"additionalProperties": False,
},
},
{
"name": "umeGetNE",
"description": "Get UME network element by ne_id.",
"inputSchema": {
"type": "object",
"properties": {"ne_id": {"type": "string"}},
"required": ["ne_id"],
"additionalProperties": False,
},
},
{
"name": "umeListCurrentAlarms",
"description": "List current UME alarms from current table.",
"inputSchema": {
"type": "object",
"properties": {
"severity": {"type": "string"},
"is_cleared": {"type": "string"},
"ne_id": {"type": "string"},
"keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
},
"additionalProperties": False,
},
},
{
"name": "umeListHistoryAlarms",
"description": "List historical UME alarms from history table.",
"inputSchema": {
"type": "object",
"properties": {
"severity": {"type": "string"},
"ne_id": {"type": "string"},
"keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500},
},
"additionalProperties": False,
},
},
]
def _call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
with SessionLocal() as db:
if name == "queryAlarms":
total, rows = query_alarms(
db,
batch_id=str(args.get("batch_id") or "").strip() or None,
alarm_code=str(args.get("alarm_code") or "").strip() or None,
severity=str(args.get("severity") or "").strip() or None,
ne_name=str(args.get("ne_name") or "").strip() or None,
page=max(1, int(args.get("page") or 1)),
page_size=min(200, max(1, int(args.get("page_size") or 50))),
)
payload = {
"total": total,
"items": [
{
"id": r.id,
"batch_id": r.batch_id,
"alarm_time": r.alarm_time.isoformat(),
"severity_norm": r.severity_norm,
"ne_name": r.ne_name,
"alarm_code": r.alarm_code,
"alarm_name": r.alarm_name,
}
for r in rows
],
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "aggregateAlarms":
group_by = str(args.get("group_by") or "").strip()
rows = aggregate_alarms(
db,
group_by=group_by,
batch_id=str(args.get("batch_id") or "").strip() or None,
)
payload = {"group_by": group_by, "buckets": [{"key": k, "count": v} for k, v in rows]}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "getImportBatch":
batch_id = str(args.get("batch_id") or "").strip()
batch = db.get(AlarmBatch, batch_id)
if not batch:
return {"content": [{"type": "text", "text": json.dumps({"error": "batch_not_found"})}], "isError": True}
payload = {
"batch_id": batch.batch_id,
"total_rows": batch.total_rows,
"success_rows": batch.success_rows,
"failed_rows": batch.failed_rows,
"status": batch.status,
"created_at": batch.created_at.isoformat(),
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "runDiagnostics":
batch_id = str(args.get("batch_id") or "").strip()
sev_rows = aggregate_alarms(db, group_by="severity_norm", batch_id=batch_id)
code_rows = aggregate_alarms(db, group_by="alarm_code", batch_id=batch_id)[:5]
ne_rows = aggregate_alarms(db, group_by="ne_name", batch_id=batch_id)[:5]
sev_map = {k: v for k, v in sev_rows}
findings: list[str] = []
actions: list[str] = []
risk_level = "low"
if int(sev_map.get("critical", 0)) > 0:
findings.append("critical 告警存在,建议优先确认核心网元影响。")
actions.append("优先处理 critical 告警,确认影响面并升级。")
risk_level = "high"
if int(sev_map.get("warning", 0)) > int(sev_map.get("major", 0)):
findings.append("warning 占比较高,疑似阈值型告警风暴。")
actions.append("检查高频 warning 告警码是否集中在单一阈值策略。")
if risk_level != "high":
risk_level = "medium"
if not findings:
findings.append("分布相对均衡,建议按 top 告警码进一步排查。")
actions.append("按 top 告警码和网元继续下钻分析。")
payload = {
"batch_id": batch_id,
"risk_level": risk_level,
"severity_summary": [{"key": k, "count": v} for k, v in sev_rows],
"top_alarm_codes": [{"key": k, "count": v} for k, v in code_rows],
"top_ne": [{"key": k, "count": v} for k, v in ne_rows],
"findings": findings,
"actions": actions,
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "umeSync":
domains_raw = args.get("domains")
domains = []
if isinstance(domains_raw, list):
domains = [str(x).strip() for x in domains_raw if str(x).strip()]
if not domains:
domains = ["inventory", "alarms_current", "alarms_history"]
trigger_mode = str(args.get("trigger_mode") or "manual").strip().lower()
if trigger_mode not in {"manual", "schedule"}:
trigger_mode = "manual"
client = _UME_CLIENT_SINGLETON
results: list[dict[str, Any]] = []
if "inventory" in domains:
j = sync_inventory_full(db, client, trigger_mode=trigger_mode)
results.append(
{
"domain": "inventory",
"status": j.status,
"pulled_count": int(j.pulled_count or 0),
"inserted_count": int(j.inserted_count or 0),
"updated_count": int(j.updated_count or 0),
"error_message": str(j.error_message or ""),
}
)
if "alarms_current" in domains:
j, b = sync_alarms_current(db, client, trigger_mode=trigger_mode)
results.append(
{
"domain": "alarms_current",
"status": j.status,
"batch_id": str(b.batch_id),
"pulled_count": int(j.pulled_count or 0),
"inserted_count": int(j.inserted_count or 0),
"updated_count": int(j.updated_count or 0),
"error_message": str(j.error_message or ""),
}
)
if "alarms_history" in domains:
j, b = sync_alarms_history_full(db, client, trigger_mode=trigger_mode)
results.append(
{
"domain": "alarms_history",
"status": j.status,
"batch_id": str(b.batch_id),
"pulled_count": int(j.pulled_count or 0),
"inserted_count": int(j.inserted_count or 0),
"updated_count": int(j.updated_count or 0),
"error_message": str(j.error_message or ""),
}
)
if "topology" in domains:
j = sync_topology_full(db, client, trigger_mode=trigger_mode)
results.append(
{
"domain": "topology",
"status": j.status,
"pulled_count": int(j.pulled_count or 0),
"inserted_count": int(j.inserted_count or 0),
"updated_count": int(j.updated_count or 0),
"error_message": str(j.error_message or ""),
}
)
return {"content": [{"type": "text", "text": json.dumps({"ok": True, "jobs": results}, ensure_ascii=False)}]}
if name == "umeListNE":
stmt = db.query(UmeInventoryNE)
keyword = str(args.get("keyword") or "").strip()
if keyword:
stmt = stmt.filter(
UmeInventoryNE.ne_id.contains(keyword)
| UmeInventoryNE.ne_name.contains(keyword)
| UmeInventoryNE.user_label.contains(keyword)
| UmeInventoryNE.ip_address.contains(keyword)
)
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
total = int(stmt.count())
rows = stmt.order_by(UmeInventoryNE.ne_id.asc()).offset((page - 1) * page_size).limit(page_size).all()
payload = {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"ne_id": str(x.ne_id or ""),
"ne_name": str(x.ne_name or ""),
"user_label": str(x.user_label or ""),
"ip_address": str(x.ip_address or ""),
"ne_type": str(x.ne_type or ""),
}
for x in rows
],
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "umeGetNE":
ne_id = str(args.get("ne_id") or "").strip()
row = db.get(UmeInventoryNE, ne_id)
if not row:
return {"content": [{"type": "text", "text": json.dumps({"error": "ume_ne_not_found"})}], "isError": True}
payload = {
"ne_id": str(row.ne_id or ""),
"ne_name": str(row.ne_name or ""),
"user_label": str(row.user_label or ""),
"ip_address": str(row.ip_address or ""),
"ne_type": str(row.ne_type or ""),
"vendor": str(row.vendor or ""),
"raw_json": str(row.raw_json or "{}"),
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "umeListCurrentAlarms":
stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin(
UmeInventoryNE, UmeAlarmCurrent.ne_id == UmeInventoryNE.ne_id
)
severity = str(args.get("severity") or "").strip()
is_cleared = str(args.get("is_cleared") or "").strip()
ne_id = str(args.get("ne_id") or "").strip()
keyword = str(args.get("keyword") or "").strip()
if severity:
stmt = stmt.filter(UmeAlarmCurrent.perceived_severity == severity)
if is_cleared:
stmt = stmt.filter(UmeAlarmCurrent.is_cleared == is_cleared)
if ne_id:
stmt = stmt.filter(UmeAlarmCurrent.ne_id == ne_id)
if keyword:
stmt = stmt.filter(
UmeAlarmCurrent.alarm_key.contains(keyword)
| UmeAlarmCurrent.object_name.contains(keyword)
| UmeAlarmCurrent.host_name.contains(keyword)
| UmeInventoryNE.ne_name.contains(keyword)
| UmeInventoryNE.user_label.contains(keyword)
| UmeInventoryNE.ip_address.contains(keyword)
| UmeInventoryNE.host_name.contains(keyword)
)
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
total = int(stmt.count())
rows = (
stmt.order_by(
UmeAlarmCurrent.time_created.desc(),
UmeAlarmCurrent.last_seen_at.desc(),
UmeAlarmCurrent.alarm_key.desc(),
)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
payload = {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"alarm_key": str(alarm.alarm_key or ""),
"ne_id": str(alarm.ne_id or ""),
"host_name": str(alarm.host_name or (ne.host_name if ne else "") or ""),
"ne_name": str((ne.ne_name if ne else "") or ""),
"user_label": str((ne.user_label if ne else "") or ""),
"object_name": str(alarm.object_name or ""),
"event_type": str(alarm.event_type or ""),
"native_probable_cause": str(alarm.native_probable_cause or ""),
"perceived_severity": str(alarm.perceived_severity or ""),
"is_cleared": str(alarm.is_cleared or ""),
"time_created": str(alarm.time_created or ""),
}
for alarm, ne in rows
],
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if name == "umeListHistoryAlarms":
stmt = db.query(UmeAlarmHistory, UmeInventoryNE).outerjoin(
UmeInventoryNE, UmeAlarmHistory.ne_id == UmeInventoryNE.ne_id
)
severity = str(args.get("severity") or "").strip()
ne_id = str(args.get("ne_id") or "").strip()
keyword = str(args.get("keyword") or "").strip()
if severity:
stmt = stmt.filter(UmeAlarmHistory.perceived_severity == severity)
if ne_id:
stmt = stmt.filter(UmeAlarmHistory.ne_id == ne_id)
if keyword:
stmt = stmt.filter(
UmeAlarmHistory.alarm_key.contains(keyword)
| UmeAlarmHistory.object_name.contains(keyword)
| UmeAlarmHistory.host_name.contains(keyword)
| UmeInventoryNE.ne_name.contains(keyword)
| UmeInventoryNE.user_label.contains(keyword)
| UmeInventoryNE.ip_address.contains(keyword)
| UmeInventoryNE.host_name.contains(keyword)
)
page = max(1, int(args.get("page") or 1))
page_size = min(500, max(1, int(args.get("page_size") or 50)))
total = int(stmt.count())
rows = stmt.order_by(UmeAlarmHistory.last_seen_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
payload = {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"alarm_key": str(alarm.alarm_key or ""),
"ne_id": str(alarm.ne_id or ""),
"host_name": str(alarm.host_name or (ne.host_name if ne else "") or ""),
"ne_name": str((ne.ne_name if ne else "") or ""),
"user_label": str((ne.user_label if ne else "") or ""),
"object_name": str(alarm.object_name or ""),
"event_type": str(alarm.event_type or ""),
"native_probable_cause": str(alarm.native_probable_cause or ""),
"perceived_severity": str(alarm.perceived_severity or ""),
"is_cleared": str(alarm.is_cleared or ""),
"time_created": str(alarm.time_created or ""),
}
for alarm, ne in rows
],
}
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
raise ValueError(f"unknown tool: {name}")
def _ensure_db_schema() -> None:
try:
Base.metadata.create_all(bind=engine)
with engine.begin() as conn:
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS device_level VARCHAR(64) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS host_name VARCHAR(256) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS location VARCHAR(512) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS ipv6_address VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql(
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS hardware_version VARCHAR(128) DEFAULT ''"
)
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS loopback VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql(
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS consistent_state VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql(
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS interface_version VARCHAR(128) DEFAULT ''"
)
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS mac VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS admin_status VARCHAR(64) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS address_type VARCHAR(64) DEFAULT ''")
conn.exec_driver_sql(
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS connection_status VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql(
"ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS maintain_status VARCHAR(64) DEFAULT ''"
)
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS net_mask VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS create_time VARCHAR(64) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_inventory_ne ADD COLUMN IF NOT EXISTS creator VARCHAR(128) DEFAULT ''")
conn.exec_driver_sql("ALTER TABLE ume_alarms_current DROP COLUMN IF EXISTS ne_name")
conn.exec_driver_sql("ALTER TABLE ume_alarms_current DROP COLUMN IF EXISTS user_label")
conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS ne_name")
conn.exec_driver_sql("ALTER TABLE ume_alarms_history DROP COLUMN IF EXISTS user_label")
conn.exec_driver_sql("COMMENT ON TABLE ume_inventory_ne IS '网元对象详细信息'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_id IS '网元uuid'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_name IS '资源名称'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ne_type IS '网元类型'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.user_label IS '用户标签'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.address_type IS '管理地址类型(1:IPv4,2:IPv6)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ip_address IS '网元IPv4地址'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.net_mask IS '管理IPv4掩码(点分十进制)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.ipv6_address IS 'IPv6地址'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.admin_status IS '管理状态(0-离线,1-在线)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.connection_status IS '连接状态(0-断链,1-正常)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.consistent_state IS '数据一致性状态(1一致,2不一致,3冲突)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.maintain_status IS '工程状态(0普通,1调测,2新建)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.vendor IS '网元提供商'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.interface_version IS '网元接口版本号'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.hardware_version IS '硬件版本'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.mac IS '设备机架MAC地址'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.loopback IS '业务环回IP(IPv4)'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.device_level IS '网元层次'")
conn.exec_driver_sql("COMMENT ON COLUMN ume_inventory_ne.host_name IS '主机名称'")
except Exception:
pass
def run_stdio_loop() -> None:
"""Legacy MCP: direct PostgreSQL access (deprecated; use HTTP mode)."""
_ensure_db_schema()
for line in sys.stdin:
raw = line.strip()
if not raw:
continue
try:
req = json.loads(raw)
except Exception:
continue
rid = req.get("id")
method = str(req.get("method") or "")
params = req.get("params") if isinstance(req.get("params"), dict) else {}
try:
if method == "initialize":
_ok(
rid,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "netx-mcp", "version": "0.1.0"},
},
)
continue
if method == "notifications/initialized":
continue
if method == "tools/list":
_ok(rid, {"tools": _tool_list()})
continue
if method == "tools/call":
name = str(params.get("name") or "")
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
_ok(rid, _call_tool(name, args))
continue
_err(rid, -32601, f"method not found: {method}")
except Exception as exc:
_err(rid, -32000, str(exc))
def main() -> None:
run_stdio_loop()
if __name__ == "__main__":
main()

View file

@ -1,3 +0,0 @@
"""Re-export from ``netx-mcp`` package (install: ``pip install -e packages/netx-mcp``)."""
from netx_mcp.http_client import * # noqa: F403

View file

@ -1,3 +0,0 @@
"""Re-export from ``netx-mcp`` package (install: ``pip install -e packages/netx-mcp``)."""
from netx_mcp.http_tools import * # noqa: F403

View file

@ -1,32 +0,0 @@
"""Backward-compatible entry for ``python netx_api/mcp_server.py`` / ``netx-mcp`` script on netx-ops install.
HTTP MCP: delegates to ``netx_mcp`` (``pip install -e packages/netx-mcp``).
Legacy DB mode (``NETX_MCP_MODE=db``): requires full ``netx-ops`` install only.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Script-path launch without pip install netx-mcp: add packages/netx-mcp/src
_repo_root = Path(__file__).resolve().parent.parent
_mcp_src = _repo_root / "packages" / "netx-mcp" / "src"
if _mcp_src.is_dir() and str(_mcp_src) not in sys.path:
sys.path.insert(0, str(_mcp_src))
def main() -> None:
if str(os.getenv("NETX_MCP_MODE") or "http").strip().lower() == "db":
from netx_api.mcp.db_server import run_stdio_loop
run_stdio_loop()
return
from netx_mcp.server import main as mcp_main
mcp_main()
if __name__ == "__main__":
main()

View file

@ -87,21 +87,28 @@ def _collect_on_device(
if conn_holder is not None:
conn_holder["conn"] = conn
conn_holder["session_log"] = log_buf
# Belt-and-suspenders: hop/bastion nested CLIs and missed session_prep.
try:
disable_target_paging(
conn,
vendor=str(creds.get("vendor") or ""),
device_type=str(creds.get("device_type") or ""),
)
except Exception:
_log.debug("collection paging disable failed", exc_info=True)
prompt = str(conn.find_prompt() or "")
# find_prompt / paging-off often leave an extra prompt in the channel;
# drain before the first show or Netmiko returns empty (IOSv).
try:
from .ne_netmiko import drain_read_channel
from .ne_netmiko import drain_read_channel, is_cisco_ios_device_type
device_type = str(creds.get("device_type") or "")
# Direct Cisco session_prep already set width/length. A second timing-based
# ``terminal length 0`` plus find_prompt() re-introduces leftover prompts
# and line-buffer races on IOSv. Keep the extra paging pass for hop/other.
need_paging_pass = bool(creds.get("hop_enabled")) or not is_cisco_ios_device_type(
device_type
)
if need_paging_pass:
try:
disable_target_paging(
conn,
vendor=str(creds.get("vendor") or ""),
device_type=device_type,
)
except Exception:
_log.debug("collection paging disable failed", exc_info=True)
# Transcript header only — never find_prompt() here (sends Enter).
base = str(getattr(conn, "base_prompt", "") or "").strip()
prompt = f"{base}#" if base else ""
try:
drain_read_channel(conn)
except Exception:
_log.debug("collection channel drain failed", exc_info=True)

View file

@ -8,6 +8,7 @@ from __future__ import annotations
import json
import os
import re
import threading
import time
import uuid
@ -24,6 +25,8 @@ _INFLIGHT = 0
_DEFAULT_TIMEOUT_S = 900
_DEFAULT_MAX_CONCURRENT = 3
_DEFAULT_ASYNC_MIN_NES = 4
_JOB_ID_RE = re.compile(r"[0-9a-f]{32}")
_MAX_JOB_TARGETS = 20 # Same combined limit as ne_exec._normalize_batch_targets.
def _env_int(name: str, default: int, *, min_v: int, max_v: int) -> int:
@ -72,7 +75,14 @@ def _jobs_dir() -> Path:
def _job_path(job_id: str) -> Path:
return _jobs_dir() / f"{job_id}.json"
if not isinstance(job_id, str) or not _JOB_ID_RE.fullmatch(job_id):
raise ValueError("invalid_job_id")
directory = _jobs_dir().resolve()
path = (directory / f"{job_id}.json").resolve()
# Also reject a valid-looking filename symlinked outside the job directory.
if path.parent != directory:
raise ValueError("invalid_job_id")
return path
def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
@ -90,13 +100,11 @@ def _read_job(job_id: str) -> dict[str, Any] | None:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
return data if isinstance(data, dict) else None
return data if isinstance(data, dict) and data.get("job_id") == job_id else None
def _write_job(job: dict[str, Any]) -> None:
jid = str(job.get("job_id") or "").strip()
if not jid:
return
jid = job.get("job_id")
_atomic_write(_job_path(jid), job)
@ -116,17 +124,16 @@ def default_job_timeout_s() -> int:
def count_exec_ne_targets(args: dict[str, Any] | None) -> int:
a = args if isinstance(args, dict) else {}
n = 0
for key in ("ne_ids", "ume_ne_ids", "nms_ne_ids"):
for key in ("ne_ids", "ume_ne_ids"):
val = a.get(key)
if isinstance(val, list):
n = max(n, len([x for x in val if str(x or "").strip()]))
n += len([x for x in val if str(x or "").strip()])
targets = a.get("targets")
if isinstance(targets, list):
n = max(n, len([t for t in targets if isinstance(t, dict)]))
n += len([t for t in targets if isinstance(t, dict)])
if n == 0 and (
str(a.get("ne_id") or "").strip()
or str(a.get("ume_ne_id") or "").strip()
or str(a.get("nms_ne_id") or "").strip()
):
return 1
return int(n)
@ -164,11 +171,18 @@ def start_ne_exec_job(
*,
kind: str,
arguments: dict[str, Any],
owner_user_id: str,
runner: Callable[[], dict[str, Any]],
timeout_s: int | None = None,
) -> dict[str, Any]:
"""Start background runner; returns immediate ack with job_id."""
global _INFLIGHT
owner = str(owner_user_id or "").strip()
if not owner:
return {"ok": False, "error_code": "job_owner_required", "error": "job_owner_required"}
ne_count = count_exec_ne_targets(arguments)
if ne_count > _MAX_JOB_TARGETS:
return {"ok": False, "error_code": "too_many_targets", "error": "too_many_targets", "max_targets": _MAX_JOB_TARGETS}
max_c = max_concurrent_jobs()
with _LOCK:
if _INFLIGHT >= max_c:
@ -189,10 +203,11 @@ def start_ne_exec_job(
now = int(time.time() * 1000)
job = {
"job_id": job_id,
"owner_user_id": owner,
"status": _STATUS_RUNNING,
"kind": str(kind or "exec"),
"arguments": dict(arguments or {}),
"ne_count": count_exec_ne_targets(arguments),
"ne_count": ne_count,
"created_at_ms": now,
"updated_at_ms": now,
"timeout_s": timeout,
@ -260,13 +275,26 @@ def start_ne_exec_job(
}
def get_ne_exec_job(job_id: str) -> dict[str, Any]:
jid = str(job_id or "").strip()
def get_ne_exec_job(job_id: str, *, user_id: str, is_admin: bool = False) -> dict[str, Any]:
"""Read only the caller's jobs; explicit admins may inspect any persisted job.
Ownerless legacy records are never granted to ordinary callers.
"""
jid = job_id
if not jid:
return {"ok": False, "error_code": "job_id_required", "error": "job_id_required"}
job = _read_job(jid)
try:
job = _read_job(jid)
except ValueError:
return {"ok": False, "error_code": "invalid_job_id", "error": "invalid_job_id"}
requester = str(user_id or "").strip()
if not requester:
return {"ok": False, "error_code": "job_forbidden", "error": "job_forbidden"}
if not job:
return {"ok": False, "error_code": "job_not_found", "error": "job_not_found", "job_id": jid}
owner = str(job.get("owner_user_id") or "").strip()
if not is_admin and (not owner or owner != requester):
return {"ok": False, "error_code": "job_forbidden", "error": "job_forbidden"}
status = str(job.get("status") or "")
out: dict[str, Any] = {
"ok": True,

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import re
import time
from typing import Any
@ -138,6 +139,35 @@ def drain_read_channel(
def _send_command_expect_prompt(conn: Any, cmd: str, *, read_timeout: int) -> str:
device_type = getattr(conn, "device_type", "")
if isinstance(device_type, str) and is_cisco_ios_device_type(device_type):
# IOSv has the same late-prompt race as RouterOS: auto_find_prompt sends
# a newline after our drain and its reply can terminate the next show.
# Use the session's cached hostname, but require a full prompt line so
# "R2 uptime ..." inside show version cannot end the command early.
#
# cmd_verify must stay False: Netmiko hardcodes a 10s echo wait, and
# IOSv often echoes ``show version | include ...`` slower than that
# (live: ~13s) even when the command itself succeeds. Correctness comes
# from drain_read_channel + full-line expect_string, not echo verify.
base_prompt = getattr(conn, "base_prompt", "")
if not isinstance(base_prompt, str) or not base_prompt.strip():
raise ValueError("cisco_base_prompt_missing")
prompt_pattern = rf"(?m:^{re.escape(base_prompt.strip())}[>#][ \t]*$)"
out = str(
conn.send_command(
command_string=cmd,
read_timeout=read_timeout,
auto_find_prompt=False,
expect_string=prompt_pattern,
cmd_verify=False,
)
or ""
)
# Leftover-prompt races still yield a silent empty body with ok=true at
# higher layers. Fail loud here — never re-send the command.
if not out.strip():
raise ValueError("cisco_empty_show_output")
return out
if isinstance(device_type, str) and device_type.lower().startswith("mikrotik"):
# find_prompt sends an extra Enter *after* our channel drain. RouterOS
# can repaint that old prompt after send_command starts, ending the read
@ -179,8 +209,10 @@ def send_show_command(conn: Any, command: str, *, read_timeout: int = 120) -> st
Prefer ``send_command`` over ``send_command_timing`` for long config dumps:
idle during ``Building configuration...`` truncates timing reads.
``cmd_verify=False``: Netmiko's default echo check often raises
``Pattern not detected: 'show\\ lldp\\ ...'`` on IOSv / hop / slow echo paths.
Cisco uses ``cmd_verify=False`` with a full-line prompt expect after drain;
Netmiko's echo verify is hardcoded to 10s and false-fails slow IOSv echoes.
Other drivers also use ``cmd_verify=False``: the default echo check often
raises ``Pattern not detected`` on hop / slow echo paths.
"""
cmd = str(command or "").strip()
if not cmd:
@ -188,5 +220,9 @@ def send_show_command(conn: Any, command: str, *, read_timeout: int = 120) -> st
# IOSv / hop: find_prompt / paging-off often leave an extra prompt in the
# channel; drain so send_command does not match that leftover immediately.
drain_read_channel(conn)
device_type = getattr(conn, "device_type", "")
if isinstance(device_type, str) and is_cisco_ios_device_type(device_type):
drain_read_channel(conn, idle_reads=6, pause_sec=0.08)
else:
drain_read_channel(conn)
return _send_command_expect_prompt(conn, cmd, read_timeout=read_timeout)

View file

@ -304,24 +304,23 @@ class _ProgressSessionLog:
def _cisco_ios_collection_driver_class(base_cls: type) -> type:
"""Cisco IOSv-friendly session prep: avoid cmd_verify on terminal width/length."""
"""Cisco IOSv collection prep: establish hostname prompt only (like interactive)."""
class _CiscoIosCollectionSession(base_cls): # type: ignore[misc,valid-type]
def session_preparation(self) -> None:
# Default Netmiko waits for exact echo of "terminal width 511" (ReadTimeout on IOSv).
self._test_channel_read(pattern=r"[>#]")
try:
self.set_terminal_width(command="terminal width 511", pattern=r"[>#]")
except Exception:
pass
try:
self.disable_paging(command="terminal length 0", cmd_verify=False, pattern=r"[>#]")
except Exception:
try:
self.send_command_timing("terminal length 0", read_timeout=15)
except Exception:
pass
self.set_base_prompt()
# Stock Netmiko width/paging on IOSv+CR races leftover prompts
# (``terexit`` / ``terminal lengexit``) or blocks forever waiting for
# setup echo once VTY pressure builds. The interactive WebCRT path
# skips these commands and still returns complete show output; MCP
# show/display bodies are short enough that --More-- is not required.
# Keep hostname discovery so send_show_command can use a full-line
# prompt expect without auto_find_prompt.
from .ne_netmiko import drain_read_channel
initial_pattern = r"(?m:^[^\r\n#>]+[>#][ \t]*$)"
self._test_channel_read(pattern=initial_pattern)
self.set_base_prompt(pattern=initial_pattern)
drain_read_channel(self, idle_reads=8, pause_sec=0.1)
_CiscoIosCollectionSession.__name__ = (
f"CiscoIosCollection{getattr(base_cls, '__name__', 'Netmiko')}"
@ -508,6 +507,13 @@ def _base_connect_kwargs(
"auth_timeout": timeout,
"banner_timeout": timeout,
}
# IOSv SSH accepts CR as Enter. LF alone can leave command input pending
# until a later write, producing missing echo, empty output or prompt timeouts.
# Keep Telnet's own line ending and other vendors' defaults unchanged.
from .ne_netmiko import is_cisco_ios_device_type
if is_cisco_ios_device_type(device_type) and "telnet" not in device_type.lower():
dev["default_enter"] = "\r"
if session_timeout is not None:
dev["session_timeout"] = session_timeout
if keepalive is not None and int(keepalive) > 0:

View file

@ -749,6 +749,8 @@ def patch_view_positions(
updated += 1
else:
from .topology_region_canvas import add_missing_region_placement, is_region_canvas_node_id
from .ume_topology_world import is_ume_canvas_view, is_world_flat_view
from .ume_topology_world_graph import get_level_view_graph
positions = list(body.positions or [])
if len(positions) > VIEW_GRAPH_NODE_HARD_CAP:
@ -769,6 +771,24 @@ def patch_view_positions(
.filter(TopoFabricNode.id.in_(check_ids))
.all()
}
# UME canvases synthesize their members instead of adding them via /nodes.
# Persist placements only for actual members of this canvas; a position
# patch must never add unrelated fabric nodes to an ordinary view.
ume_member_ids: set[str] = set()
if is_ume_canvas_view(view) and any(
str(p.fabric_node_id or "").strip() not in existing for p in positions
):
if is_world_flat_view(view):
ume_member_ids = {
str(row[0])
for row in db.query(TopoFabricNode.id).filter(
TopoFabricNode.id.in_(check_ids),
TopoFabricNode.world_x.isnot(None),
TopoFabricNode.world_y.isnot(None),
).all()
}
else:
ume_member_ids = {node.fabric_node_id for node in get_level_view_graph(db, view).nodes}
for p in positions:
fid = str(p.fabric_node_id or "").strip()
if not fid:
@ -777,9 +797,17 @@ def patch_view_positions(
raise HTTPException(status_code=400, detail=f"fabric_node_not_found:{fid}")
row = existing.get(fid)
if row is None:
if fid in ume_member_ids:
row = TopoViewNode(
id=uuid4().hex, view_id=view.id, fabric_node_id=fid,
x=float(p.x), y=float(p.y), label="", locked=False,
created_at=now, updated_at=now,
)
db.add(row)
# Child-region icons may be synthesized without a placement row; persist those.
# Fabric NEs must not bypass membership caps / freeze (MCP adds via /nodes).
row = add_missing_region_placement(db, view, fid, x=p.x, y=p.y)
else:
row = add_missing_region_placement(db, view, fid, x=p.x, y=p.y)
if row is None:
skipped_missing += 1
continue

View file

@ -46,9 +46,6 @@ WORLD_FLAT_DETAIL_CAP = VIEW_GRAPH_NODE_HARD_CAP
# Viewport span (display/world units) at/under which we prefer a full region layout
# instead of stride-thinning — stops “zoomed in still a peppered blob”.
WORLD_FLAT_CLOSE_SPAN = 28000.0
# Legacy crush span (pre-LOD). Flat graph is now 1:1 with fabric world_*;
# this constant only detects stale TopoViewNode overrides from that era.
WORLD_FLAT_DISPLAY_SPAN = 6000.0
def apply_persisted_view_positions(
@ -553,8 +550,6 @@ def get_flat_view_graph(
full_max_x = float(extent_row[1])
full_min_y = float(extent_row[2])
full_max_y = float(extent_row[3])
span_x = max(1.0, full_max_x - full_min_x)
span_y = max(1.0, full_max_y - full_min_y)
# 1:1 with fabric world_* (only origin-shift to ~0). Infinite zoom keeps
# each packed region block identical to its UME local relative layout.
scale = 1.0
@ -768,25 +763,9 @@ def get_flat_view_graph(
)
for n in nodes
]
# Drag overrides in display space (world - origin). Skip legacy rows saved
# under the old ~6000 crush — they would yank nodes to the wrong place.
override_rows = (
db.query(TopoViewNode).filter(TopoViewNode.view_id == view.id).limit(8).all()
)
display_span = max(span_x, span_y)
overrides_ok = False
if override_rows and display_span > 0:
oxs = [abs(float(r.x or 0)) for r in override_rows]
oys = [abs(float(r.y or 0)) for r in override_rows]
peak = max(oxs + oys) if (oxs or oys) else 0.0
legacy_crush = display_span > WORLD_FLAT_DISPLAY_SPAN * 2 and peak <= WORLD_FLAT_DISPLAY_SPAN * 1.5
overrides_ok = (not legacy_crush) and all(
0.0 <= float(r.x or 0) <= display_span * 1.2
and 0.0 <= float(r.y or 0) <= display_span * 1.2
for r in override_rows
)
if overrides_ok:
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
# Saved placements are authoritative display coordinates. Do not infer an
# obsolete coordinate system from their magnitude or reject valid negatives.
nodes_out = apply_persisted_view_positions(db, view.id, nodes_out)
edges_out: list[ViewEdgeOut] = []
if include_edges and fids: