This is page 4 of 5. Use http://codebase.md/grafana/mcp-grafana?lines=true&page={x} to view the full context.
# Directory Structure
```
├── .dockerignore
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── docker.yml
│ ├── e2e.yml
│ ├── integration.yml
│ ├── release.yml
│ └── unit.yml
├── .gitignore
├── .golangci.yaml
├── .goreleaser.yaml
├── cmd
│ ├── linters
│ │ └── jsonschema
│ │ └── main.go
│ └── mcp-grafana
│ └── main.go
├── CODEOWNERS
├── docker-compose.yaml
├── Dockerfile
├── examples
│ └── tls_example.go
├── gemini-extension.json
├── go.mod
├── go.sum
├── image-tag
├── internal
│ └── linter
│ └── jsonschema
│ ├── jsonschema_lint_test.go
│ ├── jsonschema_lint.go
│ └── README.md
├── LICENSE
├── Makefile
├── mcpgrafana_test.go
├── mcpgrafana.go
├── proxied_client.go
├── proxied_handler.go
├── proxied_tools_test.go
├── proxied_tools.go
├── README.md
├── renovate.json
├── server.json
├── session_test.go
├── session.go
├── testdata
│ ├── dashboards
│ │ └── demo.json
│ ├── loki-config.yml
│ ├── prometheus-entrypoint.sh
│ ├── prometheus-seed.yml
│ ├── prometheus.yml
│ ├── promtail-config.yml
│ ├── provisioning
│ │ ├── alerting
│ │ │ ├── alert_rules.yaml
│ │ │ └── contact_points.yaml
│ │ ├── dashboards
│ │ │ └── dashboards.yaml
│ │ └── datasources
│ │ └── datasources.yaml
│ ├── tempo-config-2.yaml
│ └── tempo-config.yaml
├── tests
│ ├── .gitignore
│ ├── .python-version
│ ├── admin_test.py
│ ├── conftest.py
│ ├── dashboards_test.py
│ ├── disable_write_test.py
│ ├── health_test.py
│ ├── loki_test.py
│ ├── navigation_test.py
│ ├── pyproject.toml
│ ├── README.md
│ ├── tempo_test.py
│ ├── utils.py
│ └── uv.lock
├── tls_test.go
├── tools
│ ├── admin_test.go
│ ├── admin.go
│ ├── alerting_client_test.go
│ ├── alerting_client.go
│ ├── alerting_test.go
│ ├── alerting_unit_test.go
│ ├── alerting.go
│ ├── annotations_integration_test.go
│ ├── annotations_unit_test.go
│ ├── annotations.go
│ ├── asserts_cloud_test.go
│ ├── asserts_test.go
│ ├── asserts.go
│ ├── cloud_testing_utils.go
│ ├── dashboard_test.go
│ ├── dashboard.go
│ ├── datasources_test.go
│ ├── datasources.go
│ ├── folder.go
│ ├── incident_integration_test.go
│ ├── incident_test.go
│ ├── incident.go
│ ├── loki_test.go
│ ├── loki.go
│ ├── navigation_test.go
│ ├── navigation.go
│ ├── oncall_cloud_test.go
│ ├── oncall.go
│ ├── prometheus_test.go
│ ├── prometheus_unit_test.go
│ ├── prometheus.go
│ ├── pyroscope_test.go
│ ├── pyroscope.go
│ ├── search_test.go
│ ├── search.go
│ ├── sift_cloud_test.go
│ ├── sift.go
│ └── testcontext_test.go
├── tools_test.go
└── tools.go
```
# Files
--------------------------------------------------------------------------------
/tools/oncall.go:
--------------------------------------------------------------------------------
```go
1 | package tools
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "reflect"
9 | "strconv"
10 | "strings"
11 |
12 | aapi "github.com/grafana/amixr-api-go-client"
13 | mcpgrafana "github.com/grafana/mcp-grafana"
14 | "github.com/mark3labs/mcp-go/mcp"
15 | "github.com/mark3labs/mcp-go/server"
16 | )
17 |
18 | // getOnCallURLFromSettings retrieves the OnCall API URL from the Grafana settings endpoint.
19 | // It makes a GET request to <grafana-url>/api/plugins/grafana-irm-app/settings and extracts
20 | // the OnCall URL from the jsonData.onCallApiUrl field in the response.
21 | // Returns the OnCall URL if found, or an error if the URL cannot be retrieved.
22 | func getOnCallURLFromSettings(ctx context.Context, cfg mcpgrafana.GrafanaConfig) (string, error) {
23 | settingsURL := fmt.Sprintf("%s/api/plugins/grafana-irm-app/settings", strings.TrimRight(cfg.URL, "/"))
24 |
25 | req, err := http.NewRequestWithContext(ctx, "GET", settingsURL, nil)
26 | if err != nil {
27 | return "", fmt.Errorf("creating settings request: %w", err)
28 | }
29 |
30 | if cfg.APIKey != "" {
31 | req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
32 | } else if cfg.BasicAuth != nil {
33 | password, _ := cfg.BasicAuth.Password()
34 | req.SetBasicAuth(cfg.BasicAuth.Username(), password)
35 | }
36 |
37 | // Add org ID header for multi-org support
38 | if cfg.OrgID > 0 {
39 | req.Header.Set("X-Scope-OrgId", strconv.FormatInt(cfg.OrgID, 10))
40 | }
41 |
42 | // Add user agent for tracking
43 | req.Header.Set("User-Agent", mcpgrafana.UserAgent())
44 |
45 | resp, err := http.DefaultClient.Do(req)
46 | if err != nil {
47 | return "", fmt.Errorf("fetching settings: %w", err)
48 | }
49 | defer func() {
50 | _ = resp.Body.Close() //nolint:errcheck
51 | }()
52 |
53 | if resp.StatusCode != http.StatusOK {
54 | return "", fmt.Errorf("unexpected status code from settings API: %d", resp.StatusCode)
55 | }
56 |
57 | var settings struct {
58 | JSONData struct {
59 | OnCallAPIURL string `json:"onCallApiUrl"`
60 | } `json:"jsonData"`
61 | }
62 |
63 | if err := json.NewDecoder(resp.Body).Decode(&settings); err != nil {
64 | return "", fmt.Errorf("decoding settings response: %w", err)
65 | }
66 |
67 | if settings.JSONData.OnCallAPIURL == "" {
68 | return "", fmt.Errorf("OnCall API URL is not set in settings")
69 | }
70 |
71 | return settings.JSONData.OnCallAPIURL, nil
72 | }
73 |
74 | func oncallClientFromContext(ctx context.Context) (*aapi.Client, error) {
75 | // Get the standard Grafana URL and API key
76 | cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
77 |
78 | // Try to get OnCall URL from settings endpoint
79 | grafanaOnCallURL, err := getOnCallURLFromSettings(ctx, cfg)
80 | if err != nil {
81 | return nil, fmt.Errorf("getting OnCall URL from settings: %w", err)
82 | }
83 |
84 | grafanaOnCallURL = strings.TrimRight(grafanaOnCallURL, "/")
85 |
86 | // TODO: Allow access to OnCall using an access token instead of an API key.
87 | client, err := aapi.NewWithGrafanaURL(grafanaOnCallURL, cfg.APIKey, cfg.URL)
88 | if err != nil {
89 | return nil, fmt.Errorf("creating OnCall client: %w", err)
90 | }
91 |
92 | // Try to customize the HTTP client with user agent using reflection
93 | // since the OnCall client doesn't expose its HTTP client directly
94 | clientValue := reflect.ValueOf(client)
95 | if clientValue.Kind() == reflect.Ptr && !clientValue.IsNil() {
96 | clientValue = clientValue.Elem()
97 | if clientValue.Kind() == reflect.Struct {
98 | httpClientField := clientValue.FieldByName("HTTPClient")
99 | if !httpClientField.IsValid() {
100 | // Try alternative field names
101 | httpClientField = clientValue.FieldByName("HttpClient")
102 | }
103 | if !httpClientField.IsValid() {
104 | httpClientField = clientValue.FieldByName("Client")
105 | }
106 | if httpClientField.IsValid() && httpClientField.CanSet() {
107 | if httpClient, ok := httpClientField.Interface().(*http.Client); ok {
108 | // Wrap the transport with user agent
109 | if httpClient.Transport == nil {
110 | httpClient.Transport = http.DefaultTransport
111 | }
112 | httpClient.Transport = mcpgrafana.NewUserAgentTransport(
113 | httpClient.Transport,
114 | )
115 | }
116 | }
117 | }
118 | }
119 |
120 | return client, nil
121 | }
122 |
123 | // getUserServiceFromContext creates a new UserService using the OnCall client from the context
124 | func getUserServiceFromContext(ctx context.Context) (*aapi.UserService, error) {
125 | client, err := oncallClientFromContext(ctx)
126 | if err != nil {
127 | return nil, fmt.Errorf("getting OnCall client: %w", err)
128 | }
129 |
130 | return aapi.NewUserService(client), nil
131 | }
132 |
133 | // getScheduleServiceFromContext creates a new ScheduleService using the OnCall client from the context
134 | func getScheduleServiceFromContext(ctx context.Context) (*aapi.ScheduleService, error) {
135 | client, err := oncallClientFromContext(ctx)
136 | if err != nil {
137 | return nil, fmt.Errorf("getting OnCall client: %w", err)
138 | }
139 |
140 | return aapi.NewScheduleService(client), nil
141 | }
142 |
143 | // getTeamServiceFromContext creates a new TeamService using the OnCall client from the context
144 | func getTeamServiceFromContext(ctx context.Context) (*aapi.TeamService, error) {
145 | client, err := oncallClientFromContext(ctx)
146 | if err != nil {
147 | return nil, fmt.Errorf("getting OnCall client: %w", err)
148 | }
149 |
150 | return aapi.NewTeamService(client), nil
151 | }
152 |
153 | // getOnCallShiftServiceFromContext creates a new OnCallShiftService using the OnCall client from the context
154 | func getOnCallShiftServiceFromContext(ctx context.Context) (*aapi.OnCallShiftService, error) {
155 | client, err := oncallClientFromContext(ctx)
156 | if err != nil {
157 | return nil, fmt.Errorf("getting OnCall client: %w", err)
158 | }
159 |
160 | return aapi.NewOnCallShiftService(client), nil
161 | }
162 |
163 | type ListOnCallSchedulesParams struct {
164 | TeamID string `json:"teamId,omitempty" jsonschema:"description=The ID of the team to list schedules for"`
165 | ScheduleID string `json:"scheduleId,omitempty" jsonschema:"description=The ID of the schedule to get details for. If provided\\, returns only that schedule's details"`
166 | Page int `json:"page,omitempty" jsonschema:"description=The page number to return (1-based)"`
167 | }
168 |
169 | // ScheduleSummary represents a simplified view of an OnCall schedule
170 | type ScheduleSummary struct {
171 | ID string `json:"id" jsonschema:"description=The unique identifier of the schedule"`
172 | Name string `json:"name" jsonschema:"description=The name of the schedule"`
173 | TeamID string `json:"teamId" jsonschema:"description=The ID of the team this schedule belongs to"`
174 | Timezone string `json:"timezone" jsonschema:"description=The timezone for this schedule"`
175 | Shifts []string `json:"shifts" jsonschema:"description=List of shift IDs in this schedule"`
176 | }
177 |
178 | func listOnCallSchedules(ctx context.Context, args ListOnCallSchedulesParams) ([]*ScheduleSummary, error) {
179 | scheduleService, err := getScheduleServiceFromContext(ctx)
180 | if err != nil {
181 | return nil, fmt.Errorf("getting OnCall schedule service: %w", err)
182 | }
183 |
184 | if args.ScheduleID != "" {
185 | schedule, _, err := scheduleService.GetSchedule(args.ScheduleID, &aapi.GetScheduleOptions{})
186 | if err != nil {
187 | return nil, fmt.Errorf("getting OnCall schedule %s: %w", args.ScheduleID, err)
188 | }
189 | summary := &ScheduleSummary{
190 | ID: schedule.ID,
191 | Name: schedule.Name,
192 | TeamID: schedule.TeamId,
193 | Timezone: schedule.TimeZone,
194 | }
195 | if schedule.Shifts != nil {
196 | summary.Shifts = *schedule.Shifts
197 | }
198 | return []*ScheduleSummary{summary}, nil
199 | }
200 |
201 | listOptions := &aapi.ListScheduleOptions{}
202 | if args.Page > 0 {
203 | listOptions.Page = args.Page
204 | }
205 | if args.TeamID != "" {
206 | listOptions.TeamID = args.TeamID
207 | }
208 |
209 | response, _, err := scheduleService.ListSchedules(listOptions)
210 | if err != nil {
211 | return nil, fmt.Errorf("listing OnCall schedules: %w", err)
212 | }
213 |
214 | // Convert schedules to summaries
215 | summaries := make([]*ScheduleSummary, 0, len(response.Schedules))
216 | for _, schedule := range response.Schedules {
217 | summary := &ScheduleSummary{
218 | ID: schedule.ID,
219 | Name: schedule.Name,
220 | TeamID: schedule.TeamId,
221 | Timezone: schedule.TimeZone,
222 | }
223 | if schedule.Shifts != nil {
224 | summary.Shifts = *schedule.Shifts
225 | }
226 | summaries = append(summaries, summary)
227 | }
228 |
229 | return summaries, nil
230 | }
231 |
232 | var ListOnCallSchedules = mcpgrafana.MustTool(
233 | "list_oncall_schedules",
234 | "List Grafana OnCall schedules, optionally filtering by team ID. If a specific schedule ID is provided, retrieves details for only that schedule. Returns a list of schedule summaries including ID, name, team ID, timezone, and shift IDs. Supports pagination.",
235 | listOnCallSchedules,
236 | mcp.WithTitleAnnotation("List OnCall schedules"),
237 | mcp.WithIdempotentHintAnnotation(true),
238 | mcp.WithReadOnlyHintAnnotation(true),
239 | )
240 |
241 | type GetOnCallShiftParams struct {
242 | ShiftID string `json:"shiftId" jsonschema:"required,description=The ID of the shift to get details for"`
243 | }
244 |
245 | func getOnCallShift(ctx context.Context, args GetOnCallShiftParams) (*aapi.OnCallShift, error) {
246 | shiftService, err := getOnCallShiftServiceFromContext(ctx)
247 | if err != nil {
248 | return nil, fmt.Errorf("getting OnCall shift service: %w", err)
249 | }
250 |
251 | shift, _, err := shiftService.GetOnCallShift(args.ShiftID, &aapi.GetOnCallShiftOptions{})
252 | if err != nil {
253 | return nil, fmt.Errorf("getting OnCall shift %s: %w", args.ShiftID, err)
254 | }
255 |
256 | return shift, nil
257 | }
258 |
259 | var GetOnCallShift = mcpgrafana.MustTool(
260 | "get_oncall_shift",
261 | "Get detailed information for a specific Grafana OnCall shift using its ID. A shift represents a designated time period within a schedule when users are actively on-call. Returns the full shift details.",
262 | getOnCallShift,
263 | mcp.WithTitleAnnotation("Get OnCall shift"),
264 | mcp.WithIdempotentHintAnnotation(true),
265 | mcp.WithReadOnlyHintAnnotation(true),
266 | )
267 |
268 | // CurrentOnCallUsers represents the currently on-call users for a schedule
269 | type CurrentOnCallUsers struct {
270 | ScheduleID string `json:"scheduleId" jsonschema:"description=The ID of the schedule"`
271 | ScheduleName string `json:"scheduleName" jsonschema:"description=The name of the schedule"`
272 | Users []*aapi.User `json:"users" jsonschema:"description=List of users currently on call"`
273 | }
274 |
275 | type GetCurrentOnCallUsersParams struct {
276 | ScheduleID string `json:"scheduleId" jsonschema:"required,description=The ID of the schedule to get current on-call users for"`
277 | }
278 |
279 | func getCurrentOnCallUsers(ctx context.Context, args GetCurrentOnCallUsersParams) (*CurrentOnCallUsers, error) {
280 | scheduleService, err := getScheduleServiceFromContext(ctx)
281 | if err != nil {
282 | return nil, fmt.Errorf("getting OnCall schedule service: %w", err)
283 | }
284 |
285 | schedule, _, err := scheduleService.GetSchedule(args.ScheduleID, &aapi.GetScheduleOptions{})
286 | if err != nil {
287 | return nil, fmt.Errorf("getting schedule %s: %w", args.ScheduleID, err)
288 | }
289 |
290 | // Create the result with the schedule info
291 | result := &CurrentOnCallUsers{
292 | ScheduleID: schedule.ID,
293 | ScheduleName: schedule.Name,
294 | Users: make([]*aapi.User, 0, len(schedule.OnCallNow)),
295 | }
296 |
297 | // If there are no users on call, return early
298 | if len(schedule.OnCallNow) == 0 {
299 | return result, nil
300 | }
301 |
302 | // Get the user service to fetch user details
303 | userService, err := getUserServiceFromContext(ctx)
304 | if err != nil {
305 | return nil, fmt.Errorf("getting OnCall user service: %w", err)
306 | }
307 |
308 | // Fetch details for each user currently on call
309 | for _, userID := range schedule.OnCallNow {
310 | user, _, err := userService.GetUser(userID, &aapi.GetUserOptions{})
311 | if err != nil {
312 | // Log the error but continue with other users
313 | fmt.Printf("Error fetching user %s: %v\n", userID, err)
314 | continue
315 | }
316 | result.Users = append(result.Users, user)
317 | }
318 |
319 | return result, nil
320 | }
321 |
322 | var GetCurrentOnCallUsers = mcpgrafana.MustTool(
323 | "get_current_oncall_users",
324 | "Get the list of users currently on-call for a specific Grafana OnCall schedule ID. Returns the schedule ID, name, and a list of detailed user objects for those currently on call.",
325 | getCurrentOnCallUsers,
326 | mcp.WithTitleAnnotation("Get current on-call users"),
327 | mcp.WithIdempotentHintAnnotation(true),
328 | mcp.WithReadOnlyHintAnnotation(true),
329 | )
330 |
331 | type ListOnCallTeamsParams struct {
332 | Page int `json:"page,omitempty" jsonschema:"description=The page number to return"`
333 | }
334 |
335 | func listOnCallTeams(ctx context.Context, args ListOnCallTeamsParams) ([]*aapi.Team, error) {
336 | teamService, err := getTeamServiceFromContext(ctx)
337 | if err != nil {
338 | return nil, fmt.Errorf("getting OnCall team service: %w", err)
339 | }
340 |
341 | listOptions := &aapi.ListTeamOptions{}
342 | if args.Page > 0 {
343 | listOptions.Page = args.Page
344 | }
345 |
346 | response, _, err := teamService.ListTeams(listOptions)
347 | if err != nil {
348 | return nil, fmt.Errorf("listing OnCall teams: %w", err)
349 | }
350 |
351 | return response.Teams, nil
352 | }
353 |
354 | var ListOnCallTeams = mcpgrafana.MustTool(
355 | "list_oncall_teams",
356 | "List teams configured in Grafana OnCall. Returns a list of team objects with their details. Supports pagination.",
357 | listOnCallTeams,
358 | mcp.WithTitleAnnotation("List OnCall teams"),
359 | mcp.WithIdempotentHintAnnotation(true),
360 | mcp.WithReadOnlyHintAnnotation(true),
361 | )
362 |
363 | type ListOnCallUsersParams struct {
364 | UserID string `json:"userId,omitempty" jsonschema:"description=The ID of the user to get details for. If provided\\, returns only that user's details"`
365 | Username string `json:"username,omitempty" jsonschema:"description=The username to filter users by. If provided\\, returns only the user matching this username"`
366 | Page int `json:"page,omitempty" jsonschema:"description=The page number to return"`
367 | }
368 |
369 | func listOnCallUsers(ctx context.Context, args ListOnCallUsersParams) ([]*aapi.User, error) {
370 | userService, err := getUserServiceFromContext(ctx)
371 | if err != nil {
372 | return nil, fmt.Errorf("getting OnCall user service: %w", err)
373 | }
374 |
375 | if args.UserID != "" {
376 | user, _, err := userService.GetUser(args.UserID, &aapi.GetUserOptions{})
377 | if err != nil {
378 | return nil, fmt.Errorf("getting OnCall user %s: %w", args.UserID, err)
379 | }
380 | return []*aapi.User{user}, nil
381 | }
382 |
383 | // Otherwise, list all users
384 | listOptions := &aapi.ListUserOptions{}
385 | if args.Page > 0 {
386 | listOptions.Page = args.Page
387 | }
388 | if args.Username != "" {
389 | listOptions.Username = args.Username
390 | }
391 |
392 | response, _, err := userService.ListUsers(listOptions)
393 | if err != nil {
394 | return nil, fmt.Errorf("listing OnCall users: %w", err)
395 | }
396 |
397 | return response.Users, nil
398 | }
399 |
400 | var ListOnCallUsers = mcpgrafana.MustTool(
401 | "list_oncall_users",
402 | "List users from Grafana OnCall. Can retrieve all users, a specific user by ID, or filter by username. Returns a list of user objects with their details. Supports pagination.",
403 | listOnCallUsers,
404 | mcp.WithTitleAnnotation("List OnCall users"),
405 | mcp.WithIdempotentHintAnnotation(true),
406 | mcp.WithReadOnlyHintAnnotation(true),
407 | )
408 |
409 | func getAlertGroupServiceFromContext(ctx context.Context) (*aapi.AlertGroupService, error) {
410 | client, err := oncallClientFromContext(ctx)
411 | if err != nil {
412 | return nil, fmt.Errorf("getting OnCall client: %w", err)
413 | }
414 |
415 | return aapi.NewAlertGroupService(client), nil
416 | }
417 |
418 | type ListAlertGroupsParams struct {
419 | Page int `json:"page,omitempty" jsonschema:"description=The page number to return"`
420 | AlertGroupID string `json:"id,omitempty" jsonschema:"description=Filter by specific alert group ID"`
421 | RouteID string `json:"routeId,omitempty" jsonschema:"description=Filter by route ID"`
422 | IntegrationID string `json:"integrationId,omitempty" jsonschema:"description=Filter by integration ID"`
423 | State string `json:"state,omitempty" jsonschema:"description=Filter by alert group state (one of: new\\, acknowledged\\, resolved\\, silenced)"`
424 | TeamID string `json:"teamId,omitempty" jsonschema:"description=Filter by team ID"`
425 | StartedAt string `json:"startedAt,omitempty" jsonschema:"description=Filter by time range in format '{start}_{end}' ISO 8601 timestamp range (UTC assumed\\, no timezone indicator needed) (e.g.\\, '2025-01-19T00:00:00_2025-01-19T23:59:59')"`
426 | Labels []string `json:"labels,omitempty" jsonschema:"description=Filter by labels in format key:value (e.g.\\, ['env:prod'\\, 'severity:high'])"`
427 | Name string `json:"name,omitempty" jsonschema:"description=Filter by alert group name"`
428 | }
429 |
430 | func listAlertGroups(ctx context.Context, args ListAlertGroupsParams) ([]*aapi.AlertGroup, error) {
431 | alertGroupService, err := getAlertGroupServiceFromContext(ctx)
432 | if err != nil {
433 | return nil, fmt.Errorf("getting OnCall alert group service: %w", err)
434 | }
435 |
436 | listOptions := &aapi.ListAlertGroupOptions{}
437 | if args.Page > 0 {
438 | listOptions.Page = args.Page
439 | }
440 | if args.AlertGroupID != "" {
441 | listOptions.AlertGroupID = args.AlertGroupID
442 | }
443 | if args.RouteID != "" {
444 | listOptions.RouteID = args.RouteID
445 | }
446 | if args.IntegrationID != "" {
447 | listOptions.IntegrationID = args.IntegrationID
448 | }
449 | if args.State != "" {
450 | listOptions.State = args.State
451 | }
452 | if args.TeamID != "" {
453 | listOptions.TeamID = args.TeamID
454 | }
455 | if args.StartedAt != "" {
456 | listOptions.StartedAt = args.StartedAt
457 | }
458 | if len(args.Labels) > 0 {
459 | listOptions.Labels = args.Labels
460 | }
461 | if args.Name != "" {
462 | listOptions.Name = args.Name
463 | }
464 |
465 | response, _, err := alertGroupService.ListAlertGroups(listOptions)
466 | if err != nil {
467 | return nil, fmt.Errorf("listing OnCall alert groups: %w", err)
468 | }
469 |
470 | return response.AlertGroups, nil
471 | }
472 |
473 | var ListAlertGroups = mcpgrafana.MustTool(
474 | "list_alert_groups",
475 | "List alert groups from Grafana OnCall with filtering options. Supports filtering by alert group ID, route ID, integration ID, state (new, acknowledged, resolved, silenced), team ID, time range, labels, and name. For time ranges, use format '{start}_{end}' ISO 8601 timestamp range (e.g., '2025-01-19T00:00:00_2025-01-19T23:59:59' for a specific day). For labels, use format 'key:value' (e.g., ['env:prod', 'severity:high']). Returns a list of alert group objects with their details. Supports pagination.",
476 | listAlertGroups,
477 | mcp.WithTitleAnnotation("List IRM alert groups"),
478 | mcp.WithIdempotentHintAnnotation(true),
479 | mcp.WithReadOnlyHintAnnotation(true),
480 | )
481 |
482 | type GetAlertGroupParams struct {
483 | AlertGroupID string `json:"alertGroupId" jsonschema:"required,description=The ID of the alert group to retrieve"`
484 | }
485 |
486 | func getAlertGroup(ctx context.Context, args GetAlertGroupParams) (*aapi.AlertGroup, error) {
487 | alertGroupService, err := getAlertGroupServiceFromContext(ctx)
488 | if err != nil {
489 | return nil, fmt.Errorf("getting OnCall alert group service: %w", err)
490 | }
491 |
492 | alertGroup, _, err := alertGroupService.GetAlertGroup(args.AlertGroupID)
493 | if err != nil {
494 | return nil, fmt.Errorf("getting OnCall alert group %s: %w", args.AlertGroupID, err)
495 | }
496 |
497 | return alertGroup, nil
498 | }
499 |
500 | var GetAlertGroup = mcpgrafana.MustTool(
501 | "get_alert_group",
502 | "Get a specific alert group from Grafana OnCall by its ID. Returns the full alert group details.",
503 | getAlertGroup,
504 | mcp.WithTitleAnnotation("Get IRM alert group"),
505 | mcp.WithIdempotentHintAnnotation(true),
506 | mcp.WithReadOnlyHintAnnotation(true),
507 | )
508 |
509 | func AddOnCallTools(mcp *server.MCPServer) {
510 | ListOnCallSchedules.Register(mcp)
511 | GetOnCallShift.Register(mcp)
512 | GetCurrentOnCallUsers.Register(mcp)
513 | ListOnCallTeams.Register(mcp)
514 | ListOnCallUsers.Register(mcp)
515 | ListAlertGroups.Register(mcp)
516 | GetAlertGroup.Register(mcp)
517 | }
518 |
```
--------------------------------------------------------------------------------
/tools/alerting_test.go:
--------------------------------------------------------------------------------
```go
1 | // Requires a Grafana instance running on localhost:3000,
2 | // with alert rules configured.
3 | // Run with `go test -tags integration`.
4 | //go:build integration
5 |
6 | package tools
7 |
8 | import (
9 | "testing"
10 |
11 | "github.com/stretchr/testify/require"
12 | )
13 |
14 | const (
15 | rule1UID = "test_alert_rule_1"
16 | rule1Title = "Test Alert Rule 1"
17 | rule2UID = "test_alert_rule_2"
18 | rule2Title = "Test Alert Rule 2"
19 | rulePausedUID = "test_alert_rule_paused"
20 | rulePausedTitle = "Test Alert Rule (Paused)"
21 | )
22 |
23 | var (
24 | rule1Labels = map[string]string{
25 | "severity": "info",
26 | "type": "test",
27 | "rule": "first",
28 | }
29 | rule2Labels = map[string]string{
30 | "severity": "info",
31 | "type": "test",
32 | "rule": "second",
33 | }
34 | rule3Labels = map[string]string{
35 | "severity": "info",
36 | "type": "test",
37 | "rule": "third",
38 | }
39 |
40 | rule1 = alertRuleSummary{
41 | UID: rule1UID,
42 | State: "",
43 | Title: rule1Title,
44 | Labels: rule1Labels,
45 | }
46 | rule2 = alertRuleSummary{
47 | UID: rule2UID,
48 | State: "",
49 | Title: rule2Title,
50 | Labels: rule2Labels,
51 | }
52 | rulePaused = alertRuleSummary{
53 | UID: rulePausedUID,
54 | State: "",
55 | Title: rulePausedTitle,
56 | Labels: rule3Labels,
57 | }
58 | allExpectedRules = []alertRuleSummary{rule1, rule2, rulePaused}
59 | )
60 |
61 | // Because the state depends on the evaluation of the alert rules,
62 | // clear it and other variable runtime fields before comparing the results
63 | // to avoid waiting for the alerts to start firing or be in the pending state.
64 | func clearState(rules []alertRuleSummary) []alertRuleSummary {
65 | for i := range rules {
66 | rules[i].State = ""
67 | rules[i].Health = ""
68 | rules[i].FolderUID = ""
69 | rules[i].RuleGroup = ""
70 | rules[i].For = ""
71 | rules[i].LastEvaluation = ""
72 | rules[i].Annotations = nil
73 | }
74 |
75 | return rules
76 | }
77 |
78 | func TestAlertingTools_ListAlertRules(t *testing.T) {
79 | t.Run("list alert rules", func(t *testing.T) {
80 | ctx := newTestContext()
81 | result, err := listAlertRules(ctx, ListAlertRulesParams{})
82 | require.NoError(t, err)
83 |
84 | require.ElementsMatch(t, allExpectedRules, clearState(result))
85 | })
86 |
87 | t.Run("list alert rules with pagination", func(t *testing.T) {
88 | ctx := newTestContext()
89 |
90 | // Get the first page with limit 1
91 | result1, err := listAlertRules(ctx, ListAlertRulesParams{
92 | Limit: 1,
93 | Page: 1,
94 | })
95 | require.NoError(t, err)
96 | require.Len(t, result1, 1)
97 |
98 | // Get the second page with limit 1
99 | result2, err := listAlertRules(ctx, ListAlertRulesParams{
100 | Limit: 1,
101 | Page: 2,
102 | })
103 | require.NoError(t, err)
104 | require.Len(t, result2, 1)
105 |
106 | // Get the third page with limit 1
107 | result3, err := listAlertRules(ctx, ListAlertRulesParams{
108 | Limit: 1,
109 | Page: 3,
110 | })
111 | require.NoError(t, err)
112 | require.Len(t, result3, 1)
113 |
114 | // The next page is empty
115 | result4, err := listAlertRules(ctx, ListAlertRulesParams{
116 | Limit: 1,
117 | Page: 4,
118 | })
119 | require.NoError(t, err)
120 | require.Empty(t, result4)
121 | })
122 |
123 | t.Run("list alert rules without the page and limit params", func(t *testing.T) {
124 | ctx := newTestContext()
125 | result, err := listAlertRules(ctx, ListAlertRulesParams{})
126 | require.NoError(t, err)
127 | require.ElementsMatch(t, allExpectedRules, clearState(result))
128 | })
129 |
130 | t.Run("list alert rules with selectors that match", func(t *testing.T) {
131 | ctx := newTestContext()
132 | result, err := listAlertRules(ctx, ListAlertRulesParams{
133 | LabelSelectors: []Selector{
134 | {
135 | Filters: []LabelMatcher{
136 | {
137 | Name: "severity",
138 | Value: "info",
139 | Type: "=",
140 | },
141 | },
142 | },
143 | },
144 | })
145 | require.NoError(t, err)
146 | require.ElementsMatch(t, allExpectedRules, clearState(result))
147 | })
148 |
149 | t.Run("list alert rules with selectors that don't match", func(t *testing.T) {
150 | ctx := newTestContext()
151 | result, err := listAlertRules(ctx, ListAlertRulesParams{
152 | LabelSelectors: []Selector{
153 | {
154 | Filters: []LabelMatcher{
155 | {
156 | Name: "severity",
157 | Value: "critical",
158 | Type: "=",
159 | },
160 | },
161 | },
162 | },
163 | })
164 | require.NoError(t, err)
165 | require.Empty(t, result)
166 | })
167 |
168 | t.Run("list alert rules with multiple selectors", func(t *testing.T) {
169 | ctx := newTestContext()
170 | result, err := listAlertRules(ctx, ListAlertRulesParams{
171 | LabelSelectors: []Selector{
172 | {
173 | Filters: []LabelMatcher{
174 | {
175 | Name: "severity",
176 | Value: "info",
177 | Type: "=",
178 | },
179 | },
180 | },
181 | {
182 | Filters: []LabelMatcher{
183 | {
184 | Name: "rule",
185 | Value: "second",
186 | Type: "=",
187 | },
188 | },
189 | },
190 | },
191 | })
192 | require.NoError(t, err)
193 | require.ElementsMatch(t, []alertRuleSummary{rule2}, clearState(result))
194 | })
195 |
196 | t.Run("list alert rules with regex matcher", func(t *testing.T) {
197 | ctx := newTestContext()
198 | result, err := listAlertRules(ctx, ListAlertRulesParams{
199 | LabelSelectors: []Selector{
200 | {
201 | Filters: []LabelMatcher{
202 | {
203 | Name: "rule",
204 | Value: "fi.*",
205 | Type: "=~",
206 | },
207 | },
208 | },
209 | },
210 | })
211 | require.NoError(t, err)
212 | require.ElementsMatch(t, []alertRuleSummary{rule1}, clearState(result))
213 | })
214 |
215 | t.Run("list alert rules with selectors and pagination", func(t *testing.T) {
216 | ctx := newTestContext()
217 | result, err := listAlertRules(ctx, ListAlertRulesParams{
218 | LabelSelectors: []Selector{
219 | {
220 | Filters: []LabelMatcher{
221 | {
222 | Name: "severity",
223 | Value: "info",
224 | Type: "=",
225 | },
226 | },
227 | },
228 | },
229 | Limit: 1,
230 | Page: 1,
231 | })
232 | require.NoError(t, err)
233 | require.Len(t, result, 1)
234 | require.ElementsMatch(t, []alertRuleSummary{rule1}, clearState(result))
235 |
236 | // Second page
237 | result, err = listAlertRules(ctx, ListAlertRulesParams{
238 | LabelSelectors: []Selector{
239 | {
240 | Filters: []LabelMatcher{
241 | {
242 | Name: "severity",
243 | Value: "info",
244 | Type: "=",
245 | },
246 | },
247 | },
248 | },
249 | Limit: 1,
250 | Page: 2,
251 | })
252 | require.NoError(t, err)
253 | require.Len(t, result, 1)
254 | require.ElementsMatch(t, []alertRuleSummary{rule2}, clearState(result))
255 | })
256 |
257 | t.Run("list alert rules with not equals operator", func(t *testing.T) {
258 | ctx := newTestContext()
259 | result, err := listAlertRules(ctx, ListAlertRulesParams{
260 | LabelSelectors: []Selector{
261 | {
262 | Filters: []LabelMatcher{
263 | {
264 | Name: "severity",
265 | Value: "critical",
266 | Type: "!=",
267 | },
268 | },
269 | },
270 | },
271 | })
272 | require.NoError(t, err)
273 | require.ElementsMatch(t, allExpectedRules, clearState(result))
274 | })
275 |
276 | t.Run("list alert rules with not matches operator", func(t *testing.T) {
277 | ctx := newTestContext()
278 | result, err := listAlertRules(ctx, ListAlertRulesParams{
279 | LabelSelectors: []Selector{
280 | {
281 | Filters: []LabelMatcher{
282 | {
283 | Name: "severity",
284 | Value: "crit.*",
285 | Type: "!~",
286 | },
287 | },
288 | },
289 | },
290 | })
291 | require.NoError(t, err)
292 | require.ElementsMatch(t, allExpectedRules, clearState(result))
293 | })
294 |
295 | t.Run("list alert rules with non-existent label", func(t *testing.T) {
296 | // Equality with non-existent label should return no results
297 | ctx := newTestContext()
298 | result, err := listAlertRules(ctx, ListAlertRulesParams{
299 | LabelSelectors: []Selector{
300 | {
301 | Filters: []LabelMatcher{
302 | {
303 | Name: "nonexistent",
304 | Value: "value",
305 | Type: "=",
306 | },
307 | },
308 | },
309 | },
310 | })
311 | require.NoError(t, err)
312 | require.Empty(t, result)
313 | })
314 |
315 | t.Run("list alert rules with non-existent label and inequality", func(t *testing.T) {
316 | // Inequality with non-existent label should return all results
317 | ctx := newTestContext()
318 | result, err := listAlertRules(ctx, ListAlertRulesParams{
319 | LabelSelectors: []Selector{
320 | {
321 | Filters: []LabelMatcher{
322 | {
323 | Name: "nonexistent",
324 | Value: "value",
325 | Type: "!=",
326 | },
327 | },
328 | },
329 | },
330 | })
331 | require.NoError(t, err)
332 | require.ElementsMatch(t, allExpectedRules, clearState(result))
333 | })
334 |
335 | t.Run("list alert rules with a limit that is larger than the number of rules", func(t *testing.T) {
336 | ctx := newTestContext()
337 | result, err := listAlertRules(ctx, ListAlertRulesParams{
338 | Limit: 1000,
339 | Page: 1,
340 | })
341 | require.NoError(t, err)
342 | require.ElementsMatch(t, allExpectedRules, clearState(result))
343 | })
344 |
345 | t.Run("list alert rules with a page that doesn't exist", func(t *testing.T) {
346 | ctx := newTestContext()
347 | result, err := listAlertRules(ctx, ListAlertRulesParams{
348 | Limit: 10,
349 | Page: 1000,
350 | })
351 | require.NoError(t, err)
352 | require.Empty(t, result)
353 | })
354 |
355 | t.Run("list alert rules with invalid page parameter", func(t *testing.T) {
356 | ctx := newTestContext()
357 | result, err := listAlertRules(ctx, ListAlertRulesParams{
358 | Page: -1,
359 | })
360 | require.Error(t, err)
361 | require.Empty(t, result)
362 | })
363 |
364 | t.Run("list alert rules with invalid limit parameter", func(t *testing.T) {
365 | ctx := newTestContext()
366 | result, err := listAlertRules(ctx, ListAlertRulesParams{
367 | Limit: -1,
368 | })
369 | require.Error(t, err)
370 | require.Empty(t, result)
371 | })
372 | }
373 |
374 | func TestAlertingTools_GetAlertRuleByUID(t *testing.T) {
375 | t.Run("get running alert rule by uid", func(t *testing.T) {
376 | ctx := newTestContext()
377 | result, err := getAlertRuleByUID(ctx, GetAlertRuleByUIDParams{
378 | UID: rule1UID,
379 | })
380 |
381 | require.NoError(t, err)
382 | require.Equal(t, rule1UID, result.UID)
383 | require.NotNil(t, result.Title)
384 | require.Equal(t, rule1Title, *result.Title)
385 | require.False(t, result.IsPaused)
386 | })
387 |
388 | t.Run("get paused alert rule by uid", func(t *testing.T) {
389 | ctx := newTestContext()
390 | result, err := getAlertRuleByUID(ctx, GetAlertRuleByUIDParams{
391 | UID: "test_alert_rule_paused",
392 | })
393 |
394 | require.NoError(t, err)
395 | require.Equal(t, rulePausedUID, result.UID)
396 | require.NotNil(t, result.Title)
397 | require.Equal(t, rulePausedTitle, *result.Title)
398 | require.True(t, result.IsPaused)
399 | })
400 |
401 | t.Run("get alert rule with empty UID fails", func(t *testing.T) {
402 | ctx := newTestContext()
403 | result, err := getAlertRuleByUID(ctx, GetAlertRuleByUIDParams{
404 | UID: "",
405 | })
406 |
407 | require.Nil(t, result)
408 | require.Error(t, err)
409 | })
410 |
411 | t.Run("get non-existing alert rule by uid", func(t *testing.T) {
412 | ctx := newTestContext()
413 | result, err := getAlertRuleByUID(ctx, GetAlertRuleByUIDParams{
414 | UID: "some-non-existing-alert-rule-uid",
415 | })
416 |
417 | require.Nil(t, result)
418 | require.Error(t, err)
419 | require.Contains(t, err.Error(), "getAlertRuleNotFound")
420 | })
421 | }
422 |
423 | var (
424 | emailType = "email"
425 |
426 | contactPoint1 = contactPointSummary{
427 | UID: "email1",
428 | Name: "Email1",
429 | Type: &emailType,
430 | }
431 | contactPoint2 = contactPointSummary{
432 | UID: "email2",
433 | Name: "Email2",
434 | Type: &emailType,
435 | }
436 | contactPoint3 = contactPointSummary{
437 | UID: "",
438 | Name: "email receiver",
439 | Type: &emailType,
440 | }
441 | allExpectedContactPoints = []contactPointSummary{contactPoint1, contactPoint2, contactPoint3}
442 | )
443 |
444 | func TestAlertingTools_ListContactPoints(t *testing.T) {
445 | t.Run("list contact points", func(t *testing.T) {
446 | ctx := newTestContext()
447 | result, err := listContactPoints(ctx, ListContactPointsParams{})
448 | require.NoError(t, err)
449 | require.ElementsMatch(t, allExpectedContactPoints, result)
450 | })
451 |
452 | t.Run("list one contact point", func(t *testing.T) {
453 | ctx := newTestContext()
454 |
455 | // Get the contact points with limit 1
456 | result1, err := listContactPoints(ctx, ListContactPointsParams{
457 | Limit: 1,
458 | })
459 | require.NoError(t, err)
460 | require.Len(t, result1, 1)
461 | })
462 |
463 | t.Run("list contact points with name filter", func(t *testing.T) {
464 | ctx := newTestContext()
465 | name := "Email1"
466 |
467 | result, err := listContactPoints(ctx, ListContactPointsParams{
468 | Name: &name,
469 | })
470 | require.NoError(t, err)
471 | require.Len(t, result, 1)
472 | require.Equal(t, "Email1", result[0].Name)
473 | })
474 |
475 | t.Run("list contact points with invalid limit parameter", func(t *testing.T) {
476 | ctx := newTestContext()
477 | result, err := listContactPoints(ctx, ListContactPointsParams{
478 | Limit: -1,
479 | })
480 | require.Error(t, err)
481 | require.Empty(t, result)
482 | })
483 |
484 | t.Run("list contact points with large limit", func(t *testing.T) {
485 | ctx := newTestContext()
486 | result, err := listContactPoints(ctx, ListContactPointsParams{
487 | Limit: 1000,
488 | })
489 | require.NoError(t, err)
490 | require.NotEmpty(t, result)
491 | })
492 |
493 | t.Run("list contact points with non-existent name filter", func(t *testing.T) {
494 | ctx := newTestContext()
495 | name := "NonExistentAlert"
496 |
497 | result, err := listContactPoints(ctx, ListContactPointsParams{
498 | Name: &name,
499 | })
500 | require.NoError(t, err)
501 | require.Empty(t, result)
502 | })
503 | }
504 |
505 | func TestAlertingTools_CreateAlertRule(t *testing.T) {
506 | t.Run("create alert rule with valid parameters", func(t *testing.T) {
507 | ctx := newTestContext()
508 |
509 | // Sample query data that matches Grafana's expected format
510 | sampleData := []any{
511 | map[string]any{
512 | "refId": "A",
513 | "queryType": "",
514 | "relativeTimeRange": map[string]any{
515 | "from": 600,
516 | "to": 0,
517 | },
518 | "datasourceUid": "prometheus-uid",
519 | "model": map[string]any{
520 | "expr": "up",
521 | "hide": false,
522 | "intervalMs": 1000,
523 | "maxDataPoints": 43200,
524 | "refId": "A",
525 | },
526 | },
527 | map[string]any{
528 | "refId": "B",
529 | "queryType": "",
530 | "relativeTimeRange": map[string]any{
531 | "from": 0,
532 | "to": 0,
533 | },
534 | "datasourceUid": "__expr__",
535 | "model": map[string]any{
536 | "conditions": []any{
537 | map[string]any{
538 | "evaluator": map[string]any{
539 | "params": []any{1},
540 | "type": "gt",
541 | },
542 | "operator": map[string]any{
543 | "type": "and",
544 | },
545 | "query": map[string]any{
546 | "params": []any{"A"},
547 | },
548 | "reducer": map[string]any{
549 | "params": []any{},
550 | "type": "last",
551 | },
552 | "type": "query",
553 | },
554 | },
555 | "datasource": map[string]any{
556 | "type": "__expr__",
557 | "uid": "__expr__",
558 | },
559 | "hide": false,
560 | "intervalMs": 1000,
561 | "maxDataPoints": 43200,
562 | "refId": "B",
563 | "type": "classic_conditions",
564 | },
565 | },
566 | }
567 |
568 | testUID := "test_create_alert_rule"
569 | params := CreateAlertRuleParams{
570 | Title: "Test Created Alert Rule",
571 | RuleGroup: "test-group",
572 | FolderUID: "tests",
573 | Condition: "B",
574 | Data: sampleData,
575 | NoDataState: "OK",
576 | ExecErrState: "OK",
577 | For: "5m",
578 | Annotations: map[string]string{
579 | "summary": "Test alert rule created via API",
580 | },
581 | Labels: map[string]string{
582 | "team": "test-team",
583 | },
584 | UID: &testUID,
585 | OrgID: 1,
586 | }
587 |
588 | result, err := createAlertRule(ctx, params)
589 | require.NoError(t, err)
590 | require.NotNil(t, result)
591 | require.Equal(t, testUID, result.UID)
592 | require.Equal(t, "Test Created Alert Rule", *result.Title)
593 | require.Equal(t, "test-group", *result.RuleGroup)
594 |
595 | // Clean up: delete the created rule
596 | _, cleanupErr := deleteAlertRule(ctx, DeleteAlertRuleParams{UID: testUID})
597 | require.NoError(t, cleanupErr)
598 | })
599 |
600 | t.Run("create alert rule with missing required fields", func(t *testing.T) {
601 | ctx := newTestContext()
602 |
603 | params := CreateAlertRuleParams{
604 | Title: "Incomplete Rule",
605 | // Missing other required fields
606 | }
607 |
608 | result, err := createAlertRule(ctx, params)
609 | require.Error(t, err)
610 | require.Nil(t, result)
611 | require.Contains(t, err.Error(), "ruleGroup is required")
612 | })
613 |
614 | t.Run("create alert rule with empty title", func(t *testing.T) {
615 | ctx := newTestContext()
616 |
617 | params := CreateAlertRuleParams{
618 | Title: "",
619 | }
620 |
621 | result, err := createAlertRule(ctx, params)
622 | require.Error(t, err)
623 | require.Nil(t, result)
624 | require.Contains(t, err.Error(), "title is required")
625 | })
626 | }
627 |
628 | func TestAlertingTools_UpdateAlertRule(t *testing.T) {
629 | t.Run("update existing alert rule", func(t *testing.T) {
630 | ctx := newTestContext()
631 |
632 | // First create a rule to update
633 | sampleData := []any{
634 | map[string]any{
635 | "refId": "A",
636 | "queryType": "",
637 | "relativeTimeRange": map[string]any{
638 | "from": 600,
639 | "to": 0,
640 | },
641 | "datasourceUid": "prometheus-uid",
642 | "model": map[string]any{
643 | "expr": "up",
644 | "hide": false,
645 | "intervalMs": 1000,
646 | "maxDataPoints": 43200,
647 | "refId": "A",
648 | },
649 | },
650 | }
651 |
652 | testUID := "test_update_alert_rule"
653 | createParams := CreateAlertRuleParams{
654 | Title: "Original Title",
655 | RuleGroup: "test-group",
656 | FolderUID: "tests",
657 | Condition: "A",
658 | Data: sampleData,
659 | NoDataState: "OK",
660 | ExecErrState: "OK",
661 | For: "5m",
662 | UID: &testUID,
663 | OrgID: 1,
664 | }
665 |
666 | // Create the rule
667 | created, err := createAlertRule(ctx, createParams)
668 | require.NoError(t, err)
669 | require.NotNil(t, created)
670 |
671 | // Now update it
672 | updateParams := UpdateAlertRuleParams{
673 | UID: testUID,
674 | Title: "Updated Title",
675 | RuleGroup: "test-group",
676 | FolderUID: "tests",
677 | Condition: "A",
678 | Data: sampleData,
679 | NoDataState: "Alerting",
680 | ExecErrState: "Alerting",
681 | For: "10m",
682 | Annotations: map[string]string{
683 | "summary": "Updated alert rule",
684 | },
685 | Labels: map[string]string{
686 | "team": "updated-team",
687 | },
688 | OrgID: 1,
689 | }
690 |
691 | result, err := updateAlertRule(ctx, updateParams)
692 | require.NoError(t, err)
693 | require.NotNil(t, result)
694 | require.Equal(t, testUID, result.UID)
695 | require.Equal(t, "Updated Title", *result.Title)
696 | require.Equal(t, "Alerting", *result.NoDataState)
697 |
698 | // Clean up: delete the rule
699 | _, cleanupErr := deleteAlertRule(ctx, DeleteAlertRuleParams{UID: testUID})
700 | require.NoError(t, cleanupErr)
701 | })
702 |
703 | t.Run("update non-existent alert rule", func(t *testing.T) {
704 | ctx := newTestContext()
705 |
706 | params := UpdateAlertRuleParams{
707 | UID: "non-existent-uid",
708 | Title: "Updated Title",
709 | RuleGroup: "test-group",
710 | FolderUID: "tests",
711 | Condition: "A",
712 | Data: []any{},
713 | NoDataState: "OK",
714 | ExecErrState: "OK",
715 | For: "5m",
716 | OrgID: 1,
717 | }
718 |
719 | result, err := updateAlertRule(ctx, params)
720 | require.Error(t, err)
721 | require.Nil(t, result)
722 | })
723 |
724 | t.Run("update alert rule with empty UID", func(t *testing.T) {
725 | ctx := newTestContext()
726 |
727 | params := UpdateAlertRuleParams{
728 | UID: "",
729 | }
730 |
731 | result, err := updateAlertRule(ctx, params)
732 | require.Error(t, err)
733 | require.Nil(t, result)
734 | require.Contains(t, err.Error(), "uid is required")
735 | })
736 | }
737 |
738 | func TestAlertingTools_DeleteAlertRule(t *testing.T) {
739 | t.Run("delete existing alert rule", func(t *testing.T) {
740 | ctx := newTestContext()
741 |
742 | // First create a rule to delete
743 | sampleData := []any{
744 | map[string]any{
745 | "refId": "A",
746 | "queryType": "",
747 | "relativeTimeRange": map[string]any{
748 | "from": 600,
749 | "to": 0,
750 | },
751 | "datasourceUid": "prometheus-uid",
752 | "model": map[string]any{
753 | "expr": "up",
754 | "hide": false,
755 | "intervalMs": 1000,
756 | "maxDataPoints": 43200,
757 | "refId": "A",
758 | },
759 | },
760 | }
761 |
762 | testUID := "test_delete_alert_rule"
763 | createParams := CreateAlertRuleParams{
764 | Title: "Rule to Delete",
765 | RuleGroup: "test-group",
766 | FolderUID: "tests",
767 | Condition: "A",
768 | Data: sampleData,
769 | NoDataState: "OK",
770 | ExecErrState: "OK",
771 | For: "5m",
772 | UID: &testUID,
773 | OrgID: 1,
774 | }
775 |
776 | // Create the rule
777 | created, err := createAlertRule(ctx, createParams)
778 | require.NoError(t, err)
779 | require.NotNil(t, created)
780 |
781 | // Now delete it
782 | result, err := deleteAlertRule(ctx, DeleteAlertRuleParams{UID: testUID})
783 | require.NoError(t, err)
784 | require.Contains(t, result, "deleted successfully")
785 | require.Contains(t, result, testUID)
786 |
787 | // Verify it's gone by trying to get it
788 | _, getErr := getAlertRuleByUID(ctx, GetAlertRuleByUIDParams{UID: testUID})
789 | require.Error(t, getErr)
790 | })
791 |
792 | t.Run("delete non-existent alert rule", func(t *testing.T) {
793 | ctx := newTestContext()
794 |
795 | result, err := deleteAlertRule(ctx, DeleteAlertRuleParams{UID: "non-existent-uid"})
796 | require.NoError(t, err) // DELETE is idempotent - success even if rule doesn't exist
797 | require.Contains(t, result, "deleted successfully")
798 | require.Contains(t, result, "non-existent-uid")
799 | })
800 |
801 | t.Run("delete alert rule with empty UID", func(t *testing.T) {
802 | ctx := newTestContext()
803 |
804 | result, err := deleteAlertRule(ctx, DeleteAlertRuleParams{UID: ""})
805 | require.Error(t, err)
806 | require.Empty(t, result)
807 | require.Contains(t, err.Error(), "uid is required")
808 | })
809 | }
810 |
```
--------------------------------------------------------------------------------
/tools/loki.go:
--------------------------------------------------------------------------------
```go
1 | package tools
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "net/http"
10 | "net/url"
11 | "strconv"
12 | "strings"
13 | "time"
14 |
15 | mcpgrafana "github.com/grafana/mcp-grafana"
16 | "github.com/mark3labs/mcp-go/mcp"
17 | "github.com/mark3labs/mcp-go/server"
18 | )
19 |
20 | const (
21 | // DefaultLokiLogLimit is the default number of log lines to return if not specified
22 | DefaultLokiLogLimit = 10
23 |
24 | // MaxLokiLogLimit is the maximum number of log lines that can be requested
25 | MaxLokiLogLimit = 100
26 | )
27 |
28 | type Client struct {
29 | httpClient *http.Client
30 | baseURL string
31 | }
32 |
33 | // LabelResponse represents the http json response to a label query
34 | type LabelResponse struct {
35 | Status string `json:"status"`
36 | Data []string `json:"data,omitempty"`
37 | }
38 |
39 | // Stats represents the statistics returned by Loki's index/stats endpoint
40 | type Stats struct {
41 | Streams int `json:"streams"`
42 | Chunks int `json:"chunks"`
43 | Entries int `json:"entries"`
44 | Bytes int `json:"bytes"`
45 | }
46 |
47 | func newLokiClient(ctx context.Context, uid string) (*Client, error) {
48 | // First check if the datasource exists
49 | _, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{UID: uid})
50 | if err != nil {
51 | return nil, err
52 | }
53 |
54 | cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
55 | url := fmt.Sprintf("%s/api/datasources/proxy/uid/%s", strings.TrimRight(cfg.URL, "/"), uid)
56 |
57 | // Create custom transport with TLS configuration if available
58 | var transport = http.DefaultTransport
59 | if tlsConfig := cfg.TLSConfig; tlsConfig != nil {
60 | var err error
61 | transport, err = tlsConfig.HTTPTransport(transport.(*http.Transport))
62 | if err != nil {
63 | return nil, fmt.Errorf("failed to create custom transport: %w", err)
64 | }
65 | }
66 |
67 | transport = NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth)
68 | transport = mcpgrafana.NewOrgIDRoundTripper(transport, cfg.OrgID)
69 |
70 | client := &http.Client{
71 | Transport: mcpgrafana.NewUserAgentTransport(
72 | transport,
73 | ),
74 | }
75 |
76 | return &Client{
77 | httpClient: client,
78 | baseURL: url,
79 | }, nil
80 | }
81 |
82 | // buildURL constructs a full URL for a Loki API endpoint
83 | func (c *Client) buildURL(urlPath string) string {
84 | fullURL := c.baseURL
85 | if !strings.HasSuffix(fullURL, "/") && !strings.HasPrefix(urlPath, "/") {
86 | fullURL += "/"
87 | } else if strings.HasSuffix(fullURL, "/") && strings.HasPrefix(urlPath, "/") {
88 | // Remove the leading slash from urlPath to avoid double slash
89 | urlPath = strings.TrimPrefix(urlPath, "/")
90 | }
91 | return fullURL + urlPath
92 | }
93 |
94 | // makeRequest makes an HTTP request to the Loki API and returns the response body
95 | func (c *Client) makeRequest(ctx context.Context, method, urlPath string, params url.Values) ([]byte, error) {
96 | fullURL := c.buildURL(urlPath)
97 |
98 | u, err := url.Parse(fullURL)
99 | if err != nil {
100 | return nil, fmt.Errorf("parsing URL: %w", err)
101 | }
102 |
103 | if params != nil {
104 | u.RawQuery = params.Encode()
105 | }
106 |
107 | req, err := http.NewRequestWithContext(ctx, method, u.String(), nil)
108 | if err != nil {
109 | return nil, fmt.Errorf("creating request: %w", err)
110 | }
111 |
112 | resp, err := c.httpClient.Do(req)
113 | if err != nil {
114 | return nil, fmt.Errorf("executing request: %w", err)
115 | }
116 | defer func() {
117 | _ = resp.Body.Close() //nolint:errcheck
118 | }()
119 |
120 | // Check for non-200 status code
121 | if resp.StatusCode != http.StatusOK {
122 | bodyBytes, _ := io.ReadAll(resp.Body)
123 | return nil, fmt.Errorf("loki API returned status code %d: %s", resp.StatusCode, string(bodyBytes))
124 | }
125 |
126 | // Read the response body with a limit to prevent memory issues
127 | body := io.LimitReader(resp.Body, 1024*1024*48)
128 | bodyBytes, err := io.ReadAll(body)
129 | if err != nil {
130 | return nil, fmt.Errorf("reading response body: %w", err)
131 | }
132 |
133 | // Check if the response is empty
134 | if len(bodyBytes) == 0 {
135 | return nil, fmt.Errorf("empty response from Loki API")
136 | }
137 |
138 | // Trim any whitespace that might cause JSON parsing issues
139 | return bytes.TrimSpace(bodyBytes), nil
140 | }
141 |
142 | // fetchData is a generic method to fetch data from Loki API
143 | func (c *Client) fetchData(ctx context.Context, urlPath string, startRFC3339, endRFC3339 string) ([]string, error) {
144 | params := url.Values{}
145 | if startRFC3339 != "" {
146 | params.Add("start", startRFC3339)
147 | }
148 | if endRFC3339 != "" {
149 | params.Add("end", endRFC3339)
150 | }
151 |
152 | bodyBytes, err := c.makeRequest(ctx, "GET", urlPath, params)
153 | if err != nil {
154 | return nil, err
155 | }
156 |
157 | var labelResponse LabelResponse
158 | err = json.Unmarshal(bodyBytes, &labelResponse)
159 | if err != nil {
160 | return nil, fmt.Errorf("unmarshalling response (content: %s): %w", string(bodyBytes), err)
161 | }
162 |
163 | if labelResponse.Status != "success" {
164 | return nil, fmt.Errorf("loki API returned unexpected response format: %s", string(bodyBytes))
165 | }
166 |
167 | // Check if Data is nil or empty and handle it explicitly
168 | if labelResponse.Data == nil {
169 | // Return empty slice instead of nil to avoid potential nil pointer issues
170 | return []string{}, nil
171 | }
172 |
173 | if len(labelResponse.Data) == 0 {
174 | return []string{}, nil
175 | }
176 |
177 | return labelResponse.Data, nil
178 | }
179 |
180 | func NewAuthRoundTripper(rt http.RoundTripper, accessToken, idToken, apiKey string, basicAuth *url.Userinfo) *authRoundTripper {
181 | return &authRoundTripper{
182 | accessToken: accessToken,
183 | idToken: idToken,
184 | apiKey: apiKey,
185 | basicAuth: basicAuth,
186 | underlying: rt,
187 | }
188 | }
189 |
190 | type authRoundTripper struct {
191 | accessToken string
192 | idToken string
193 | apiKey string
194 | basicAuth *url.Userinfo
195 | underlying http.RoundTripper
196 | }
197 |
198 | func (rt *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
199 | if rt.accessToken != "" && rt.idToken != "" {
200 | req.Header.Set("X-Access-Token", rt.accessToken)
201 | req.Header.Set("X-Grafana-Id", rt.idToken)
202 | } else if rt.apiKey != "" {
203 | req.Header.Set("Authorization", "Bearer "+rt.apiKey)
204 | } else if rt.basicAuth != nil {
205 | password, _ := rt.basicAuth.Password()
206 | req.SetBasicAuth(rt.basicAuth.Username(), password)
207 | }
208 |
209 | resp, err := rt.underlying.RoundTrip(req)
210 | if err != nil {
211 | return nil, err
212 | }
213 |
214 | return resp, nil
215 | }
216 |
217 | // ListLokiLabelNamesParams defines the parameters for listing Loki label names
218 | type ListLokiLabelNamesParams struct {
219 | DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
220 | StartRFC3339 string `json:"startRfc3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format (defaults to 1 hour ago)"`
221 | EndRFC3339 string `json:"endRfc3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format (defaults to now)"`
222 | }
223 |
224 | // listLokiLabelNames lists all label names in a Loki datasource
225 | func listLokiLabelNames(ctx context.Context, args ListLokiLabelNamesParams) ([]string, error) {
226 | client, err := newLokiClient(ctx, args.DatasourceUID)
227 | if err != nil {
228 | return nil, fmt.Errorf("creating Loki client: %w", err)
229 | }
230 |
231 | result, err := client.fetchData(ctx, "/loki/api/v1/labels", args.StartRFC3339, args.EndRFC3339)
232 | if err != nil {
233 | return nil, err
234 | }
235 |
236 | if len(result) == 0 {
237 | return []string{}, nil
238 | }
239 |
240 | return result, nil
241 | }
242 |
243 | // ListLokiLabelNames is a tool for listing Loki label names
244 | var ListLokiLabelNames = mcpgrafana.MustTool(
245 | "list_loki_label_names",
246 | "Lists all available label names (keys) found in logs within a specified Loki datasource and time range. Returns a list of unique label strings (e.g., `[\"app\", \"env\", \"pod\"]`). If the time range is not provided, it defaults to the last hour.",
247 | listLokiLabelNames,
248 | mcp.WithTitleAnnotation("List Loki label names"),
249 | mcp.WithIdempotentHintAnnotation(true),
250 | mcp.WithReadOnlyHintAnnotation(true),
251 | )
252 |
253 | // ListLokiLabelValuesParams defines the parameters for listing Loki label values
254 | type ListLokiLabelValuesParams struct {
255 | DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
256 | LabelName string `json:"labelName" jsonschema:"required,description=The name of the label to retrieve values for (e.g. 'app'\\, 'env'\\, 'pod')"`
257 | StartRFC3339 string `json:"startRfc3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format (defaults to 1 hour ago)"`
258 | EndRFC3339 string `json:"endRfc3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format (defaults to now)"`
259 | }
260 |
261 | // listLokiLabelValues lists all values for a specific label in a Loki datasource
262 | func listLokiLabelValues(ctx context.Context, args ListLokiLabelValuesParams) ([]string, error) {
263 | client, err := newLokiClient(ctx, args.DatasourceUID)
264 | if err != nil {
265 | return nil, fmt.Errorf("creating Loki client: %w", err)
266 | }
267 |
268 | // Use the client's fetchData method
269 | urlPath := fmt.Sprintf("/loki/api/v1/label/%s/values", args.LabelName)
270 |
271 | result, err := client.fetchData(ctx, urlPath, args.StartRFC3339, args.EndRFC3339)
272 | if err != nil {
273 | return nil, err
274 | }
275 |
276 | if len(result) == 0 {
277 | // Return empty slice instead of nil
278 | return []string{}, nil
279 | }
280 |
281 | return result, nil
282 | }
283 |
284 | // ListLokiLabelValues is a tool for listing Loki label values
285 | var ListLokiLabelValues = mcpgrafana.MustTool(
286 | "list_loki_label_values",
287 | "Retrieves all unique values associated with a specific `labelName` within a Loki datasource and time range. Returns a list of string values (e.g., for `labelName=\"env\"`, might return `[\"prod\", \"staging\", \"dev\"]`). Useful for discovering filter options. Defaults to the last hour if the time range is omitted.",
288 | listLokiLabelValues,
289 | mcp.WithTitleAnnotation("List Loki label values"),
290 | mcp.WithIdempotentHintAnnotation(true),
291 | mcp.WithReadOnlyHintAnnotation(true),
292 | )
293 |
294 | // LogStream represents a stream of log entries from Loki
295 | type LogStream struct {
296 | Stream map[string]string `json:"stream"`
297 | Values [][]json.RawMessage `json:"values"` // [timestamp, value] where value can be string or number
298 | }
299 |
300 | // QueryRangeResponse represents the response from Loki's query_range API
301 | type QueryRangeResponse struct {
302 | Status string `json:"status"`
303 | Data struct {
304 | ResultType string `json:"resultType"`
305 | Result []LogStream `json:"result"`
306 | } `json:"data"`
307 | }
308 |
309 | // addTimeRangeParams adds start and end time parameters to the URL values
310 | // It handles conversion from RFC3339 to Unix nanoseconds
311 | func addTimeRangeParams(params url.Values, startRFC3339, endRFC3339 string) error {
312 | if startRFC3339 != "" {
313 | startTime, err := time.Parse(time.RFC3339, startRFC3339)
314 | if err != nil {
315 | return fmt.Errorf("parsing start time: %w", err)
316 | }
317 | params.Add("start", fmt.Sprintf("%d", startTime.UnixNano()))
318 | }
319 |
320 | if endRFC3339 != "" {
321 | endTime, err := time.Parse(time.RFC3339, endRFC3339)
322 | if err != nil {
323 | return fmt.Errorf("parsing end time: %w", err)
324 | }
325 | params.Add("end", fmt.Sprintf("%d", endTime.UnixNano()))
326 | }
327 |
328 | return nil
329 | }
330 |
331 | // getDefaultTimeRange returns default start and end times if not provided
332 | // Returns start time (1 hour ago) and end time (now) in RFC3339 format
333 | func getDefaultTimeRange(startRFC3339, endRFC3339 string) (string, string) {
334 | if startRFC3339 == "" {
335 | // Default to 1 hour ago if not specified
336 | startRFC3339 = time.Now().Add(-1 * time.Hour).Format(time.RFC3339)
337 | }
338 | if endRFC3339 == "" {
339 | // Default to now if not specified
340 | endRFC3339 = time.Now().Format(time.RFC3339)
341 | }
342 | return startRFC3339, endRFC3339
343 | }
344 |
345 | // fetchLogs is a method to fetch logs from Loki API
346 | func (c *Client) fetchLogs(ctx context.Context, query, startRFC3339, endRFC3339 string, limit int, direction string) ([]LogStream, error) {
347 | params := url.Values{}
348 | params.Add("query", query)
349 |
350 | // Add time range parameters
351 | if err := addTimeRangeParams(params, startRFC3339, endRFC3339); err != nil {
352 | return nil, err
353 | }
354 |
355 | if limit > 0 {
356 | params.Add("limit", fmt.Sprintf("%d", limit))
357 | }
358 |
359 | if direction != "" {
360 | params.Add("direction", direction)
361 | }
362 |
363 | bodyBytes, err := c.makeRequest(ctx, "GET", "/loki/api/v1/query_range", params)
364 | if err != nil {
365 | return nil, err
366 | }
367 |
368 | var queryResponse QueryRangeResponse
369 | err = json.Unmarshal(bodyBytes, &queryResponse)
370 | if err != nil {
371 | return nil, fmt.Errorf("unmarshalling response (content: %s): %w", string(bodyBytes), err)
372 | }
373 |
374 | if queryResponse.Status != "success" {
375 | return nil, fmt.Errorf("loki API returned unexpected response format: %s", string(bodyBytes))
376 | }
377 |
378 | return queryResponse.Data.Result, nil
379 | }
380 |
381 | // QueryLokiLogsParams defines the parameters for querying Loki logs
382 | type QueryLokiLogsParams struct {
383 | DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
384 | LogQL string `json:"logql" jsonschema:"required,description=The LogQL query to execute against Loki. This can be a simple label matcher or a complex query with filters\\, parsers\\, and expressions. Supports full LogQL syntax including label matchers\\, filter operators\\, pattern expressions\\, and pipeline operations."`
385 | StartRFC3339 string `json:"startRfc3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format"`
386 | EndRFC3339 string `json:"endRfc3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format"`
387 | Limit int `json:"limit,omitempty" jsonschema:"description=Optionally\\, the maximum number of log lines to return (default: 10\\, max: 100)"`
388 | Direction string `json:"direction,omitempty" jsonschema:"description=Optionally\\, the direction of the query: 'forward' (oldest first) or 'backward' (newest first\\, default)"`
389 | }
390 |
391 | // LogEntry represents a single log entry or metric sample with metadata
392 | type LogEntry struct {
393 | Timestamp string `json:"timestamp"`
394 | Line string `json:"line,omitempty"` // For log queries
395 | Value *float64 `json:"value,omitempty"` // For metric queries
396 | Labels map[string]string `json:"labels"`
397 | }
398 |
399 | // enforceLogLimit ensures a log limit value is within acceptable bounds
400 | func enforceLogLimit(requestedLimit int) int {
401 | if requestedLimit <= 0 {
402 | return DefaultLokiLogLimit
403 | }
404 | if requestedLimit > MaxLokiLogLimit {
405 | return MaxLokiLogLimit
406 | }
407 | return requestedLimit
408 | }
409 |
410 | // queryLokiLogs queries logs from a Loki datasource using LogQL
411 | func queryLokiLogs(ctx context.Context, args QueryLokiLogsParams) ([]LogEntry, error) {
412 | client, err := newLokiClient(ctx, args.DatasourceUID)
413 | if err != nil {
414 | return nil, fmt.Errorf("creating Loki client: %w", err)
415 | }
416 |
417 | // Get default time range if not provided
418 | startTime, endTime := getDefaultTimeRange(args.StartRFC3339, args.EndRFC3339)
419 |
420 | // Apply limit constraints
421 | limit := enforceLogLimit(args.Limit)
422 |
423 | // Set default direction if not provided
424 | direction := args.Direction
425 | if direction == "" {
426 | direction = "backward" // Most recent logs first
427 | }
428 |
429 | streams, err := client.fetchLogs(ctx, args.LogQL, startTime, endTime, limit, direction)
430 | if err != nil {
431 | return nil, err
432 | }
433 |
434 | // Handle empty results
435 | if len(streams) == 0 {
436 | return []LogEntry{}, nil
437 | }
438 |
439 | // Convert the streams to a flat list of log entries
440 | var entries []LogEntry
441 | for _, stream := range streams {
442 | for _, value := range stream.Values {
443 | if len(value) >= 2 {
444 | entry := LogEntry{
445 | Timestamp: string(value[0]),
446 | Labels: stream.Stream,
447 | }
448 |
449 | // Handle metric queries (numeric values) vs log queries
450 | if stream.Stream["__type__"] == "metrics" {
451 | // For metric queries, parse the value as a number
452 | var numStr string
453 | if err := json.Unmarshal(value[1], &numStr); err == nil {
454 | if v, err := strconv.ParseFloat(numStr, 64); err == nil {
455 | entry.Value = &v
456 | } else {
457 | // Skip invalid numeric values
458 | continue
459 | }
460 | } else {
461 | // Try direct number parsing if string parsing fails
462 | var v float64
463 | if err := json.Unmarshal(value[1], &v); err == nil {
464 | entry.Value = &v
465 | } else {
466 | // Skip invalid values
467 | continue
468 | }
469 | }
470 | } else {
471 | // For log queries, parse the value as a string
472 | var logLine string
473 | if err := json.Unmarshal(value[1], &logLine); err == nil {
474 | entry.Line = logLine
475 | } else {
476 | // Skip invalid log lines
477 | continue
478 | }
479 | }
480 |
481 | entries = append(entries, entry)
482 | }
483 | }
484 | }
485 |
486 | // If we processed all streams but still have no entries, return an empty slice
487 | if len(entries) == 0 {
488 | return []LogEntry{}, nil
489 | }
490 |
491 | return entries, nil
492 | }
493 |
494 | // QueryLokiLogs is a tool for querying logs from Loki
495 | var QueryLokiLogs = mcpgrafana.MustTool(
496 | "query_loki_logs",
497 | "Executes a LogQL query against a Loki datasource to retrieve log entries or metric values. Returns a list of results, each containing a timestamp, labels, and either a log line (`line`) or a numeric metric value (`value`). Defaults to the last hour, a limit of 10 entries, and 'backward' direction (newest first). Supports full LogQL syntax for log and metric queries (e.g., `{app=\"foo\"} |= \"error\"`, `rate({app=\"bar\"}[1m])`). Prefer using `query_loki_stats` first to check stream size and `list_loki_label_names` and `list_loki_label_values` to verify labels exist.",
498 | queryLokiLogs,
499 | mcp.WithTitleAnnotation("Query Loki logs"),
500 | mcp.WithIdempotentHintAnnotation(true),
501 | mcp.WithReadOnlyHintAnnotation(true),
502 | )
503 |
504 | // fetchStats is a method to fetch stats data from Loki API
505 | func (c *Client) fetchStats(ctx context.Context, query, startRFC3339, endRFC3339 string) (*Stats, error) {
506 | params := url.Values{}
507 | params.Add("query", query)
508 |
509 | // Add time range parameters
510 | if err := addTimeRangeParams(params, startRFC3339, endRFC3339); err != nil {
511 | return nil, err
512 | }
513 |
514 | bodyBytes, err := c.makeRequest(ctx, "GET", "/loki/api/v1/index/stats", params)
515 | if err != nil {
516 | return nil, err
517 | }
518 |
519 | var stats Stats
520 | err = json.Unmarshal(bodyBytes, &stats)
521 | if err != nil {
522 | return nil, fmt.Errorf("unmarshalling response (content: %s): %w", string(bodyBytes), err)
523 | }
524 |
525 | return &stats, nil
526 | }
527 |
528 | // QueryLokiStatsParams defines the parameters for querying Loki stats
529 | type QueryLokiStatsParams struct {
530 | DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
531 | LogQL string `json:"logql" jsonschema:"required,description=The LogQL matcher expression to execute. This parameter only accepts label matcher expressions and does not support full LogQL queries. Line filters\\, pattern operations\\, and metric aggregations are not supported by the stats API endpoint. Only simple label selectors can be used here."`
532 | StartRFC3339 string `json:"startRfc3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format"`
533 | EndRFC3339 string `json:"endRfc3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format"`
534 | }
535 |
536 | // queryLokiStats queries stats from a Loki datasource using LogQL
537 | func queryLokiStats(ctx context.Context, args QueryLokiStatsParams) (*Stats, error) {
538 | client, err := newLokiClient(ctx, args.DatasourceUID)
539 | if err != nil {
540 | return nil, fmt.Errorf("creating Loki client: %w", err)
541 | }
542 |
543 | // Get default time range if not provided
544 | startTime, endTime := getDefaultTimeRange(args.StartRFC3339, args.EndRFC3339)
545 |
546 | stats, err := client.fetchStats(ctx, args.LogQL, startTime, endTime)
547 | if err != nil {
548 | return nil, err
549 | }
550 |
551 | return stats, nil
552 | }
553 |
554 | // QueryLokiStats is a tool for querying stats from Loki
555 | var QueryLokiStats = mcpgrafana.MustTool(
556 | "query_loki_stats",
557 | "Retrieves statistics about log streams matching a given LogQL *selector* within a Loki datasource and time range. Returns an object containing the count of streams, chunks, entries, and total bytes (e.g., `{\"streams\": 5, \"chunks\": 50, \"entries\": 10000, \"bytes\": 512000}`). The `logql` parameter **must** be a simple label selector (e.g., `{app=\"nginx\", env=\"prod\"}`) and does not support line filters, parsers, or aggregations. Defaults to the last hour if the time range is omitted.",
558 | queryLokiStats,
559 | mcp.WithTitleAnnotation("Get Loki log statistics"),
560 | mcp.WithIdempotentHintAnnotation(true),
561 | mcp.WithReadOnlyHintAnnotation(true),
562 | )
563 |
564 | // AddLokiTools registers all Loki tools with the MCP server
565 | func AddLokiTools(mcp *server.MCPServer) {
566 | ListLokiLabelNames.Register(mcp)
567 | ListLokiLabelValues.Register(mcp)
568 | QueryLokiStats.Register(mcp)
569 | QueryLokiLogs.Register(mcp)
570 | }
571 |
```
--------------------------------------------------------------------------------
/tools/sift.go:
--------------------------------------------------------------------------------
```go
1 | package tools
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "encoding/json"
7 | "fmt"
8 | "io"
9 | "log/slog"
10 | "net/http"
11 | "time"
12 |
13 | "github.com/google/uuid"
14 | mcpgrafana "github.com/grafana/mcp-grafana"
15 | "github.com/mark3labs/mcp-go/mcp"
16 | "github.com/mark3labs/mcp-go/server"
17 | )
18 |
19 | type investigationStatus string
20 |
21 | const (
22 | investigationStatusPending investigationStatus = "pending"
23 | investigationStatusRunning investigationStatus = "running"
24 | investigationStatusFinished investigationStatus = "finished"
25 | investigationStatusFailed investigationStatus = "failed"
26 | )
27 |
28 | // errorPatternLogExampleLimit controls how many log examples are fetched per error pattern.
29 | const errorPatternLogExampleLimit = 3
30 |
31 | type analysisStatus string
32 |
33 | type investigationRequest struct {
34 | AlertLabels map[string]string `json:"alertLabels,omitempty"`
35 | Labels map[string]string `json:"labels"`
36 |
37 | Start time.Time `json:"start"`
38 | End time.Time `json:"end"`
39 |
40 | QueryURL string `json:"queryUrl"`
41 |
42 | Checks []string `json:"checks"`
43 | }
44 |
45 | // Interesting: The analysis complete with results that indicate a probable cause for failure.
46 | type analysisResult struct {
47 | Successful bool `json:"successful"`
48 | Interesting bool `json:"interesting"`
49 | Message string `json:"message"`
50 | Details map[string]any `json:"details"`
51 | }
52 |
53 | type analysisMeta struct {
54 | Items []analysis `json:"items"`
55 | }
56 |
57 | // An analysis struct provides the status and results
58 | // of running a specific type of check.
59 | type analysis struct {
60 | ID uuid.UUID `json:"id"`
61 | CreatedAt time.Time `json:"created"`
62 | UpdatedAt time.Time `json:"modified"`
63 |
64 | Status analysisStatus `json:"status"`
65 | StartedAt *time.Time `json:"started"`
66 |
67 | // Foreign key to the Investigation that created this Analysis.
68 | InvestigationID uuid.UUID `json:"investigationId"`
69 |
70 | // Name is the name of the check that this analysis represents.
71 | Name string `json:"name"`
72 | Title string `json:"title"`
73 | Result analysisResult `json:"result"`
74 | }
75 |
76 | type InvestigationDatasources struct {
77 | LokiDatasource struct {
78 | UID string `json:"uid"`
79 | } `json:"lokiDatasource"`
80 | }
81 |
82 | type Investigation struct {
83 | ID uuid.UUID `json:"id"`
84 | CreatedAt time.Time `json:"created"`
85 | UpdatedAt time.Time `json:"modified"`
86 |
87 | TenantID string `json:"tenantId"`
88 |
89 | Name string `json:"name"`
90 |
91 | // GrafanaURL is the Grafana URL to be used for datasource queries
92 | // for this investigation.
93 | GrafanaURL string `json:"grafanaUrl"`
94 |
95 | // Status describes the state of the investigation (pending, running, failed, or finished).
96 | Status investigationStatus `json:"status"`
97 |
98 | // FailureReason is a short human-friendly string that explains the reason that the
99 | // investigation failed.
100 | FailureReason string `json:"failureReason,omitempty"`
101 |
102 | Analyses analysisMeta `json:"analyses"`
103 |
104 | Datasources InvestigationDatasources `json:"datasources"`
105 | }
106 |
107 | // siftClient represents a client for interacting with the Sift API.
108 | type siftClient struct {
109 | client *http.Client
110 | url string
111 | }
112 |
113 | func newSiftClient(cfg mcpgrafana.GrafanaConfig) (*siftClient, error) {
114 | // Create custom transport with TLS configuration if available
115 | var transport = http.DefaultTransport
116 | if tlsConfig := cfg.TLSConfig; tlsConfig != nil {
117 | var err error
118 | transport, err = tlsConfig.HTTPTransport(transport.(*http.Transport))
119 | if err != nil {
120 | return nil, fmt.Errorf("failed to create custom transport: %w", err)
121 | }
122 | }
123 |
124 | transport = NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth)
125 | transport = mcpgrafana.NewOrgIDRoundTripper(transport, cfg.OrgID)
126 |
127 | client := &http.Client{
128 | Transport: transport,
129 | }
130 | return &siftClient{
131 | client: client,
132 | url: cfg.URL,
133 | }, nil
134 | }
135 |
136 | func siftClientFromContext(ctx context.Context) (*siftClient, error) {
137 | // Get the standard Grafana URL and API key
138 | cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
139 | client, err := newSiftClient(cfg)
140 | if err != nil {
141 | return nil, fmt.Errorf("creating Sift client: %w", err)
142 | }
143 | return client, nil
144 | }
145 |
146 | // checkType represents the type of analysis check to perform.
147 | type checkType string
148 |
149 | const (
150 | checkTypeErrorPatternLogs checkType = "ErrorPatternLogs"
151 | checkTypeSlowRequests checkType = "SlowRequests"
152 | )
153 |
154 | // GetSiftInvestigationParams defines the parameters for retrieving an investigation
155 | type GetSiftInvestigationParams struct {
156 | ID string `json:"id" jsonschema:"required,description=The UUID of the investigation as a string (e.g. '02adab7c-bf5b-45f2-9459-d71a2c29e11b')"`
157 | }
158 |
159 | // getSiftInvestigation retrieves an existing investigation
160 | func getSiftInvestigation(ctx context.Context, args GetSiftInvestigationParams) (*Investigation, error) {
161 | client, err := siftClientFromContext(ctx)
162 | if err != nil {
163 | return nil, fmt.Errorf("creating Sift client: %w", err)
164 | }
165 |
166 | // Parse the UUID string
167 | id, err := uuid.Parse(args.ID)
168 | if err != nil {
169 | return nil, fmt.Errorf("invalid investigation ID format: %w", err)
170 | }
171 |
172 | investigation, err := client.getSiftInvestigation(ctx, id)
173 | if err != nil {
174 | return nil, fmt.Errorf("getting investigation: %w", err)
175 | }
176 |
177 | return investigation, nil
178 | }
179 |
180 | // GetSiftInvestigation is a tool for retrieving an existing investigation
181 | var GetSiftInvestigation = mcpgrafana.MustTool(
182 | "get_sift_investigation",
183 | "Retrieves an existing Sift investigation by its UUID. The ID should be provided as a string in UUID format (e.g. '02adab7c-bf5b-45f2-9459-d71a2c29e11b').",
184 | getSiftInvestigation,
185 | mcp.WithTitleAnnotation("Get Sift investigation"),
186 | mcp.WithIdempotentHintAnnotation(true),
187 | mcp.WithReadOnlyHintAnnotation(true),
188 | )
189 |
190 | // GetSiftAnalysisParams defines the parameters for retrieving a specific analysis
191 | type GetSiftAnalysisParams struct {
192 | InvestigationID string `json:"investigationId" jsonschema:"required,description=The UUID of the investigation as a string (e.g. '02adab7c-bf5b-45f2-9459-d71a2c29e11b')"`
193 | AnalysisID string `json:"analysisId" jsonschema:"required,description=The UUID of the specific analysis to retrieve"`
194 | }
195 |
196 | // getSiftAnalysis retrieves a specific analysis from an investigation
197 | func getSiftAnalysis(ctx context.Context, args GetSiftAnalysisParams) (*analysis, error) {
198 | client, err := siftClientFromContext(ctx)
199 | if err != nil {
200 | return nil, fmt.Errorf("creating Sift client: %w", err)
201 | }
202 |
203 | // Parse the UUID strings
204 | investigationID, err := uuid.Parse(args.InvestigationID)
205 | if err != nil {
206 | return nil, fmt.Errorf("invalid investigation ID format: %w", err)
207 | }
208 |
209 | analysisID, err := uuid.Parse(args.AnalysisID)
210 | if err != nil {
211 | return nil, fmt.Errorf("invalid analysis ID format: %w", err)
212 | }
213 |
214 | analysis, err := client.getSiftAnalysis(ctx, investigationID, analysisID)
215 | if err != nil {
216 | return nil, fmt.Errorf("getting analysis: %w", err)
217 | }
218 |
219 | return analysis, nil
220 | }
221 |
222 | // GetSiftAnalysis is a tool for retrieving a specific analysis from an investigation
223 | var GetSiftAnalysis = mcpgrafana.MustTool(
224 | "get_sift_analysis",
225 | "Retrieves a specific analysis from an investigation by its UUID. The investigation ID and analysis ID should be provided as strings in UUID format.",
226 | getSiftAnalysis,
227 | mcp.WithTitleAnnotation("Get Sift analysis"),
228 | mcp.WithIdempotentHintAnnotation(true),
229 | mcp.WithReadOnlyHintAnnotation(true),
230 | )
231 |
232 | // ListSiftInvestigationsParams defines the parameters for retrieving investigations
233 | type ListSiftInvestigationsParams struct {
234 | Limit int `json:"limit,omitempty" jsonschema:"description=Maximum number of investigations to return. Defaults to 10 if not specified."`
235 | }
236 |
237 | // listSiftInvestigations retrieves a list of investigations with an optional limit
238 | func listSiftInvestigations(ctx context.Context, args ListSiftInvestigationsParams) ([]Investigation, error) {
239 | client, err := siftClientFromContext(ctx)
240 | if err != nil {
241 | return nil, fmt.Errorf("creating Sift client: %w", err)
242 | }
243 |
244 | // Set default limit if not provided
245 | if args.Limit <= 0 {
246 | args.Limit = 10
247 | }
248 |
249 | investigations, err := client.listSiftInvestigations(ctx, args.Limit)
250 | if err != nil {
251 | return nil, fmt.Errorf("getting investigations: %w", err)
252 | }
253 |
254 | return investigations, nil
255 | }
256 |
257 | // ListSiftInvestigations is a tool for retrieving a list of investigations
258 | var ListSiftInvestigations = mcpgrafana.MustTool(
259 | "list_sift_investigations",
260 | "Retrieves a list of Sift investigations with an optional limit. If no limit is specified, defaults to 10 investigations.",
261 | listSiftInvestigations,
262 | mcp.WithTitleAnnotation("List Sift investigations"),
263 | mcp.WithIdempotentHintAnnotation(true),
264 | mcp.WithReadOnlyHintAnnotation(true),
265 | )
266 |
267 | // FindErrorPatternLogsParams defines the parameters for running an ErrorPatternLogs check
268 | type FindErrorPatternLogsParams struct {
269 | Name string `json:"name" jsonschema:"required,description=The name of the investigation"`
270 | Labels map[string]string `json:"labels" jsonschema:"required,description=Labels to scope the analysis"`
271 | Start time.Time `json:"start,omitempty" jsonschema:"description=Start time for the investigation. Defaults to 30 minutes ago if not specified."`
272 | End time.Time `json:"end,omitempty" jsonschema:"description=End time for the investigation. Defaults to now if not specified."`
273 | }
274 |
275 | // findErrorPatternLogs creates an investigation with ErrorPatternLogs check, waits for it to complete, and returns the analysis
276 | func findErrorPatternLogs(ctx context.Context, args FindErrorPatternLogsParams) (*analysis, error) {
277 | client, err := siftClientFromContext(ctx)
278 | if err != nil {
279 | return nil, fmt.Errorf("creating Sift client: %w", err)
280 | }
281 |
282 | // Create the investigation request with ErrorPatternLogs check
283 | requestData := investigationRequest{
284 | Labels: args.Labels,
285 | Start: args.Start,
286 | End: args.End,
287 | Checks: []string{string(checkTypeErrorPatternLogs)},
288 | }
289 |
290 | investigation := &Investigation{
291 | Name: args.Name,
292 | GrafanaURL: client.url,
293 | Status: investigationStatusPending,
294 | }
295 |
296 | // Create the investigation and wait for it to complete
297 | completedInvestigation, err := client.createSiftInvestigation(ctx, investigation, requestData)
298 | if err != nil {
299 | return nil, fmt.Errorf("creating investigation: %w", err)
300 | }
301 |
302 | // Get all analyses from the completed investigation
303 | slog.Debug("Getting analyses", "investigation_id", completedInvestigation.ID)
304 | analyses, err := client.getSiftAnalyses(ctx, completedInvestigation.ID)
305 | if err != nil {
306 | return nil, fmt.Errorf("getting analyses: %w", err)
307 | }
308 |
309 | // Find the ErrorPatternLogs analysis
310 | var errorPatternLogsAnalysis *analysis
311 | for i := range analyses {
312 | if analyses[i].Name == string(checkTypeErrorPatternLogs) {
313 | errorPatternLogsAnalysis = &analyses[i]
314 | break
315 | }
316 | }
317 |
318 | if errorPatternLogsAnalysis == nil {
319 | return nil, fmt.Errorf("ErrorPatternLogs analysis not found in investigation %s", completedInvestigation.ID)
320 | }
321 | slog.Debug("Found ErrorPatternLogs analysis", "analysis_id", errorPatternLogsAnalysis.ID)
322 |
323 | datasourceUID := completedInvestigation.Datasources.LokiDatasource.UID
324 |
325 | if errorPatternLogsAnalysis.Result.Details == nil {
326 | // No patterns found, return the analysis without examples
327 | return errorPatternLogsAnalysis, nil
328 | }
329 | for _, pattern := range errorPatternLogsAnalysis.Result.Details["patterns"].([]any) {
330 | patternMap, ok := pattern.(map[string]any)
331 | if !ok {
332 | continue
333 | }
334 | examples, err := fetchErrorPatternLogExamples(ctx, patternMap, datasourceUID)
335 | if err != nil {
336 | return nil, err
337 | }
338 | patternMap["examples"] = examples
339 | }
340 |
341 | return errorPatternLogsAnalysis, nil
342 | }
343 |
344 | // FindErrorPatternLogs is a tool for running an ErrorPatternLogs check
345 | var FindErrorPatternLogs = mcpgrafana.MustTool(
346 | "find_error_pattern_logs",
347 | "Searches Loki logs for elevated error patterns compared to the last day's average, waits for the analysis to complete, and returns the results including any patterns found.",
348 | findErrorPatternLogs,
349 | mcp.WithTitleAnnotation("Find error patterns in logs"),
350 | mcp.WithReadOnlyHintAnnotation(true),
351 | )
352 |
353 | // FindSlowRequestsParams defines the parameters for running an SlowRequests check
354 | type FindSlowRequestsParams struct {
355 | Name string `json:"name" jsonschema:"required,description=The name of the investigation"`
356 | Labels map[string]string `json:"labels" jsonschema:"required,description=Labels to scope the analysis"`
357 | Start time.Time `json:"start,omitempty" jsonschema:"description=Start time for the investigation. Defaults to 30 minutes ago if not specified."`
358 | End time.Time `json:"end,omitempty" jsonschema:"description=End time for the investigation. Defaults to now if not specified."`
359 | }
360 |
361 | // findSlowRequests creates an investigation with SlowRequests check, waits for it to complete, and returns the analysis
362 | func findSlowRequests(ctx context.Context, args FindSlowRequestsParams) (*analysis, error) {
363 | client, err := siftClientFromContext(ctx)
364 | if err != nil {
365 | return nil, fmt.Errorf("creating Sift client: %w", err)
366 | }
367 |
368 | // Create the investigation request with SlowRequests check
369 | requestData := investigationRequest{
370 | Labels: args.Labels,
371 | Start: args.Start,
372 | End: args.End,
373 | Checks: []string{string(checkTypeSlowRequests)},
374 | }
375 |
376 | investigation := &Investigation{
377 | Name: args.Name,
378 | GrafanaURL: client.url,
379 | Status: investigationStatusPending,
380 | }
381 |
382 | // Create the investigation and wait for it to complete
383 | completedInvestigation, err := client.createSiftInvestigation(ctx, investigation, requestData)
384 | if err != nil {
385 | return nil, fmt.Errorf("creating investigation: %w", err)
386 | }
387 |
388 | // Get all analyses from the completed investigation
389 | analyses, err := client.getSiftAnalyses(ctx, completedInvestigation.ID)
390 | if err != nil {
391 | return nil, fmt.Errorf("getting analyses: %w", err)
392 | }
393 |
394 | // Find the SlowRequests analysis
395 | var slowRequestsAnalysis *analysis
396 | for i := range analyses {
397 | if analyses[i].Name == string(checkTypeSlowRequests) {
398 | slowRequestsAnalysis = &analyses[i]
399 | break
400 | }
401 | }
402 |
403 | if slowRequestsAnalysis == nil {
404 | return nil, fmt.Errorf("SlowRequests analysis not found in investigation %s", completedInvestigation.ID)
405 | }
406 |
407 | return slowRequestsAnalysis, nil
408 | }
409 |
410 | // FindSlowRequests is a tool for running an SlowRequests check
411 | var FindSlowRequests = mcpgrafana.MustTool(
412 | "find_slow_requests",
413 | "Searches relevant Tempo datasources for slow requests, waits for the analysis to complete, and returns the results.",
414 | findSlowRequests,
415 | mcp.WithTitleAnnotation("Find slow requests"),
416 | mcp.WithReadOnlyHintAnnotation(true),
417 | )
418 |
419 | // AddSiftTools registers all Sift tools with the MCP server
420 | func AddSiftTools(mcp *server.MCPServer, enableWriteTools bool) {
421 | GetSiftInvestigation.Register(mcp)
422 | GetSiftAnalysis.Register(mcp)
423 | ListSiftInvestigations.Register(mcp)
424 | if enableWriteTools {
425 | FindErrorPatternLogs.Register(mcp)
426 | FindSlowRequests.Register(mcp)
427 | }
428 | }
429 |
430 | // makeRequest is a helper method to make HTTP requests and handle common response patterns
431 | func (c *siftClient) makeRequest(ctx context.Context, method, path string, body []byte) ([]byte, error) {
432 | var req *http.Request
433 | var err error
434 |
435 | if body != nil {
436 | req, err = http.NewRequestWithContext(ctx, method, c.url+path, bytes.NewBuffer(body))
437 | if err != nil {
438 | return nil, fmt.Errorf("creating request: %w", err)
439 | }
440 | req.Header.Set("Content-Type", "application/json")
441 | } else {
442 | req, err = http.NewRequestWithContext(ctx, method, c.url+path, nil)
443 | if err != nil {
444 | return nil, fmt.Errorf("creating request: %w", err)
445 | }
446 | }
447 |
448 | response, err := c.client.Do(req)
449 | if err != nil {
450 | return nil, fmt.Errorf("executing request: %w", err)
451 | }
452 | defer func() {
453 | _ = response.Body.Close() //nolint:errcheck
454 | }()
455 |
456 | // Check for non-200 status code (matching Loki client's logic)
457 | if response.StatusCode != http.StatusOK {
458 | bodyBytes, _ := io.ReadAll(response.Body) // Read full body on error
459 | return nil, fmt.Errorf("API request returned status code %d: %s", response.StatusCode, string(bodyBytes))
460 | }
461 |
462 | // Read the response body with a limit to prevent memory issues
463 | reader := io.LimitReader(response.Body, 1024*1024*48) // 48MB limit
464 | buf, err := io.ReadAll(reader)
465 | if err != nil {
466 | return nil, fmt.Errorf("failed to read response body: %w", err)
467 | }
468 |
469 | // Check if the response is empty (matching Loki client's logic)
470 | if len(buf) == 0 {
471 | return nil, fmt.Errorf("empty response from API")
472 | }
473 |
474 | // Trim any whitespace that might cause JSON parsing issues (matching Loki client's logic)
475 | return bytes.TrimSpace(buf), nil
476 | }
477 |
478 | // getSiftInvestigation is a helper method to get the current status of an investigation
479 | func (c *siftClient) getSiftInvestigation(ctx context.Context, id uuid.UUID) (*Investigation, error) {
480 | buf, err := c.makeRequest(ctx, "GET", fmt.Sprintf("/api/plugins/grafana-ml-app/resources/sift/api/v1/investigations/%s", id), nil)
481 | if err != nil {
482 | return nil, err
483 | }
484 |
485 | investigationResponse := struct {
486 | Status string `json:"status"`
487 | Data Investigation `json:"data"`
488 | }{}
489 |
490 | if err := json.Unmarshal(buf, &investigationResponse); err != nil {
491 | return nil, fmt.Errorf("failed to unmarshal response body: %w. body: %s", err, buf)
492 | }
493 |
494 | return &investigationResponse.Data, nil
495 | }
496 |
497 | func (c *siftClient) createSiftInvestigation(ctx context.Context, investigation *Investigation, requestData investigationRequest) (*Investigation, error) {
498 | // Set default time range to last 30 minutes if not provided
499 | if requestData.Start.IsZero() {
500 | requestData.Start = time.Now().Add(-30 * time.Minute)
501 | }
502 | if requestData.End.IsZero() {
503 | requestData.End = time.Now()
504 | }
505 |
506 | // Create the payload including the necessary fields for the API
507 | payload := struct {
508 | Investigation
509 | RequestData investigationRequest `json:"requestData"`
510 | }{
511 | Investigation: *investigation,
512 | RequestData: requestData,
513 | }
514 |
515 | jsonData, err := json.Marshal(payload)
516 | if err != nil {
517 | return nil, fmt.Errorf("marshaling investigation: %w", err)
518 | }
519 |
520 | slog.Debug("Creating investigation", "payload", string(jsonData))
521 | buf, err := c.makeRequest(ctx, "POST", "/api/plugins/grafana-ml-app/resources/sift/api/v1/investigations", jsonData)
522 | if err != nil {
523 | return nil, err
524 | }
525 | slog.Debug("Investigation created", "response", string(buf))
526 |
527 | investigationResponse := struct {
528 | Status string `json:"status"`
529 | Data Investigation `json:"data"`
530 | }{}
531 |
532 | if err := json.Unmarshal(buf, &investigationResponse); err != nil {
533 | return nil, fmt.Errorf("failed to unmarshal response body: %w. body: %s", err, buf)
534 | }
535 |
536 | // Poll for investigation completion
537 | ticker := time.NewTicker(5 * time.Second)
538 | defer ticker.Stop()
539 |
540 | timeout := time.After(5 * time.Minute)
541 |
542 | for {
543 | select {
544 | case <-ctx.Done():
545 | return nil, fmt.Errorf("context cancelled while waiting for investigation completion")
546 | case <-timeout:
547 | return nil, fmt.Errorf("timeout waiting for investigation completion after 5 minutes")
548 | case <-ticker.C:
549 | slog.Debug("Polling investigation status", "investigation_id", investigationResponse.Data.ID)
550 | investigation, err := c.getSiftInvestigation(ctx, investigationResponse.Data.ID)
551 | if err != nil {
552 | return nil, err
553 | }
554 |
555 | if investigation.Status == investigationStatusFailed {
556 | return nil, fmt.Errorf("investigation failed: %s", investigation.FailureReason)
557 | }
558 |
559 | if investigation.Status == investigationStatusFinished {
560 | return investigation, nil
561 | }
562 | }
563 | }
564 | }
565 |
566 | // getSiftAnalyses is a helper method to get all analyses from an investigation
567 | func (c *siftClient) getSiftAnalyses(ctx context.Context, investigationID uuid.UUID) ([]analysis, error) {
568 | path := fmt.Sprintf("/api/plugins/grafana-ml-app/resources/sift/api/v1/investigations/%s/analyses", investigationID)
569 | buf, err := c.makeRequest(ctx, "GET", path, nil)
570 | if err != nil {
571 | return nil, fmt.Errorf("making request: %w", err)
572 | }
573 |
574 | var response struct {
575 | Status string `json:"status"`
576 | Data []analysis `json:"data"`
577 | }
578 |
579 | if err := json.Unmarshal(buf, &response); err != nil {
580 | return nil, fmt.Errorf("failed to unmarshal response body: %w. body: %s", err, buf)
581 | }
582 |
583 | return response.Data, nil
584 | }
585 |
586 | // getSiftAnalysis is a helper method to get a specific analysis from an investigation
587 | func (c *siftClient) getSiftAnalysis(ctx context.Context, investigationID, analysisID uuid.UUID) (*analysis, error) {
588 | // First get all analyses to verify the analysis exists
589 | analyses, err := c.getSiftAnalyses(ctx, investigationID)
590 | if err != nil {
591 | return nil, fmt.Errorf("getting analyses: %w", err)
592 | }
593 |
594 | // Find the specific analysis
595 | var targetAnalysis *analysis
596 | for _, analysis := range analyses {
597 | if analysis.ID == analysisID {
598 | targetAnalysis = &analysis
599 | break
600 | }
601 | }
602 |
603 | if targetAnalysis == nil {
604 | return nil, fmt.Errorf("analysis with ID %s not found in investigation %s", analysisID, investigationID)
605 | }
606 |
607 | return targetAnalysis, nil
608 | }
609 |
610 | // listSiftInvestigations is a helper method to get a list of investigations
611 | func (c *siftClient) listSiftInvestigations(ctx context.Context, limit int) ([]Investigation, error) {
612 | path := fmt.Sprintf("/api/plugins/grafana-ml-app/resources/sift/api/v1/investigations?limit=%d", limit)
613 | buf, err := c.makeRequest(ctx, "GET", path, nil)
614 | if err != nil {
615 | return nil, fmt.Errorf("making request: %w", err)
616 | }
617 |
618 | var response struct {
619 | Status string `json:"status"`
620 | Data []Investigation `json:"data"`
621 | }
622 |
623 | if err := json.Unmarshal(buf, &response); err != nil {
624 | return nil, fmt.Errorf("failed to unmarshal response body: %w. body: %s", err, buf)
625 | }
626 |
627 | return response.Data, nil
628 | }
629 |
630 | func fetchErrorPatternLogExamples(ctx context.Context, patternMap map[string]any, datasourceUID string) ([]string, error) {
631 | query, _ := patternMap["query"].(string)
632 | logEntries, err := queryLokiLogs(ctx, QueryLokiLogsParams{
633 | DatasourceUID: datasourceUID,
634 | LogQL: query,
635 | Limit: errorPatternLogExampleLimit,
636 | })
637 | if err != nil {
638 | return nil, fmt.Errorf("querying Loki: %w", err)
639 | }
640 | var examples []string
641 | for _, entry := range logEntries {
642 | if entry.Line != "" {
643 | examples = append(examples, entry.Line)
644 | }
645 | }
646 | return examples, nil
647 | }
648 |
```
--------------------------------------------------------------------------------
/tools/alerting.go:
--------------------------------------------------------------------------------
```go
1 | package tools
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "time"
8 |
9 | "github.com/go-openapi/strfmt"
10 | "github.com/grafana/grafana-openapi-client-go/client/provisioning"
11 | "github.com/grafana/grafana-openapi-client-go/models"
12 | "github.com/mark3labs/mcp-go/mcp"
13 | "github.com/mark3labs/mcp-go/server"
14 | "github.com/prometheus/prometheus/model/labels"
15 |
16 | mcpgrafana "github.com/grafana/mcp-grafana"
17 | )
18 |
19 | const (
20 | DefaultListAlertRulesLimit = 100
21 | DefaultListContactPointsLimit = 100
22 | )
23 |
24 | type ListAlertRulesParams struct {
25 | Limit int `json:"limit,omitempty" jsonschema:"description=The maximum number of results to return. Default is 100."`
26 | Page int `json:"page,omitempty" jsonschema:"description=The page number to return."`
27 | LabelSelectors []Selector `json:"label_selectors,omitempty" jsonschema:"description=Optionally\\, a list of matchers to filter alert rules by labels"`
28 | }
29 |
30 | func (p ListAlertRulesParams) validate() error {
31 | if p.Limit < 0 {
32 | return fmt.Errorf("invalid limit: %d, must be greater than 0", p.Limit)
33 | }
34 | if p.Page < 0 {
35 | return fmt.Errorf("invalid page: %d, must be greater than 0", p.Page)
36 | }
37 |
38 | return nil
39 | }
40 |
41 | type alertRuleSummary struct {
42 | UID string `json:"uid"`
43 | Title string `json:"title"`
44 | // State can be one of: pending, firing, error, recovering, inactive.
45 | // "inactive" means the alert state is normal, not firing.
46 | State string `json:"state"`
47 | Health string `json:"health,omitempty"`
48 | FolderUID string `json:"folderUID,omitempty"`
49 | RuleGroup string `json:"ruleGroup,omitempty"`
50 | For string `json:"for,omitempty"`
51 | LastEvaluation string `json:"lastEvaluation,omitempty"`
52 | Labels map[string]string `json:"labels,omitempty"`
53 | Annotations map[string]string `json:"annotations,omitempty"`
54 | }
55 |
56 | func listAlertRules(ctx context.Context, args ListAlertRulesParams) ([]alertRuleSummary, error) {
57 | if err := args.validate(); err != nil {
58 | return nil, fmt.Errorf("list alert rules: %w", err)
59 | }
60 |
61 | // Get configuration data from provisioning API (has UIDs, configuration)
62 | c := mcpgrafana.GrafanaClientFromContext(ctx)
63 | provisioningResponse, err := c.Provisioning.GetAlertRules()
64 | if err != nil {
65 | return nil, fmt.Errorf("list alert rules (provisioning): %w", err)
66 | }
67 |
68 | // Get runtime state data from alerting client API (has state, health, etc.)
69 | alertingClient, err := newAlertingClientFromContext(ctx)
70 | if err != nil {
71 | return nil, fmt.Errorf("list alert rules (alerting client): %w", err)
72 | }
73 | runtimeResponse, err := alertingClient.GetRules(ctx)
74 | if err != nil {
75 | return nil, fmt.Errorf("list alert rules (runtime): %w", err)
76 | }
77 |
78 | // Extract runtime rules from groups
79 | var runtimeRules []alertingRule
80 | for _, group := range runtimeResponse.Data.RuleGroups {
81 | runtimeRules = append(runtimeRules, group.Rules...)
82 | }
83 |
84 | // Merge the data from both APIs
85 | mergedRules := mergeAlertRuleData(provisioningResponse.Payload, runtimeRules)
86 |
87 | filteredRules, err := filterMergedAlertRules(mergedRules, args.LabelSelectors)
88 | if err != nil {
89 | return nil, fmt.Errorf("list alert rules: %w", err)
90 | }
91 |
92 | paginatedRules, err := applyPaginationToMerged(filteredRules, args.Limit, args.Page)
93 | if err != nil {
94 | return nil, fmt.Errorf("list alert rules: %w", err)
95 | }
96 |
97 | return summarizeMergedAlertRules(paginatedRules), nil
98 | }
99 |
100 | // mergedAlertRule combines data from both provisioning API and runtime API
101 | type mergedAlertRule struct {
102 | // From provisioning API (configuration)
103 | UID string
104 | Title string
105 | FolderUID string
106 | RuleGroup string
107 | Condition string
108 | NoDataState string
109 | ExecErrState string
110 | For string
111 | Labels map[string]string
112 | Annotations map[string]string
113 |
114 | // From runtime API (state)
115 | State string
116 | Health string
117 | LastEvaluation string
118 | ActiveAt string
119 | }
120 |
121 | // mergeAlertRuleData combines data from provisioning API and runtime API
122 | func mergeAlertRuleData(provisionedRules []*models.ProvisionedAlertRule, runtimeRules []alertingRule) []mergedAlertRule {
123 | var merged []mergedAlertRule
124 |
125 | // Create a map of runtime rules by name for quick lookup
126 | runtimeByName := make(map[string]alertingRule)
127 | for _, runtime := range runtimeRules {
128 | runtimeByName[runtime.Name] = runtime
129 | }
130 |
131 | // Merge each provisioned rule with its runtime counterpart
132 | for _, provisioned := range provisionedRules {
133 | title := ""
134 | if provisioned.Title != nil {
135 | title = *provisioned.Title
136 | }
137 |
138 | mergedRule := mergedAlertRule{
139 | // From provisioning API
140 | UID: provisioned.UID,
141 | Title: title,
142 | Labels: provisioned.Labels,
143 | Annotations: provisioned.Annotations,
144 | }
145 |
146 | if provisioned.FolderUID != nil {
147 | mergedRule.FolderUID = *provisioned.FolderUID
148 | }
149 | if provisioned.RuleGroup != nil {
150 | mergedRule.RuleGroup = *provisioned.RuleGroup
151 | }
152 | if provisioned.Condition != nil {
153 | mergedRule.Condition = *provisioned.Condition
154 | }
155 | if provisioned.NoDataState != nil {
156 | mergedRule.NoDataState = *provisioned.NoDataState
157 | }
158 | if provisioned.ExecErrState != nil {
159 | mergedRule.ExecErrState = *provisioned.ExecErrState
160 | }
161 | if provisioned.For != nil {
162 | mergedRule.For = provisioned.For.String()
163 | }
164 |
165 | // Try to find matching runtime data by title
166 | if runtime, found := runtimeByName[title]; found {
167 | mergedRule.State = runtime.State
168 | mergedRule.Health = runtime.Health
169 | mergedRule.LastEvaluation = runtime.LastEvaluation.Format(time.RFC3339)
170 | if runtime.ActiveAt != nil {
171 | mergedRule.ActiveAt = runtime.ActiveAt.Format(time.RFC3339)
172 | }
173 | }
174 |
175 | merged = append(merged, mergedRule)
176 | }
177 |
178 | return merged
179 | }
180 |
181 | // filterMergedAlertRules filters a list of merged alert rules based on label selectors
182 | func filterMergedAlertRules(rules []mergedAlertRule, selectors []Selector) ([]mergedAlertRule, error) {
183 | if len(selectors) == 0 {
184 | return rules, nil
185 | }
186 |
187 | filteredResult := []mergedAlertRule{}
188 | for _, rule := range rules {
189 | match, err := matchesSelectorsForMerged(rule, selectors)
190 | if err != nil {
191 | return nil, fmt.Errorf("filtering alert rules: %w", err)
192 | }
193 |
194 | if match {
195 | filteredResult = append(filteredResult, rule)
196 | }
197 | }
198 |
199 | return filteredResult, nil
200 | }
201 |
202 | // matchesSelectorsForMerged checks if a merged alert rule matches all provided selectors
203 | func matchesSelectorsForMerged(rule mergedAlertRule, selectors []Selector) (bool, error) {
204 | // Convert map[string]string to labels.Labels for compatibility with selector
205 | lbls := rule.Labels
206 | if lbls == nil {
207 | lbls = make(map[string]string)
208 | }
209 |
210 | for _, selector := range selectors {
211 | // Create a labels.Labels from the map for the selector
212 | labelsForSelector := labels.FromMap(lbls)
213 |
214 | match, err := selector.Matches(labelsForSelector)
215 | if err != nil {
216 | return false, err
217 | }
218 | if !match {
219 | return false, nil
220 | }
221 | }
222 | return true, nil
223 | }
224 |
225 | func summarizeMergedAlertRules(alertRules []mergedAlertRule) []alertRuleSummary {
226 | result := make([]alertRuleSummary, 0, len(alertRules))
227 | for _, r := range alertRules {
228 | result = append(result, alertRuleSummary{
229 | UID: r.UID,
230 | Title: r.Title,
231 | State: r.State,
232 | Health: r.Health,
233 | FolderUID: r.FolderUID,
234 | RuleGroup: r.RuleGroup,
235 | For: r.For,
236 | LastEvaluation: r.LastEvaluation,
237 | Labels: r.Labels,
238 | Annotations: r.Annotations,
239 | })
240 | }
241 | return result
242 | }
243 |
244 | // applyPaginationToMerged applies pagination to the list of merged alert rules.
245 | // It doesn't sort the items and relies on the order returned by the API.
246 | func applyPaginationToMerged(items []mergedAlertRule, limit, page int) ([]mergedAlertRule, error) {
247 | if limit == 0 {
248 | limit = DefaultListAlertRulesLimit
249 | }
250 | if page == 0 {
251 | page = 1
252 | }
253 |
254 | start := (page - 1) * limit
255 | end := start + limit
256 |
257 | if start >= len(items) {
258 | return nil, nil
259 | } else if end > len(items) {
260 | return items[start:], nil
261 | }
262 |
263 | return items[start:end], nil
264 | }
265 |
266 | var ListAlertRules = mcpgrafana.MustTool(
267 | "list_alert_rules",
268 | "Lists Grafana alert rules, returning a summary including UID, title, current state (e.g., 'pending', 'firing', 'inactive'), and labels. Supports filtering by labels using selectors and pagination. Example label selector: `[{'name': 'severity', 'type': '=', 'value': 'critical'}]`. Inactive state means the alert state is normal, not firing",
269 | listAlertRules,
270 | mcp.WithTitleAnnotation("List alert rules"),
271 | mcp.WithIdempotentHintAnnotation(true),
272 | mcp.WithReadOnlyHintAnnotation(true),
273 | )
274 |
275 | type GetAlertRuleByUIDParams struct {
276 | UID string `json:"uid" jsonschema:"required,description=The uid of the alert rule"`
277 | }
278 |
279 | func (p GetAlertRuleByUIDParams) validate() error {
280 | if p.UID == "" {
281 | return fmt.Errorf("uid is required")
282 | }
283 |
284 | return nil
285 | }
286 |
287 | func getAlertRuleByUID(ctx context.Context, args GetAlertRuleByUIDParams) (*models.ProvisionedAlertRule, error) {
288 | if err := args.validate(); err != nil {
289 | return nil, fmt.Errorf("get alert rule by uid: %w", err)
290 | }
291 |
292 | c := mcpgrafana.GrafanaClientFromContext(ctx)
293 | alertRule, err := c.Provisioning.GetAlertRule(args.UID)
294 | if err != nil {
295 | return nil, fmt.Errorf("get alert rule by uid %s: %w", args.UID, err)
296 | }
297 | return alertRule.Payload, nil
298 | }
299 |
300 | var GetAlertRuleByUID = mcpgrafana.MustTool(
301 | "get_alert_rule_by_uid",
302 | "Retrieves the full configuration and detailed status of a specific Grafana alert rule identified by its unique ID (UID). The response includes fields like title, condition, query data, folder UID, rule group, state settings (no data, error), evaluation interval, annotations, and labels.",
303 | getAlertRuleByUID,
304 | mcp.WithTitleAnnotation("Get alert rule details"),
305 | mcp.WithIdempotentHintAnnotation(true),
306 | mcp.WithReadOnlyHintAnnotation(true),
307 | )
308 |
309 | type ListContactPointsParams struct {
310 | Limit int `json:"limit,omitempty" jsonschema:"description=The maximum number of results to return. Default is 100."`
311 | Name *string `json:"name,omitempty" jsonschema:"description=Filter contact points by name"`
312 | }
313 |
314 | func (p ListContactPointsParams) validate() error {
315 | if p.Limit < 0 {
316 | return fmt.Errorf("invalid limit: %d, must be greater than 0", p.Limit)
317 | }
318 | return nil
319 | }
320 |
321 | type contactPointSummary struct {
322 | UID string `json:"uid"`
323 | Name string `json:"name"`
324 | Type *string `json:"type,omitempty"`
325 | }
326 |
327 | func listContactPoints(ctx context.Context, args ListContactPointsParams) ([]contactPointSummary, error) {
328 | if err := args.validate(); err != nil {
329 | return nil, fmt.Errorf("list contact points: %w", err)
330 | }
331 |
332 | c := mcpgrafana.GrafanaClientFromContext(ctx)
333 |
334 | params := provisioning.NewGetContactpointsParams().WithContext(ctx)
335 | if args.Name != nil {
336 | params.Name = args.Name
337 | }
338 |
339 | response, err := c.Provisioning.GetContactpoints(params)
340 | if err != nil {
341 | return nil, fmt.Errorf("list contact points: %w", err)
342 | }
343 |
344 | filteredContactPoints, err := applyLimitToContactPoints(response.Payload, args.Limit)
345 | if err != nil {
346 | return nil, fmt.Errorf("list contact points: %w", err)
347 | }
348 |
349 | return summarizeContactPoints(filteredContactPoints), nil
350 | }
351 |
352 | func summarizeContactPoints(contactPoints []*models.EmbeddedContactPoint) []contactPointSummary {
353 | result := make([]contactPointSummary, 0, len(contactPoints))
354 | for _, cp := range contactPoints {
355 | result = append(result, contactPointSummary{
356 | UID: cp.UID,
357 | Name: cp.Name,
358 | Type: cp.Type,
359 | })
360 | }
361 | return result
362 | }
363 |
364 | func applyLimitToContactPoints(items []*models.EmbeddedContactPoint, limit int) ([]*models.EmbeddedContactPoint, error) {
365 | if limit == 0 {
366 | limit = DefaultListContactPointsLimit
367 | }
368 |
369 | if limit > len(items) {
370 | return items, nil
371 | }
372 |
373 | return items[:limit], nil
374 | }
375 |
376 | var ListContactPoints = mcpgrafana.MustTool(
377 | "list_contact_points",
378 | "Lists Grafana notification contact points, returning a summary including UID, name, and type for each. Supports filtering by name - exact match - and limiting the number of results.",
379 | listContactPoints,
380 | mcp.WithTitleAnnotation("List notification contact points"),
381 | mcp.WithIdempotentHintAnnotation(true),
382 | mcp.WithReadOnlyHintAnnotation(true),
383 | )
384 |
385 | type CreateAlertRuleParams struct {
386 | Title string `json:"title" jsonschema:"required,description=The title of the alert rule"`
387 | RuleGroup string `json:"ruleGroup" jsonschema:"required,description=The rule group name"`
388 | FolderUID string `json:"folderUID" jsonschema:"required,description=The folder UID where the rule will be created"`
389 | Condition string `json:"condition" jsonschema:"required,description=The query condition identifier (e.g. 'A'\\, 'B')"`
390 | Data any `json:"data" jsonschema:"required,description=Array of query data objects"`
391 | NoDataState string `json:"noDataState" jsonschema:"required,description=State when no data (NoData\\, Alerting\\, OK)"`
392 | ExecErrState string `json:"execErrState" jsonschema:"required,description=State on execution error (NoData\\, Alerting\\, OK)"`
393 | For string `json:"for" jsonschema:"required,description=Duration before alert fires (e.g. '5m')"`
394 | Annotations map[string]string `json:"annotations,omitempty" jsonschema:"description=Optional annotations"`
395 | Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Optional labels"`
396 | UID *string `json:"uid,omitempty" jsonschema:"description=Optional UID for the alert rule"`
397 | OrgID int64 `json:"orgID" jsonschema:"required,description=The organization ID"`
398 | }
399 |
400 | func (p CreateAlertRuleParams) validate() error {
401 | if p.Title == "" {
402 | return fmt.Errorf("title is required")
403 | }
404 | if p.RuleGroup == "" {
405 | return fmt.Errorf("ruleGroup is required")
406 | }
407 | if p.FolderUID == "" {
408 | return fmt.Errorf("folderUID is required")
409 | }
410 | if p.Condition == "" {
411 | return fmt.Errorf("condition is required")
412 | }
413 | if p.Data == nil {
414 | return fmt.Errorf("data is required")
415 | }
416 | if p.NoDataState == "" {
417 | return fmt.Errorf("noDataState is required")
418 | }
419 | if p.ExecErrState == "" {
420 | return fmt.Errorf("execErrState is required")
421 | }
422 | if p.For == "" {
423 | return fmt.Errorf("for duration is required")
424 | }
425 | if p.OrgID <= 0 {
426 | return fmt.Errorf("orgID is required and must be greater than 0")
427 | }
428 | return nil
429 | }
430 |
431 | func createAlertRule(ctx context.Context, args CreateAlertRuleParams) (*models.ProvisionedAlertRule, error) {
432 | if err := args.validate(); err != nil {
433 | return nil, fmt.Errorf("create alert rule: %w", err)
434 | }
435 |
436 | c := mcpgrafana.GrafanaClientFromContext(ctx)
437 |
438 | // Parse duration string
439 | duration, err := time.ParseDuration(args.For)
440 | if err != nil {
441 | return nil, fmt.Errorf("create alert rule: invalid duration format %q: %w", args.For, err)
442 | }
443 |
444 | // Convert Data field to AlertQuery array
445 | var alertQueries []*models.AlertQuery
446 | if args.Data != nil {
447 | // Convert interface{} to JSON and then to AlertQuery structs
448 | dataBytes, err := json.Marshal(args.Data)
449 | if err != nil {
450 | return nil, fmt.Errorf("create alert rule: failed to marshal data: %w", err)
451 | }
452 | if err := json.Unmarshal(dataBytes, &alertQueries); err != nil {
453 | return nil, fmt.Errorf("create alert rule: failed to unmarshal data to AlertQuery: %w", err)
454 | }
455 | }
456 |
457 | rule := &models.ProvisionedAlertRule{
458 | Title: &args.Title,
459 | RuleGroup: &args.RuleGroup,
460 | FolderUID: &args.FolderUID,
461 | Condition: &args.Condition,
462 | Data: alertQueries,
463 | NoDataState: &args.NoDataState,
464 | ExecErrState: &args.ExecErrState,
465 | For: func() *strfmt.Duration { d := strfmt.Duration(duration); return &d }(),
466 | Annotations: args.Annotations,
467 | Labels: args.Labels,
468 | OrgID: &args.OrgID,
469 | }
470 |
471 | if args.UID != nil {
472 | rule.UID = *args.UID
473 | }
474 |
475 | // Validate the rule using the built-in OpenAPI validation
476 | if err := rule.Validate(strfmt.Default); err != nil {
477 | return nil, fmt.Errorf("create alert rule: invalid rule configuration: %w", err)
478 | }
479 |
480 | params := provisioning.NewPostAlertRuleParams().WithContext(ctx).WithBody(rule)
481 | response, err := c.Provisioning.PostAlertRule(params)
482 | if err != nil {
483 | return nil, fmt.Errorf("create alert rule: %w", err)
484 | }
485 |
486 | return response.Payload, nil
487 | }
488 |
489 | var CreateAlertRule = mcpgrafana.MustTool(
490 | "create_alert_rule",
491 | "Creates a new Grafana alert rule with the specified configuration. Requires title, rule group, folder UID, condition, query data, no data state, execution error state, and duration settings.",
492 | createAlertRule,
493 | mcp.WithTitleAnnotation("Create alert rule"),
494 | )
495 |
496 | type UpdateAlertRuleParams struct {
497 | UID string `json:"uid" jsonschema:"required,description=The UID of the alert rule to update"`
498 | Title string `json:"title" jsonschema:"required,description=The title of the alert rule"`
499 | RuleGroup string `json:"ruleGroup" jsonschema:"required,description=The rule group name"`
500 | FolderUID string `json:"folderUID" jsonschema:"required,description=The folder UID where the rule will be created"`
501 | Condition string `json:"condition" jsonschema:"required,description=The query condition identifier (e.g. 'A'\\, 'B')"`
502 | Data any `json:"data" jsonschema:"required,description=Array of query data objects"`
503 | NoDataState string `json:"noDataState" jsonschema:"required,description=State when no data (NoData\\, Alerting\\, OK)"`
504 | ExecErrState string `json:"execErrState" jsonschema:"required,description=State on execution error (NoData\\, Alerting\\, OK)"`
505 | For string `json:"for" jsonschema:"required,description=Duration before alert fires (e.g. '5m')"`
506 | Annotations map[string]string `json:"annotations,omitempty" jsonschema:"description=Optional annotations"`
507 | Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Optional labels"`
508 | OrgID int64 `json:"orgID" jsonschema:"required,description=The organization ID"`
509 | }
510 |
511 | func (p UpdateAlertRuleParams) validate() error {
512 | if p.UID == "" {
513 | return fmt.Errorf("uid is required")
514 | }
515 | if p.Title == "" {
516 | return fmt.Errorf("title is required")
517 | }
518 | if p.RuleGroup == "" {
519 | return fmt.Errorf("ruleGroup is required")
520 | }
521 | if p.FolderUID == "" {
522 | return fmt.Errorf("folderUID is required")
523 | }
524 | if p.Condition == "" {
525 | return fmt.Errorf("condition is required")
526 | }
527 | if p.Data == nil {
528 | return fmt.Errorf("data is required")
529 | }
530 | if p.NoDataState == "" {
531 | return fmt.Errorf("noDataState is required")
532 | }
533 | if p.ExecErrState == "" {
534 | return fmt.Errorf("execErrState is required")
535 | }
536 | if p.For == "" {
537 | return fmt.Errorf("for duration is required")
538 | }
539 | if p.OrgID <= 0 {
540 | return fmt.Errorf("orgID is required and must be greater than 0")
541 | }
542 | return nil
543 | }
544 |
545 | func updateAlertRule(ctx context.Context, args UpdateAlertRuleParams) (*models.ProvisionedAlertRule, error) {
546 | if err := args.validate(); err != nil {
547 | return nil, fmt.Errorf("update alert rule: %w", err)
548 | }
549 |
550 | c := mcpgrafana.GrafanaClientFromContext(ctx)
551 |
552 | // Parse duration string
553 | duration, err := time.ParseDuration(args.For)
554 | if err != nil {
555 | return nil, fmt.Errorf("update alert rule: invalid duration format %q: %w", args.For, err)
556 | }
557 |
558 | // Convert Data field to AlertQuery array
559 | var alertQueries []*models.AlertQuery
560 | if args.Data != nil {
561 | // Convert interface{} to JSON and then to AlertQuery structs
562 | dataBytes, err := json.Marshal(args.Data)
563 | if err != nil {
564 | return nil, fmt.Errorf("update alert rule: failed to marshal data: %w", err)
565 | }
566 | if err := json.Unmarshal(dataBytes, &alertQueries); err != nil {
567 | return nil, fmt.Errorf("update alert rule: failed to unmarshal data to AlertQuery: %w", err)
568 | }
569 | }
570 |
571 | rule := &models.ProvisionedAlertRule{
572 | UID: args.UID,
573 | Title: &args.Title,
574 | RuleGroup: &args.RuleGroup,
575 | FolderUID: &args.FolderUID,
576 | Condition: &args.Condition,
577 | Data: alertQueries,
578 | NoDataState: &args.NoDataState,
579 | ExecErrState: &args.ExecErrState,
580 | For: func() *strfmt.Duration { d := strfmt.Duration(duration); return &d }(),
581 | Annotations: args.Annotations,
582 | Labels: args.Labels,
583 | OrgID: &args.OrgID,
584 | }
585 |
586 | // Validate the rule using the built-in OpenAPI validation
587 | if err := rule.Validate(strfmt.Default); err != nil {
588 | return nil, fmt.Errorf("update alert rule: invalid rule configuration: %w", err)
589 | }
590 |
591 | params := provisioning.NewPutAlertRuleParams().WithContext(ctx).WithUID(args.UID).WithBody(rule)
592 | response, err := c.Provisioning.PutAlertRule(params)
593 | if err != nil {
594 | return nil, fmt.Errorf("update alert rule %s: %w", args.UID, err)
595 | }
596 |
597 | return response.Payload, nil
598 | }
599 |
600 | var UpdateAlertRule = mcpgrafana.MustTool(
601 | "update_alert_rule",
602 | "Updates an existing Grafana alert rule identified by its UID. Requires all the same parameters as creating a new rule.",
603 | updateAlertRule,
604 | mcp.WithTitleAnnotation("Update alert rule"),
605 | )
606 |
607 | type DeleteAlertRuleParams struct {
608 | UID string `json:"uid" jsonschema:"required,description=The UID of the alert rule to delete"`
609 | }
610 |
611 | func (p DeleteAlertRuleParams) validate() error {
612 | if p.UID == "" {
613 | return fmt.Errorf("uid is required")
614 | }
615 | return nil
616 | }
617 |
618 | func deleteAlertRule(ctx context.Context, args DeleteAlertRuleParams) (string, error) {
619 | if err := args.validate(); err != nil {
620 | return "", fmt.Errorf("delete alert rule: %w", err)
621 | }
622 |
623 | c := mcpgrafana.GrafanaClientFromContext(ctx)
624 |
625 | params := provisioning.NewDeleteAlertRuleParams().WithContext(ctx).WithUID(args.UID)
626 | _, err := c.Provisioning.DeleteAlertRule(params)
627 | if err != nil {
628 | return "", fmt.Errorf("delete alert rule %s: %w", args.UID, err)
629 | }
630 |
631 | return fmt.Sprintf("Alert rule %s deleted successfully", args.UID), nil
632 | }
633 |
634 | var DeleteAlertRule = mcpgrafana.MustTool(
635 | "delete_alert_rule",
636 | "Deletes a Grafana alert rule by its UID. This action cannot be undone.",
637 | deleteAlertRule,
638 | mcp.WithTitleAnnotation("Delete alert rule"),
639 | )
640 |
641 | func AddAlertingTools(mcp *server.MCPServer, enableWriteTools bool) {
642 | ListAlertRules.Register(mcp)
643 | GetAlertRuleByUID.Register(mcp)
644 | if enableWriteTools {
645 | CreateAlertRule.Register(mcp)
646 | UpdateAlertRule.Register(mcp)
647 | DeleteAlertRule.Register(mcp)
648 | }
649 | ListContactPoints.Register(mcp)
650 | }
651 |
```
--------------------------------------------------------------------------------
/tools/dashboard.go:
--------------------------------------------------------------------------------
```go
1 | package tools
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "regexp"
8 | "strconv"
9 |
10 | "github.com/PaesslerAG/gval"
11 | "github.com/PaesslerAG/jsonpath"
12 | "github.com/mark3labs/mcp-go/mcp"
13 | "github.com/mark3labs/mcp-go/server"
14 |
15 | "github.com/grafana/grafana-openapi-client-go/models"
16 | mcpgrafana "github.com/grafana/mcp-grafana"
17 | )
18 |
19 | type GetDashboardByUIDParams struct {
20 | UID string `json:"uid" jsonschema:"required,description=The UID of the dashboard"`
21 | }
22 |
23 | func getDashboardByUID(ctx context.Context, args GetDashboardByUIDParams) (*models.DashboardFullWithMeta, error) {
24 | c := mcpgrafana.GrafanaClientFromContext(ctx)
25 | dashboard, err := c.Dashboards.GetDashboardByUID(args.UID)
26 | if err != nil {
27 | return nil, fmt.Errorf("get dashboard by uid %s: %w", args.UID, err)
28 | }
29 | return dashboard.Payload, nil
30 | }
31 |
32 | // PatchOperation represents a single patch operation
33 | type PatchOperation struct {
34 | Op string `json:"op" jsonschema:"required,description=Operation type: 'replace'\\, 'add'\\, 'remove'"`
35 | Path string `json:"path" jsonschema:"required,description=JSONPath to the property to modify. Supports: '$.title'\\, '$.panels[0].title'\\, '$.panels[0].targets[0].expr'\\, '$.panels[1].targets[0].datasource'\\, etc. For appending to arrays\\, use '/- ' syntax: '$.panels/- ' (append to panels array) or '$.panels[2]/- ' (append to nested array at index 2)."`
36 | Value interface{} `json:"value,omitempty" jsonschema:"description=New value for replace/add operations"`
37 | }
38 |
39 | type UpdateDashboardParams struct {
40 | // For full dashboard updates (creates new dashboards or complete rewrites)
41 | Dashboard map[string]interface{} `json:"dashboard,omitempty" jsonschema:"description=The full dashboard JSON. Use for creating new dashboards or complete updates. Large dashboards consume significant context - consider using patches for small changes."`
42 |
43 | // For targeted updates using patch operations (preferred for existing dashboards)
44 | UID string `json:"uid,omitempty" jsonschema:"description=UID of existing dashboard to update. Required when using patch operations."`
45 | Operations []PatchOperation `json:"operations,omitempty" jsonschema:"description=Array of patch operations for targeted updates. More efficient than full dashboard JSON for small changes."`
46 |
47 | // Common parameters
48 | FolderUID string `json:"folderUid,omitempty" jsonschema:"description=The UID of the dashboard's folder"`
49 | Message string `json:"message,omitempty" jsonschema:"description=Set a commit message for the version history"`
50 | Overwrite bool `json:"overwrite,omitempty" jsonschema:"description=Overwrite the dashboard if it exists. Otherwise create one"`
51 | UserID int64 `json:"userId,omitempty" jsonschema:"description=ID of the user making the change"`
52 | }
53 |
54 | // updateDashboard intelligently handles dashboard updates using either full JSON or patch operations.
55 | // It automatically uses the most efficient approach based on the provided parameters.
56 | func updateDashboard(ctx context.Context, args UpdateDashboardParams) (*models.PostDashboardOKBody, error) {
57 | // Determine the update strategy based on provided parameters
58 | if len(args.Operations) > 0 && args.UID != "" {
59 | // Patch-based update: fetch current dashboard and apply operations
60 | return updateDashboardWithPatches(ctx, args)
61 | } else if args.Dashboard != nil {
62 | // Full dashboard update: use the provided JSON
63 | return updateDashboardWithFullJSON(ctx, args)
64 | } else {
65 | return nil, fmt.Errorf("either dashboard JSON or (uid + operations) must be provided")
66 | }
67 | }
68 |
69 | // updateDashboardWithPatches applies patch operations to an existing dashboard
70 | func updateDashboardWithPatches(ctx context.Context, args UpdateDashboardParams) (*models.PostDashboardOKBody, error) {
71 | // Get the current dashboard
72 | dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{UID: args.UID})
73 | if err != nil {
74 | return nil, fmt.Errorf("get dashboard by uid: %w", err)
75 | }
76 |
77 | // Convert to modifiable map
78 | dashboardMap, ok := dashboard.Dashboard.(map[string]interface{})
79 | if !ok {
80 | return nil, fmt.Errorf("dashboard is not a JSON object")
81 | }
82 |
83 | // Apply each patch operation
84 | for i, op := range args.Operations {
85 | switch op.Op {
86 | case "replace", "add":
87 | if err := applyJSONPath(dashboardMap, op.Path, op.Value, false); err != nil {
88 | return nil, fmt.Errorf("operation %d (%s at %s): %w", i, op.Op, op.Path, err)
89 | }
90 | case "remove":
91 | if err := applyJSONPath(dashboardMap, op.Path, nil, true); err != nil {
92 | return nil, fmt.Errorf("operation %d (%s at %s): %w", i, op.Op, op.Path, err)
93 | }
94 | default:
95 | return nil, fmt.Errorf("operation %d: unsupported operation '%s'", i, op.Op)
96 | }
97 | }
98 |
99 | // Use the folder UID from the existing dashboard if not provided
100 | folderUID := args.FolderUID
101 | if folderUID == "" && dashboard.Meta != nil {
102 | folderUID = dashboard.Meta.FolderUID
103 | }
104 |
105 | // Update with the patched dashboard
106 | return updateDashboardWithFullJSON(ctx, UpdateDashboardParams{
107 | Dashboard: dashboardMap,
108 | FolderUID: folderUID,
109 | Message: args.Message,
110 | Overwrite: true,
111 | UserID: args.UserID,
112 | })
113 | }
114 |
115 | // updateDashboardWithFullJSON performs a traditional full dashboard update
116 | func updateDashboardWithFullJSON(ctx context.Context, args UpdateDashboardParams) (*models.PostDashboardOKBody, error) {
117 | c := mcpgrafana.GrafanaClientFromContext(ctx)
118 | cmd := &models.SaveDashboardCommand{
119 | Dashboard: args.Dashboard,
120 | FolderUID: args.FolderUID,
121 | Message: args.Message,
122 | Overwrite: args.Overwrite,
123 | UserID: args.UserID,
124 | }
125 | dashboard, err := c.Dashboards.PostDashboard(cmd)
126 | if err != nil {
127 | return nil, fmt.Errorf("unable to save dashboard: %w", err)
128 | }
129 | return dashboard.Payload, nil
130 | }
131 |
132 | var GetDashboardByUID = mcpgrafana.MustTool(
133 | "get_dashboard_by_uid",
134 | "Retrieves the complete dashboard, including panels, variables, and settings, for a specific dashboard identified by its UID. WARNING: Large dashboards can consume significant context window space. Consider using get_dashboard_summary for overview or get_dashboard_property for specific data instead.",
135 | getDashboardByUID,
136 | mcp.WithTitleAnnotation("Get dashboard details"),
137 | mcp.WithIdempotentHintAnnotation(true),
138 | mcp.WithReadOnlyHintAnnotation(true),
139 | )
140 |
141 | var UpdateDashboard = mcpgrafana.MustTool(
142 | "update_dashboard",
143 | "Create or update a dashboard using either full JSON or efficient patch operations. For new dashboards\\, provide the 'dashboard' field. For updating existing dashboards\\, use 'uid' + 'operations' for better context window efficiency. Patch operations support complex JSONPaths like '$.panels[0].targets[0].expr'\\, '$.panels[1].title'\\, '$.panels[2].targets[0].datasource'\\, etc. Supports appending to arrays using '/- ' syntax: '$.panels/- ' appends to panels array\\, '$.panels[2]/- ' appends to nested array at index 2.",
144 | updateDashboard,
145 | mcp.WithTitleAnnotation("Create or update dashboard"),
146 | mcp.WithDestructiveHintAnnotation(true),
147 | )
148 |
149 | type DashboardPanelQueriesParams struct {
150 | UID string `json:"uid" jsonschema:"required,description=The UID of the dashboard"`
151 | }
152 |
153 | type datasourceInfo struct {
154 | UID string `json:"uid"`
155 | Type string `json:"type"`
156 | }
157 |
158 | type panelQuery struct {
159 | Title string `json:"title"`
160 | Query string `json:"query"`
161 | Datasource datasourceInfo `json:"datasource"`
162 | }
163 |
164 | func GetDashboardPanelQueriesTool(ctx context.Context, args DashboardPanelQueriesParams) ([]panelQuery, error) {
165 | result := make([]panelQuery, 0)
166 |
167 | dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams(args))
168 | if err != nil {
169 | return result, fmt.Errorf("get dashboard by uid: %w", err)
170 | }
171 |
172 | db, ok := dashboard.Dashboard.(map[string]any)
173 | if !ok {
174 | return result, fmt.Errorf("dashboard is not a JSON object")
175 | }
176 | panels, ok := db["panels"].([]any)
177 | if !ok {
178 | return result, fmt.Errorf("panels is not a JSON array")
179 | }
180 |
181 | for _, p := range panels {
182 | panel, ok := p.(map[string]any)
183 | if !ok {
184 | continue
185 | }
186 | title, _ := panel["title"].(string)
187 |
188 | var datasourceInfo datasourceInfo
189 | if dsField, dsExists := panel["datasource"]; dsExists && dsField != nil {
190 | if dsMap, ok := dsField.(map[string]any); ok {
191 | if uid, ok := dsMap["uid"].(string); ok {
192 | datasourceInfo.UID = uid
193 | }
194 | if dsType, ok := dsMap["type"].(string); ok {
195 | datasourceInfo.Type = dsType
196 | }
197 | }
198 | }
199 |
200 | targets, ok := panel["targets"].([]any)
201 | if !ok {
202 | continue
203 | }
204 | for _, t := range targets {
205 | target, ok := t.(map[string]any)
206 | if !ok {
207 | continue
208 | }
209 | expr, _ := target["expr"].(string)
210 | if expr != "" {
211 | result = append(result, panelQuery{
212 | Title: title,
213 | Query: expr,
214 | Datasource: datasourceInfo,
215 | })
216 | }
217 | }
218 | }
219 |
220 | return result, nil
221 | }
222 |
223 | var GetDashboardPanelQueries = mcpgrafana.MustTool(
224 | "get_dashboard_panel_queries",
225 | "Use this tool to retrieve panel queries and information from a Grafana dashboard. When asked about panel queries, queries in a dashboard, or what queries a dashboard contains, call this tool with the dashboard UID. The datasource is an object with fields `uid` (which may be a concrete UID or a template variable like \"$datasource\") and `type`. If the datasource UID is a template variable, it won't be usable directly for queries. Returns an array of objects, each representing a panel, with fields: title, query, and datasource (an object with uid and type).",
226 | GetDashboardPanelQueriesTool,
227 | mcp.WithTitleAnnotation("Get dashboard panel queries"),
228 | mcp.WithIdempotentHintAnnotation(true),
229 | mcp.WithReadOnlyHintAnnotation(true),
230 | )
231 |
232 | // GetDashboardPropertyParams defines parameters for getting specific dashboard properties
233 | type GetDashboardPropertyParams struct {
234 | UID string `json:"uid" jsonschema:"required,description=The UID of the dashboard"`
235 | JSONPath string `json:"jsonPath" jsonschema:"required,description=JSONPath expression to extract specific data (e.g.\\, '$.panels[0].title' for first panel title\\, '$.panels[*].title' for all panel titles\\, '$.templating.list' for variables)"`
236 | }
237 |
238 | // getDashboardProperty retrieves specific parts of a dashboard using JSONPath expressions.
239 | // This helps reduce context window usage by fetching only the needed data.
240 | func getDashboardProperty(ctx context.Context, args GetDashboardPropertyParams) (interface{}, error) {
241 | dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{UID: args.UID})
242 | if err != nil {
243 | return nil, fmt.Errorf("get dashboard by uid: %w", err)
244 | }
245 |
246 | // Convert dashboard to JSON for JSONPath processing
247 | dashboardJSON, err := json.Marshal(dashboard.Dashboard)
248 | if err != nil {
249 | return nil, fmt.Errorf("marshal dashboard to JSON: %w", err)
250 | }
251 |
252 | var dashboardData interface{}
253 | if err := json.Unmarshal(dashboardJSON, &dashboardData); err != nil {
254 | return nil, fmt.Errorf("unmarshal dashboard JSON: %w", err)
255 | }
256 |
257 | // Apply JSONPath expression
258 | builder := gval.Full(jsonpath.Language())
259 | path, err := builder.NewEvaluable(args.JSONPath)
260 | if err != nil {
261 | return nil, fmt.Errorf("create JSONPath evaluable '%s': %w", args.JSONPath, err)
262 | }
263 |
264 | result, err := path(ctx, dashboardData)
265 | if err != nil {
266 | return nil, fmt.Errorf("apply JSONPath '%s': %w", args.JSONPath, err)
267 | }
268 |
269 | return result, nil
270 | }
271 |
272 | var GetDashboardProperty = mcpgrafana.MustTool(
273 | "get_dashboard_property",
274 | "Get specific parts of a dashboard using JSONPath expressions to minimize context window usage. Common paths: '$.title' (title)\\, '$.panels[*].title' (all panel titles)\\, '$.panels[0]' (first panel)\\, '$.templating.list' (variables)\\, '$.tags' (tags)\\, '$.panels[*].targets[*].expr' (all queries). Use this instead of get_dashboard_by_uid when you only need specific dashboard properties.",
275 | getDashboardProperty,
276 | mcp.WithTitleAnnotation("Get dashboard property"),
277 | mcp.WithIdempotentHintAnnotation(true),
278 | mcp.WithReadOnlyHintAnnotation(true),
279 | )
280 |
281 | // GetDashboardSummaryParams defines parameters for getting a dashboard summary
282 | type GetDashboardSummaryParams struct {
283 | UID string `json:"uid" jsonschema:"required,description=The UID of the dashboard"`
284 | }
285 |
286 | // DashboardSummary provides a compact overview of a dashboard without the full JSON
287 | type DashboardSummary struct {
288 | UID string `json:"uid"`
289 | Title string `json:"title"`
290 | Description string `json:"description,omitempty"`
291 | Tags []string `json:"tags,omitempty"`
292 | PanelCount int `json:"panelCount"`
293 | Panels []PanelSummary `json:"panels"`
294 | Variables []VariableSummary `json:"variables,omitempty"`
295 | TimeRange TimeRangeSummary `json:"timeRange"`
296 | Refresh string `json:"refresh,omitempty"`
297 | Meta *models.DashboardMeta `json:"meta,omitempty"`
298 | }
299 |
300 | type PanelSummary struct {
301 | ID int `json:"id"`
302 | Title string `json:"title"`
303 | Type string `json:"type"`
304 | Description string `json:"description,omitempty"`
305 | QueryCount int `json:"queryCount"`
306 | }
307 |
308 | type VariableSummary struct {
309 | Name string `json:"name"`
310 | Type string `json:"type"`
311 | Label string `json:"label,omitempty"`
312 | }
313 |
314 | type TimeRangeSummary struct {
315 | From string `json:"from"`
316 | To string `json:"to"`
317 | }
318 |
319 | // getDashboardSummary provides a compact overview of a dashboard to help with context management
320 | func getDashboardSummary(ctx context.Context, args GetDashboardSummaryParams) (*DashboardSummary, error) {
321 | dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams(args))
322 | if err != nil {
323 | return nil, fmt.Errorf("get dashboard by uid: %w", err)
324 | }
325 |
326 | db, ok := dashboard.Dashboard.(map[string]interface{})
327 | if !ok {
328 | return nil, fmt.Errorf("dashboard is not a JSON object")
329 | }
330 |
331 | summary := &DashboardSummary{
332 | UID: args.UID,
333 | Meta: dashboard.Meta,
334 | }
335 |
336 | // Extract basic info using helper functions
337 | extractBasicDashboardInfo(db, summary)
338 |
339 | // Extract time range
340 | summary.TimeRange = extractTimeRange(db)
341 |
342 | // Extract panel summaries
343 | if panels := safeArray(db, "panels"); panels != nil {
344 | summary.PanelCount = len(panels)
345 | for _, p := range panels {
346 | if panelObj, ok := p.(map[string]interface{}); ok {
347 | summary.Panels = append(summary.Panels, extractPanelSummary(panelObj))
348 | }
349 | }
350 | }
351 |
352 | // Extract variable summaries
353 | if templating := safeObject(db, "templating"); templating != nil {
354 | if list := safeArray(templating, "list"); list != nil {
355 | for _, v := range list {
356 | if variable, ok := v.(map[string]interface{}); ok {
357 | summary.Variables = append(summary.Variables, extractVariableSummary(variable))
358 | }
359 | }
360 | }
361 | }
362 |
363 | return summary, nil
364 | }
365 |
366 | var GetDashboardSummary = mcpgrafana.MustTool(
367 | "get_dashboard_summary",
368 | "Get a compact summary of a dashboard including title\\, panel count\\, panel types\\, variables\\, and other metadata without the full JSON. Use this for dashboard overview and planning modifications without consuming large context windows.",
369 | getDashboardSummary,
370 | mcp.WithTitleAnnotation("Get dashboard summary"),
371 | mcp.WithIdempotentHintAnnotation(true),
372 | mcp.WithReadOnlyHintAnnotation(true),
373 | )
374 |
375 | // applyJSONPath applies a value to a JSONPath or removes it if remove=true
376 | func applyJSONPath(data map[string]interface{}, path string, value interface{}, remove bool) error {
377 | // Remove the leading "$." if present
378 | if len(path) > 2 && path[:2] == "$." {
379 | path = path[2:]
380 | }
381 |
382 | // Split the path into segments
383 | segments := parseJSONPath(path)
384 | if len(segments) == 0 {
385 | return fmt.Errorf("empty JSONPath")
386 | }
387 |
388 | // Navigate to the parent of the target
389 | current := data
390 | for i, segment := range segments[:len(segments)-1] {
391 | next, err := navigateSegment(current, segment)
392 | if err != nil {
393 | return fmt.Errorf("at segment %d (%s): %w", i, segment.String(), err)
394 | }
395 | current = next
396 | }
397 |
398 | // Apply the final operation
399 | finalSegment := segments[len(segments)-1]
400 | if remove {
401 | return removeAtSegment(current, finalSegment)
402 | }
403 | return setAtSegment(current, finalSegment, value)
404 | }
405 |
406 | // JSONPathSegment represents a segment of a JSONPath
407 | type JSONPathSegment struct {
408 | Key string
409 | Index int
410 | IsArray bool
411 | IsAppend bool // true when using /- syntax to append to array
412 | }
413 |
414 | func (s JSONPathSegment) String() string {
415 | if s.IsAppend {
416 | return fmt.Sprintf("%s/-", s.Key)
417 | }
418 | if s.IsArray {
419 | return fmt.Sprintf("%s[%d]", s.Key, s.Index)
420 | }
421 | return s.Key
422 | }
423 |
424 | // parseJSONPath parses a JSONPath string into segments
425 | // Supports paths like "panels[0].targets[1].expr", "title", "templating.list[0].name"
426 | // Also supports append syntax: "panels/-" or "panels[2]/-"
427 | func parseJSONPath(path string) []JSONPathSegment {
428 | var segments []JSONPathSegment
429 |
430 | // Handle empty path
431 | if path == "" {
432 | return segments
433 | }
434 |
435 | // Enhanced regex to handle /- append syntax
436 | // Matches: key, key[index], key/-, key[index]/-
437 | re := regexp.MustCompile(`([^.\[\]\/]+)(?:\[(\d+)\])?(?:(\/-))?`)
438 | matches := re.FindAllStringSubmatch(path, -1)
439 |
440 | for _, match := range matches {
441 | if len(match) >= 2 && match[1] != "" {
442 | segment := JSONPathSegment{
443 | Key: match[1],
444 | IsArray: len(match) >= 3 && match[2] != "",
445 | IsAppend: len(match) >= 4 && match[3] == "/-",
446 | }
447 |
448 | if segment.IsArray && !segment.IsAppend {
449 | if index, err := strconv.Atoi(match[2]); err == nil {
450 | segment.Index = index
451 | }
452 | }
453 |
454 | segments = append(segments, segment)
455 | }
456 | }
457 |
458 | return segments
459 | }
460 |
461 | // validateArrayAccess validates array access for a segment
462 | func validateArrayAccess(current map[string]interface{}, segment JSONPathSegment) ([]interface{}, error) {
463 | arr, ok := current[segment.Key].([]interface{})
464 | if !ok {
465 | return nil, fmt.Errorf("field '%s' is not an array", segment.Key)
466 | }
467 |
468 | // For append operations, we don't need to validate index bounds
469 | if segment.IsAppend {
470 | return arr, nil
471 | }
472 |
473 | if segment.Index < 0 || segment.Index >= len(arr) {
474 | return nil, fmt.Errorf("index %d out of bounds for array '%s' (length %d)", segment.Index, segment.Key, len(arr))
475 | }
476 |
477 | return arr, nil
478 | }
479 |
480 | // navigateSegment navigates to the next level in the JSON structure
481 | func navigateSegment(current map[string]interface{}, segment JSONPathSegment) (map[string]interface{}, error) {
482 | // Append operations can only be at the final segment
483 | if segment.IsAppend {
484 | return nil, fmt.Errorf("append operation (/- ) can only be used at the final path segment")
485 | }
486 |
487 | if segment.IsArray {
488 | arr, err := validateArrayAccess(current, segment)
489 | if err != nil {
490 | return nil, err
491 | }
492 |
493 | // Get the object at the index
494 | obj, ok := arr[segment.Index].(map[string]interface{})
495 | if !ok {
496 | return nil, fmt.Errorf("element at %s[%d] is not an object", segment.Key, segment.Index)
497 | }
498 |
499 | return obj, nil
500 | }
501 |
502 | // Get the object
503 | obj, ok := current[segment.Key].(map[string]interface{})
504 | if !ok {
505 | return nil, fmt.Errorf("field '%s' is not an object", segment.Key)
506 | }
507 |
508 | return obj, nil
509 | }
510 |
511 | // setAtSegment sets a value at the final segment
512 | func setAtSegment(current map[string]interface{}, segment JSONPathSegment, value interface{}) error {
513 | if segment.IsAppend {
514 | // Handle append operation: add to the end of the array
515 | arr, err := validateArrayAccess(current, segment)
516 | if err != nil {
517 | return err
518 | }
519 |
520 | // Append the value to the array
521 | arr = append(arr, value)
522 | current[segment.Key] = arr
523 | return nil
524 | }
525 |
526 | if segment.IsArray {
527 | arr, err := validateArrayAccess(current, segment)
528 | if err != nil {
529 | return err
530 | }
531 |
532 | // Set the value in the array
533 | arr[segment.Index] = value
534 | return nil
535 | }
536 |
537 | // Set the value directly
538 | current[segment.Key] = value
539 | return nil
540 | }
541 |
542 | // removeAtSegment removes a value at the final segment
543 | func removeAtSegment(current map[string]interface{}, segment JSONPathSegment) error {
544 | if segment.IsAppend {
545 | return fmt.Errorf("cannot use remove operation with append syntax (/- ) at %s", segment.Key)
546 | }
547 |
548 | if segment.IsArray {
549 | return fmt.Errorf("cannot remove array element %s[%d] (not supported)", segment.Key, segment.Index)
550 | }
551 |
552 | delete(current, segment.Key)
553 | return nil
554 | }
555 |
556 | // Helper functions for safe type conversions and field extraction
557 |
558 | // safeGet safely extracts a value from a map with type conversion
559 | func safeGet[T any](data map[string]interface{}, key string, defaultVal T) T {
560 | if val, ok := data[key]; ok {
561 | if typedVal, ok := val.(T); ok {
562 | return typedVal
563 | }
564 | }
565 | return defaultVal
566 | }
567 |
568 | func safeString(data map[string]interface{}, key string) string {
569 | return safeGet(data, key, "")
570 | }
571 |
572 | func safeStringSlice(data map[string]interface{}, key string) []string {
573 | var result []string
574 | if arr := safeArray(data, key); arr != nil {
575 | for _, item := range arr {
576 | if str, ok := item.(string); ok {
577 | result = append(result, str)
578 | }
579 | }
580 | }
581 | return result
582 | }
583 |
584 | func safeFloat64(data map[string]interface{}, key string) float64 {
585 | return safeGet(data, key, 0.0)
586 | }
587 |
588 | func safeInt(data map[string]interface{}, key string) int {
589 | return int(safeFloat64(data, key))
590 | }
591 |
592 | func safeObject(data map[string]interface{}, key string) map[string]interface{} {
593 | return safeGet(data, key, map[string]interface{}(nil))
594 | }
595 |
596 | func safeArray(data map[string]interface{}, key string) []interface{} {
597 | return safeGet(data, key, []interface{}(nil))
598 | }
599 |
600 | // extractBasicDashboardInfo extracts common dashboard fields
601 | func extractBasicDashboardInfo(db map[string]interface{}, summary *DashboardSummary) {
602 | summary.Title = safeString(db, "title")
603 | summary.Description = safeString(db, "description")
604 | summary.Tags = safeStringSlice(db, "tags")
605 | summary.Refresh = safeString(db, "refresh")
606 | }
607 |
608 | // extractTimeRange extracts time range information
609 | func extractTimeRange(db map[string]interface{}) TimeRangeSummary {
610 | timeObj := safeObject(db, "time")
611 | if timeObj == nil {
612 | return TimeRangeSummary{}
613 | }
614 |
615 | return TimeRangeSummary{
616 | From: safeString(timeObj, "from"),
617 | To: safeString(timeObj, "to"),
618 | }
619 | }
620 |
621 | // extractPanelSummary creates a panel summary from panel data
622 | func extractPanelSummary(panel map[string]interface{}) PanelSummary {
623 | summary := PanelSummary{
624 | ID: safeInt(panel, "id"),
625 | Title: safeString(panel, "title"),
626 | Type: safeString(panel, "type"),
627 | Description: safeString(panel, "description"),
628 | }
629 |
630 | // Count queries
631 | if targets := safeArray(panel, "targets"); targets != nil {
632 | summary.QueryCount = len(targets)
633 | }
634 |
635 | return summary
636 | }
637 |
638 | // extractVariableSummary creates a variable summary from variable data
639 | func extractVariableSummary(variable map[string]interface{}) VariableSummary {
640 | return VariableSummary{
641 | Name: safeString(variable, "name"),
642 | Type: safeString(variable, "type"),
643 | Label: safeString(variable, "label"),
644 | }
645 | }
646 |
647 | func AddDashboardTools(mcp *server.MCPServer, enableWriteTools bool) {
648 | GetDashboardByUID.Register(mcp)
649 | if enableWriteTools {
650 | UpdateDashboard.Register(mcp)
651 | }
652 | GetDashboardPanelQueries.Register(mcp)
653 | GetDashboardProperty.Register(mcp)
654 | GetDashboardSummary.Register(mcp)
655 | }
656 |
```