Skip to content

Commit 45425a1

Browse files
Add state retrieval for alert rules (#87)
* Add state retrieval for alert rules * Make alerting client and its structs private
1 parent 27817b7 commit 45425a1

File tree

5 files changed

+304
-43
lines changed

5 files changed

+304
-43
lines changed

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ This provides access to your Grafana instance and the surrounding ecosystem.
2929
- [ ] Start Sift investigations and view the results
3030
- [ ] Alerting
3131
- [x] List and fetch alert rule information
32-
- [ ] Get alert rule statuses (firing/normal/error/etc.)
32+
- [x] Get alert rule statuses (firing/normal/error/etc.)
3333
- [ ] Create and change alert rules
34-
- [ ] List contact points
34+
- [x] List contact points
3535
- [ ] Create and change contact points
3636
- [x] Access Grafana OnCall functionality
3737
- [x] List and manage schedules

tools/alerting.go

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import (
77
"github.com/grafana/grafana-openapi-client-go/client/provisioning"
88
"github.com/grafana/grafana-openapi-client-go/models"
99
"github.com/mark3labs/mcp-go/server"
10-
"github.com/prometheus/prometheus/model/labels"
1110

1211
mcpgrafana "github.com/grafana/mcp-grafana"
1312
)
@@ -35,8 +34,11 @@ func (p ListAlertRulesParams) validate() error {
3534
}
3635

3736
type alertRuleSummary struct {
38-
UID string `json:"uid"`
39-
Title string `json:"title"`
37+
UID string `json:"uid"`
38+
Title string `json:"title"`
39+
// State can be one of: pending, firing, error, recovering, inactive.
40+
// "inactive" means the alert state is normal, not firing.
41+
State string `json:"state"`
4042
Labels map[string]string `json:"labels,omitempty"`
4143
}
4244

@@ -45,13 +47,21 @@ func listAlertRules(ctx context.Context, args ListAlertRulesParams) ([]alertRule
4547
return nil, fmt.Errorf("list alert rules: %w", err)
4648
}
4749

48-
c := mcpgrafana.GrafanaClientFromContext(ctx)
49-
response, err := c.Provisioning.GetAlertRules()
50+
c, err := newAlertingClientFromContext(ctx)
51+
if err != nil {
52+
return nil, fmt.Errorf("list alert rules: %w", err)
53+
}
54+
response, err := c.GetRules(ctx)
5055
if err != nil {
5156
return nil, fmt.Errorf("list alert rules: %w", err)
5257
}
5358

54-
alertRules, err := filterAlertRules(response.Payload, args.LabelSelectors)
59+
alertRules := []alertingRule{}
60+
for _, group := range response.Data.RuleGroups {
61+
alertRules = append(alertRules, group.Rules...)
62+
}
63+
64+
alertRules, err = filterAlertRules(alertRules, args.LabelSelectors)
5565
if err != nil {
5666
return nil, fmt.Errorf("list alert rules: %w", err)
5767
}
@@ -65,18 +75,14 @@ func listAlertRules(ctx context.Context, args ListAlertRulesParams) ([]alertRule
6575
}
6676

6777
// filterAlertRules filters a list of alert rules based on label selectors
68-
func filterAlertRules(rules models.ProvisionedAlertRules, selectors []Selector) (models.ProvisionedAlertRules, error) {
78+
func filterAlertRules(rules []alertingRule, selectors []Selector) ([]alertingRule, error) {
6979
if len(selectors) == 0 {
7080
return rules, nil
7181
}
7282

73-
filteredResult := models.ProvisionedAlertRules{}
83+
filteredResult := []alertingRule{}
7484
for _, rule := range rules {
75-
if rule == nil {
76-
continue
77-
}
78-
79-
match, err := matchesSelectors(*rule, selectors)
85+
match, err := matchesSelectors(rule, selectors)
8086
if err != nil {
8187
return nil, fmt.Errorf("filtering alert rules: %w", err)
8288
}
@@ -90,11 +96,9 @@ func filterAlertRules(rules models.ProvisionedAlertRules, selectors []Selector)
9096
}
9197

9298
// matchesSelectors checks if an alert rule matches all provided selectors
93-
func matchesSelectors(rule models.ProvisionedAlertRule, selectors []Selector) (bool, error) {
94-
promLabels := labels.FromMap(rule.Labels)
95-
99+
func matchesSelectors(rule alertingRule, selectors []Selector) (bool, error) {
96100
for _, selector := range selectors {
97-
match, err := selector.Matches(promLabels)
101+
match, err := selector.Matches(rule.Labels)
98102
if err != nil {
99103
return false, err
100104
}
@@ -105,26 +109,22 @@ func matchesSelectors(rule models.ProvisionedAlertRule, selectors []Selector) (b
105109
return true, nil
106110
}
107111

108-
func summarizeAlertRules(alertRules models.ProvisionedAlertRules) []alertRuleSummary {
112+
func summarizeAlertRules(alertRules []alertingRule) []alertRuleSummary {
109113
result := make([]alertRuleSummary, 0, len(alertRules))
110114
for _, r := range alertRules {
111-
title := ""
112-
if r.Title != nil {
113-
title = *r.Title
114-
}
115-
116115
result = append(result, alertRuleSummary{
117116
UID: r.UID,
118-
Title: title,
119-
Labels: r.Labels,
117+
Title: r.Name,
118+
State: r.State,
119+
Labels: r.Labels.Map(),
120120
})
121121
}
122122
return result
123123
}
124124

125125
// applyPagination applies pagination to the list of alert rules.
126126
// It doesn't sort the items and relies on the order returned by the API.
127-
func applyPagination(items models.ProvisionedAlertRules, limit, page int) (models.ProvisionedAlertRules, error) {
127+
func applyPagination(items []alertingRule, limit, page int) ([]alertingRule, error) {
128128
if limit == 0 {
129129
limit = DefaultListAlertRulesLimit
130130
}
@@ -136,7 +136,7 @@ func applyPagination(items models.ProvisionedAlertRules, limit, page int) (model
136136
end := start + limit
137137

138138
if start >= len(items) {
139-
return models.ProvisionedAlertRules{}, nil
139+
return nil, nil
140140
} else if end > len(items) {
141141
return items[start:], nil
142142
}
@@ -146,7 +146,7 @@ func applyPagination(items models.ProvisionedAlertRules, limit, page int) (model
146146

147147
var ListAlertRules = mcpgrafana.MustTool(
148148
"list_alert_rules",
149-
"List alert rules",
149+
"Lists alert rules with their current states (pending, firing, error, recovering, inactive) and labels. Inactive state means the alert state is normal, not firing.",
150150
listAlertRules,
151151
)
152152

@@ -177,7 +177,7 @@ func getAlertRuleByUID(ctx context.Context, args GetAlertRuleByUIDParams) (*mode
177177

178178
var GetAlertRuleByUID = mcpgrafana.MustTool(
179179
"get_alert_rule_by_uid",
180-
"Get alert rule by uid",
180+
"Retrieves detailed information about a specific alert rule by its UID.",
181181
getAlertRuleByUID,
182182
)
183183

@@ -250,7 +250,7 @@ func applyLimitToContactPoints(items []*models.EmbeddedContactPoint, limit int)
250250

251251
var ListContactPoints = mcpgrafana.MustTool(
252252
"list_contact_points",
253-
"List contact points",
253+
"Lists notification contact points with their type, name, and configuration.",
254254
listContactPoints,
255255
)
256256

tools/alerting_client.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"strings"
11+
"time"
12+
13+
"github.com/prometheus/prometheus/model/labels"
14+
15+
mcpgrafana "github.com/grafana/mcp-grafana"
16+
)
17+
18+
const (
19+
defaultTimeout = 30 * time.Second
20+
rulesEndpointPath = "/api/prometheus/grafana/api/v1/rules"
21+
)
22+
23+
type alertingClient struct {
24+
baseURL *url.URL
25+
apiKey string
26+
httpClient *http.Client
27+
}
28+
29+
func newAlertingClientFromContext(ctx context.Context) (*alertingClient, error) {
30+
baseURL := strings.TrimRight(mcpgrafana.GrafanaURLFromContext(ctx), "/")
31+
parsedBaseURL, err := url.Parse(baseURL)
32+
if err != nil {
33+
return nil, fmt.Errorf("invalid Grafana base URL %q: %w", baseURL, err)
34+
}
35+
36+
return &alertingClient{
37+
baseURL: parsedBaseURL,
38+
apiKey: mcpgrafana.GrafanaAPIKeyFromContext(ctx),
39+
httpClient: &http.Client{
40+
Timeout: defaultTimeout,
41+
},
42+
}, nil
43+
}
44+
45+
func (c *alertingClient) makeRequest(ctx context.Context, path string) (*http.Response, error) {
46+
p := c.baseURL.JoinPath(path).String()
47+
48+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p, nil)
49+
if err != nil {
50+
return nil, fmt.Errorf("failed to create request to %s: %w", p, err)
51+
}
52+
53+
req.Header.Set("Accept", "application/json")
54+
req.Header.Set("Content-Type", "application/json")
55+
56+
if c.apiKey != "" {
57+
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
58+
}
59+
60+
resp, err := c.httpClient.Do(req)
61+
if err != nil {
62+
return nil, fmt.Errorf("failed to execute request to %s: %w", p, err)
63+
}
64+
if resp.StatusCode != http.StatusOK {
65+
bodyBytes, _ := io.ReadAll(resp.Body)
66+
resp.Body.Close()
67+
return nil, fmt.Errorf("Grafana API returned status code %d: %s", resp.StatusCode, string(bodyBytes))
68+
}
69+
70+
return resp, nil
71+
}
72+
73+
func (c *alertingClient) GetRules(ctx context.Context) (*rulesResponse, error) {
74+
resp, err := c.makeRequest(ctx, rulesEndpointPath)
75+
if err != nil {
76+
return nil, fmt.Errorf("failed to get alert rules from Grafana API: %w", err)
77+
}
78+
defer resp.Body.Close()
79+
80+
var rulesResponse rulesResponse
81+
decoder := json.NewDecoder(resp.Body)
82+
if err := decoder.Decode(&rulesResponse); err != nil {
83+
return nil, fmt.Errorf("failed to decode rules response from %s: %w", rulesEndpointPath, err)
84+
}
85+
86+
return &rulesResponse, nil
87+
}
88+
89+
type rulesResponse struct {
90+
Data struct {
91+
RuleGroups []ruleGroup `json:"groups"`
92+
NextToken string `json:"groupNextToken,omitempty"`
93+
Totals map[string]int64 `json:"totals,omitempty"`
94+
} `json:"data"`
95+
}
96+
97+
type ruleGroup struct {
98+
Name string `json:"name"`
99+
FolderUID string `json:"folderUid"`
100+
Rules []alertingRule `json:"rules"`
101+
Interval float64 `json:"interval"`
102+
LastEvaluation time.Time `json:"lastEvaluation"`
103+
EvaluationTime float64 `json:"evaluationTime"`
104+
}
105+
106+
type alertingRule struct {
107+
State string `json:"state,omitempty"`
108+
Name string `json:"name,omitempty"`
109+
Query string `json:"query,omitempty"`
110+
Duration float64 `json:"duration,omitempty"`
111+
KeepFiringFor float64 `json:"keepFiringFor,omitempty"`
112+
Annotations labels.Labels `json:"annotations,omitempty"`
113+
ActiveAt *time.Time `json:"activeAt,omitempty"`
114+
Alerts []alert `json:"alerts,omitempty"`
115+
Totals map[string]int64 `json:"totals,omitempty"`
116+
TotalsFiltered map[string]int64 `json:"totalsFiltered,omitempty"`
117+
UID string `json:"uid"`
118+
FolderUID string `json:"folderUid"`
119+
Labels labels.Labels `json:"labels,omitempty"`
120+
Health string `json:"health"`
121+
LastError string `json:"lastError,omitempty"`
122+
Type string `json:"type"`
123+
LastEvaluation time.Time `json:"lastEvaluation"`
124+
EvaluationTime float64 `json:"evaluationTime"`
125+
}
126+
127+
type alert struct {
128+
Labels labels.Labels `json:"labels"`
129+
Annotations labels.Labels `json:"annotations"`
130+
State string `json:"state"`
131+
ActiveAt *time.Time `json:"activeAt"`
132+
Value string `json:"value"`
133+
}

0 commit comments

Comments
 (0)