feat: add lab-monitoring MCP server for Prometheus and Alertmanager

New MCP server that queries live Prometheus and Alertmanager HTTP APIs
with 8 tools: list_alerts, get_alert, search_metrics, get_metric_metadata,
query (PromQL), list_targets, list_silences, and create_silence.

Extends the MCP core with ModeCustom and NewGenericServer for servers
that don't require a database. Includes CLI with direct commands
(alerts, query, targets, metrics), NixOS module, and comprehensive
httptest-based tests.

Bumps existing binaries to 0.2.1 due to shared internal/mcp change.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-04 23:11:53 +01:00
parent 0bd4ed778a
commit 1755364bba
19 changed files with 2567 additions and 22 deletions

View File

@@ -0,0 +1,175 @@
package monitoring
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestAlertmanagerClient_ListAlerts(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v2/alerts" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{
"annotations": {"summary": "Target is down"},
"endsAt": "2024-01-01T01:00:00Z",
"fingerprint": "abc123",
"receivers": [{"name": "default"}],
"startsAt": "2024-01-01T00:00:00Z",
"status": {"inhibitedBy": [], "silencedBy": [], "state": "active"},
"updatedAt": "2024-01-01T00:00:00Z",
"generatorURL": "http://prometheus:9090/graph",
"labels": {"alertname": "TargetDown", "severity": "critical", "instance": "node1:9100"}
}
]`))
}))
defer srv.Close()
client := NewAlertmanagerClient(srv.URL)
alerts, err := client.ListAlerts(context.Background(), AlertFilters{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(alerts) != 1 {
t.Fatalf("expected 1 alert, got %d", len(alerts))
}
if alerts[0].Fingerprint != "abc123" {
t.Errorf("expected fingerprint=abc123, got %s", alerts[0].Fingerprint)
}
if alerts[0].Labels["alertname"] != "TargetDown" {
t.Errorf("expected alertname=TargetDown, got %s", alerts[0].Labels["alertname"])
}
if alerts[0].Status.State != "active" {
t.Errorf("expected state=active, got %s", alerts[0].Status.State)
}
}
func TestAlertmanagerClient_ListAlertsWithFilters(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("active") != "true" {
t.Errorf("expected active=true, got %s", q.Get("active"))
}
if q.Get("silenced") != "false" {
t.Errorf("expected silenced=false, got %s", q.Get("silenced"))
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[]`))
}))
defer srv.Close()
client := NewAlertmanagerClient(srv.URL)
active := true
silenced := false
_, err := client.ListAlerts(context.Background(), AlertFilters{
Active: &active,
Silenced: &silenced,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestAlertmanagerClient_ListSilences(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v2/silences" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`[
{
"id": "silence-1",
"matchers": [{"name": "alertname", "value": "TargetDown", "isRegex": false}],
"startsAt": "2024-01-01T00:00:00Z",
"endsAt": "2024-01-01T02:00:00Z",
"createdBy": "admin",
"comment": "Maintenance window",
"status": {"state": "active"}
}
]`))
}))
defer srv.Close()
client := NewAlertmanagerClient(srv.URL)
silences, err := client.ListSilences(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(silences) != 1 {
t.Fatalf("expected 1 silence, got %d", len(silences))
}
if silences[0].ID != "silence-1" {
t.Errorf("expected id=silence-1, got %s", silences[0].ID)
}
if silences[0].CreatedBy != "admin" {
t.Errorf("expected createdBy=admin, got %s", silences[0].CreatedBy)
}
}
func TestAlertmanagerClient_CreateSilence(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/v2/silences" {
t.Errorf("unexpected path: %s", r.URL.Path)
}
if r.Header.Get("Content-Type") != "application/json" {
t.Errorf("expected Content-Type=application/json, got %s", r.Header.Get("Content-Type"))
}
var silence Silence
if err := json.NewDecoder(r.Body).Decode(&silence); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
if silence.CreatedBy != "admin" {
t.Errorf("expected createdBy=admin, got %s", silence.CreatedBy)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"silenceID": "new-silence-id"}`))
}))
defer srv.Close()
client := NewAlertmanagerClient(srv.URL)
id, err := client.CreateSilence(context.Background(), Silence{
Matchers: []Matcher{
{Name: "alertname", Value: "TargetDown", IsRegex: false},
},
StartsAt: time.Now(),
EndsAt: time.Now().Add(2 * time.Hour),
CreatedBy: "admin",
Comment: "Test silence",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if id != "new-silence-id" {
t.Errorf("expected id=new-silence-id, got %s", id)
}
}
func TestAlertmanagerClient_HTTPError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("internal error"))
}))
defer srv.Close()
client := NewAlertmanagerClient(srv.URL)
_, err := client.ListAlerts(context.Background(), AlertFilters{})
if err == nil {
t.Fatal("expected error, got nil")
}
}