mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 17:20:46 +08:00
netx-mcp 0.3.1: fix alarm paging, tool-level arg errors, leaner outputs.
- queryNmsAlarms no longer silently clamps page to 2; add is_cleared.
- Bad integer args return invalid_argument tool errors instead of JSON-RPC crashes.
- Nested {"ok": true, "data": {"ok": false}} results are flagged isError.
- stdio: answer ping, never respond to notifications, version from metadata.
- getNmsNe resolves exact host_name/IP; severity lowercased.
- Outputs use nms_ne_id/source=nms; drop ME{uuid} names, echoed field lists, idle hop_* fields.
- Fabric paths: parallel links count as one hop (edges[].parallel_links).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
aac4521766
commit
c141a37263
9 changed files with 358 additions and 63 deletions
|
|
@ -38,7 +38,7 @@ python -m netx_mcp
|
|||
|
||||
[`mcp.json`](./mcp.json) — `command: python`,`args: ["-m", "netx_mcp"]`,`env` 见文件。
|
||||
|
||||
## 工具(14)— skill 组 **ops**(`netx-ops`)
|
||||
## 工具(15)— skill 组 **ops**(`netx-ops`)
|
||||
|
||||
**NMS**(模型面通用名;当前适配器 zte-ume,REST 仍 `/v1/ume/*`):
|
||||
`queryNmsAlarms`, `aggregateNmsAlarms`, `runNmsDiagnostics`, `queryNmsNeInventory`, `getNmsNe`, `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms`
|
||||
|
|
@ -46,6 +46,8 @@ python -m netx_mcp
|
|||
**Managed CLI + paths**(问「能否登录」必须走这里,不要只查 inventory):
|
||||
`listManagedNe`, `getManagedNe`, `execManagedNe`, `getNeExecJob`, `listCliTargets`, `findTopologyPaths`
|
||||
|
||||
0.3.1:`getNmsNe` 可直接传 host_name/IP;输出统一 `nms_ne_id` / `source=nms`;参数类型错误返回工具错误而非协议崩溃。
|
||||
|
||||
参数优先 `nms_ne_id` / `nms_ne_ids`(保留 `ume_*` 别名)。
|
||||
|
||||
拓扑画布 / Fabric → skill **topology** / [`netx-topology-mcp`](../netx-topology-mcp)(见 [docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md))。
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||
|
||||
[project]
|
||||
name = "netx-mcp"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
description = "stdio MCP server for netx REST API (NMS alarms, managed NE CLI)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
|
|
|||
|
|
@ -117,7 +117,9 @@ def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]:
|
|||
|
||||
|
||||
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
|
||||
if not result.get("ok"):
|
||||
# API envelopes may nest a failure: {"ok": true, "data": {"ok": false, ...}} (e.g. job_not_found).
|
||||
data = result.get("data")
|
||||
if not result.get("ok") or (isinstance(data, dict) and data.get("ok") is False):
|
||||
return mcp_text_result(result, is_error=True)
|
||||
return mcp_text_result(result)
|
||||
|
||||
|
|
|
|||
|
|
@ -75,6 +75,80 @@ def _should_run_exec_async(args: dict[str, Any]) -> bool:
|
|||
return _count_exec_ne_targets(args) >= min_n
|
||||
|
||||
|
||||
class ToolArgError(ValueError):
|
||||
"""Bad tool argument; surfaced to the model as a tool error (not a JSON-RPC crash)."""
|
||||
|
||||
|
||||
def _int_arg(args: dict[str, Any], key: str, default: int, lo: int, hi: int) -> int:
|
||||
raw = args.get(key)
|
||||
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||
return default
|
||||
try:
|
||||
val = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
raise ToolArgError(f"{key} must be an integer, got {raw!r}") from None
|
||||
return max(lo, min(hi, val))
|
||||
|
||||
|
||||
def _severity(args: dict[str, Any]) -> str:
|
||||
# Stored values are lowercase (critical/major/minor/warning); exact match server-side.
|
||||
return str(args.get("severity") or "").strip().lower()
|
||||
|
||||
|
||||
def _is_me_placeholder(ne_name: Any, ne_id: Any) -> bool:
|
||||
return bool(ne_id) and str(ne_name or "") == f"ME{{{ne_id}}}"
|
||||
|
||||
|
||||
def _compact_rows(out: dict[str, Any], *, name_key: str, id_key: str) -> dict[str, Any]:
|
||||
"""Drop ZTE ``ME{<uuid>}`` ne_name placeholders: they repeat ne_id and carry no meaning."""
|
||||
data = out.get("data") if isinstance(out, dict) else None
|
||||
items = data.get("items") if isinstance(data, dict) else None
|
||||
if isinstance(items, list):
|
||||
for row in items:
|
||||
if isinstance(row, dict) and _is_me_placeholder(row.get(name_key), row.get(id_key)):
|
||||
row.pop(name_key, None)
|
||||
return out
|
||||
|
||||
|
||||
def _strip_meta_field_lists(out: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Raw endpoints echo ~40 field names per call; listNmsAlarmFields already serves them."""
|
||||
data = out.get("data") if isinstance(out, dict) else None
|
||||
meta = data.get("meta") if isinstance(data, dict) else None
|
||||
if isinstance(meta, dict):
|
||||
for k in ("available_fields", "group_by_allowed", "order_by_allowed"):
|
||||
meta.pop(k, None)
|
||||
return out
|
||||
|
||||
|
||||
def _nms_naming(row: dict[str, Any]) -> None:
|
||||
"""REST still speaks ume_*; model-facing params are nms_* -- keep output consistent."""
|
||||
if "ume_ne_id" in row:
|
||||
row["nms_ne_id"] = row.pop("ume_ne_id")
|
||||
if row.get("source") == "ume":
|
||||
row["source"] = "nms"
|
||||
|
||||
|
||||
_HOP_KEYS = (
|
||||
"hop_vendor",
|
||||
"hop_host",
|
||||
"hop_port",
|
||||
"hop_protocol",
|
||||
"hop_username",
|
||||
"hop_command_template",
|
||||
"hop_vrf",
|
||||
"hop_target_auth_mode",
|
||||
"hop_enter_system_view",
|
||||
)
|
||||
|
||||
|
||||
def _compact_managed_ne(row: dict[str, Any], *, keep_detail: bool) -> None:
|
||||
if row.get("hop_enabled") is False:
|
||||
for k in _HOP_KEYS:
|
||||
row.pop(k, None)
|
||||
if not keep_detail:
|
||||
row.pop("connect_detail", None)
|
||||
|
||||
|
||||
UME_RAW_GROUP_FIELDS = [
|
||||
"alarm_alarm_key",
|
||||
"alarm_host_name",
|
||||
|
|
@ -121,7 +195,6 @@ _UME_RAW_FIELD_PRESETS: dict[str, list[str]] = {
|
|||
"alarm_last_seen_at",
|
||||
"ne_host_name",
|
||||
"ne_user_label",
|
||||
"ne_ne_name",
|
||||
"ne_ip_address",
|
||||
"ne_exists",
|
||||
],
|
||||
|
|
@ -137,7 +210,6 @@ _UME_RAW_FIELD_PRESETS: dict[str, list[str]] = {
|
|||
"alarm_last_seen_at",
|
||||
"ne_host_name",
|
||||
"ne_user_label",
|
||||
"ne_ne_name",
|
||||
"ne_ip_address",
|
||||
"ne_connection_status",
|
||||
"ne_exists",
|
||||
|
|
@ -148,7 +220,6 @@ _UME_RAW_FIELD_PRESETS: dict[str, list[str]] = {
|
|||
"alarm_perceived_severity",
|
||||
"alarm_last_seen_at",
|
||||
"ne_user_label",
|
||||
"ne_ne_name",
|
||||
"ne_ip_address",
|
||||
"ne_ipv6_address",
|
||||
"ne_device_level",
|
||||
|
|
@ -163,13 +234,14 @@ _UME_RAW_FIELD_PRESETS: dict[str, list[str]] = {
|
|||
|
||||
|
||||
def _query_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
if page > 2:
|
||||
page = 2
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
||||
if str(args.get("severity") or "").strip():
|
||||
params["severity"] = str(args.get("severity")).strip()
|
||||
params: dict[str, Any] = {
|
||||
"page": _int_arg(args, "page", 1, 1, 100000),
|
||||
"page_size": _int_arg(args, "page_size", 50, 1, 500),
|
||||
}
|
||||
if _severity(args):
|
||||
params["severity"] = _severity(args)
|
||||
if str(args.get("is_cleared") or "").strip():
|
||||
params["is_cleared"] = str(args.get("is_cleared")).strip()
|
||||
keyword = str(args.get("keyword") or "").strip()
|
||||
ne_name = str(args.get("ne_name") or "").strip()
|
||||
if keyword:
|
||||
|
|
@ -183,7 +255,7 @@ def _query_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
|
|||
for k in ("time_from", "time_to"):
|
||||
if str(args.get(k) or "").strip():
|
||||
params[k] = str(args.get(k)).strip()
|
||||
return http_json("GET", "/v1/ume/alarms", params=params)
|
||||
return _compact_rows(http_json("GET", "/v1/ume/alarms", params=params), name_key="ne_name", id_key="ne_id")
|
||||
|
||||
|
||||
def _aggregate_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -192,11 +264,12 @@ def _aggregate_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
|
|||
if group_by:
|
||||
return _aggregate_ume_alarms_raw(args)
|
||||
# Default top 50 named NEs — missing host buckets reported separately.
|
||||
top_ne = max(0, min(500, int(args.get("top_ne") if args.get("top_ne") is not None else 50)))
|
||||
params: dict[str, Any] = {"top_ne": top_ne}
|
||||
params: dict[str, Any] = {"top_ne": _int_arg(args, "top_ne", 50, 0, 500)}
|
||||
if "exclude_missing_host" in args:
|
||||
params["exclude_missing_host"] = bool(args.get("exclude_missing_host"))
|
||||
for k in ("severity", "time_from", "time_to"):
|
||||
if _severity(args):
|
||||
params["severity"] = _severity(args)
|
||||
for k in ("time_from", "time_to"):
|
||||
if str(args.get(k) or "").strip():
|
||||
params[k] = str(args.get(k)).strip()
|
||||
return http_json("GET", "/v1/ume/alarms/aggregate", params=params)
|
||||
|
|
@ -208,26 +281,58 @@ def _run_ume_diagnostics(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _query_ume_ne_inventory(args: dict[str, Any]) -> dict[str, Any]:
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
||||
params: dict[str, Any] = {
|
||||
"page": _int_arg(args, "page", 1, 1, 100000),
|
||||
"page_size": _int_arg(args, "page_size", 50, 1, 500),
|
||||
}
|
||||
if str(args.get("keyword") or "").strip():
|
||||
params["keyword"] = str(args.get("keyword")).strip()
|
||||
return http_json("GET", "/v1/ume/inventory/ne", params=params)
|
||||
return _compact_rows(http_json("GET", "/v1/ume/inventory/ne", params=params), name_key="ne_name", id_key="ne_id")
|
||||
|
||||
|
||||
def _looks_like_uuid(text: str) -> bool:
|
||||
parts = text.split("-")
|
||||
return len(parts) == 5 and all(p and all(c in "0123456789abcdefABCDEF" for c in p) for p in parts)
|
||||
|
||||
|
||||
def _get_ume_ne(args: dict[str, Any]) -> dict[str, Any]:
|
||||
ne_id = str(args.get("ne_id") or "").strip()
|
||||
if not ne_id:
|
||||
return {"ok": False, "error": "ne_id_required", "error_code": "ne_id_required"}
|
||||
return http_json("GET", f"/v1/ume/inventory/ne/{quote_ne_id(ne_id)}", params=None)
|
||||
out = http_json("GET", f"/v1/ume/inventory/ne/{quote_ne_id(ne_id)}", params=None)
|
||||
if out.get("ok") or out.get("error") != "netx_http_404" or _looks_like_uuid(ne_id):
|
||||
return out
|
||||
# Agents usually know the host_name (alarms show it), not the UUID: resolve it.
|
||||
found = http_json("GET", "/v1/ume/inventory/ne", params={"keyword": ne_id, "page": 1, "page_size": 20})
|
||||
items = ((found.get("data") or {}).get("items") or []) if found.get("ok") else []
|
||||
needle = ne_id.lower()
|
||||
exact = [
|
||||
r
|
||||
for r in items
|
||||
if isinstance(r, dict)
|
||||
and needle in {str(r.get(k) or "").lower() for k in ("host_name", "user_label", "ip_address", "ne_name")}
|
||||
]
|
||||
if len(exact) == 1 and exact[0].get("ne_id"):
|
||||
return http_json("GET", f"/v1/ume/inventory/ne/{quote_ne_id(str(exact[0]['ne_id']))}", params=None)
|
||||
out = dict(out)
|
||||
out["hint"] = "ne_id is the NMS UUID. Resolve host_name/IP via queryNmsNeInventory(keyword=...)."
|
||||
if items:
|
||||
out["candidates"] = [
|
||||
{"ne_id": r.get("ne_id"), "host_name": r.get("host_name"), "ip_address": r.get("ip_address")}
|
||||
for r in items[:10]
|
||||
if isinstance(r, dict)
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
def _query_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
||||
for k in ("severity", "is_cleared", "ne_id", "event_type", "keyword", "time_from", "time_to", "order_by", "order"):
|
||||
params: dict[str, Any] = {
|
||||
"page": _int_arg(args, "page", 1, 1, 100000),
|
||||
"page_size": _int_arg(args, "page_size", 50, 1, 500),
|
||||
}
|
||||
if _severity(args):
|
||||
params["severity"] = _severity(args)
|
||||
for k in ("is_cleared", "ne_id", "event_type", "keyword", "time_from", "time_to", "order_by", "order"):
|
||||
v = str(args.get(k) or "").strip()
|
||||
if v:
|
||||
params[k] = v
|
||||
|
|
@ -240,7 +345,15 @@ def _query_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
|
|||
fields = list(_UME_RAW_FIELD_PRESETS.get(preset) or [])
|
||||
if fields:
|
||||
params["select_fields"] = ",".join(fields)
|
||||
return http_json("GET", "/v1/ume/alarms/raw", params=params)
|
||||
out = http_json("GET", "/v1/ume/alarms/raw", params=params)
|
||||
data = out.get("data") if isinstance(out.get("data"), dict) else None
|
||||
if data and isinstance(data.get("items"), list):
|
||||
for row in data["items"]:
|
||||
if isinstance(row, dict) and _is_me_placeholder(
|
||||
row.get("ne_ne_name"), row.get("ne_ne_id") or row.get("alarm_ne_id")
|
||||
):
|
||||
row.pop("ne_ne_name", None)
|
||||
return _strip_meta_field_lists(out)
|
||||
|
||||
|
||||
def _aggregate_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -248,7 +361,6 @@ def _aggregate_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
|
|||
for k in (
|
||||
"group_by",
|
||||
"group_by2",
|
||||
"severity",
|
||||
"is_cleared",
|
||||
"ne_id",
|
||||
"event_type",
|
||||
|
|
@ -263,9 +375,13 @@ def _aggregate_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
|
|||
sv = str(v).strip()
|
||||
if sv:
|
||||
params[k] = sv
|
||||
if _severity(args):
|
||||
params["severity"] = _severity(args)
|
||||
if "limit" in params:
|
||||
params["limit"] = _int_arg(args, "limit", 200, 1, 2000)
|
||||
if "exclude_missing_host" in args:
|
||||
params["exclude_missing_host"] = bool(args.get("exclude_missing_host"))
|
||||
return http_json("GET", "/v1/ume/alarms/aggregate/raw", params=params)
|
||||
return _strip_meta_field_lists(http_json("GET", "/v1/ume/alarms/aggregate/raw", params=params))
|
||||
|
||||
|
||||
def _list_ume_alarm_fields(args: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -275,8 +391,8 @@ def _list_ume_alarm_fields(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
def _sql_query_ume(args: dict[str, Any]) -> dict[str, Any]:
|
||||
sql = str(args.get("sql") or "").strip()
|
||||
limit = max(1, min(2000, int(args.get("limit") or 200)))
|
||||
statement_timeout_ms = max(0, min(30000, int(args.get("statement_timeout_ms") or 0)))
|
||||
limit = _int_arg(args, "limit", 200, 1, 2000)
|
||||
statement_timeout_ms = _int_arg(args, "statement_timeout_ms", 0, 0, 30000)
|
||||
if not sql:
|
||||
return {"ok": False, "error": "sql_required"}
|
||||
return http_post_json(
|
||||
|
|
@ -287,13 +403,18 @@ def _sql_query_ume(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _list_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(100, max(1, int(args.get("page_size") or 20)))
|
||||
page = _int_arg(args, "page", 1, 1, 100000)
|
||||
page_size = _int_arg(args, "page_size", 20, 1, 100)
|
||||
keyword = str(args.get("keyword") or "").strip()
|
||||
vendor = str(args.get("vendor") or "").strip()
|
||||
connect_status = str(args.get("connect_status") or "").strip()
|
||||
if not (keyword or vendor or connect_status):
|
||||
return {"ok": False, "error": "managed_ne_filter_required", "error_code": "managed_ne_filter_required"}
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "managed_ne_filter_required",
|
||||
"error_code": "managed_ne_filter_required",
|
||||
"hint": "Pass keyword (name/IP, >=2 chars), vendor, or connect_status (e.g. pass).",
|
||||
}
|
||||
if keyword and len(keyword) < 2:
|
||||
return {"ok": False, "error": "managed_ne_keyword_too_short", "error_code": "managed_ne_keyword_too_short"}
|
||||
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
||||
|
|
@ -303,7 +424,13 @@ def _list_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
params["vendor"] = vendor
|
||||
if connect_status:
|
||||
params["connect_status"] = connect_status
|
||||
return http_json("GET", "/v1/managed-ne", params=params)
|
||||
out = http_json("GET", "/v1/managed-ne", params=params)
|
||||
items = (out.get("data") or {}).get("items") if out.get("ok") else None
|
||||
if isinstance(items, list):
|
||||
for row in items:
|
||||
if isinstance(row, dict):
|
||||
_compact_managed_ne(row, keep_detail=False)
|
||||
return out
|
||||
|
||||
|
||||
def _first_str(*vals: Any) -> str:
|
||||
|
|
@ -330,7 +457,9 @@ def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
"example": {"ne_id": "<managed-ne-uuid-from-listManagedNe>"},
|
||||
}
|
||||
# NMS ne_id is typically a UUID; managed NE may differ. Soft-guide when callers mix them.
|
||||
out = http_json("GET", f"/v1/managed-ne/{ne_id}", params=None)
|
||||
out = http_json("GET", f"/v1/managed-ne/{quote_ne_id(ne_id)}", params=None)
|
||||
if out.get("ok") and isinstance(out.get("data"), dict):
|
||||
_compact_managed_ne(out["data"], keep_detail=True)
|
||||
if isinstance(out, dict) and out.get("ok") is False:
|
||||
detail = str(out.get("detail") or out.get("error") or "")
|
||||
low = detail.lower()
|
||||
|
|
@ -391,11 +520,9 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
if len(shared_commands) > exec_max_commands():
|
||||
return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"}
|
||||
body["commands"] = shared_commands
|
||||
rts = args.get("read_timeout_sec")
|
||||
body["read_timeout_sec"] = int(rts) if rts is not None else 60
|
||||
conc = args.get("concurrency")
|
||||
if conc is not None:
|
||||
body["concurrency"] = int(conc)
|
||||
body["read_timeout_sec"] = _int_arg(args, "read_timeout_sec", 60, 10, 120)
|
||||
if args.get("concurrency") is not None:
|
||||
body["concurrency"] = _int_arg(args, "concurrency", 4, 1, 8)
|
||||
if want_async:
|
||||
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
|
||||
if not out.get("ok"):
|
||||
|
|
@ -434,8 +561,7 @@ def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
|
|||
if nms_ne_id:
|
||||
body["ume_ne_id"] = nms_ne_id
|
||||
# Default 60s matches netx API default; slow show commands often exceed 30s.
|
||||
rts = args.get("read_timeout_sec")
|
||||
body["read_timeout_sec"] = int(rts) if rts is not None else 60
|
||||
body["read_timeout_sec"] = _int_arg(args, "read_timeout_sec", 60, 10, 120)
|
||||
if want_async:
|
||||
out = http_post_json("/v1/managed-ne/exec-jobs", body, timeout=60.0)
|
||||
if not out.get("ok"):
|
||||
|
|
@ -464,16 +590,23 @@ def _get_ne_exec_job(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
|
||||
def _list_cli_targets(args: dict[str, Any]) -> dict[str, Any]:
|
||||
page = max(1, int(args.get("page") or 1))
|
||||
page_size = min(500, max(1, int(args.get("page_size") or 50)))
|
||||
params: dict[str, Any] = {"page": page, "page_size": page_size}
|
||||
params: dict[str, Any] = {
|
||||
"page": _int_arg(args, "page", 1, 1, 100000),
|
||||
"page_size": _int_arg(args, "page_size", 50, 1, 500),
|
||||
}
|
||||
source = str(args.get("source") or "").strip()
|
||||
if source:
|
||||
# API still uses source=ume; accept model-facing "nms".
|
||||
params["source"] = "ume" if source.lower() == "nms" else source
|
||||
if str(args.get("keyword") or "").strip():
|
||||
params["keyword"] = str(args.get("keyword")).strip()
|
||||
return http_json("GET", "/v1/cli/targets", params=params)
|
||||
out = http_json("GET", "/v1/cli/targets", params=params)
|
||||
items = (out.get("data") or {}).get("items") if out.get("ok") else None
|
||||
if isinstance(items, list):
|
||||
for row in items:
|
||||
if isinstance(row, dict):
|
||||
_nms_naming(row)
|
||||
return out
|
||||
|
||||
|
||||
def _find_topology_paths(args: dict[str, Any]) -> dict[str, Any]:
|
||||
|
|
@ -489,8 +622,8 @@ def _find_topology_paths(args: dict[str, Any]) -> dict[str, Any]:
|
|||
if detail not in {"summary", "full"}:
|
||||
detail = "summary"
|
||||
body: dict[str, Any] = {
|
||||
"max_paths": max(1, min(10, int(args.get("max_paths") or 3))),
|
||||
"max_hops": max(1, min(12, int(args.get("max_hops") or 6))),
|
||||
"max_paths": _int_arg(args, "max_paths", 3, 1, 10),
|
||||
"max_hops": _int_arg(args, "max_hops", 6, 1, 12),
|
||||
"layer": str(args.get("layer") or "physical").strip() or "physical",
|
||||
"detail": detail,
|
||||
}
|
||||
|
|
@ -502,7 +635,14 @@ def _find_topology_paths(args: dict[str, Any]) -> dict[str, Any]:
|
|||
body["to_ume_ne_id"] = to_uid
|
||||
else:
|
||||
body["to_managed_ne_id"] = to_mid
|
||||
return http_post_json("/v1/topology/fabric/paths", body, timeout=30.0)
|
||||
out = http_post_json("/v1/topology/fabric/paths", body, timeout=30.0)
|
||||
paths = (out.get("data") or {}).get("paths") if out.get("ok") else None
|
||||
if isinstance(paths, list):
|
||||
for p in paths:
|
||||
for node in (p.get("nodes") or []) if isinstance(p, dict) else []:
|
||||
if isinstance(node, dict):
|
||||
_nms_naming(node)
|
||||
return out
|
||||
|
||||
|
||||
HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
||||
|
|
@ -520,7 +660,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"severity": {"type": "string"},
|
||||
"severity": {"type": "string", "description": "critical/major/minor/warning"},
|
||||
"is_cleared": {"type": "string", "description": "\"False\" = active only; \"True\" = cleared."},
|
||||
"ne_id": {"type": "string", "description": "Filter only; do not show UUID to users"},
|
||||
"host_name": {"type": "string", "description": "Filter by NE host_name"},
|
||||
"ne_name": {"type": "string", "description": "Legacy alias mapped to keyword"},
|
||||
|
|
@ -626,10 +767,10 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
},
|
||||
{
|
||||
"name": "getNmsNe",
|
||||
"description": "Get single NMS NE detail by ne_id (UUID).",
|
||||
"description": "Get single NMS NE detail by ne_id (UUID); exact host_name / user_label / IP also resolve.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {"ne_id": {"type": "string"}},
|
||||
"properties": {"ne_id": {"type": "string", "description": "NMS UUID, or exact host_name / IP."}},
|
||||
"required": ["ne_id"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
|
|
@ -907,7 +1048,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
|
|||
{
|
||||
"name": "findTopologyPaths",
|
||||
"description": (
|
||||
"Find up to max_paths simple paths between two fabric nodes for troubleshooting. "
|
||||
"Find up to max_paths simple paths between two fabric nodes for troubleshooting "
|
||||
"(parallel links between the same NEs count as one hop; see edges[].parallel_links). "
|
||||
"For each endpoint provide exactly one of nms_ne_id (from NMS alarm ne_id; alias "
|
||||
"from_ume_ne_id) or managed_ne_id — resolved to fabric node internally. Returns "
|
||||
"shortest paths first with compact label + node/edge summary (detail=summary default). "
|
||||
|
|
@ -1014,4 +1156,8 @@ def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
|
|||
fn = _HANDLERS.get(str(name or "").strip())
|
||||
if not fn:
|
||||
raise ValueError(f"unknown tool: {name}")
|
||||
return mcp_from_handler_result(fn(dict(args or {})))
|
||||
try:
|
||||
result = fn(dict(args or {}))
|
||||
except ToolArgError as exc:
|
||||
result = {"ok": False, "error": "invalid_argument", "error_code": "invalid_argument", "detail": str(exc)}
|
||||
return mcp_from_handler_result(result)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,15 @@ def _err(rid: Any, code: int, message: str) -> None:
|
|||
_UNSET = object()
|
||||
|
||||
|
||||
def _version() -> str:
|
||||
try:
|
||||
from importlib.metadata import version
|
||||
|
||||
return version("netx-mcp")
|
||||
except Exception:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def _fetch_scopes() -> list[str] | None:
|
||||
"""Return granted scopes from /v1/auth/me, or None if the call fails (show all tools)."""
|
||||
try:
|
||||
|
|
@ -81,6 +90,7 @@ def run_stdio_loop() -> None:
|
|||
except Exception:
|
||||
continue
|
||||
rid = req.get("id")
|
||||
is_notification = "id" not in req
|
||||
method = str(req.get("method") or "")
|
||||
params = req.get("params") if isinstance(req.get("params"), dict) else {}
|
||||
|
||||
|
|
@ -91,11 +101,15 @@ def run_stdio_loop() -> None:
|
|||
{
|
||||
"protocolVersion": "2024-11-05",
|
||||
"capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "netx-mcp", "version": "0.3.0", "mode": "http"},
|
||||
"serverInfo": {"name": "netx-mcp", "version": _version(), "mode": "http"},
|
||||
},
|
||||
)
|
||||
continue
|
||||
if method == "notifications/initialized":
|
||||
if is_notification:
|
||||
# JSON-RPC: notifications (initialized, cancelled, ...) never get a response.
|
||||
continue
|
||||
if method == "ping":
|
||||
_ok(rid, {})
|
||||
continue
|
||||
if method == "tools/list":
|
||||
_ok(rid, {"tools": tools_for_scopes(scopes())})
|
||||
|
|
|
|||
|
|
@ -74,7 +74,7 @@ def test_call_aggregate_nms_alarms_group_by_routes_to_raw() -> None:
|
|||
params = mock_http.call_args[1]["params"]
|
||||
assert params["group_by"] == "alarm_host_name"
|
||||
assert params["severity"] == "critical"
|
||||
assert params["limit"] == "20"
|
||||
assert params["limit"] == 20
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["ok"] is True
|
||||
|
||||
|
|
@ -285,6 +285,14 @@ def test_stdio_initialize_and_tools_list() -> None:
|
|||
assert msg.get("id") == 1
|
||||
assert "result" in msg
|
||||
|
||||
# Notifications must not be answered; the next line belongs to ping.
|
||||
for note in ("notifications/initialized", "notifications/cancelled"):
|
||||
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": note, "params": {}}) + "\n")
|
||||
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 9, "method": "ping"}) + "\n")
|
||||
proc.stdin.flush()
|
||||
pong = json.loads(proc.stdout.readline())
|
||||
assert pong == {"jsonrpc": "2.0", "id": 9, "result": {}}
|
||||
|
||||
proc.stdin.write(json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}) + "\n")
|
||||
proc.stdin.flush()
|
||||
line2 = proc.stdout.readline()
|
||||
|
|
@ -297,3 +305,92 @@ def test_stdio_initialize_and_tools_list() -> None:
|
|||
finally:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
|
||||
def test_bad_integer_arg_is_tool_error_not_crash() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
out = call_http_tool("queryNmsAlarms", {"page": "two"})
|
||||
mock_http.assert_not_called()
|
||||
assert out["isError"] is True
|
||||
payload = json.loads(out["content"][0]["text"])
|
||||
assert payload["error"] == "invalid_argument"
|
||||
assert "page" in payload["detail"]
|
||||
|
||||
|
||||
def test_query_nms_alarms_pages_past_two_and_normalizes_severity() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {
|
||||
"ok": True,
|
||||
"data": {"items": [{"ne_id": "u-1", "ne_name": "ME{u-1}", "host_name": "H1"}]},
|
||||
}
|
||||
out = call_http_tool("queryNmsAlarms", {"page": 5, "severity": "Critical", "is_cleared": "False"})
|
||||
params = mock_http.call_args[1]["params"]
|
||||
assert params["page"] == 5
|
||||
assert params["severity"] == "critical"
|
||||
assert params["is_cleared"] == "False"
|
||||
row = json.loads(out["content"][0]["text"])["data"]["items"][0]
|
||||
assert "ne_name" not in row
|
||||
assert row["host_name"] == "H1"
|
||||
|
||||
|
||||
def test_nested_api_failure_is_marked_error() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"ok": False, "error": "job_not_found"}}
|
||||
out = call_http_tool("getNeExecJob", {"job_id": "nope"})
|
||||
assert out.get("isError") is True
|
||||
|
||||
|
||||
def test_raw_query_strips_echoed_field_lists() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"items": [],
|
||||
"meta": {"available_fields": ["a"], "order_by_allowed": ["b"], "time_filter_field": "last_seen_at"},
|
||||
},
|
||||
}
|
||||
out = call_http_tool("queryNmsAlarmsRaw", {"field_preset": "brief"})
|
||||
meta = json.loads(out["content"][0]["text"])["data"]["meta"]
|
||||
assert meta == {"time_filter_field": "last_seen_at"}
|
||||
|
||||
|
||||
def test_get_nms_ne_resolves_host_name() -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
def fake(method: str, path: str, params: dict | None = None) -> dict:
|
||||
calls.append(path)
|
||||
if path == "/v1/ume/inventory/ne":
|
||||
return {"ok": True, "data": {"items": [{"ne_id": "uuid-1", "host_name": "MDN-X-AN1"}]}}
|
||||
if path.endswith("/uuid-1"):
|
||||
return {"ok": True, "data": {"ne_id": "uuid-1"}}
|
||||
return {"ok": False, "error": "netx_http_404", "detail": "ume_ne_not_found"}
|
||||
|
||||
with patch("netx_mcp.http_tools.http_json", side_effect=fake):
|
||||
out = call_http_tool("getNmsNe", {"ne_id": "mdn-x-an1"})
|
||||
assert json.loads(out["content"][0]["text"])["data"]["ne_id"] == "uuid-1"
|
||||
assert calls[-1] == "/v1/ume/inventory/ne/uuid-1"
|
||||
|
||||
|
||||
def test_cli_targets_and_paths_use_nms_naming() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {"ok": True, "data": {"items": [{"source": "ume", "ume_ne_id": "u-1"}]}}
|
||||
out = call_http_tool("listCliTargets", {"source": "nms"})
|
||||
row = json.loads(out["content"][0]["text"])["data"]["items"][0]
|
||||
assert row == {"source": "nms", "nms_ne_id": "u-1"}
|
||||
|
||||
with patch("netx_mcp.http_tools.http_post_json") as mock_post:
|
||||
mock_post.return_value = {"ok": True, "data": {"paths": [{"nodes": [{"name": "A", "ume_ne_id": "u-1"}]}]}}
|
||||
out = call_http_tool("findTopologyPaths", {"from_nms_ne_id": "u-1", "to_nms_ne_id": "u-2"})
|
||||
node = json.loads(out["content"][0]["text"])["data"]["paths"][0]["nodes"][0]
|
||||
assert node == {"name": "A", "nms_ne_id": "u-1"}
|
||||
|
||||
|
||||
def test_managed_ne_list_drops_disabled_hop_noise() -> None:
|
||||
with patch("netx_mcp.http_tools.http_json") as mock_http:
|
||||
mock_http.return_value = {
|
||||
"ok": True,
|
||||
"data": {"items": [{"id": "m1", "hop_enabled": False, "hop_vrf": "", "connect_detail": "long"}]},
|
||||
}
|
||||
out = call_http_tool("listManagedNe", {"connect_status": "pass"})
|
||||
row = json.loads(out["content"][0]["text"])["data"]["items"][0]
|
||||
assert row == {"id": "m1", "hop_enabled": False}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue