Skip to content

Commit 97802b2

Browse files
feat: add proactive monitoring, resources, and prompts
Transform SysMetrics MCP from a pull-based query tool into a proactive monitoring agent. - Add internal/monitor package: delta-based sampler (CPU/net/disk rates), snapshot history ring, and threshold evaluation producing alerts. - Add 7 monitoring tools: start_monitoring, stop_monitoring, get_monitoring_status, get_metrics_history, get_alerts, get_network_throughput, get_disk_throughput. - Add subscribable sys://metrics/* MCP resources plus a processes resource template. - Add analyze_system_health and diagnose_performance_issue MCP prompts. - Enable MCP sampling capability for proactive alert push. - Refactor get_system_health to share threshold logic with the monitor. - Add --monitor-interval CLI flag and unit tests.
1 parent 866f5b7 commit 97802b2

13 files changed

Lines changed: 1468 additions & 50 deletions

File tree

README.md

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ A lightweight MCP (Model Context Protocol) server that exposes Linux system metr
44

55
## Features
66

7-
- **12 MCP Tools**: System info, CPU, memory, disk, disk I/O, network, network connections, processes, thermal, Docker, system health, and service status
7+
- **19 MCP Tools**: System info, CPU, memory, disk, disk I/O, network, network connections, processes, thermal, Docker, system health, service status, plus monitoring and alerting tools
8+
- **MCP Resources**: Subscribable `sys://metrics/*` resources for proactive state reads
9+
- **MCP Prompts**: `analyze_system_health` and `diagnose_performance_issue` prompt templates
10+
- **Streaming Metrics**: Delta-based throughput sampling (network bytes/s, disk IOPS/bytes/s) and history
11+
- **Threshold Alerting**: Background monitoring that generates warning/critical alerts on resource saturation
812
- **Configurable**: CLI arguments for temperature units, process limits, mount points, and interfaces
913
- **Cross-Platform**: Works on any Linux system (enhanced metrics for Raspberry Pi)
1014
- **AI-Ready**: Designed for integration with Claude Desktop, Cursor, or any MCP client
@@ -74,6 +78,7 @@ Add to your agent's configuration file:
7478
| `--mount-points` | `""` | Comma-separated mount points (empty = all) |
7579
| `--interfaces` | `""` | Comma-separated interfaces (empty = all, excludes `lo`) |
7680
| `--enable-gpu` | `true` | Attempt to read GPU metrics (Raspberry Pi only) |
81+
| `--monitor-interval` | `5` | Default sampling interval in seconds for monitoring (1-60) |
7782

7883
## MCP Tools
7984

@@ -143,6 +148,54 @@ Returns systemd service health information via `systemctl show`.
143148
**Required Arguments:**
144149
- `services`: Comma-separated list of service names to check
145150

151+
### `start_monitoring`
152+
Starts background sampling of system metrics. Once running, the server retains a history buffer and evaluates resource thresholds to generate alerts.
153+
154+
**Optional Arguments:**
155+
- `interval`: Sampling interval in seconds (defaults to `--monitor-interval`)
156+
157+
### `stop_monitoring`
158+
Stops background sampling.
159+
160+
### `get_monitoring_status`
161+
Returns whether monitoring is running, the aggregate health status, and the latest snapshot.
162+
163+
### `get_metrics_history`
164+
Returns recent metric snapshots captured by the monitor.
165+
166+
**Optional Arguments:**
167+
- `seconds`: Only return snapshots captured within the last N seconds
168+
169+
### `get_alerts`
170+
Returns threshold alerts generated since the last read (read-then-drain).
171+
172+
**Optional Arguments:**
173+
- `severity`: Filter by `warning` or `critical`
174+
175+
### `get_network_throughput`
176+
Returns per-interface network throughput rates (bytes/sec) since the last sample.
177+
178+
### `get_disk_throughput`
179+
Returns per-device disk I/O throughput rates (bytes/sec and IOPS) since the last sample.
180+
181+
## MCP Resources
182+
183+
The server exposes subscribable resources under the `sys://metrics/*` namespace so clients can read current state without issuing a tool call:
184+
185+
| URI | Description |
186+
|-----|-------------|
187+
| `sys://metrics/overview` | Aggregated health dashboard |
188+
| `sys://metrics/cpu` | CPU model, cores, temperature |
189+
| `sys://metrics/memory` | RAM usage |
190+
| `sys://metrics/disk` | Disk usage across mount points |
191+
| `sys://metrics/network` | Network interface throughput |
192+
| `sys://metrics/processes/top` | Template: top N processes (e.g. `sys://metrics/processes/top?limit=10`) |
193+
194+
## MCP Prompts
195+
196+
- **`analyze_system_health`** — gathers and summarizes overall system health.
197+
- **`diagnose_performance_issue`** — diagnoses a reported performance problem by checking CPU, memory, disk, network, and top processes.
198+
146199
## Example Usage
147200

148201
Once configured, you can ask your AI assistant:
@@ -157,6 +210,9 @@ Once configured, you can ask your AI assistant:
157210
- "Check if the SSH and Docker services are running"
158211
- "What are the disk I/O stats for my drives?"
159212
- "How much CPU and memory are my Docker containers using?"
213+
- "Start monitoring every 2 seconds and alert me if CPU is high"
214+
- "Show me disk throughput over the last minute"
215+
- "Are there any recent resource warnings?"
160216

161217
## Raspberry Pi Enhancements
162218

cmd/sysmetrics-mcp/main.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ func main() {
2121
flag.StringVar(&cfg.MountPointsStr, "mount-points", "", "Comma-separated mount points to monitor (empty = all)")
2222
flag.StringVar(&cfg.InterfacesStr, "interfaces", "", "Comma-separated interfaces to monitor (empty = all)")
2323
flag.BoolVar(&cfg.EnableGPU, "enable-gpu", true, "Attempt to read GPU metrics if available")
24+
flag.IntVar(&cfg.MonitorInterval, "monitor-interval", config.DefaultMonitorInterval, "Default sampling interval in seconds for monitoring")
2425
flag.Parse()
2526

2627
// Validate and parse comma-separated lists
@@ -35,9 +36,16 @@ func main() {
3536
"1.0.0",
3637
)
3738

38-
// Create handler manager and register tools
39+
// Enable sampling so the server can proactively push alerts to clients
40+
// that declare sampling support.
41+
s.EnableSampling()
42+
43+
// Create handler manager and register tools, resources, and prompts
3944
hm := handlers.NewHandlerManager(&cfg)
4045
hm.RegisterTools(s)
46+
hm.RegisterMonitoringTools(s)
47+
hm.RegisterResources(s)
48+
hm.RegisterPrompts(s)
4149

4250
// Start server via stdio
4351
if err := server.ServeStdio(s); err != nil {

internal/config/config.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,25 @@ const (
1717
UnitKelvin = "kelvin"
1818
)
1919

20+
// DefaultMonitorInterval is the default sampling interval in seconds.
21+
const DefaultMonitorInterval = 5
22+
23+
// MaxMonitorInterval and MinMonitorInterval bound the monitor interval.
24+
const (
25+
MaxMonitorInterval = 60
26+
MinMonitorInterval = 1
27+
)
28+
2029
// Config holds the server configuration from CLI args
2130
type Config struct {
22-
TempUnit string
23-
MaxProcesses int
24-
MountPoints []string
25-
Interfaces []string
26-
EnableGPU bool
27-
MountPointsStr string
28-
InterfacesStr string
31+
TempUnit string
32+
MaxProcesses int
33+
MountPoints []string
34+
Interfaces []string
35+
EnableGPU bool
36+
MountPointsStr string
37+
InterfacesStr string
38+
MonitorInterval int
2939
}
3040

3141
// Validate checks the configuration and parses string lists
@@ -54,6 +64,14 @@ func (c *Config) Validate() error {
5464
c.Interfaces = SplitAndTrim(c.InterfacesStr)
5565
}
5666

67+
// Validate monitor interval
68+
if c.MonitorInterval < MinMonitorInterval {
69+
c.MonitorInterval = DefaultMonitorInterval
70+
}
71+
if c.MonitorInterval > MaxMonitorInterval {
72+
c.MonitorInterval = MaxMonitorInterval
73+
}
74+
5775
return nil
5876
}
5977

internal/handlers/handlers.go

Lines changed: 20 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"time"
1313

1414
"sysmetrics-mcp/internal/config"
15+
"sysmetrics-mcp/internal/monitor"
1516

1617
"github.com/mark3labs/mcp-go/mcp"
1718
"github.com/mark3labs/mcp-go/server"
@@ -24,13 +25,6 @@ import (
2425
"github.com/shirou/gopsutil/v3/process"
2526
)
2627

27-
// Health status constants.
28-
const (
29-
statusHealthy = "healthy"
30-
statusCritical = "critical"
31-
statusWarning = "warning"
32-
)
33-
3428
// Network kind constants.
3529
const (
3630
kindTCP = "tcp"
@@ -40,12 +34,16 @@ const (
4034

4135
// HandlerManager manages the MCP tool handlers
4236
type HandlerManager struct {
43-
cfg *config.Config
37+
cfg *config.Config
38+
monitor *monitor.Monitor
4439
}
4540

4641
// NewHandlerManager creates a new HandlerManager
4742
func NewHandlerManager(cfg *config.Config) *HandlerManager {
48-
return &HandlerManager{cfg: cfg}
43+
return &HandlerManager{
44+
cfg: cfg,
45+
monitor: monitor.NewMonitor(monitor.DefaultThresholds(), 60),
46+
}
4947
}
5048

5149
// RegisterTools registers all available tools with the MCP server
@@ -642,38 +640,19 @@ func (h *HandlerManager) HandleGetSystemHealth(ctx context.Context, request mcp.
642640
//nolint:gosec // G115: integer overflow conversion safe for reasonable uptimes
643641
uptime := time.Duration(info.Uptime) * time.Second
644642

645-
// Determine overall status
646-
status := statusHealthy
647-
var warnings []string
648-
649-
if cpuUsage > 95 {
650-
status = statusCritical
651-
warnings = append(warnings, "CPU usage is critical (>95%)")
652-
} else if cpuUsage > 80 {
653-
if status != statusCritical {
654-
status = statusWarning
655-
}
656-
warnings = append(warnings, "CPU usage is high (>80%)")
657-
}
658-
659-
if memInfo.UsedPercent > 95 {
660-
status = statusCritical
661-
warnings = append(warnings, "Memory usage is critical (>95%)")
662-
} else if memInfo.UsedPercent > 85 {
663-
if status != statusCritical {
664-
status = statusWarning
665-
}
666-
warnings = append(warnings, "Memory usage is high (>85%)")
667-
}
668-
669-
if rootDisk.UsedPercent > 95 {
670-
status = statusCritical
671-
warnings = append(warnings, "Disk usage is critical (>95%)")
672-
} else if rootDisk.UsedPercent > 85 {
673-
if status != statusCritical {
674-
status = statusWarning
675-
}
676-
warnings = append(warnings, "Disk usage is high (>85%)")
643+
// Determine overall status via shared threshold evaluation
644+
status, alerts := monitor.EvaluateHealth(
645+
monitor.DefaultThresholds(),
646+
cpuUsage,
647+
memInfo.UsedPercent,
648+
rootDisk.UsedPercent,
649+
[]monitor.NetStat{},
650+
)
651+
652+
// Keep warnings as human-readable strings
653+
warnings := make([]string, 0, len(alerts))
654+
for _, a := range alerts {
655+
warnings = append(warnings, a.Message)
677656
}
678657

679658
result := map[string]interface{}{

internal/handlers/handlers_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,3 +266,93 @@ func TestHandleGetServiceStatusMissing(t *testing.T) {
266266
t.Error("Expected error result when services parameter is missing")
267267
}
268268
}
269+
270+
func TestHandleStartStopMonitoring(t *testing.T) {
271+
h := NewHandlerManager(&config.Config{MonitorInterval: 5})
272+
req := mcp.CallToolRequest{
273+
Params: mcp.CallToolParams{
274+
Arguments: map[string]interface{}{
275+
"interval": 1,
276+
},
277+
},
278+
}
279+
280+
// Start monitoring
281+
res, err := h.HandleStartMonitoring(context.Background(), req)
282+
checkToolResult(t, res, err, []string{"started", "interval_seconds"})
283+
284+
// Stop monitoring
285+
stopReq := mcp.CallToolRequest{}
286+
res, err = h.HandleStopMonitoring(context.Background(), stopReq)
287+
checkToolResult(t, res, err, []string{"was_running", "is_running"})
288+
}
289+
290+
func TestHandleGetMonitoringStatus(t *testing.T) {
291+
h := NewHandlerManager(&config.Config{MonitorInterval: 5})
292+
req := mcp.CallToolRequest{}
293+
res, err := h.HandleGetMonitoringStatus(context.Background(), req)
294+
checkToolResult(t, res, err, []string{"running", "status", "has_snapshot"})
295+
}
296+
297+
func TestHandleGetAlerts(t *testing.T) {
298+
h := NewHandlerManager(&config.Config{})
299+
req := mcp.CallToolRequest{
300+
Params: mcp.CallToolParams{
301+
Arguments: map[string]interface{}{},
302+
},
303+
}
304+
res, err := h.HandleGetAlerts(context.Background(), req)
305+
checkToolResult(t, res, err, []string{"count", "alerts"})
306+
}
307+
308+
func TestHandleGetMetricsHistory(t *testing.T) {
309+
h := NewHandlerManager(&config.Config{})
310+
req := mcp.CallToolRequest{
311+
Params: mcp.CallToolParams{
312+
Arguments: map[string]interface{}{},
313+
},
314+
}
315+
res, err := h.HandleGetMetricsHistory(context.Background(), req)
316+
checkToolResult(t, res, err, []string{"count", "samples", "running"})
317+
}
318+
319+
func TestHandleGetNetworkThroughput(t *testing.T) {
320+
h := NewHandlerManager(&config.Config{})
321+
req := mcp.CallToolRequest{}
322+
res, err := h.HandleGetNetworkThroughput(context.Background(), req)
323+
checkToolResult(t, res, err, []string{"timestamp", "interfaces"})
324+
}
325+
326+
func TestHandleGetDiskThroughput(t *testing.T) {
327+
h := NewHandlerManager(&config.Config{})
328+
req := mcp.CallToolRequest{}
329+
res, err := h.HandleGetDiskThroughput(context.Background(), req)
330+
checkToolResult(t, res, err, []string{"timestamp", "devices"})
331+
}
332+
333+
func TestPrompts(t *testing.T) {
334+
h := NewHandlerManager(&config.Config{})
335+
336+
res, err := h.promptAnalyzeHealth(context.Background(), mcp.GetPromptRequest{})
337+
if err != nil {
338+
t.Fatalf("promptAnalyzeHealth error: %v", err)
339+
}
340+
if res.Description == "" {
341+
t.Error("prompt result missing description")
342+
}
343+
if len(res.Messages) == 0 {
344+
t.Error("prompt result missing messages")
345+
}
346+
347+
res, err = h.promptDiagnosePerformance(context.Background(), mcp.GetPromptRequest{
348+
Params: mcp.GetPromptParams{
349+
Arguments: map[string]string{"symptom": "slow"},
350+
},
351+
})
352+
if err != nil {
353+
t.Fatalf("promptDiagnosePerformance error: %v", err)
354+
}
355+
if len(res.Messages) == 0 {
356+
t.Error("prompt result missing messages")
357+
}
358+
}

0 commit comments

Comments
 (0)