This repository has been archived on 2026-03-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
labmcp/internal/monitoring/handlers_test.go
Torjus Håkestad 06e62eb6ad feat: gate create_silence behind --enable-silences flag
The create_silence tool is a write operation that can suppress alerts.
Disable it by default and require explicit opt-in via --enable-silences
CLI flag (or enableSilences NixOS option) as a safety measure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 23:23:46 +01:00

427 lines
12 KiB
Go

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) (*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)
RegisterHandlers(server, prom, am, HandlerOptions{EnableSilences: true})
cleanup := func() {
promSrv.Close()
amSrv.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)
if len(tools) != 8 {
t.Errorf("expected 8 tools with silences enabled, 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_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, 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
}
// 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
}