diff --git a/packages/netx-mcp/src/netx_mcp/http_tools.py b/packages/netx-mcp/src/netx_mcp/http_tools.py index 33edb6c..2be2898 100644 --- a/packages/netx-mcp/src/netx_mcp/http_tools.py +++ b/packages/netx-mcp/src/netx_mcp/http_tools.py @@ -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": { diff --git a/packages/netx-mcp/tests/test_mcp_http.py b/packages/netx-mcp/tests/test_mcp_http.py index c70b7b0..a430eaf 100644 --- a/packages/netx-mcp/tests/test_mcp_http.py +++ b/packages/netx-mcp/tests/test_mcp_http.py @@ -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": []}}