netx/packages/netx-mcp/src/netx_mcp/http_tools.py
oliver 13ce2482ab Add shared skills tree and rename MCP NMS tools to *Nms*.
Canonical playbooks live under skills/; netx-mcp 0.3.0 exposes queryNmsAlarms et al. with nms_ne_id aliases.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 01:46:21 +08:00

916 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""MCP tool schemas and HTTP-backed handlers (NMS adapter + managed NE).
Model-facing tool names use generic Nms*; REST paths remain /v1/ume/* for the
current zte-ume provider. Params prefer nms_ne_id with ume_ne_id aliases.
"""
from __future__ import annotations
import os
from typing import Any, Callable
from .http_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id
_EXEC_MAX_COMMANDS_CAP = 50
_EXEC_MAX_COMMANDS_DEFAULT = 5
def exec_max_commands() -> int:
"""Mirror netx API NETX_NE_EXEC_MAX_COMMANDS (default 5, hard cap 50)."""
try:
raw = int(os.getenv("NETX_NE_EXEC_MAX_COMMANDS") or _EXEC_MAX_COMMANDS_DEFAULT)
except ValueError:
raw = _EXEC_MAX_COMMANDS_DEFAULT
return max(1, min(_EXEC_MAX_COMMANDS_CAP, raw))
UME_RAW_GROUP_FIELDS = [
"alarm_alarm_key",
"alarm_host_name",
"alarm_ne_id",
"alarm_object_name",
"alarm_event_type",
"alarm_native_probable_cause",
"alarm_perceived_severity",
"alarm_is_cleared",
"alarm_time_created",
"alarm_root_cause_alarm_indication",
"ne_ne_id",
"ne_ne_name",
"ne_user_label",
"ne_ip_address",
"ne_ipv6_address",
"ne_ne_type",
"ne_device_level",
"ne_host_name",
"ne_location",
"ne_hardware_version",
"ne_loopback",
"ne_consistent_state",
"ne_interface_version",
"ne_mac",
"ne_admin_status",
"ne_address_type",
"ne_connection_status",
"ne_maintain_status",
"ne_net_mask",
"ne_create_time",
"ne_creator",
"ne_vendor",
"ne_source_type",
"ne_exists",
]
_UME_RAW_FIELD_PRESETS: dict[str, list[str]] = {
"brief": [
"alarm_alarm_key",
"alarm_host_name",
"alarm_perceived_severity",
"alarm_event_type",
"alarm_last_seen_at",
"ne_host_name",
"ne_user_label",
"ne_ne_name",
"ne_ip_address",
"ne_exists",
],
"evidence": [
"alarm_alarm_key",
"alarm_host_name",
"alarm_object_name",
"alarm_event_type",
"alarm_native_probable_cause",
"alarm_perceived_severity",
"alarm_is_cleared",
"alarm_time_created",
"alarm_last_seen_at",
"ne_host_name",
"ne_user_label",
"ne_ne_name",
"ne_ip_address",
"ne_connection_status",
"ne_exists",
],
"ne_debug": [
"alarm_alarm_key",
"alarm_ne_id",
"alarm_perceived_severity",
"alarm_last_seen_at",
"ne_user_label",
"ne_ne_name",
"ne_ip_address",
"ne_ipv6_address",
"ne_device_level",
"ne_host_name",
"ne_connection_status",
"ne_admin_status",
"ne_address_type",
"ne_maintain_status",
"ne_exists",
],
}
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()
keyword = str(args.get("keyword") or "").strip()
ne_name = str(args.get("ne_name") or "").strip()
if keyword:
params["keyword"] = keyword
elif ne_name:
params["keyword"] = ne_name
if str(args.get("ne_id") or "").strip():
params["ne_id"] = str(args.get("ne_id")).strip()
if str(args.get("host_name") or "").strip():
params["host_name"] = str(args.get("host_name")).strip()
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)
def _aggregate_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
# Agents often pass group_by here (docs historically mixed tools). Route to raw aggregate.
group_by = str(args.get("group_by") or "").strip()
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}
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 str(args.get(k) or "").strip():
params[k] = str(args.get(k)).strip()
return http_json("GET", "/v1/ume/alarms/aggregate", params=params)
def _run_ume_diagnostics(args: dict[str, Any]) -> dict[str, Any]:
_ = args
return http_json("GET", "/v1/ume/diagnostics", params=None)
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}
if str(args.get("keyword") or "").strip():
params["keyword"] = str(args.get("keyword")).strip()
return http_json("GET", "/v1/ume/inventory/ne", params=params)
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)
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"):
v = str(args.get(k) or "").strip()
if v:
params[k] = v
sf = args.get("select_fields")
fields: list[str] = []
if isinstance(sf, list):
fields = [str(x).strip() for x in sf if str(x).strip()]
if not fields:
preset = str(args.get("field_preset") or "").strip().lower()
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)
def _aggregate_ume_alarms_raw(args: dict[str, Any]) -> dict[str, Any]:
params: dict[str, Any] = {}
for k in (
"group_by",
"group_by2",
"severity",
"is_cleared",
"ne_id",
"event_type",
"keyword",
"time_from",
"time_to",
"limit",
):
v = args.get(k)
if v is None:
continue
sv = str(v).strip()
if sv:
params[k] = sv
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)
def _list_ume_alarm_fields(args: dict[str, Any]) -> dict[str, Any]:
_ = args
return http_json("GET", "/v1/ume/alarms/fields", params=None)
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)))
if not sql:
return {"ok": False, "error": "sql_required"}
return http_post_json(
"/v1/sql/ume_query",
{"sql": sql, "limit": limit, "statement_timeout_ms": statement_timeout_ms},
timeout=60.0,
)
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)))
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"}
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}
if keyword:
params["keyword"] = keyword
if vendor:
params["vendor"] = vendor
if connect_status:
params["connect_status"] = connect_status
return http_json("GET", "/v1/managed-ne", params=params)
def _first_str(*vals: Any) -> str:
for v in vals:
s = str(v or "").strip()
if s:
return s
return ""
def _get_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
ne_id = str(
args.get("ne_id") or args.get("managed_ne_id") or args.get("id") or ""
).strip()
if not ne_id:
return {
"ok": False,
"error": "ne_id_required",
"error_code": "ne_id_required",
"hint": (
"Pass managed NE id from listManagedNe/listCliTargets (source=managed). "
"For NMS inventory UUIDs use execManagedNe(nms_ne_id=...) or getNmsNe, not getManagedNe."
),
"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)
if isinstance(out, dict) and out.get("ok") is False:
detail = str(out.get("detail") or out.get("error") or "")
low = detail.lower()
if "404" in low or "not_found" in low or "not found" in low or out.get("error") == "netx_http_404":
out = dict(out)
out["hint"] = (
"Managed NE not found for this ne_id. Call listManagedNe(keyword=...) or "
"listCliTargets(source=managed) first. If this is an NMS ne_id, use "
"execManagedNe(nms_ne_id=...) / getNmsNe instead of getManagedNe."
)
return out
def _exec_managed_ne(args: dict[str, Any]) -> dict[str, Any]:
targets_raw = args.get("targets")
ne_ids_raw = args.get("ne_ids")
nms_ne_ids_raw = args.get("nms_ne_ids")
if not isinstance(nms_ne_ids_raw, list) or not nms_ne_ids_raw:
nms_ne_ids_raw = args.get("ume_ne_ids")
shared_cmds_raw = args.get("commands")
shared_commands = (
[str(c).strip() for c in shared_cmds_raw if str(c).strip()]
if isinstance(shared_cmds_raw, list)
else []
)
multi = bool(
(isinstance(targets_raw, list) and targets_raw)
or (isinstance(ne_ids_raw, list) and ne_ids_raw)
or (isinstance(nms_ne_ids_raw, list) and nms_ne_ids_raw)
)
if multi:
body: dict[str, Any] = {}
if isinstance(targets_raw, list) and targets_raw:
cleaned_targets: list[dict[str, Any]] = []
for t in targets_raw:
if not isinstance(t, dict):
continue
row: dict[str, Any] = {}
if str(t.get("ne_id") or "").strip():
row["ne_id"] = str(t.get("ne_id")).strip()
# Wire still uses ume_ne_id on REST; accept nms_ne_id as preferred model param.
nms_id = _first_str(t.get("nms_ne_id"), t.get("ume_ne_id"))
if nms_id:
row["ume_ne_id"] = nms_id
cmds = t.get("commands")
if isinstance(cmds, list) and cmds:
row["commands"] = [str(c).strip() for c in cmds if str(c).strip()]
if row:
cleaned_targets.append(row)
body["targets"] = cleaned_targets
if isinstance(ne_ids_raw, list) and ne_ids_raw:
body["ne_ids"] = [str(x).strip() for x in ne_ids_raw if str(x).strip()]
if isinstance(nms_ne_ids_raw, list) and nms_ne_ids_raw:
body["ume_ne_ids"] = [str(x).strip() for x in nms_ne_ids_raw if str(x).strip()]
if shared_commands:
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)
# Wall clock: many NEs × per-cmd timeout; keep below oclaw MCP override.
out = http_post_json("/v1/managed-ne/exec-batch", body, timeout=600.0)
if not out.get("ok"):
return out
data = out.get("data") or {}
if isinstance(data, dict) and data.get("ok") is False:
return {"ok": False, "data": data, "error": str(data.get("error") or "exec_batch_failed")}
return {"ok": True, "data": data}
ne_id = str(args.get("ne_id") or "").strip()
nms_ne_id = _first_str(args.get("nms_ne_id"), args.get("ume_ne_id"))
if bool(ne_id) == bool(nms_ne_id):
return {
"ok": False,
"error": "exactly_one_of_ne_id_or_nms_ne_id_required",
"error_code": "exactly_one_of_ne_id_or_nms_ne_id_required",
"hint": (
"For one NE pass ne_id OR nms_ne_id (alias ume_ne_id). For many NEs pass "
"ne_ids / nms_ne_ids with shared commands, or targets[] with per-NE commands — "
"one call, concurrent on server."
),
}
if not shared_commands:
return {"ok": False, "error": "commands_required", "error_code": "commands_required"}
if len(shared_commands) > exec_max_commands():
return {"ok": False, "error": "too_many_commands", "error_code": "too_many_commands"}
body = {"commands": shared_commands}
if ne_id:
body["ne_id"] = ne_id
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
out = http_post_json("/v1/managed-ne/exec", body, timeout=300.0)
if not out.get("ok"):
return out
data = out.get("data") or {}
if isinstance(data, dict) and data.get("ok") is False:
return {"ok": False, "data": data, "error": str(data.get("error") or "exec_failed")}
return {"ok": True, "data": data}
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}
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)
def _find_topology_paths(args: dict[str, Any]) -> dict[str, Any]:
from_uid = _first_str(args.get("from_nms_ne_id"), args.get("from_ume_ne_id"))
from_mid = str(args.get("from_managed_ne_id") or "").strip()
to_uid = _first_str(args.get("to_nms_ne_id"), args.get("to_ume_ne_id"))
to_mid = str(args.get("to_managed_ne_id") or "").strip()
if bool(from_uid) == bool(from_mid):
return {"ok": False, "error": "exactly_one_of_from_nms_ne_id_or_from_managed_ne_id_required"}
if bool(to_uid) == bool(to_mid):
return {"ok": False, "error": "exactly_one_of_to_nms_ne_id_or_to_managed_ne_id_required"}
detail = str(args.get("detail") or "summary").strip().lower() or "summary"
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))),
"layer": str(args.get("layer") or "physical").strip() or "physical",
"detail": detail,
}
if from_uid:
body["from_ume_ne_id"] = from_uid
else:
body["from_managed_ne_id"] = from_mid
if to_uid:
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)
HTTP_MCP_TOOLS: list[dict[str, Any]] = [
{
"name": "queryNmsAlarms",
"description": (
"Query NMS current alarms (each row includes host_name). "
"Supports severity/ne_id/host_name/keyword, last_seen time_from/time_to, pagination. "
"Prefer host_name for display; ne_id is for filters only. "
"Field keyword examples (native_probable_cause): LOS, Fiber Break, bandwidth, CRC, "
"BN EMS, dying gasp, License, BGP Neighbour, Port down, optical power, NTP. "
"Area = hostname prefix (MDN-/MKS-/PLG-/ACH-/PAD-/BJM-/…). "
"Optical-power threshold ≠ fiber cut; capacity A<>B is CLI optics, not this tally alone."
),
"inputSchema": {
"type": "object",
"properties": {
"severity": {"type": "string"},
"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"},
"keyword": {
"type": "string",
"description": (
"Substring on cause/object/event. Examples: LOS, Fiber Break, bandwidth, "
"CRC, BN EMS, dying gasp, License, optical power, BGP Neighbour."
),
},
"time_from": {"type": "string", "description": "ISO time; filters last_seen_at >="},
"time_to": {"type": "string", "description": "ISO time; filters last_seen_at <="},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "aggregateNmsAlarms",
"description": (
"Aggregate NMS current alarms (by_severity + top by_ne). "
"Optional severity filter (e.g. critical) for risk Top-N. "
"by_ne is capped by top_ne (default 50) and excludes missing host_name by default "
"(see by_ne_missing). meta.last_seen_min/max show data freshness. "
"If group_by is set (e.g. alarm_host_name), automatically routes to the same "
"behavior as aggregateNmsAlarmsRaw — preferred for custom dimensions. "
"Snapshot volumes are large (tens of thousands); always filter severity/keyword/time "
"before paging — do not dump unfiltered lists."
),
"inputSchema": {
"type": "object",
"properties": {
"severity": {
"type": "string",
"description": "Optional perceived_severity filter (critical/major/minor/warning).",
},
"top_ne": {
"type": "integer",
"minimum": 0,
"maximum": 500,
"default": 50,
"description": "Max NE buckets to return (0 = all, capped at API 5000). Ignored when group_by is set.",
},
"exclude_missing_host": {
"type": "boolean",
"default": True,
"description": "Omit (host_name missing) from by_ne / host rankings.",
},
"time_from": {"type": "string", "description": "ISO time; filters last_seen_at >="},
"time_to": {"type": "string", "description": "ISO time; filters last_seen_at <="},
"group_by": {
"type": "string",
"enum": UME_RAW_GROUP_FIELDS,
"description": (
"Optional. When set, routes to dynamic raw aggregation "
"(same as aggregateNmsAlarmsRaw). Prefer alarm_host_name for NE Top."
),
},
"group_by2": {
"type": "string",
"enum": UME_RAW_GROUP_FIELDS,
"description": "Optional second group field when group_by is set.",
},
"is_cleared": {"type": "string", "description": "Only used when group_by is set."},
"ne_id": {"type": "string", "description": "Only used when group_by is set."},
"event_type": {"type": "string", "description": "Only used when group_by is set."},
"keyword": {"type": "string", "description": "Only used when group_by is set."},
"limit": {
"type": "integer",
"minimum": 1,
"maximum": 2000,
"default": 200,
"description": "Bucket limit when group_by is set (default 200).",
},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "runNmsDiagnostics",
"description": (
"NMS alarm diagnostics: severity, top_event_types, top_alarm_codes (vendor alarmCode), "
"top_ne (excludes missing host), protocol buckets, and meta.last_seen_min/max freshness."
),
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"name": "queryNmsNeInventory",
"description": "Paged NMS NE inventory synced in netx (keyword matches ne_id/ne_name/user_label/ip/host_name).",
"inputSchema": {
"type": "object",
"properties": {
"keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "getNmsNe",
"description": "Get single NMS NE detail by ne_id (UUID).",
"inputSchema": {
"type": "object",
"properties": {"ne_id": {"type": "string"}},
"required": ["ne_id"],
"additionalProperties": False,
},
},
{
"name": "queryNmsAlarmsRaw",
"description": (
"Power query NMS current alarms with full alarm_* + ne_* fields; optional field_preset or select_fields. "
"Use field_preset=evidence for WA citations. Same keyword vocabulary as queryNmsAlarms "
"(LOS / Fiber Break / bandwidth / CRC / BN EMS / dying gasp / License / optical power). "
"For area asks: keyword then keep hosts starting with AREA- (e.g. PAD-, ACH-, BPP-)."
),
"inputSchema": {
"type": "object",
"properties": {
"severity": {"type": "string"},
"is_cleared": {"type": "string"},
"ne_id": {"type": "string"},
"event_type": {"type": "string"},
"keyword": {
"type": "string",
"description": "Substring filter; see queryNmsAlarms keyword examples.",
},
"time_from": {"type": "string"},
"time_to": {"type": "string"},
"order_by": {
"type": "string",
"enum": ["last_seen_at", "time_created", "perceived_severity", "event_type", "ne_id"],
},
"order": {"type": "string", "enum": ["asc", "desc"]},
"select_fields": {"type": "array", "items": {"type": "string"}},
"field_preset": {"type": "string", "enum": ["brief", "evidence", "ne_debug"]},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "aggregateNmsAlarmsRaw",
"description": (
"Dynamic aggregation on NMS raw fields (group_by/group_by2); prefer alarm_host_name. "
"When grouping by host fields, (host_name missing) is omitted by default "
"(see by_ne_missing)."
),
"inputSchema": {
"type": "object",
"properties": {
"group_by": {"type": "string", "enum": UME_RAW_GROUP_FIELDS},
"group_by2": {"type": "string", "enum": UME_RAW_GROUP_FIELDS},
"severity": {"type": "string"},
"is_cleared": {"type": "string"},
"ne_id": {"type": "string"},
"event_type": {"type": "string"},
"keyword": {"type": "string"},
"time_from": {"type": "string"},
"time_to": {"type": "string"},
"exclude_missing_host": {
"type": "boolean",
"default": True,
"description": "Omit missing host buckets when grouping by host/user_label fields.",
},
"limit": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 200},
},
"required": ["group_by"],
"additionalProperties": False,
},
},
{
"name": "listNmsAlarmFields",
"description": "List available fields for NMS raw alarm queries.",
"inputSchema": {"type": "object", "properties": {}, "required": [], "additionalProperties": False},
},
{
"name": "sqlQueryNms",
"description": (
"Read-only SELECT on NMS tables (ume_alarms_current/ume_inventory_ne; zte-ume adapter); "
"server enforces limits. Requires netx scope sql:query. If insufficient_scope, use "
"aggregateNmsAlarms / queryNmsAlarmsRaw instead."
),
"inputSchema": {
"type": "object",
"properties": {
"sql": {"type": "string"},
"limit": {"type": "integer", "minimum": 1, "maximum": 2000, "default": 200},
"statement_timeout_ms": {"type": "integer", "minimum": 0, "maximum": 30000, "default": 0},
},
"required": ["sql"],
"additionalProperties": False,
},
},
{
"name": "listManagedNe",
"description": "List filtered netx managed NEs (keyword/vendor/connect_status required); use before execManagedNe.",
"inputSchema": {
"type": "object",
"properties": {
"keyword": {"type": "string"},
"vendor": {"type": "string"},
"connect_status": {"type": "string", "enum": ["unknown", "testing", "pass", "fail"]},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 100, "default": 20},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "getManagedNe",
"description": (
"Get one **managed** NE by managed ne_id (from listManagedNe / listCliTargets source=managed). "
"Do NOT pass NMS inventory UUID here — use getNmsNe or execManagedNe(nms_ne_id=...) instead."
),
"inputSchema": {
"type": "object",
"properties": {
"ne_id": {
"type": "string",
"description": "Managed NE id (not NMS host_name / not NMS ne_id unless they coincide).",
},
"managed_ne_id": {"type": "string", "description": "Alias for ne_id."},
"id": {"type": "string", "description": "Alias for ne_id."},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "execManagedNe",
"description": (
f"Run read-only CLI via netx (show/display/ping/traceroute; "
f"max {exec_max_commands()} commands per NE, NETX_NE_EXEC_MAX_COMMANDS). "
"Single NE: ne_id OR nms_ne_id (+ alias ume_ne_id) + commands. "
"Many NEs (batch-first, server concurrency default 4, max 20): "
"(1) same CLI on all → ne_ids[]/nms_ne_ids[] + shared commands; "
"(2) different CLI per NE (vendor/role) → ONE targets=["
"{nms_ne_id|ne_id, commands:[…]}, …] — do NOT fall back to one-NE loops. "
"Do NOT loop one-NE execManagedNe for multi-NE work. "
"Default read_timeout_sec=60; on timeout raise to 90–120 — do not blind-retry. "
"Large batches (≈4+ NEs) may auto-run async in oclaw: returns job_id immediately; "
"poll get_ne_exec_job. Pass async=true to force background, async=false to force sync."
),
"inputSchema": {
"type": "object",
"properties": {
"ne_id": {"type": "string"},
"nms_ne_id": {"type": "string", "description": "NMS inventory id (preferred)."},
"ume_ne_id": {"type": "string", "description": "Legacy alias of nms_ne_id."},
"ne_ids": {
"type": "array",
"items": {"type": "string"},
"maxItems": 20,
"description": "Managed NE ids for concurrent batch (shared commands).",
},
"nms_ne_ids": {
"type": "array",
"items": {"type": "string"},
"maxItems": 20,
"description": "NMS inventory ne_ids for concurrent batch (shared commands).",
},
"ume_ne_ids": {
"type": "array",
"items": {"type": "string"},
"maxItems": 20,
"description": "Legacy alias of nms_ne_ids.",
},
"targets": {
"type": "array",
"maxItems": 20,
"items": {
"type": "object",
"properties": {
"ne_id": {"type": "string"},
"nms_ne_id": {"type": "string"},
"ume_ne_id": {"type": "string", "description": "Legacy alias of nms_ne_id."},
"commands": {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": exec_max_commands(),
},
},
"additionalProperties": False,
},
"description": (
"Preferred for mixed-vendor / per-NE command sets: "
"each item is one NE (ne_id OR nms_ne_id) with its own commands[]. "
"Per-target commands override top-level shared commands. "
"Still one concurrent batch — not N single-NE calls."
),
},
"commands": {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": exec_max_commands(),
"description": (
"Commands for single NE, or shared commands for ne_ids/nms_ne_ids. "
"Optional fallback for targets that omit per-target commands."
),
},
"read_timeout_sec": {
"type": "integer",
"minimum": 10,
"maximum": 120,
"default": 60,
"description": "Per-command read timeout (default 60; use 90–120 for slow show).",
},
"concurrency": {
"type": "integer",
"minimum": 1,
"maximum": 8,
"default": 4,
"description": "Parallel NEs for batch mode (ignored for single-NE).",
},
"async": {
"type": "boolean",
"description": (
"oclaw-only: true=background job_id + get_ne_exec_job; "
"false=force sync; omit=auto for large batches (~4+ NEs)."
),
},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "listCliTargets",
"description": (
"List CLI-capable targets (managed NE and/or NMS inventory). "
"Call once per session with keyword/source, cache nms_ne_id/ne_id, "
"then execManagedNe — do not re-list before every command."
),
"inputSchema": {
"type": "object",
"properties": {
"source": {
"type": "string",
"enum": ["managed", "nms", "ume", "all"],
"default": "all",
"description": "nms preferred; ume is a legacy alias mapped to the same inventory.",
},
"keyword": {"type": "string"},
"page": {"type": "integer", "minimum": 1, "default": 1},
"page_size": {"type": "integer", "minimum": 1, "maximum": 500, "default": 50},
},
"required": [],
"additionalProperties": False,
},
},
{
"name": "findTopologyPaths",
"description": (
"Find up to max_paths simple paths between two fabric nodes for troubleshooting. "
"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). "
"Use after critical alarms to correlate neighboring NEs before CLI login."
),
"inputSchema": {
"type": "object",
"properties": {
"from_nms_ne_id": {
"type": "string",
"description": "Source NMS ne_id (from alarm ne_id); mutually exclusive with from_managed_ne_id",
},
"from_ume_ne_id": {
"type": "string",
"description": "Legacy alias of from_nms_ne_id",
},
"from_managed_ne_id": {
"type": "string",
"description": "Source managed NE id; mutually exclusive with from_nms_ne_id",
},
"to_nms_ne_id": {
"type": "string",
"description": "Target NMS ne_id; mutually exclusive with to_managed_ne_id",
},
"to_ume_ne_id": {
"type": "string",
"description": "Legacy alias of to_nms_ne_id",
},
"to_managed_ne_id": {
"type": "string",
"description": "Target managed NE id; mutually exclusive with to_nms_ne_id",
},
"max_paths": {"type": "integer", "minimum": 1, "maximum": 10, "default": 3},
"max_hops": {"type": "integer", "minimum": 1, "maximum": 12, "default": 6},
"layer": {"type": "string", "default": "physical"},
"detail": {
"type": "string",
"enum": ["summary", "full"],
"default": "summary",
"description": "summary=compact ops fields; full=include attrs/coords.",
},
},
"required": [],
"additionalProperties": False,
},
},
]
_HANDLERS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
"queryNmsAlarms": _query_ume_alarms,
"aggregateNmsAlarms": _aggregate_ume_alarms,
"runNmsDiagnostics": _run_ume_diagnostics,
"queryNmsNeInventory": _query_ume_ne_inventory,
"getNmsNe": _get_ume_ne,
"queryNmsAlarmsRaw": _query_ume_alarms_raw,
"aggregateNmsAlarmsRaw": _aggregate_ume_alarms_raw,
"listNmsAlarmFields": _list_ume_alarm_fields,
"sqlQueryNms": _sql_query_ume,
"listManagedNe": _list_managed_ne,
"getManagedNe": _get_managed_ne,
"execManagedNe": _exec_managed_ne,
"listCliTargets": _list_cli_targets,
"findTopologyPaths": _find_topology_paths,
}
# Minimum scope required to advertise / invoke each tool (matches netx API RBAC).
TOOL_REQUIRED_SCOPE: dict[str, str] = {
"queryNmsAlarms": "alarms:read",
"aggregateNmsAlarms": "alarms:read",
"runNmsDiagnostics": "alarms:read",
"queryNmsNeInventory": "ne:read",
"getNmsNe": "ne:read",
"queryNmsAlarmsRaw": "alarms:read",
"aggregateNmsAlarmsRaw": "alarms:read",
"listNmsAlarmFields": "alarms:read",
"sqlQueryNms": "sql:query",
"listManagedNe": "ne:read",
"getManagedNe": "ne:read",
"execManagedNe": "ne:exec",
"listCliTargets": "ne:read",
"findTopologyPaths": "ne:read",
}
def tools_for_scopes(scopes: list[str] | set[str] | frozenset[str] | None) -> list[dict[str, Any]]:
"""Filter MCP tool list by granted scopes. Empty/None => return all (offline / unauthenticated listing)."""
if scopes is None:
return list(HTTP_MCP_TOOLS)
granted = {str(s).strip().lower() for s in scopes if str(s).strip()}
if not granted:
return []
out: list[dict[str, Any]] = []
for tool in HTTP_MCP_TOOLS:
name = str(tool.get("name") or "")
need = TOOL_REQUIRED_SCOPE.get(name)
if need is None or need in granted:
out.append(tool)
return out
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 {})))