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,153 @@
package monitoring
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// AlertmanagerClient is an HTTP client for the Alertmanager API v2.
type AlertmanagerClient struct {
baseURL string
httpClient *http.Client
}
// NewAlertmanagerClient creates a new Alertmanager API client.
func NewAlertmanagerClient(baseURL string) *AlertmanagerClient {
return &AlertmanagerClient{
baseURL: strings.TrimRight(baseURL, "/"),
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
}
}
// ListAlerts returns alerts matching the given filters.
func (c *AlertmanagerClient) ListAlerts(ctx context.Context, filters AlertFilters) ([]Alert, error) {
params := url.Values{}
if filters.Active != nil {
params.Set("active", fmt.Sprintf("%t", *filters.Active))
}
if filters.Silenced != nil {
params.Set("silenced", fmt.Sprintf("%t", *filters.Silenced))
}
if filters.Inhibited != nil {
params.Set("inhibited", fmt.Sprintf("%t", *filters.Inhibited))
}
if filters.Unprocessed != nil {
params.Set("unprocessed", fmt.Sprintf("%t", *filters.Unprocessed))
}
if filters.Receiver != "" {
params.Set("receiver", filters.Receiver)
}
for _, f := range filters.Filter {
params.Add("filter", f)
}
u := c.baseURL + "/api/v2/alerts"
if len(params) > 0 {
u += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close() //nolint:errcheck // cleanup on exit
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
var alerts []Alert
if err := json.Unmarshal(body, &alerts); err != nil {
return nil, fmt.Errorf("failed to parse alerts: %w", err)
}
return alerts, nil
}
// ListSilences returns all silences.
func (c *AlertmanagerClient) ListSilences(ctx context.Context) ([]Silence, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/v2/silences", nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close() //nolint:errcheck // cleanup on exit
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
var silences []Silence
if err := json.Unmarshal(body, &silences); err != nil {
return nil, fmt.Errorf("failed to parse silences: %w", err)
}
return silences, nil
}
// CreateSilence creates a new silence and returns the silence ID.
func (c *AlertmanagerClient) CreateSilence(ctx context.Context, silence Silence) (string, error) {
data, err := json.Marshal(silence)
if err != nil {
return "", fmt.Errorf("failed to marshal silence: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v2/silences", bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close() //nolint:errcheck // cleanup on exit
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
}
var result struct {
SilenceID string `json:"silenceID"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("failed to parse response: %w", err)
}
return result.SilenceID, nil
}