package monitoring import ( "context" "encoding/json" "io" "log" "net/http" "net/http/httptest" "strings" "testing" "git.t-juice.club/torjus/labmcp/internal/mcp" ) // setupTestServer creates a test MCP server with monitoring handlers backed by test HTTP servers. func setupTestServer(t *testing.T, promHandler, amHandler http.HandlerFunc, lokiHandler ...http.HandlerFunc) (*mcp.Server, func()) { t.Helper() promSrv := httptest.NewServer(promHandler) amSrv := httptest.NewServer(amHandler) logger := log.New(io.Discard, "", 0) config := mcp.DefaultMonitoringConfig() server := mcp.NewGenericServer(logger, config) prom := NewPrometheusClient(promSrv.URL) am := NewAlertmanagerClient(amSrv.URL) var loki *LokiClient var lokiSrv *httptest.Server if len(lokiHandler) > 0 && lokiHandler[0] != nil { lokiSrv = httptest.NewServer(lokiHandler[0]) loki = NewLokiClient(lokiSrv.URL) } RegisterHandlers(server, prom, am, loki, HandlerOptions{EnableSilences: true}) cleanup := func() { promSrv.Close() amSrv.Close() if lokiSrv != nil { lokiSrv.Close() } } return server, cleanup } func TestHandler_ListAlerts(t *testing.T) { server, cleanup := setupTestServer(t, nil, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[ { "annotations": {"summary": "Node is down"}, "endsAt": "2024-01-01T01:00:00Z", "fingerprint": "fp1", "receivers": [{"name": "default"}], "startsAt": "2024-01-01T00:00:00Z", "status": {"inhibitedBy": [], "silencedBy": [], "state": "active"}, "updatedAt": "2024-01-01T00:00:00Z", "generatorURL": "", "labels": {"alertname": "NodeDown", "severity": "critical"} } ]`)) }, ) defer cleanup() result := callTool(t, server, "list_alerts", map[string]interface{}{}) if result.IsError { t.Fatalf("unexpected error: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "NodeDown") { t.Errorf("expected output to contain 'NodeDown', got: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "1 alert") { t.Errorf("expected output to contain '1 alert', got: %s", result.Content[0].Text) } } func TestHandler_GetAlert(t *testing.T) { server, cleanup := setupTestServer(t, nil, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[ { "annotations": {"summary": "Found it"}, "endsAt": "2024-01-01T01:00:00Z", "fingerprint": "target-fp", "receivers": [{"name": "default"}], "startsAt": "2024-01-01T00:00:00Z", "status": {"inhibitedBy": [], "silencedBy": [], "state": "active"}, "updatedAt": "2024-01-01T00:00:00Z", "generatorURL": "", "labels": {"alertname": "TestAlert", "severity": "warning"} }, { "annotations": {}, "endsAt": "2024-01-01T01:00:00Z", "fingerprint": "other-fp", "receivers": [{"name": "default"}], "startsAt": "2024-01-01T00:00:00Z", "status": {"inhibitedBy": [], "silencedBy": [], "state": "active"}, "updatedAt": "2024-01-01T00:00:00Z", "generatorURL": "", "labels": {"alertname": "OtherAlert", "severity": "info"} } ]`)) }, ) defer cleanup() result := callTool(t, server, "get_alert", map[string]interface{}{ "fingerprint": "target-fp", }) if result.IsError { t.Fatalf("unexpected error: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "TestAlert") { t.Errorf("expected output to contain 'TestAlert', got: %s", result.Content[0].Text) } } func TestHandler_GetAlertNotFound(t *testing.T) { server, cleanup := setupTestServer(t, nil, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[]`)) }, ) defer cleanup() result := callTool(t, server, "get_alert", map[string]interface{}{ "fingerprint": "nonexistent", }) if !result.IsError { t.Error("expected error result for nonexistent fingerprint") } } func TestHandler_Query(t *testing.T) { server, cleanup := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/query" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "status": "success", "data": { "resultType": "vector", "result": [ { "metric": {"__name__": "up", "job": "node"}, "value": [1234567890, "1"] } ] } }`)) }, nil, ) defer cleanup() result := callTool(t, server, "query", map[string]interface{}{ "promql": "up", }) if result.IsError { t.Fatalf("unexpected error: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "node") { t.Errorf("expected output to contain 'node', got: %s", result.Content[0].Text) } } func TestHandler_ListTargets(t *testing.T) { server, cleanup := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/targets" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "status": "success", "data": { "activeTargets": [ { "labels": {"instance": "localhost:9090", "job": "prometheus"}, "scrapePool": "prometheus", "scrapeUrl": "http://localhost:9090/metrics", "globalUrl": "http://localhost:9090/metrics", "lastError": "", "lastScrape": "2024-01-01T00:00:00Z", "lastScrapeDuration": 0.015, "health": "up", "scrapeInterval": "15s", "scrapeTimeout": "10s" } ], "droppedTargets": [] } }`)) }, nil, ) defer cleanup() result := callTool(t, server, "list_targets", map[string]interface{}{}) if result.IsError { t.Fatalf("unexpected error: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "prometheus") { t.Errorf("expected output to contain 'prometheus', got: %s", result.Content[0].Text) } } func TestHandler_SearchMetrics(t *testing.T) { server, cleanup := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") switch r.URL.Path { case "/api/v1/label/__name__/values": _, _ = w.Write([]byte(`{ "status": "success", "data": ["node_cpu_seconds_total", "node_memory_MemTotal_bytes", "up"] }`)) case "/api/v1/metadata": _, _ = w.Write([]byte(`{ "status": "success", "data": { "node_cpu_seconds_total": [{"type": "counter", "help": "CPU time", "unit": ""}], "node_memory_MemTotal_bytes": [{"type": "gauge", "help": "Total memory", "unit": "bytes"}] } }`)) default: http.NotFound(w, r) } }, nil, ) defer cleanup() result := callTool(t, server, "search_metrics", map[string]interface{}{ "query": "node", }) if result.IsError { t.Fatalf("unexpected error: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "node_cpu") { t.Errorf("expected output to contain 'node_cpu', got: %s", result.Content[0].Text) } // "up" should be filtered out since it doesn't match "node" if strings.Contains(result.Content[0].Text, "| up |") { t.Errorf("expected 'up' to be filtered out, got: %s", result.Content[0].Text) } } func TestHandler_ListSilences(t *testing.T) { server, cleanup := setupTestServer(t, nil, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v2/silences" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`[ { "id": "s1", "matchers": [{"name": "alertname", "value": "Test", "isRegex": false}], "startsAt": "2024-01-01T00:00:00Z", "endsAt": "2024-01-01T02:00:00Z", "createdBy": "admin", "comment": "Testing", "status": {"state": "active"} }, { "id": "s2", "matchers": [{"name": "job", "value": "node", "isRegex": false}], "startsAt": "2023-01-01T00:00:00Z", "endsAt": "2023-01-01T02:00:00Z", "createdBy": "admin", "comment": "Old", "status": {"state": "expired"} } ]`)) }, ) defer cleanup() result := callTool(t, server, "list_silences", map[string]interface{}{}) if result.IsError { t.Fatalf("unexpected error: %s", result.Content[0].Text) } // Should show active silence but filter out expired if !strings.Contains(result.Content[0].Text, "s1") { t.Errorf("expected active silence s1 in output, got: %s", result.Content[0].Text) } if strings.Contains(result.Content[0].Text, "s2") { t.Errorf("expected expired silence s2 to be filtered out, got: %s", result.Content[0].Text) } } func TestHandler_ToolCount(t *testing.T) { server, cleanup := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) {}, func(w http.ResponseWriter, r *http.Request) {}, ) defer cleanup() tools := listTools(t, server) // Without Loki: 7 base + 1 silence = 8 if len(tools) != 8 { t.Errorf("expected 8 tools with silences enabled (no Loki), got %d", len(tools)) for _, tool := range tools { t.Logf(" tool: %s", tool.Name) } } // Verify create_silence is present found := false for _, tool := range tools { if tool.Name == "create_silence" { found = true break } } if !found { t.Error("expected create_silence tool when silences enabled") } } func TestHandler_ToolCountWithLoki(t *testing.T) { server, cleanup := setupTestServer(t, func(w http.ResponseWriter, r *http.Request) {}, func(w http.ResponseWriter, r *http.Request) {}, func(w http.ResponseWriter, r *http.Request) {}, ) defer cleanup() tools := listTools(t, server) // With Loki: 7 base + 1 silence + 3 loki = 11 if len(tools) != 11 { t.Errorf("expected 11 tools with silences and Loki enabled, got %d", len(tools)) for _, tool := range tools { t.Logf(" tool: %s", tool.Name) } } // Verify Loki tools are present lokiTools := map[string]bool{"query_logs": false, "list_labels": false, "list_label_values": false} for _, tool := range tools { if _, ok := lokiTools[tool.Name]; ok { lokiTools[tool.Name] = true } } for name, found := range lokiTools { if !found { t.Errorf("expected %s tool when Loki enabled", name) } } } func TestHandler_ToolCountWithoutSilences(t *testing.T) { promSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) amSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) defer promSrv.Close() defer amSrv.Close() logger := log.New(io.Discard, "", 0) config := mcp.DefaultMonitoringConfig() server := mcp.NewGenericServer(logger, config) prom := NewPrometheusClient(promSrv.URL) am := NewAlertmanagerClient(amSrv.URL) RegisterHandlers(server, prom, am, nil, HandlerOptions{EnableSilences: false}) tools := listTools(t, server) if len(tools) != 7 { t.Errorf("expected 7 tools without silences, got %d", len(tools)) for _, tool := range tools { t.Logf(" tool: %s", tool.Name) } } // Verify create_silence is NOT present for _, tool := range tools { if tool.Name == "create_silence" { t.Error("expected create_silence tool to be absent when silences disabled") } } } func listTools(t *testing.T, server *mcp.Server) []mcp.Tool { t.Helper() req := &mcp.Request{ JSONRPC: "2.0", ID: 1, Method: "tools/list", } resp := server.HandleRequest(context.Background(), req) if resp == nil { t.Fatal("expected response, got nil") } if resp.Error != nil { t.Fatalf("unexpected error: %s", resp.Error.Message) } resultJSON, err := json.Marshal(resp.Result) if err != nil { t.Fatalf("failed to marshal result: %v", err) } var listResult mcp.ListToolsResult if err := json.Unmarshal(resultJSON, &listResult); err != nil { t.Fatalf("failed to unmarshal result: %v", err) } return listResult.Tools } func TestHandler_QueryLogs(t *testing.T) { server, cleanup := setupTestServer(t, nil, nil, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/loki/api/v1/query_range" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "status": "success", "data": { "resultType": "streams", "result": [ { "stream": {"job": "varlogs", "filename": "/var/log/syslog"}, "values": [ ["1704067200000000000", "Jan 1 00:00:00 host kernel: test message"] ] } ] } }`)) }, ) defer cleanup() result := callTool(t, server, "query_logs", map[string]interface{}{ "logql": `{job="varlogs"}`, }) if result.IsError { t.Fatalf("unexpected error: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "varlogs") { t.Errorf("expected output to contain 'varlogs', got: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "test message") { t.Errorf("expected output to contain 'test message', got: %s", result.Content[0].Text) } } func TestHandler_ListLabels(t *testing.T) { server, cleanup := setupTestServer(t, nil, nil, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/loki/api/v1/labels" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "status": "success", "data": ["job", "instance", "filename"] }`)) }, ) defer cleanup() result := callTool(t, server, "list_labels", map[string]interface{}{}) if result.IsError { t.Fatalf("unexpected error: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "3 label") { t.Errorf("expected output to contain '3 label', got: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "job") { t.Errorf("expected output to contain 'job', got: %s", result.Content[0].Text) } } func TestHandler_ListLabelValues(t *testing.T) { server, cleanup := setupTestServer(t, nil, nil, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/loki/api/v1/label/job/values" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{ "status": "success", "data": ["varlogs", "nginx", "systemd"] }`)) }, ) defer cleanup() result := callTool(t, server, "list_label_values", map[string]interface{}{ "label": "job", }) if result.IsError { t.Fatalf("unexpected error: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "3 value") { t.Errorf("expected output to contain '3 value', got: %s", result.Content[0].Text) } if !strings.Contains(result.Content[0].Text, "nginx") { t.Errorf("expected output to contain 'nginx', got: %s", result.Content[0].Text) } } // callTool is a test helper that calls a tool through the MCP server. func callTool(t *testing.T, server *mcp.Server, name string, args map[string]interface{}) mcp.CallToolResult { t.Helper() params := mcp.CallToolParams{ Name: name, Arguments: args, } paramsJSON, err := json.Marshal(params) if err != nil { t.Fatalf("failed to marshal params: %v", err) } req := &mcp.Request{ JSONRPC: "2.0", ID: 1, Method: "tools/call", Params: paramsJSON, } resp := server.HandleRequest(context.Background(), req) if resp == nil { t.Fatal("expected response, got nil") } if resp.Error != nil { t.Fatalf("JSON-RPC error: %s", resp.Error.Message) } resultJSON, err := json.Marshal(resp.Result) if err != nil { t.Fatalf("failed to marshal result: %v", err) } var result mcp.CallToolResult if err := json.Unmarshal(resultJSON, &result); err != nil { t.Fatalf("failed to unmarshal result: %v", err) } return result }