Accept group_by on aggregateUmeAlarms and clarify CLI tool guidance.

Agents frequently passed group_by to the fixed aggregate tool; route those calls to raw aggregation and discourage list/exec retry loops.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-10 22:32:45 +08:00
parent 78d174232d
commit 9457ac9353
2 changed files with 73 additions and 7 deletions

View file

@ -132,6 +132,10 @@ def _query_ume_alarms(args: dict[str, Any]) -> dict[str, Any]:
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}
@ -360,7 +364,8 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"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. "
"For custom grouping use aggregateUmeAlarmsRaw."
"If group_by is set (e.g. alarm_host_name), automatically routes to the same "
"behavior as aggregateUmeAlarmsRaw — preferred for custom dimensions."
),
"inputSchema": {
"type": "object",
@ -374,15 +379,39 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"minimum": 0,
"maximum": 500,
"default": 50,
"description": "Max NE buckets to return (0 = all, capped at API 5000).",
"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 ranking.",
"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 aggregateUmeAlarmsRaw). 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,
@ -526,8 +555,10 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"name": "execManagedNe",
"description": (
f"Run read-only CLI via netx (show/display/ping/traceroute; "
f"max {exec_max_commands()} commands, NETX_NE_EXEC_MAX_COMMANDS). "
"Use ne_id (managed NE) OR ume_ne_id (UME inventory)."
f"max {exec_max_commands()} commands per call, NETX_NE_EXEC_MAX_COMMANDS). "
"Use ne_id (managed NE) OR ume_ne_id (UME inventory). "
"Batch multiple show commands in one call instead of looping. "
"On timeout, raise read_timeout_sec (max 120) or shrink commands — do not blind-retry."
),
"inputSchema": {
"type": "object",
@ -540,7 +571,12 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
"minItems": 1,
"maxItems": exec_max_commands(),
},
"read_timeout_sec": {"type": "integer", "minimum": 10, "maximum": 120},
"read_timeout_sec": {
"type": "integer",
"minimum": 10,
"maximum": 120,
"description": "Per-command read timeout; use 60–120 for slow show commands.",
},
},
"required": ["commands"],
"additionalProperties": False,
@ -548,7 +584,11 @@ HTTP_MCP_TOOLS: list[dict[str, Any]] = [
},
{
"name": "listCliTargets",
"description": "List CLI-capable targets (managed NE and/or UME inventory); use before execManagedNe.",
"description": (
"List CLI-capable targets (managed NE and/or UME inventory). "
"Call once per session with keyword/source, cache ume_ne_id/ne_id, "
"then execManagedNe — do not re-list before every command."
),
"inputSchema": {
"type": "object",
"properties": {

View file

@ -57,6 +57,32 @@ def test_call_aggregate_ume_alarms_forwards_top_ne() -> None:
assert payload["data"]["top_ne"] == 20
def test_call_aggregate_ume_alarms_group_by_routes_to_raw() -> None:
with patch("netx_mcp.http_tools.http_json") as mock_http:
mock_http.return_value = {"ok": True, "data": {"buckets": []}}
out = call_http_tool(
"aggregateUmeAlarms",
{"group_by": "alarm_host_name", "severity": "critical", "limit": 20},
)
mock_http.assert_called_once()
assert mock_http.call_args[0][0] == "GET"
assert mock_http.call_args[0][1] == "/v1/ume/alarms/aggregate/raw"
params = mock_http.call_args[1]["params"]
assert params["group_by"] == "alarm_host_name"
assert params["severity"] == "critical"
assert params["limit"] == "20"
payload = json.loads(out["content"][0]["text"])
assert payload["ok"] is True
def test_aggregate_ume_alarms_schema_accepts_group_by() -> None:
tool = next(t for t in HTTP_MCP_TOOLS if t.get("name") == "aggregateUmeAlarms")
props = tool["inputSchema"]["properties"]
assert "group_by" in props
assert "group_by2" in props
assert "alarm_host_name" in props["group_by"]["enum"]
def test_call_find_topology_paths_defaults_summary_detail() -> None:
with patch("netx_mcp.http_tools.http_post_json") as mock_post:
mock_post.return_value = {"ok": True, "data": {"path_count": 1, "detail": "summary", "paths": []}}