netdata-cli 0.1.0__py3-none-any.whl → 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
netdata_cli/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """netdata-cli — A powerful CLI for the Netdata Agent REST API."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"
netdata_cli/cli.py CHANGED
@@ -1,13 +1,18 @@
1
- """netdata-cli — CLI entry point."""
1
+ """netdata-cli — CLI entry point.
2
+
3
+ Combines Netdata REST API commands with MCP (Model Context Protocol) tools.
4
+ """
2
5
 
3
6
  from __future__ import annotations
4
7
 
8
+ import json
5
9
  import sys
6
10
 
7
11
  import click
8
12
 
9
13
  from . import __version__
10
14
  from .client import NetdataClient, NetdataError
15
+ from .mcp_client import MCPClient, MCPError
11
16
  from .formatters import (
12
17
  HAS_RICH,
13
18
  console,
@@ -26,7 +31,11 @@ def _client(url: str, timeout: int) -> NetdataClient:
26
31
  return NetdataClient(base_url=url, timeout=timeout)
27
32
 
28
33
 
29
- def _handle_error(exc: NetdataError) -> None:
34
+ def _mcp_client(url: str, timeout: int) -> MCPClient:
35
+ return MCPClient(base_url=url, timeout=timeout)
36
+
37
+
38
+ def _handle_error(exc: Exception) -> None:
30
39
  msg = f"Error: {exc}"
31
40
  if console:
32
41
  console.print(f"[bold red]{msg}[/]")
@@ -35,6 +44,16 @@ def _handle_error(exc: NetdataError) -> None:
35
44
  sys.exit(1)
36
45
 
37
46
 
47
+ def _output(data, as_json: bool, title: str = "") -> None:
48
+ """Output data as JSON or pretty-printed."""
49
+ if as_json:
50
+ print_json(data)
51
+ elif HAS_RICH and isinstance(data, (dict, list)):
52
+ console.print_json(json.dumps(data, default=str, ensure_ascii=False))
53
+ else:
54
+ print(json.dumps(data, indent=2, default=str, ensure_ascii=False))
55
+
56
+
38
57
  # ------------------------------------------------------------------
39
58
  # Root group
40
59
  # ------------------------------------------------------------------
@@ -54,18 +73,30 @@ def _handle_error(exc: NetdataError) -> None:
54
73
  @click.option("--json", "-j", "as_json", is_flag=True, help="Output raw JSON.")
55
74
  @click.pass_context
56
75
  def cli(ctx: click.Context, url: str, timeout: int, as_json: bool) -> None:
57
- """netdata-cli — A powerful CLI for the Netdata Agent REST API.
76
+ """netdata-cli — A powerful CLI for the Netdata Agent REST API + MCP.
58
77
 
59
78
  Query metrics, charts, alarms, functions, and agent info from the
60
79
  command line. Supports rich tables, JSON output, and time filtering.
61
80
 
81
+ REST API commands: info, ping, charts, chart, data, alarms, alarm-log,
82
+ allmetrics, function, functions, collectors
83
+
84
+ MCP commands: mcp list-tools, mcp nodes, mcp metrics, mcp query,
85
+ mcp anomalies, mcp correlated, mcp alerts, etc.
86
+
62
87
  Set NETDATA_URL env var to change the default agent address.
63
88
  """
64
89
  ctx.ensure_object(dict)
65
90
  ctx.obj["client"] = _client(url, timeout)
91
+ ctx.obj["mcp_client"] = _mcp_client(url, timeout)
66
92
  ctx.obj["as_json"] = as_json
67
93
 
68
94
 
95
+ # ==================================================================
96
+ # REST API COMMANDS (existing)
97
+ # ==================================================================
98
+
99
+
69
100
  # ------------------------------------------------------------------
70
101
  # info
71
102
  # ------------------------------------------------------------------
@@ -134,11 +165,9 @@ def charts(ctx: click.Context, query: str | None, chart_type: str | None, limit:
134
165
  for chart_id, info in items.items():
135
166
  entry = {"id": chart_id, **info}
136
167
 
137
- # type filter
138
168
  if chart_type and info.get("type", "") != chart_type:
139
169
  continue
140
170
 
141
- # text search
142
171
  if query:
143
172
  q = query.lower()
144
173
  searchable = " ".join(
@@ -258,7 +287,6 @@ def alarms(ctx: click.Context, show_all: bool, active_only: bool) -> None:
258
287
  if a.get("status", "").upper() in ("WARNING", "CRITICAL")
259
288
  ]
260
289
 
261
- # sort by status severity
262
290
  severity_order = {"CRITICAL": 0, "WARNING": 1, "UNDEFINED": 2, "CLEAR": 3, "REMOVED": 4}
263
291
  alarm_list.sort(key=lambda a: severity_order.get(a.get("status", "").upper(), 5))
264
292
 
@@ -292,7 +320,6 @@ def alarm_log(ctx: click.Context, after: int, unique: bool) -> None:
292
320
  else:
293
321
  entries = c.alarm_log(after=after if after else 0)
294
322
 
295
- # Normalize — API returns dict with 'alarms' key or a list
296
323
  if isinstance(entries, dict):
297
324
  entries = entries.get("alarms", [])
298
325
  if isinstance(entries, list):
@@ -325,7 +352,6 @@ def allmetrics(ctx: click.Context, fmt: str) -> None:
325
352
  if ctx.obj["as_json"]:
326
353
  print_json(data)
327
354
  else:
328
- # Show summary
329
355
  for name in sorted(data.keys())[:30]:
330
356
  entry = data[name]
331
357
  dims = entry.get("dimensions", {})
@@ -448,6 +474,438 @@ def collectors(ctx: click.Context) -> None:
448
474
  _handle_error(exc)
449
475
 
450
476
 
477
+ # ==================================================================
478
+ # MCP COMMANDS (new)
479
+ # ==================================================================
480
+
481
+
482
+ @cli.group()
483
+ @click.pass_context
484
+ def mcp(ctx: click.Context) -> None:
485
+ """MCP (Model Context Protocol) commands — ML-powered analysis.
486
+
487
+ These commands use Netdata's MCP server for anomaly detection,
488
+ metric correlations, and advanced querying.
489
+ """
490
+ pass
491
+
492
+
493
+ # ------------------------------------------------------------------
494
+ # mcp ping
495
+ # ------------------------------------------------------------------
496
+
497
+
498
+ @mcp.command()
499
+ @click.pass_context
500
+ def ping(ctx: click.Context) -> None:
501
+ """Check if MCP server is reachable."""
502
+ try:
503
+ ok = ctx.obj["mcp_client"].ping()
504
+ if ok:
505
+ msg = "MCP pong ✓"
506
+ if console:
507
+ console.print(f"[bold green]{msg}[/]")
508
+ else:
509
+ print(msg)
510
+ else:
511
+ msg = "MCP server not responding"
512
+ if console:
513
+ console.print(f"[bold red]{msg}[/]")
514
+ else:
515
+ print(msg)
516
+ sys.exit(1)
517
+ except MCPError as exc:
518
+ _handle_error(exc)
519
+
520
+
521
+ # ------------------------------------------------------------------
522
+ # mcp list-tools
523
+ # ------------------------------------------------------------------
524
+
525
+
526
+ @mcp.command("list-tools")
527
+ @click.pass_context
528
+ def mcp_list_tools(ctx: click.Context) -> None:
529
+ """List available MCP tools."""
530
+ try:
531
+ tools = ctx.obj["mcp_client"].list_tools()
532
+ if ctx.obj["as_json"]:
533
+ print_json(tools)
534
+ return
535
+
536
+ if HAS_RICH:
537
+ from rich.table import Table
538
+
539
+ t = Table(title=f"MCP Tools ({len(tools)})")
540
+ t.add_column("Name", style="cyan")
541
+ t.add_column("Description", max_width=70)
542
+ for tool in tools:
543
+ t.add_row(tool.get("name", "?"), tool.get("description", "")[:70])
544
+ console.print(t)
545
+ else:
546
+ for tool in tools:
547
+ print(f" {tool.get('name', '?')}: {tool.get('description', '')[:80]}")
548
+ except MCPError as exc:
549
+ _handle_error(exc)
550
+
551
+
552
+ # ------------------------------------------------------------------
553
+ # mcp nodes
554
+ # ------------------------------------------------------------------
555
+
556
+
557
+ @mcp.command()
558
+ @click.option("--metrics", "-m", multiple=True, help="Filter nodes by metric name.")
559
+ @click.pass_context
560
+ def nodes(ctx: click.Context, metrics: tuple) -> None:
561
+ """List monitored nodes."""
562
+ try:
563
+ data = ctx.obj["mcp_client"].list_nodes(list(metrics) if metrics else None)
564
+ _output(data, ctx.obj["as_json"], "Nodes")
565
+ except MCPError as exc:
566
+ _handle_error(exc)
567
+
568
+
569
+ # ------------------------------------------------------------------
570
+ # mcp nodes-details
571
+ # ------------------------------------------------------------------
572
+
573
+
574
+ @mcp.command("nodes-details")
575
+ @click.option("--node", "-n", multiple=True, help="Node ID or hostname.")
576
+ @click.pass_context
577
+ def nodes_details(ctx: click.Context, node: tuple) -> None:
578
+ """Get detailed node information (hardware, OS, capabilities)."""
579
+ try:
580
+ data = ctx.obj["mcp_client"].get_nodes_details(list(node) if node else None)
581
+ _output(data, ctx.obj["as_json"], "Node Details")
582
+ except MCPError as exc:
583
+ _handle_error(exc)
584
+
585
+
586
+ # ------------------------------------------------------------------
587
+ # mcp metrics
588
+ # ------------------------------------------------------------------
589
+
590
+
591
+ @mcp.command()
592
+ @click.argument("query", required=False)
593
+ @click.option("--node", "-n", multiple=True, help="Filter by node.")
594
+ @click.pass_context
595
+ def metrics(ctx: click.Context, query: str | None, node: tuple) -> None:
596
+ """Search available metrics (contexts).
597
+
598
+ \b
599
+ Examples:
600
+ nd mcp metrics # list all
601
+ nd mcp metrics "*nginx*" # pattern search
602
+ nd mcp metrics "q:mysql|redis" # full-text search
603
+ """
604
+ try:
605
+ q = query or ""
606
+ if q.startswith("q:"):
607
+ q = q[2:]
608
+ data = ctx.obj["mcp_client"].list_metrics(
609
+ q=q,
610
+ nodes=list(node) if node else None,
611
+ )
612
+ _output(data, ctx.obj["as_json"], "Metrics")
613
+ except MCPError as exc:
614
+ _handle_error(exc)
615
+
616
+
617
+ # ------------------------------------------------------------------
618
+ # mcp metrics-details
619
+ # ------------------------------------------------------------------
620
+
621
+
622
+ @mcp.command("metrics-details")
623
+ @click.argument("metrics_names", nargs=-1, required=True)
624
+ @click.option("--node", "-n", multiple=True, help="Filter by node.")
625
+ @click.pass_context
626
+ def metrics_details(ctx: click.Context, metrics_names: tuple, node: tuple) -> None:
627
+ """Get comprehensive metadata for specific metrics.
628
+
629
+ \b
630
+ Examples:
631
+ nd mcp metrics-details system.cpu system.ram
632
+ nd mcp metrics-details disk.util.* --node ubuntuserver
633
+ """
634
+ try:
635
+ data = ctx.obj["mcp_client"].get_metrics_details(
636
+ list(metrics_names),
637
+ nodes=list(node) if node else None,
638
+ )
639
+ _output(data, ctx.obj["as_json"], "Metrics Details")
640
+ except MCPError as exc:
641
+ _handle_error(exc)
642
+
643
+
644
+ # ------------------------------------------------------------------
645
+ # mcp query
646
+ # ------------------------------------------------------------------
647
+
648
+
649
+ @mcp.command()
650
+ @click.argument("metric")
651
+ @click.option("--dimensions", "-d", multiple=True, help="Specific dimensions to query.")
652
+ @click.option("--node", "-n", multiple=True, help="Filter by node.")
653
+ @click.option("--after", "-a", default=-300, show_default=True, help="Start time (seconds from now).")
654
+ @click.option("--before", "-b", default=0, show_default=True, help="End time (0=now).")
655
+ @click.option("--points", "-p", default=20, show_default=True, help="Number of data points.")
656
+ @click.option("--time-group", "-g", default="average", show_default=True, help="Time grouping: average, min, max, sum.")
657
+ @click.option("--group-by", multiple=True, default=["dimension"], help="Group by: dimension, instance, node.")
658
+ @click.option("--aggregation", default="average", show_default=True, help="Aggregation: average, sum, min, max.")
659
+ @click.option("--cardinality-limit", default=10, show_default=True, help="Max items returned.")
660
+ @click.pass_context
661
+ def query(
662
+ ctx: click.Context,
663
+ metric: str,
664
+ dimensions: tuple,
665
+ node: tuple,
666
+ after: int,
667
+ before: int,
668
+ points: int,
669
+ time_group: str,
670
+ group_by: tuple,
671
+ aggregation: str,
672
+ cardinality_limit: int,
673
+ ) -> None:
674
+ """Query time-series metrics with ML anomaly detection.
675
+
676
+ \b
677
+ Examples:
678
+ nd mcp query system.cpu --after -60 --points 10
679
+ nd mcp query system.ram -d used -d cached -d free
680
+ nd mcp query disk.util.* --group-by dimension,node
681
+ nd mcp query net.drops --after -3600 --aggregation sum
682
+ """
683
+ try:
684
+ data = ctx.obj["mcp_client"].query_metrics(
685
+ metric=metric,
686
+ dimensions=list(dimensions) if dimensions else None,
687
+ nodes=list(node) if node else None,
688
+ after=after,
689
+ before=before,
690
+ points=points,
691
+ time_group=time_group,
692
+ group_by=list(group_by) if group_by else ["dimension"],
693
+ aggregation=aggregation,
694
+ cardinality_limit=cardinality_limit,
695
+ )
696
+ _output(data, ctx.obj["as_json"], f"Query: {metric}")
697
+ except MCPError as exc:
698
+ _handle_error(exc)
699
+
700
+
701
+ # ------------------------------------------------------------------
702
+ # mcp anomalies
703
+ # ------------------------------------------------------------------
704
+
705
+
706
+ @mcp.command()
707
+ @click.option("--after", "-a", default=-3600, show_default=True, help="Start time (seconds from now).")
708
+ @click.option("--before", "-b", default=0, show_default=True, help="End time (0=now).")
709
+ @click.option("--node", "-n", multiple=True, help="Filter by node.")
710
+ @click.pass_context
711
+ def anomalies(ctx: click.Context, after: int, before: int, node: tuple) -> None:
712
+ """Find metrics detected as anomalous by ML models.
713
+
714
+ \b
715
+ Examples:
716
+ nd mcp anomalies # last hour
717
+ nd mcp anomalies --after -86400 # last 24h
718
+ nd mcp anomalies --after -300 # last 5 min
719
+ """
720
+ try:
721
+ data = ctx.obj["mcp_client"].find_anomalous_metrics(
722
+ after=after,
723
+ before=before,
724
+ nodes=list(node) if node else None,
725
+ )
726
+ _output(data, ctx.obj["as_json"], "Anomalous Metrics")
727
+ except MCPError as exc:
728
+ _handle_error(exc)
729
+
730
+
731
+ # ------------------------------------------------------------------
732
+ # mcp correlated
733
+ # ------------------------------------------------------------------
734
+
735
+
736
+ @mcp.command()
737
+ @click.option("--after", "-a", default=-3600, show_default=True, help="Start time (seconds from now).")
738
+ @click.option("--before", "-b", default=0, show_default=True, help="End time (0=now).")
739
+ @click.option("--node", "-n", multiple=True, help="Filter by node.")
740
+ @click.pass_context
741
+ def correlated(ctx: click.Context, after: int, before: int, node: tuple) -> None:
742
+ """Find metrics that changed significantly during a time period.
743
+
744
+ Compares the specified window against a 4x earlier baseline.
745
+
746
+ \b
747
+ Examples:
748
+ nd mcp correlated # last hour vs previous 4h
749
+ nd mcp correlated --after -300 # last 5min vs previous 20min
750
+ """
751
+ try:
752
+ data = ctx.obj["mcp_client"].find_correlated_metrics(
753
+ after=after,
754
+ before=before,
755
+ nodes=list(node) if node else None,
756
+ )
757
+ _output(data, ctx.obj["as_json"], "Correlated Metrics")
758
+ except MCPError as exc:
759
+ _handle_error(exc)
760
+
761
+
762
+ # ------------------------------------------------------------------
763
+ # mcp unstable
764
+ # ------------------------------------------------------------------
765
+
766
+
767
+ @mcp.command()
768
+ @click.option("--after", "-a", default=-3600, show_default=True, help="Start time (seconds from now).")
769
+ @click.option("--before", "-b", default=0, show_default=True, help="End time (0=now).")
770
+ @click.option("--node", "-n", multiple=True, help="Filter by node.")
771
+ @click.pass_context
772
+ def unstable(ctx: click.Context, after: int, before: int, node: tuple) -> None:
773
+ """Find metrics with highest variability (coefficient of variation).
774
+
775
+ \b
776
+ Examples:
777
+ nd mcp unstable # last hour
778
+ nd mcp unstable --after -86400 # last 24h
779
+ """
780
+ try:
781
+ data = ctx.obj["mcp_client"].find_unstable_metrics(
782
+ after=after,
783
+ before=before,
784
+ nodes=list(node) if node else None,
785
+ )
786
+ _output(data, ctx.obj["as_json"], "Unstable Metrics")
787
+ except MCPError as exc:
788
+ _handle_error(exc)
789
+
790
+
791
+ # ------------------------------------------------------------------
792
+ # mcp mcp-alerts
793
+ # ------------------------------------------------------------------
794
+
795
+
796
+ @mcp.command("mcp-alerts")
797
+ @click.option("--node", "-n", multiple=True, help="Filter by node.")
798
+ @click.pass_context
799
+ def mcp_alerts(ctx: click.Context, node: tuple) -> None:
800
+ """List active alerts (WARNING/CRITICAL) via MCP."""
801
+ try:
802
+ data = ctx.obj["mcp_client"].list_raised_alerts(
803
+ nodes=list(node) if node else None,
804
+ )
805
+ _output(data, ctx.obj["as_json"], "Active Alerts (MCP)")
806
+ except MCPError as exc:
807
+ _handle_error(exc)
808
+
809
+
810
+ # ------------------------------------------------------------------
811
+ # mcp all-alerts
812
+ # ------------------------------------------------------------------
813
+
814
+
815
+ @mcp.command("all-alerts")
816
+ @click.option("--node", "-n", multiple=True, help="Filter by node.")
817
+ @click.pass_context
818
+ def all_alerts(ctx: click.Context, node: tuple) -> None:
819
+ """List all alerts including cleared and uninitialized."""
820
+ try:
821
+ data = ctx.obj["mcp_client"].list_running_alerts(
822
+ nodes=list(node) if node else None,
823
+ )
824
+ _output(data, ctx.obj["as_json"], "All Alerts (MCP)")
825
+ except MCPError as exc:
826
+ _handle_error(exc)
827
+
828
+
829
+ # ------------------------------------------------------------------
830
+ # mcp alert-history
831
+ # ------------------------------------------------------------------
832
+
833
+
834
+ @mcp.command("alert-history")
835
+ @click.option("--after", "-a", default=-3600, show_default=True, help="Start time (seconds from now).")
836
+ @click.option("--before", "-b", default=0, show_default=True, help="End time (0=now).")
837
+ @click.option("--node", "-n", multiple=True, help="Filter by node.")
838
+ @click.pass_context
839
+ def alert_history(ctx: click.Context, after: int, before: int, node: tuple) -> None:
840
+ """Show alert state transitions (history).
841
+
842
+ \b
843
+ Examples:
844
+ nd mcp alert-history # last hour
845
+ nd mcp alert-history --after -86400 # last 24h
846
+ """
847
+ try:
848
+ data = ctx.obj["mcp_client"].list_alert_transitions(
849
+ after=after,
850
+ before=before,
851
+ nodes=list(node) if node else None,
852
+ )
853
+ _output(data, ctx.obj["as_json"], "Alert History (MCP)")
854
+ except MCPError as exc:
855
+ _handle_error(exc)
856
+
857
+
858
+ # ------------------------------------------------------------------
859
+ # mcp functions
860
+ # ------------------------------------------------------------------
861
+
862
+
863
+ @mcp.command("mcp-functions")
864
+ @click.option("--node", "-n", multiple=True, help="Filter by node.")
865
+ @click.pass_context
866
+ def mcp_functions(ctx: click.Context, node: tuple) -> None:
867
+ """List available Netdata functions via MCP."""
868
+ try:
869
+ data = ctx.obj["mcp_client"].list_functions(
870
+ nodes=list(node) if node else None,
871
+ )
872
+ _output(data, ctx.obj["as_json"], "Functions (MCP)")
873
+ except MCPError as exc:
874
+ _handle_error(exc)
875
+
876
+
877
+ # ------------------------------------------------------------------
878
+ # mcp exec
879
+ # ------------------------------------------------------------------
880
+
881
+
882
+ @mcp.command("exec")
883
+ @click.argument("function_name")
884
+ @click.option("--node", "-n", multiple=True, help="Filter by node.")
885
+ @click.option("--after", "-a", default=0, help="Start time (seconds from now).")
886
+ @click.option("--before", "-b", default=0, help="End time (0=now).")
887
+ @click.pass_context
888
+ def mcp_exec(ctx: click.Context, function_name: str, node: tuple, after: int, before: int) -> None:
889
+ """Execute a Netdata function via MCP.
890
+
891
+ \b
892
+ Examples:
893
+ nd mcp exec processes
894
+ nd mcp exec network-connections --node ubuntuserver
895
+ nd mcp exec systemd-journal --after -3600
896
+ """
897
+ try:
898
+ data = ctx.obj["mcp_client"].execute_function(
899
+ function_name=function_name,
900
+ nodes=list(node) if node else None,
901
+ after=after,
902
+ before=before,
903
+ )
904
+ _output(data, ctx.obj["as_json"], f"Function: {function_name}")
905
+ except MCPError as exc:
906
+ _handle_error(exc)
907
+
908
+
451
909
  # ------------------------------------------------------------------
452
910
  # Top-level entry
453
911
  # ------------------------------------------------------------------
@@ -0,0 +1,263 @@
1
+ """Netdata MCP client — JSON-RPC 2.0 over HTTP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ import requests
9
+
10
+
11
+ class MCPError(Exception):
12
+ """Error communicating with Netdata MCP server."""
13
+
14
+
15
+ class MCPClient:
16
+ """Thin wrapper around the Netdata MCP server (JSON-RPC 2.0)."""
17
+
18
+ def __init__(self, base_url: str = "http://localhost:19999", timeout: int = 30):
19
+ self.base_url = base_url.rstrip("/")
20
+ self.mcp_url = f"{self.base_url}/mcp"
21
+ self.timeout = timeout
22
+ self._session = requests.Session()
23
+ self._session.headers.update({"Content-Type": "application/json"})
24
+ self._id = 0
25
+
26
+ def _call(self, method: str, params: dict | None = None) -> Any:
27
+ """Make a JSON-RPC 2.0 call to the MCP server."""
28
+ self._id += 1
29
+ payload: dict[str, Any] = {
30
+ "jsonrpc": "2.0",
31
+ "id": self._id,
32
+ "method": method,
33
+ }
34
+ if params is not None:
35
+ payload["params"] = params
36
+
37
+ try:
38
+ resp = self._session.post(
39
+ self.mcp_url,
40
+ json=payload,
41
+ timeout=self.timeout,
42
+ )
43
+ resp.raise_for_status()
44
+ data = resp.json()
45
+ except requests.ConnectionError:
46
+ raise MCPError(
47
+ f"Cannot connect to MCP at {self.mcp_url}. "
48
+ "Is Netdata running and MCP enabled?"
49
+ )
50
+ except requests.Timeout:
51
+ raise MCPError(f"MCP request timed out after {self.timeout}s")
52
+ except requests.HTTPError as exc:
53
+ raise MCPError(f"MCP HTTP error: {exc}")
54
+ except (ValueError, json.JSONDecodeError):
55
+ raise MCPError(f"Invalid JSON from MCP at {self.mcp_url}")
56
+
57
+ if "error" in data:
58
+ err = data["error"]
59
+ msg = err.get("message", "Unknown MCP error")
60
+ raise MCPError(f"MCP error: {msg}")
61
+
62
+ return data.get("result", {})
63
+
64
+ # ------------------------------------------------------------------
65
+ # Server info
66
+ # ------------------------------------------------------------------
67
+
68
+ def initialize(self) -> dict:
69
+ """Initialize MCP session and get server capabilities."""
70
+ return self._call("initialize", {
71
+ "protocolVersion": "2024-11-05",
72
+ "capabilities": {},
73
+ "clientInfo": {"name": "netdata-cli", "version": "1.0"},
74
+ })
75
+
76
+ def ping(self) -> bool:
77
+ """Check if MCP server is reachable."""
78
+ try:
79
+ result = self._call("initialize", {
80
+ "protocolVersion": "2024-11-05",
81
+ "capabilities": {},
82
+ "clientInfo": {"name": "netdata-cli", "version": "1.0"},
83
+ })
84
+ return bool(result.get("serverInfo"))
85
+ except MCPError:
86
+ return False
87
+
88
+ # ------------------------------------------------------------------
89
+ # Tools
90
+ # ------------------------------------------------------------------
91
+
92
+ def list_tools(self) -> list[dict]:
93
+ """List available MCP tools."""
94
+ result = self._call("tools/list", {})
95
+ return result.get("tools", [])
96
+
97
+ def call_tool(self, name: str, arguments: dict | None = None) -> Any:
98
+ """Call an MCP tool by name with arguments."""
99
+ result = self._call("tools/call", {
100
+ "name": name,
101
+ "arguments": arguments or {},
102
+ })
103
+ # Extract text content from MCP response
104
+ content = result.get("content", [])
105
+ for item in content:
106
+ if item.get("type") == "text":
107
+ text = item.get("text", "")
108
+ try:
109
+ return json.loads(text)
110
+ except (ValueError, json.JSONDecodeError):
111
+ return text
112
+ return content
113
+
114
+ # ------------------------------------------------------------------
115
+ # High-level wrappers for each MCP tool
116
+ # ------------------------------------------------------------------
117
+
118
+ def list_metrics(self, q: str = "", nodes: list[str] | None = None) -> Any:
119
+ """Search available metrics by pattern or full-text query."""
120
+ args: dict[str, Any] = {}
121
+ if q:
122
+ args["q"] = q
123
+ if nodes:
124
+ args["nodes"] = nodes
125
+ return self.call_tool("list_metrics", args)
126
+
127
+ def get_metrics_details(self, metrics: list[str], nodes: list[str] | None = None) -> Any:
128
+ """Get comprehensive metadata for specific metrics."""
129
+ args: dict[str, Any] = {"metrics": metrics}
130
+ if nodes:
131
+ args["nodes"] = nodes
132
+ return self.call_tool("get_metrics_details", args)
133
+
134
+ def list_nodes(self, metrics: list[str] | None = None) -> Any:
135
+ """List all monitored nodes."""
136
+ args: dict[str, Any] = {}
137
+ if metrics:
138
+ args["metrics"] = metrics
139
+ return self.call_tool("list_nodes", args)
140
+
141
+ def get_nodes_details(self, nodes: list[str] | None = None) -> Any:
142
+ """Get comprehensive node information."""
143
+ args: dict[str, Any] = {}
144
+ if nodes:
145
+ args["nodes"] = nodes
146
+ return self.call_tool("get_nodes_details", args)
147
+
148
+ def list_functions(self, nodes: list[str] | None = None) -> Any:
149
+ """List available Netdata functions."""
150
+ args: dict[str, Any] = {}
151
+ if nodes:
152
+ args["nodes"] = nodes
153
+ return self.call_tool("list_functions", args)
154
+
155
+ def execute_function(
156
+ self,
157
+ function_name: str,
158
+ nodes: list[str] | None = None,
159
+ after: int = 0,
160
+ before: int = 0,
161
+ ) -> Any:
162
+ """Execute a Netdata function (processes, connections, etc)."""
163
+ args: dict[str, Any] = {"function": function_name}
164
+ if nodes:
165
+ args["nodes"] = nodes
166
+ if after:
167
+ args["after"] = after
168
+ if before:
169
+ args["before"] = before
170
+ return self.call_tool("execute_function", args)
171
+
172
+ def query_metrics(
173
+ self,
174
+ metric: str,
175
+ dimensions: list[str] | None = None,
176
+ nodes: list[str] | None = None,
177
+ after: int = -300,
178
+ before: int = 0,
179
+ points: int = 20,
180
+ time_group: str = "average",
181
+ group_by: list[str] | None = None,
182
+ aggregation: str = "average",
183
+ cardinality_limit: int = 10,
184
+ ) -> Any:
185
+ """Query time-series metrics data with aggregation."""
186
+ args: dict[str, Any] = {
187
+ "metric": metric,
188
+ "after": after,
189
+ "before": before,
190
+ "points": points,
191
+ "time_group": time_group,
192
+ "aggregation": aggregation,
193
+ "cardinality_limit": cardinality_limit,
194
+ }
195
+ if dimensions:
196
+ args["dimensions"] = dimensions
197
+ if nodes:
198
+ args["nodes"] = nodes
199
+ if group_by:
200
+ args["group_by"] = group_by
201
+ return self.call_tool("query_metrics", args)
202
+
203
+ def find_correlated_metrics(
204
+ self,
205
+ after: int = -3600,
206
+ before: int = 0,
207
+ nodes: list[str] | None = None,
208
+ ) -> Any:
209
+ """Find metrics that changed significantly during a time period."""
210
+ args: dict[str, Any] = {"after": after, "before": before}
211
+ if nodes:
212
+ args["nodes"] = nodes
213
+ return self.call_tool("find_correlated_metrics", args)
214
+
215
+ def find_anomalous_metrics(
216
+ self,
217
+ after: int = -3600,
218
+ before: int = 0,
219
+ nodes: list[str] | None = None,
220
+ ) -> Any:
221
+ """Find metrics detected as anomalous by ML models."""
222
+ args: dict[str, Any] = {"after": after, "before": before}
223
+ if nodes:
224
+ args["nodes"] = nodes
225
+ return self.call_tool("find_anomalous_metrics", args)
226
+
227
+ def find_unstable_metrics(
228
+ self,
229
+ after: int = -3600,
230
+ before: int = 0,
231
+ nodes: list[str] | None = None,
232
+ ) -> Any:
233
+ """Find metrics with highest variability."""
234
+ args: dict[str, Any] = {"after": after, "before": before}
235
+ if nodes:
236
+ args["nodes"] = nodes
237
+ return self.call_tool("find_unstable_metrics", args)
238
+
239
+ def list_raised_alerts(self, nodes: list[str] | None = None) -> Any:
240
+ """List currently active alerts (WARNING/CRITICAL)."""
241
+ args: dict[str, Any] = {}
242
+ if nodes:
243
+ args["nodes"] = nodes
244
+ return self.call_tool("list_raised_alerts", args)
245
+
246
+ def list_running_alerts(self, nodes: list[str] | None = None) -> Any:
247
+ """List all alerts including cleared and uninitialized."""
248
+ args: dict[str, Any] = {}
249
+ if nodes:
250
+ args["nodes"] = nodes
251
+ return self.call_tool("list_running_alerts", args)
252
+
253
+ def list_alert_transitions(
254
+ self,
255
+ after: int = -3600,
256
+ before: int = 0,
257
+ nodes: list[str] | None = None,
258
+ ) -> Any:
259
+ """List recent alert state transitions."""
260
+ args: dict[str, Any] = {"after": after, "before": before}
261
+ if nodes:
262
+ args["nodes"] = nodes
263
+ return self.call_tool("list_alert_transitions", args)
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: netdata-cli
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: A powerful CLI for the Netdata Agent REST API — query metrics, charts, alarms, and functions from the terminal.
5
- Author-email: Sosi <sosi@example.com>
5
+ Author-email: Sosi <albertotplaza@gmail.com>
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/sosiman/netdata-cli
8
8
  Project-URL: Repository, https://github.com/sosiman/netdata-cli
@@ -35,6 +35,10 @@ Dynamic: license-file
35
35
 
36
36
  # netdata-cli
37
37
 
38
+ [![PyPI version](https://img.shields.io/pypi/v/netdata-cli)](https://pypi.org/project/netdata-cli/)
39
+ [![Python](https://img.shields.io/pypi/pyversions/netdata-cli)](https://pypi.org/project/netdata-cli/)
40
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
41
+
38
42
  A powerful CLI for the [Netdata](https://www.netdata.cloud/) Agent REST API.
39
43
 
40
44
  Query metrics, charts, alarms, functions, and agent info — all from the terminal.
@@ -0,0 +1,11 @@
1
+ netdata_cli/__init__.py,sha256=98BjFSTP9kfsojaOeT-kQNw65tfi-uiWolU549sPMpM,92
2
+ netdata_cli/cli.py,sha256=PhvxMkZo04UzvBCxHm4Pq1REyaQ-w4YAV_OJU2xhyzo,30264
3
+ netdata_cli/client.py,sha256=T4DuVOP_CKvzpF1FiXbG1OvYvqhgO29tOqHykFCp48A,7862
4
+ netdata_cli/formatters.py,sha256=rokGtvuwK1MsR2cMOEjza2ggL4YvqSOLnvCgCwiPm_w,11644
5
+ netdata_cli/mcp_client.py,sha256=XNk2aKHAfnBHRjjnewuy4dxve7WfGco2YElpUoXxxXA,9076
6
+ netdata_cli-0.2.0.dist-info/licenses/LICENSE,sha256=Tq3uYZqMEv-MzVnsiXx-L99tt5Css4jGrocf6KpFdfM,1061
7
+ netdata_cli-0.2.0.dist-info/METADATA,sha256=UhjQzTgLYHywoA7T3ipGMb6P4C9_iuHDRLFghoikzh8,5977
8
+ netdata_cli-0.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ netdata_cli-0.2.0.dist-info/entry_points.txt,sha256=15fw-SmjPRO9-1z1h0g8I2pWuyNXZDm_MmHkiDklELw,44
10
+ netdata_cli-0.2.0.dist-info/top_level.txt,sha256=TC9SwX7mrl2Q1QDRKMs045wkb5Bg3MH1y_iAiL1jhDc,12
11
+ netdata_cli-0.2.0.dist-info/RECORD,,
@@ -1,10 +0,0 @@
1
- netdata_cli/__init__.py,sha256=rIbZbYbNCaZoSgcA-IOtDHW52a1eZSUD-2ONKIaMVsM,92
2
- netdata_cli/cli.py,sha256=ZCfaEzf119wYfzv5ZM2kM4hjrZMC6BvpMInU8PBpszQ,14570
3
- netdata_cli/client.py,sha256=T4DuVOP_CKvzpF1FiXbG1OvYvqhgO29tOqHykFCp48A,7862
4
- netdata_cli/formatters.py,sha256=rokGtvuwK1MsR2cMOEjza2ggL4YvqSOLnvCgCwiPm_w,11644
5
- netdata_cli-0.1.0.dist-info/licenses/LICENSE,sha256=Tq3uYZqMEv-MzVnsiXx-L99tt5Css4jGrocf6KpFdfM,1061
6
- netdata_cli-0.1.0.dist-info/METADATA,sha256=QoL8zXtGoXNJFhlbCjhuhNmazh3m_u_fQ8dD9S4Fs7A,5686
7
- netdata_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
8
- netdata_cli-0.1.0.dist-info/entry_points.txt,sha256=15fw-SmjPRO9-1z1h0g8I2pWuyNXZDm_MmHkiDklELw,44
9
- netdata_cli-0.1.0.dist-info/top_level.txt,sha256=TC9SwX7mrl2Q1QDRKMs045wkb5Bg3MH1y_iAiL1jhDc,12
10
- netdata_cli-0.1.0.dist-info/RECORD,,