This is page 3 of 4. Use http://codebase.md/grafana/mcp-grafana?lines=false&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/prometheus.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend/gtime"
mcpgrafana "github.com/grafana/mcp-grafana"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/prometheus/client_golang/api"
promv1 "github.com/prometheus/client_golang/api/prometheus/v1"
"github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/prometheus/model/labels"
)
var (
matchTypeMap = map[string]labels.MatchType{
"": labels.MatchEqual,
"=": labels.MatchEqual,
"!=": labels.MatchNotEqual,
"=~": labels.MatchRegexp,
"!~": labels.MatchNotRegexp,
}
)
func promClientFromContext(ctx context.Context, uid string) (promv1.API, error) {
// First check if the datasource exists
_, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{UID: uid})
if err != nil {
return nil, err
}
cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
url := fmt.Sprintf("%s/api/datasources/proxy/uid/%s", strings.TrimRight(cfg.URL, "/"), uid)
// Create custom transport with TLS configuration if available
rt := api.DefaultRoundTripper
if tlsConfig := cfg.TLSConfig; tlsConfig != nil {
customTransport, err := tlsConfig.HTTPTransport(rt.(*http.Transport))
if err != nil {
return nil, fmt.Errorf("failed to create custom transport: %w", err)
}
rt = customTransport
}
if cfg.AccessToken != "" && cfg.IDToken != "" {
rt = config.NewHeadersRoundTripper(&config.Headers{
Headers: map[string]config.Header{
"X-Access-Token": {
Secrets: []config.Secret{config.Secret(cfg.AccessToken)},
},
"X-Grafana-Id": {
Secrets: []config.Secret{config.Secret(cfg.IDToken)},
},
},
}, rt)
} else if cfg.APIKey != "" {
rt = config.NewAuthorizationCredentialsRoundTripper(
"Bearer", config.NewInlineSecret(cfg.APIKey), rt,
)
} else if cfg.BasicAuth != nil {
password, _ := cfg.BasicAuth.Password()
rt = config.NewBasicAuthRoundTripper(config.NewInlineSecret(cfg.BasicAuth.Username()), config.NewInlineSecret(password), rt)
}
// Wrap with org ID support
rt = mcpgrafana.NewOrgIDRoundTripper(rt, cfg.OrgID)
c, err := api.NewClient(api.Config{
Address: url,
RoundTripper: rt,
})
if err != nil {
return nil, fmt.Errorf("creating Prometheus client: %w", err)
}
return promv1.NewAPI(c), nil
}
type ListPrometheusMetricMetadataParams struct {
DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
Limit int `json:"limit" jsonschema:"description=The maximum number of metrics to return"`
LimitPerMetric int `json:"limitPerMetric" jsonschema:"description=The maximum number of metrics to return per metric"`
Metric string `json:"metric" jsonschema:"description=The metric to query"`
}
func listPrometheusMetricMetadata(ctx context.Context, args ListPrometheusMetricMetadataParams) (map[string][]promv1.Metadata, error) {
promClient, err := promClientFromContext(ctx, args.DatasourceUID)
if err != nil {
return nil, fmt.Errorf("getting Prometheus client: %w", err)
}
limit := args.Limit
if limit == 0 {
limit = 10
}
metadata, err := promClient.Metadata(ctx, args.Metric, fmt.Sprintf("%d", limit))
if err != nil {
return nil, fmt.Errorf("listing Prometheus metric metadata: %w", err)
}
return metadata, nil
}
var ListPrometheusMetricMetadata = mcpgrafana.MustTool(
"list_prometheus_metric_metadata",
"List Prometheus metric metadata. Returns metadata about metrics currently scraped from targets. Note: This endpoint is experimental.",
listPrometheusMetricMetadata,
mcp.WithTitleAnnotation("List Prometheus metric metadata"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type QueryPrometheusParams struct {
DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
Expr string `json:"expr" jsonschema:"required,description=The PromQL expression to query"`
StartTime string `json:"startTime" jsonschema:"required,description=The start time. Supported formats are RFC3339 or relative to now (e.g. 'now'\\, 'now-1.5h'\\, 'now-2h45m'). Valid time units are 'ns'\\, 'us' (or 'µs')\\, 'ms'\\, 's'\\, 'm'\\, 'h'\\, 'd'."`
EndTime string `json:"endTime,omitempty" jsonschema:"description=The end time. Required if queryType is 'range'\\, ignored if queryType is 'instant' Supported formats are RFC3339 or relative to now (e.g. 'now'\\, 'now-1.5h'\\, 'now-2h45m'). Valid time units are 'ns'\\, 'us' (or 'µs')\\, 'ms'\\, 's'\\, 'm'\\, 'h'\\, 'd'."`
StepSeconds int `json:"stepSeconds,omitempty" jsonschema:"description=The time series step size in seconds. Required if queryType is 'range'\\, ignored if queryType is 'instant'"`
QueryType string `json:"queryType,omitempty" jsonschema:"description=The type of query to use. Either 'range' or 'instant'"`
}
func parseTime(timeStr string) (time.Time, error) {
tr := gtime.TimeRange{
From: timeStr,
Now: time.Now(),
}
return tr.ParseFrom()
}
func queryPrometheus(ctx context.Context, args QueryPrometheusParams) (model.Value, error) {
promClient, err := promClientFromContext(ctx, args.DatasourceUID)
if err != nil {
return nil, fmt.Errorf("getting Prometheus client: %w", err)
}
queryType := args.QueryType
if queryType == "" {
queryType = "range"
}
var startTime time.Time
startTime, err = parseTime(args.StartTime)
if err != nil {
return nil, fmt.Errorf("parsing start time: %w", err)
}
switch queryType {
case "range":
if args.StepSeconds == 0 {
return nil, fmt.Errorf("stepSeconds must be provided when queryType is 'range'")
}
var endTime time.Time
endTime, err = parseTime(args.EndTime)
if err != nil {
return nil, fmt.Errorf("parsing end time: %w", err)
}
step := time.Duration(args.StepSeconds) * time.Second
result, _, err := promClient.QueryRange(ctx, args.Expr, promv1.Range{
Start: startTime,
End: endTime,
Step: step,
})
if err != nil {
return nil, fmt.Errorf("querying Prometheus range: %w", err)
}
return result, nil
case "instant":
result, _, err := promClient.Query(ctx, args.Expr, startTime)
if err != nil {
return nil, fmt.Errorf("querying Prometheus instant: %w", err)
}
return result, nil
}
return nil, fmt.Errorf("invalid query type: %s", queryType)
}
var QueryPrometheus = mcpgrafana.MustTool(
"query_prometheus",
"Query Prometheus using a PromQL expression. Supports both instant queries (at a single point in time) and range queries (over a time range). Time can be specified either in RFC3339 format or as relative time expressions like 'now', 'now-1h', 'now-30m', etc.",
queryPrometheus,
mcp.WithTitleAnnotation("Query Prometheus metrics"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type ListPrometheusMetricNamesParams struct {
DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
Regex string `json:"regex" jsonschema:"description=The regex to match against the metric names"`
Limit int `json:"limit,omitempty" jsonschema:"description=The maximum number of results to return"`
Page int `json:"page,omitempty" jsonschema:"description=The page number to return"`
}
func listPrometheusMetricNames(ctx context.Context, args ListPrometheusMetricNamesParams) ([]string, error) {
promClient, err := promClientFromContext(ctx, args.DatasourceUID)
if err != nil {
return nil, fmt.Errorf("getting Prometheus client: %w", err)
}
limit := args.Limit
if limit == 0 {
limit = 10
}
page := args.Page
if page == 0 {
page = 1
}
// Get all metric names by querying for __name__ label values
labelValues, _, err := promClient.LabelValues(ctx, "__name__", nil, time.Time{}, time.Time{})
if err != nil {
return nil, fmt.Errorf("listing Prometheus metric names: %w", err)
}
// Filter by regex if provided
matches := []string{}
if args.Regex != "" {
re, err := regexp.Compile(args.Regex)
if err != nil {
return nil, fmt.Errorf("compiling regex: %w", err)
}
for _, val := range labelValues {
if re.MatchString(string(val)) {
matches = append(matches, string(val))
}
}
} else {
for _, val := range labelValues {
matches = append(matches, string(val))
}
}
// Apply pagination
start := (page - 1) * limit
end := start + limit
if start >= len(matches) {
matches = []string{}
} else if end > len(matches) {
matches = matches[start:]
} else {
matches = matches[start:end]
}
return matches, nil
}
var ListPrometheusMetricNames = mcpgrafana.MustTool(
"list_prometheus_metric_names",
"List metric names in a Prometheus datasource. Retrieves all metric names and then filters them locally using the provided regex. Supports pagination.",
listPrometheusMetricNames,
mcp.WithTitleAnnotation("List Prometheus metric names"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type LabelMatcher struct {
Name string `json:"name" jsonschema:"required,description=The name of the label to match against"`
Value string `json:"value" jsonschema:"required,description=The value to match against"`
Type string `json:"type" jsonschema:"required,description=One of the '=' or '!=' or '=~' or '!~'"`
}
type Selector struct {
Filters []LabelMatcher `json:"filters"`
}
func (s Selector) String() string {
b := strings.Builder{}
b.WriteRune('{')
for i, f := range s.Filters {
if f.Type == "" {
f.Type = "="
}
b.WriteString(fmt.Sprintf(`%s%s'%s'`, f.Name, f.Type, f.Value))
if i < len(s.Filters)-1 {
b.WriteString(", ")
}
}
b.WriteRune('}')
return b.String()
}
// Matches runs the matchers against the given labels and returns whether they match the selector.
func (s Selector) Matches(lbls labels.Labels) (bool, error) {
matchers := make(labels.Selector, 0, len(s.Filters))
for _, filter := range s.Filters {
matchType, ok := matchTypeMap[filter.Type]
if !ok {
return false, fmt.Errorf("invalid matcher type: %s", filter.Type)
}
matcher, err := labels.NewMatcher(matchType, filter.Name, filter.Value)
if err != nil {
return false, fmt.Errorf("creating matcher: %w", err)
}
matchers = append(matchers, matcher)
}
return matchers.Matches(lbls), nil
}
type ListPrometheusLabelNamesParams struct {
DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
Matches []Selector `json:"matches,omitempty" jsonschema:"description=Optionally\\, a list of label matchers to filter the results by"`
StartRFC3339 string `json:"startRfc3339,omitempty" jsonschema:"description=Optionally\\, the start time of the time range to filter the results by"`
EndRFC3339 string `json:"endRfc3339,omitempty" jsonschema:"description=Optionally\\, the end time of the time range to filter the results by"`
Limit int `json:"limit,omitempty" jsonschema:"description=Optionally\\, the maximum number of results to return"`
}
func listPrometheusLabelNames(ctx context.Context, args ListPrometheusLabelNamesParams) ([]string, error) {
promClient, err := promClientFromContext(ctx, args.DatasourceUID)
if err != nil {
return nil, fmt.Errorf("getting Prometheus client: %w", err)
}
limit := args.Limit
if limit == 0 {
limit = 100
}
var startTime, endTime time.Time
if args.StartRFC3339 != "" {
if startTime, err = time.Parse(time.RFC3339, args.StartRFC3339); err != nil {
return nil, fmt.Errorf("parsing start time: %w", err)
}
}
if args.EndRFC3339 != "" {
if endTime, err = time.Parse(time.RFC3339, args.EndRFC3339); err != nil {
return nil, fmt.Errorf("parsing end time: %w", err)
}
}
var matchers []string
for _, m := range args.Matches {
matchers = append(matchers, m.String())
}
labelNames, _, err := promClient.LabelNames(ctx, matchers, startTime, endTime)
if err != nil {
return nil, fmt.Errorf("listing Prometheus label names: %w", err)
}
// Apply limit
if len(labelNames) > limit {
labelNames = labelNames[:limit]
}
return labelNames, nil
}
var ListPrometheusLabelNames = mcpgrafana.MustTool(
"list_prometheus_label_names",
"List label names in a Prometheus datasource. Allows filtering by series selectors and time range.",
listPrometheusLabelNames,
mcp.WithTitleAnnotation("List Prometheus label names"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type ListPrometheusLabelValuesParams struct {
DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
LabelName string `json:"labelName" jsonschema:"required,description=The name of the label to query"`
Matches []Selector `json:"matches,omitempty" jsonschema:"description=Optionally\\, a list of selectors to filter the results by"`
StartRFC3339 string `json:"startRfc3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query"`
EndRFC3339 string `json:"endRfc3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query"`
Limit int `json:"limit,omitempty" jsonschema:"description=Optionally\\, the maximum number of results to return"`
}
func listPrometheusLabelValues(ctx context.Context, args ListPrometheusLabelValuesParams) (model.LabelValues, error) {
promClient, err := promClientFromContext(ctx, args.DatasourceUID)
if err != nil {
return nil, fmt.Errorf("getting Prometheus client: %w", err)
}
limit := args.Limit
if limit == 0 {
limit = 100
}
var startTime, endTime time.Time
if args.StartRFC3339 != "" {
if startTime, err = time.Parse(time.RFC3339, args.StartRFC3339); err != nil {
return nil, fmt.Errorf("parsing start time: %w", err)
}
}
if args.EndRFC3339 != "" {
if endTime, err = time.Parse(time.RFC3339, args.EndRFC3339); err != nil {
return nil, fmt.Errorf("parsing end time: %w", err)
}
}
var matchers []string
for _, m := range args.Matches {
matchers = append(matchers, m.String())
}
labelValues, _, err := promClient.LabelValues(ctx, args.LabelName, matchers, startTime, endTime)
if err != nil {
return nil, fmt.Errorf("listing Prometheus label values: %w", err)
}
// Apply limit
if len(labelValues) > limit {
labelValues = labelValues[:limit]
}
return labelValues, nil
}
var ListPrometheusLabelValues = mcpgrafana.MustTool(
"list_prometheus_label_values",
"Get the values for a specific label name in Prometheus. Allows filtering by series selectors and time range.",
listPrometheusLabelValues,
mcp.WithTitleAnnotation("List Prometheus label values"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
func AddPrometheusTools(mcp *server.MCPServer) {
ListPrometheusMetricMetadata.Register(mcp)
QueryPrometheus.Register(mcp)
ListPrometheusMetricNames.Register(mcp)
ListPrometheusLabelNames.Register(mcp)
ListPrometheusLabelValues.Register(mcp)
}
```
--------------------------------------------------------------------------------
/tools/pyroscope.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"connectrpc.com/connect"
mcpgrafana "github.com/grafana/mcp-grafana"
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
"github.com/grafana/pyroscope/api/gen/proto/go/querier/v1/querierv1connect"
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func AddPyroscopeTools(mcp *server.MCPServer) {
ListPyroscopeLabelNames.Register(mcp)
ListPyroscopeLabelValues.Register(mcp)
ListPyroscopeProfileTypes.Register(mcp)
FetchPyroscopeProfile.Register(mcp)
}
const listPyroscopeLabelNamesToolPrompt = `
Lists all available label names (keys) found in profiles within a specified Pyroscope datasource, time range, and
optional label matchers. Label matchers are typically used to qualify a service name ({service_name="foo"}). Returns a
list of unique label strings (e.g., ["app", "env", "pod"]). Label names with double underscores (e.g. __name__) are
internal and rarely useful to users. If the time range is not provided, it defaults to the last hour.
`
var ListPyroscopeLabelNames = mcpgrafana.MustTool(
"list_pyroscope_label_names",
listPyroscopeLabelNamesToolPrompt,
listPyroscopeLabelNames,
mcp.WithTitleAnnotation("List Pyroscope label names"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type ListPyroscopeLabelNamesParams struct {
DataSourceUID string `json:"data_source_uid" jsonschema:"required,description=The UID of the datasource to query"`
Matchers string `json:"matchers,omitempty" jsonschema:"Prometheus style matchers used t0 filter the result set (defaults to: {})"`
StartRFC3339 string `json:"start_rfc_3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format (defaults to 1 hour ago)"`
EndRFC3339 string `json:"end_rfc_3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format (defaults to now)"`
}
func listPyroscopeLabelNames(ctx context.Context, args ListPyroscopeLabelNamesParams) ([]string, error) {
args.Matchers = stringOrDefault(args.Matchers, "{}")
start, err := rfc3339OrDefault(args.StartRFC3339, time.Time{})
if err != nil {
return nil, fmt.Errorf("failed to parse start timestamp %q: %w", args.StartRFC3339, err)
}
end, err := rfc3339OrDefault(args.EndRFC3339, time.Time{})
if err != nil {
return nil, fmt.Errorf("failed to parse end timestamp %q: %w", args.EndRFC3339, err)
}
start, end, err = validateTimeRange(start, end)
if err != nil {
return nil, err
}
client, err := newPyroscopeClient(ctx, args.DataSourceUID)
if err != nil {
return nil, fmt.Errorf("failed to create Pyroscope client: %w", err)
}
req := &typesv1.LabelNamesRequest{
Matchers: []string{args.Matchers},
Start: start.UnixMilli(),
End: end.UnixMilli(),
}
res, err := client.LabelNames(ctx, connect.NewRequest(req))
if err != nil {
return nil, fmt.Errorf("failed to call Pyroscope API: %w", err)
}
return res.Msg.Names, nil
}
const listPyroscopeLabelValuesToolPrompt = `
Lists all available label values for a particular label name found in profiles within a specified Pyroscope datasource,
time range, and optional label matchers. Label matchers are typically used to qualify a service name ({service_name="foo"}).
Returns a list of unique label strings (e.g. for label name "env": ["dev", "staging", "prod"]). If the time range
is not provided, it defaults to the last hour.
`
var ListPyroscopeLabelValues = mcpgrafana.MustTool(
"list_pyroscope_label_values",
listPyroscopeLabelValuesToolPrompt,
listPyroscopeLabelValues,
mcp.WithTitleAnnotation("List Pyroscope label values"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type ListPyroscopeLabelValuesParams struct {
DataSourceUID string `json:"data_source_uid" jsonschema:"required,description=The UID of the datasource to query"`
Name string `json:"name" jsonschema:"required,description=A label name"`
Matchers string `json:"matchers,omitempty" jsonschema:"description=Optionally\\, Prometheus style matchers used to filter the result set (defaults to: {})"`
StartRFC3339 string `json:"start_rfc_3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format (defaults to 1 hour ago)"`
EndRFC3339 string `json:"end_rfc_3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format (defaults to now)"`
}
func listPyroscopeLabelValues(ctx context.Context, args ListPyroscopeLabelValuesParams) ([]string, error) {
args.Name = strings.TrimSpace(args.Name)
if args.Name == "" {
return nil, fmt.Errorf("name is required")
}
args.Matchers = stringOrDefault(args.Matchers, "{}")
start, err := rfc3339OrDefault(args.StartRFC3339, time.Time{})
if err != nil {
return nil, fmt.Errorf("failed to parse start timestamp %q: %w", args.StartRFC3339, err)
}
end, err := rfc3339OrDefault(args.EndRFC3339, time.Time{})
if err != nil {
return nil, fmt.Errorf("failed to parse end timestamp %q: %w", args.EndRFC3339, err)
}
start, end, err = validateTimeRange(start, end)
if err != nil {
return nil, err
}
client, err := newPyroscopeClient(ctx, args.DataSourceUID)
if err != nil {
return nil, fmt.Errorf("failed to create Pyroscope client: %w", err)
}
req := &typesv1.LabelValuesRequest{
Name: args.Name,
Matchers: []string{args.Matchers},
Start: start.UnixMilli(),
End: end.UnixMilli(),
}
res, err := client.LabelValues(ctx, connect.NewRequest(req))
if err != nil {
return nil, fmt.Errorf("failed to call Pyroscope API: %w", err)
}
return res.Msg.Names, nil
}
const listPyroscopeProfileTypesToolPrompt = `
Lists all available profile types available in a specified Pyroscope datasource and time range. Returns a list of all
available profile types (example profile type: "process_cpu:cpu:nanoseconds:cpu:nanoseconds"). A profile type has the
following structure: <name>:<sample type>:<sample unit>:<period type>:<period unit>. Not all profile types are available
for every service. If the time range is not provided, it defaults to the last hour.
`
var ListPyroscopeProfileTypes = mcpgrafana.MustTool(
"list_pyroscope_profile_types",
listPyroscopeProfileTypesToolPrompt,
listPyroscopeProfileTypes,
mcp.WithTitleAnnotation("List Pyroscope profile types"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type ListPyroscopeProfileTypesParams struct {
DataSourceUID string `json:"data_source_uid" jsonschema:"required,description=The UID of the datasource to query"`
StartRFC3339 string `json:"start_rfc_3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format (defaults to 1 hour ago)"`
EndRFC3339 string `json:"end_rfc_3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format (defaults to now)"`
}
func listPyroscopeProfileTypes(ctx context.Context, args ListPyroscopeProfileTypesParams) ([]string, error) {
start, err := rfc3339OrDefault(args.StartRFC3339, time.Time{})
if err != nil {
return nil, fmt.Errorf("failed to parse start timestamp %q: %w", args.StartRFC3339, err)
}
end, err := rfc3339OrDefault(args.EndRFC3339, time.Time{})
if err != nil {
return nil, fmt.Errorf("failed to parse end timestamp %q: %w", args.EndRFC3339, err)
}
start, end, err = validateTimeRange(start, end)
if err != nil {
return nil, err
}
client, err := newPyroscopeClient(ctx, args.DataSourceUID)
if err != nil {
return nil, fmt.Errorf("failed to create Pyroscope client: %w", err)
}
req := &querierv1.ProfileTypesRequest{
Start: start.UnixMilli(),
End: end.UnixMilli(),
}
res, err := client.ProfileTypes(ctx, connect.NewRequest(req))
if err != nil {
return nil, fmt.Errorf("failed to call Pyroscope API: %w", err)
}
profileTypes := make([]string, len(res.Msg.ProfileTypes))
for i, typ := range res.Msg.ProfileTypes {
profileTypes[i] = fmt.Sprintf("%s:%s:%s:%s:%s", typ.Name, typ.SampleType, typ.SampleUnit, typ.PeriodType, typ.PeriodUnit)
}
return profileTypes, nil
}
const fetchPyroscopeProfileToolPrompt = `
Fetches a profile from a Pyroscope data source for a given time range. By default, the time range is tha past 1 hour.
The profile type is required, available profile types can be fetched via the list_pyroscope_profile_types tool. Not all
profile types are available for every service. Expect some queries to return empty result sets, this indicates the
profile type does not exist for that query. In such a case, consider trying a related profile type or giving up.
Matchers are not required, but highly recommended, they are generally used to select an application by the service_name
label (e.g. {service_name="foo"}). Use the list_pyroscope_label_names tool to fetch available label names, and the
list_pyroscope_label_values tool to fetch available label values. The returned profile is in DOT format.
`
var FetchPyroscopeProfile = mcpgrafana.MustTool(
"fetch_pyroscope_profile",
fetchPyroscopeProfileToolPrompt,
fetchPyroscopeProfile,
mcp.WithTitleAnnotation("Fetch Pyroscope profile"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type FetchPyroscopeProfileParams struct {
DataSourceUID string `json:"data_source_uid" jsonschema:"required,description=The UID of the datasource to query"`
ProfileType string `json:"profile_type" jsonschema:"required,description=Type profile type\\, use the list_pyroscope_profile_types tool to fetch available profile types"`
Matchers string `json:"matchers,omitempty" jsonschema:"description=Optionally\\, Prometheus style matchers used to filter the result set (defaults to: {})"`
MaxNodeDepth int `json:"max_node_depth,omitempty" jsonschema:"description=Optionally\\, the maximum depth of nodes in the resulting profile. Less depth results in smaller profiles that execute faster\\, more depth result in larger profiles that have more detail. A value of -1 indicates to use an unbounded node depth (default: 100). Reducing max node depth from the default will negatively impact the accuracy of the profile"`
StartRFC3339 string `json:"start_rfc_3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format (defaults to 1 hour ago)"`
EndRFC3339 string `json:"end_rfc_3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format (defaults to now)"`
}
func fetchPyroscopeProfile(ctx context.Context, args FetchPyroscopeProfileParams) (string, error) {
args.Matchers = stringOrDefault(args.Matchers, "{}")
matchersRegex := regexp.MustCompile(`^\{.*\}$`)
if !matchersRegex.MatchString(args.Matchers) {
args.Matchers = fmt.Sprintf("{%s}", args.Matchers)
}
args.MaxNodeDepth = intOrDefault(args.MaxNodeDepth, 100)
start, err := rfc3339OrDefault(args.StartRFC3339, time.Time{})
if err != nil {
return "", fmt.Errorf("failed to parse start timestamp %q: %w", args.StartRFC3339, err)
}
end, err := rfc3339OrDefault(args.EndRFC3339, time.Time{})
if err != nil {
return "", fmt.Errorf("failed to parse end timestamp %q: %w", args.EndRFC3339, err)
}
start, end, err = validateTimeRange(start, end)
if err != nil {
return "", err
}
client, err := newPyroscopeClient(ctx, args.DataSourceUID)
if err != nil {
return "", fmt.Errorf("failed to create Pyroscope client: %w", err)
}
req := &renderRequest{
ProfileType: args.ProfileType,
Matcher: args.Matchers,
Start: start,
End: end,
Format: "dot",
MaxNodes: args.MaxNodeDepth,
}
res, err := client.Render(ctx, req)
if err != nil {
return "", fmt.Errorf("failed to call Pyroscope API: %w", err)
}
res = cleanupDotProfile(res)
return res, nil
}
func newPyroscopeClient(ctx context.Context, uid string) (*pyroscopeClient, error) {
cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
var transport http.RoundTripper = NewAuthRoundTripper(http.DefaultTransport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth)
transport = mcpgrafana.NewOrgIDRoundTripper(transport, cfg.OrgID)
httpClient := &http.Client{
Transport: mcpgrafana.NewUserAgentTransport(
transport,
),
Timeout: 10 * time.Second,
}
_, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{UID: uid})
if err != nil {
return nil, err
}
base, err := url.Parse(cfg.URL)
if err != nil {
return nil, fmt.Errorf("failed to parse base url: %w", err)
}
base = base.JoinPath("api", "datasources", "proxy", "uid", uid)
querierClient := querierv1connect.NewQuerierServiceClient(httpClient, base.String())
client := &pyroscopeClient{
QuerierServiceClient: querierClient,
http: httpClient,
base: base,
}
return client, nil
}
type renderRequest struct {
ProfileType string
Matcher string
Start time.Time
End time.Time
Format string
MaxNodes int
}
type pyroscopeClient struct {
querierv1connect.QuerierServiceClient
http *http.Client
base *url.URL
}
// Calls the /render endpoint for Pyroscope. This returns a rendered flame graph
// (typically in Flamebearer or DOT formats).
func (c *pyroscopeClient) Render(ctx context.Context, args *renderRequest) (string, error) {
params := url.Values{}
params.Add("query", fmt.Sprintf("%s%s", args.ProfileType, args.Matcher))
params.Add("from", fmt.Sprintf("%d", args.Start.UnixMilli()))
params.Add("until", fmt.Sprintf("%d", args.End.UnixMilli()))
params.Add("format", args.Format)
params.Add("max-nodes", fmt.Sprintf("%d", args.MaxNodes))
res, err := c.get(ctx, "/pyroscope/render", params)
if err != nil {
return "", err
}
return string(res), nil
}
func (c *pyroscopeClient) get(ctx context.Context, path string, params url.Values) ([]byte, error) {
u := c.base.JoinPath(path)
q := u.Query()
for k, vs := range params {
for _, v := range vs {
q.Add(k, v)
}
}
u.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return nil, fmt.Errorf("failed to create GET request: %w", err)
}
res, err := c.http.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer func() {
_ = res.Body.Close() //nolint:errcheck
}()
if res.StatusCode < 200 || res.StatusCode > 299 {
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("pyroscope API failed with status code %d", res.StatusCode)
}
return nil, fmt.Errorf("pyroscope API failed with status code %d: %s", res.StatusCode, string(body))
}
const limit = 1 << 25 // 32 MiB
body, err := io.ReadAll(io.LimitReader(res.Body, limit))
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("pyroscope API returned an empty response")
}
if strings.Contains(string(body), "Showing nodes accounting for 0, 0% of 0 total") {
return nil, fmt.Errorf("pyroscope API returned a empty profile")
}
return body, nil
}
func intOrDefault(n int, def int) int {
if n == 0 {
return def
}
return n
}
func stringOrDefault(s string, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func rfc3339OrDefault(s string, def time.Time) (time.Time, error) {
s = strings.TrimSpace(s)
var err error
if s != "" {
def, err = time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}, err
}
}
return def, nil
}
func validateTimeRange(start time.Time, end time.Time) (time.Time, time.Time, error) {
if end.IsZero() {
end = time.Now()
}
if start.IsZero() {
start = end.Add(-1 * time.Hour)
}
if start.After(end) || start.Equal(end) {
return time.Time{}, time.Time{}, fmt.Errorf("start timestamp %q must be strictly before end timestamp %q", start.Format(time.RFC3339), end.Format(time.RFC3339))
}
return start, end, nil
}
var cleanupRegex = regexp.MustCompile(`(?m)(fontsize=\d+ )|(id="node\d+" )|(labeltooltip=".*?\)" )|(tooltip=".*?\)" )|(N\d+ -> N\d+).*|(N\d+ \[label="other.*\n)|(shape=box )|(fillcolor="#\w{6}")|(color="#\w{6}" )`)
func cleanupDotProfile(profile string) string {
return cleanupRegex.ReplaceAllStringFunc(profile, func(match string) string {
// Preserve edge labels (e.g., "N1 -> N2")
if m := regexp.MustCompile(`^N\d+ -> N\d+`).FindString(match); m != "" {
return m
}
return ""
})
}
```
--------------------------------------------------------------------------------
/tools/oncall.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"encoding/json"
"fmt"
"net/http"
"reflect"
"strconv"
"strings"
aapi "github.com/grafana/amixr-api-go-client"
mcpgrafana "github.com/grafana/mcp-grafana"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// getOnCallURLFromSettings retrieves the OnCall API URL from the Grafana settings endpoint.
// It makes a GET request to <grafana-url>/api/plugins/grafana-irm-app/settings and extracts
// the OnCall URL from the jsonData.onCallApiUrl field in the response.
// Returns the OnCall URL if found, or an error if the URL cannot be retrieved.
func getOnCallURLFromSettings(ctx context.Context, cfg mcpgrafana.GrafanaConfig) (string, error) {
settingsURL := fmt.Sprintf("%s/api/plugins/grafana-irm-app/settings", strings.TrimRight(cfg.URL, "/"))
req, err := http.NewRequestWithContext(ctx, "GET", settingsURL, nil)
if err != nil {
return "", fmt.Errorf("creating settings request: %w", err)
}
if cfg.APIKey != "" {
req.Header.Set("Authorization", "Bearer "+cfg.APIKey)
} else if cfg.BasicAuth != nil {
password, _ := cfg.BasicAuth.Password()
req.SetBasicAuth(cfg.BasicAuth.Username(), password)
}
// Add org ID header for multi-org support
if cfg.OrgID > 0 {
req.Header.Set("X-Scope-OrgId", strconv.FormatInt(cfg.OrgID, 10))
}
// Add user agent for tracking
req.Header.Set("User-Agent", mcpgrafana.UserAgent())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("fetching settings: %w", err)
}
defer func() {
_ = resp.Body.Close() //nolint:errcheck
}()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status code from settings API: %d", resp.StatusCode)
}
var settings struct {
JSONData struct {
OnCallAPIURL string `json:"onCallApiUrl"`
} `json:"jsonData"`
}
if err := json.NewDecoder(resp.Body).Decode(&settings); err != nil {
return "", fmt.Errorf("decoding settings response: %w", err)
}
if settings.JSONData.OnCallAPIURL == "" {
return "", fmt.Errorf("OnCall API URL is not set in settings")
}
return settings.JSONData.OnCallAPIURL, nil
}
func oncallClientFromContext(ctx context.Context) (*aapi.Client, error) {
// Get the standard Grafana URL and API key
cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
// Try to get OnCall URL from settings endpoint
grafanaOnCallURL, err := getOnCallURLFromSettings(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("getting OnCall URL from settings: %w", err)
}
grafanaOnCallURL = strings.TrimRight(grafanaOnCallURL, "/")
// TODO: Allow access to OnCall using an access token instead of an API key.
client, err := aapi.NewWithGrafanaURL(grafanaOnCallURL, cfg.APIKey, cfg.URL)
if err != nil {
return nil, fmt.Errorf("creating OnCall client: %w", err)
}
// Try to customize the HTTP client with user agent using reflection
// since the OnCall client doesn't expose its HTTP client directly
clientValue := reflect.ValueOf(client)
if clientValue.Kind() == reflect.Ptr && !clientValue.IsNil() {
clientValue = clientValue.Elem()
if clientValue.Kind() == reflect.Struct {
httpClientField := clientValue.FieldByName("HTTPClient")
if !httpClientField.IsValid() {
// Try alternative field names
httpClientField = clientValue.FieldByName("HttpClient")
}
if !httpClientField.IsValid() {
httpClientField = clientValue.FieldByName("Client")
}
if httpClientField.IsValid() && httpClientField.CanSet() {
if httpClient, ok := httpClientField.Interface().(*http.Client); ok {
// Wrap the transport with user agent
if httpClient.Transport == nil {
httpClient.Transport = http.DefaultTransport
}
httpClient.Transport = mcpgrafana.NewUserAgentTransport(
httpClient.Transport,
)
}
}
}
}
return client, nil
}
// getUserServiceFromContext creates a new UserService using the OnCall client from the context
func getUserServiceFromContext(ctx context.Context) (*aapi.UserService, error) {
client, err := oncallClientFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall client: %w", err)
}
return aapi.NewUserService(client), nil
}
// getScheduleServiceFromContext creates a new ScheduleService using the OnCall client from the context
func getScheduleServiceFromContext(ctx context.Context) (*aapi.ScheduleService, error) {
client, err := oncallClientFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall client: %w", err)
}
return aapi.NewScheduleService(client), nil
}
// getTeamServiceFromContext creates a new TeamService using the OnCall client from the context
func getTeamServiceFromContext(ctx context.Context) (*aapi.TeamService, error) {
client, err := oncallClientFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall client: %w", err)
}
return aapi.NewTeamService(client), nil
}
// getOnCallShiftServiceFromContext creates a new OnCallShiftService using the OnCall client from the context
func getOnCallShiftServiceFromContext(ctx context.Context) (*aapi.OnCallShiftService, error) {
client, err := oncallClientFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall client: %w", err)
}
return aapi.NewOnCallShiftService(client), nil
}
type ListOnCallSchedulesParams struct {
TeamID string `json:"teamId,omitempty" jsonschema:"description=The ID of the team to list schedules for"`
ScheduleID string `json:"scheduleId,omitempty" jsonschema:"description=The ID of the schedule to get details for. If provided\\, returns only that schedule's details"`
Page int `json:"page,omitempty" jsonschema:"description=The page number to return (1-based)"`
}
// ScheduleSummary represents a simplified view of an OnCall schedule
type ScheduleSummary struct {
ID string `json:"id" jsonschema:"description=The unique identifier of the schedule"`
Name string `json:"name" jsonschema:"description=The name of the schedule"`
TeamID string `json:"teamId" jsonschema:"description=The ID of the team this schedule belongs to"`
Timezone string `json:"timezone" jsonschema:"description=The timezone for this schedule"`
Shifts []string `json:"shifts" jsonschema:"description=List of shift IDs in this schedule"`
}
func listOnCallSchedules(ctx context.Context, args ListOnCallSchedulesParams) ([]*ScheduleSummary, error) {
scheduleService, err := getScheduleServiceFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall schedule service: %w", err)
}
if args.ScheduleID != "" {
schedule, _, err := scheduleService.GetSchedule(args.ScheduleID, &aapi.GetScheduleOptions{})
if err != nil {
return nil, fmt.Errorf("getting OnCall schedule %s: %w", args.ScheduleID, err)
}
summary := &ScheduleSummary{
ID: schedule.ID,
Name: schedule.Name,
TeamID: schedule.TeamId,
Timezone: schedule.TimeZone,
}
if schedule.Shifts != nil {
summary.Shifts = *schedule.Shifts
}
return []*ScheduleSummary{summary}, nil
}
listOptions := &aapi.ListScheduleOptions{}
if args.Page > 0 {
listOptions.Page = args.Page
}
if args.TeamID != "" {
listOptions.TeamID = args.TeamID
}
response, _, err := scheduleService.ListSchedules(listOptions)
if err != nil {
return nil, fmt.Errorf("listing OnCall schedules: %w", err)
}
// Convert schedules to summaries
summaries := make([]*ScheduleSummary, 0, len(response.Schedules))
for _, schedule := range response.Schedules {
summary := &ScheduleSummary{
ID: schedule.ID,
Name: schedule.Name,
TeamID: schedule.TeamId,
Timezone: schedule.TimeZone,
}
if schedule.Shifts != nil {
summary.Shifts = *schedule.Shifts
}
summaries = append(summaries, summary)
}
return summaries, nil
}
var ListOnCallSchedules = mcpgrafana.MustTool(
"list_oncall_schedules",
"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.",
listOnCallSchedules,
mcp.WithTitleAnnotation("List OnCall schedules"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type GetOnCallShiftParams struct {
ShiftID string `json:"shiftId" jsonschema:"required,description=The ID of the shift to get details for"`
}
func getOnCallShift(ctx context.Context, args GetOnCallShiftParams) (*aapi.OnCallShift, error) {
shiftService, err := getOnCallShiftServiceFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall shift service: %w", err)
}
shift, _, err := shiftService.GetOnCallShift(args.ShiftID, &aapi.GetOnCallShiftOptions{})
if err != nil {
return nil, fmt.Errorf("getting OnCall shift %s: %w", args.ShiftID, err)
}
return shift, nil
}
var GetOnCallShift = mcpgrafana.MustTool(
"get_oncall_shift",
"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.",
getOnCallShift,
mcp.WithTitleAnnotation("Get OnCall shift"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
// CurrentOnCallUsers represents the currently on-call users for a schedule
type CurrentOnCallUsers struct {
ScheduleID string `json:"scheduleId" jsonschema:"description=The ID of the schedule"`
ScheduleName string `json:"scheduleName" jsonschema:"description=The name of the schedule"`
Users []*aapi.User `json:"users" jsonschema:"description=List of users currently on call"`
}
type GetCurrentOnCallUsersParams struct {
ScheduleID string `json:"scheduleId" jsonschema:"required,description=The ID of the schedule to get current on-call users for"`
}
func getCurrentOnCallUsers(ctx context.Context, args GetCurrentOnCallUsersParams) (*CurrentOnCallUsers, error) {
scheduleService, err := getScheduleServiceFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall schedule service: %w", err)
}
schedule, _, err := scheduleService.GetSchedule(args.ScheduleID, &aapi.GetScheduleOptions{})
if err != nil {
return nil, fmt.Errorf("getting schedule %s: %w", args.ScheduleID, err)
}
// Create the result with the schedule info
result := &CurrentOnCallUsers{
ScheduleID: schedule.ID,
ScheduleName: schedule.Name,
Users: make([]*aapi.User, 0, len(schedule.OnCallNow)),
}
// If there are no users on call, return early
if len(schedule.OnCallNow) == 0 {
return result, nil
}
// Get the user service to fetch user details
userService, err := getUserServiceFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall user service: %w", err)
}
// Fetch details for each user currently on call
for _, userID := range schedule.OnCallNow {
user, _, err := userService.GetUser(userID, &aapi.GetUserOptions{})
if err != nil {
// Log the error but continue with other users
fmt.Printf("Error fetching user %s: %v\n", userID, err)
continue
}
result.Users = append(result.Users, user)
}
return result, nil
}
var GetCurrentOnCallUsers = mcpgrafana.MustTool(
"get_current_oncall_users",
"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.",
getCurrentOnCallUsers,
mcp.WithTitleAnnotation("Get current on-call users"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type ListOnCallTeamsParams struct {
Page int `json:"page,omitempty" jsonschema:"description=The page number to return"`
}
func listOnCallTeams(ctx context.Context, args ListOnCallTeamsParams) ([]*aapi.Team, error) {
teamService, err := getTeamServiceFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall team service: %w", err)
}
listOptions := &aapi.ListTeamOptions{}
if args.Page > 0 {
listOptions.Page = args.Page
}
response, _, err := teamService.ListTeams(listOptions)
if err != nil {
return nil, fmt.Errorf("listing OnCall teams: %w", err)
}
return response.Teams, nil
}
var ListOnCallTeams = mcpgrafana.MustTool(
"list_oncall_teams",
"List teams configured in Grafana OnCall. Returns a list of team objects with their details. Supports pagination.",
listOnCallTeams,
mcp.WithTitleAnnotation("List OnCall teams"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type ListOnCallUsersParams struct {
UserID string `json:"userId,omitempty" jsonschema:"description=The ID of the user to get details for. If provided\\, returns only that user's details"`
Username string `json:"username,omitempty" jsonschema:"description=The username to filter users by. If provided\\, returns only the user matching this username"`
Page int `json:"page,omitempty" jsonschema:"description=The page number to return"`
}
func listOnCallUsers(ctx context.Context, args ListOnCallUsersParams) ([]*aapi.User, error) {
userService, err := getUserServiceFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall user service: %w", err)
}
if args.UserID != "" {
user, _, err := userService.GetUser(args.UserID, &aapi.GetUserOptions{})
if err != nil {
return nil, fmt.Errorf("getting OnCall user %s: %w", args.UserID, err)
}
return []*aapi.User{user}, nil
}
// Otherwise, list all users
listOptions := &aapi.ListUserOptions{}
if args.Page > 0 {
listOptions.Page = args.Page
}
if args.Username != "" {
listOptions.Username = args.Username
}
response, _, err := userService.ListUsers(listOptions)
if err != nil {
return nil, fmt.Errorf("listing OnCall users: %w", err)
}
return response.Users, nil
}
var ListOnCallUsers = mcpgrafana.MustTool(
"list_oncall_users",
"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.",
listOnCallUsers,
mcp.WithTitleAnnotation("List OnCall users"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
func getAlertGroupServiceFromContext(ctx context.Context) (*aapi.AlertGroupService, error) {
client, err := oncallClientFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall client: %w", err)
}
return aapi.NewAlertGroupService(client), nil
}
type ListAlertGroupsParams struct {
Page int `json:"page,omitempty" jsonschema:"description=The page number to return"`
AlertGroupID string `json:"id,omitempty" jsonschema:"description=Filter by specific alert group ID"`
RouteID string `json:"routeId,omitempty" jsonschema:"description=Filter by route ID"`
IntegrationID string `json:"integrationId,omitempty" jsonschema:"description=Filter by integration ID"`
State string `json:"state,omitempty" jsonschema:"description=Filter by alert group state (one of: new\\, acknowledged\\, resolved\\, silenced)"`
TeamID string `json:"teamId,omitempty" jsonschema:"description=Filter by team ID"`
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')"`
Labels []string `json:"labels,omitempty" jsonschema:"description=Filter by labels in format key:value (e.g.\\, ['env:prod'\\, 'severity:high'])"`
Name string `json:"name,omitempty" jsonschema:"description=Filter by alert group name"`
}
func listAlertGroups(ctx context.Context, args ListAlertGroupsParams) ([]*aapi.AlertGroup, error) {
alertGroupService, err := getAlertGroupServiceFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall alert group service: %w", err)
}
listOptions := &aapi.ListAlertGroupOptions{}
if args.Page > 0 {
listOptions.Page = args.Page
}
if args.AlertGroupID != "" {
listOptions.AlertGroupID = args.AlertGroupID
}
if args.RouteID != "" {
listOptions.RouteID = args.RouteID
}
if args.IntegrationID != "" {
listOptions.IntegrationID = args.IntegrationID
}
if args.State != "" {
listOptions.State = args.State
}
if args.TeamID != "" {
listOptions.TeamID = args.TeamID
}
if args.StartedAt != "" {
listOptions.StartedAt = args.StartedAt
}
if len(args.Labels) > 0 {
listOptions.Labels = args.Labels
}
if args.Name != "" {
listOptions.Name = args.Name
}
response, _, err := alertGroupService.ListAlertGroups(listOptions)
if err != nil {
return nil, fmt.Errorf("listing OnCall alert groups: %w", err)
}
return response.AlertGroups, nil
}
var ListAlertGroups = mcpgrafana.MustTool(
"list_alert_groups",
"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.",
listAlertGroups,
mcp.WithTitleAnnotation("List IRM alert groups"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type GetAlertGroupParams struct {
AlertGroupID string `json:"alertGroupId" jsonschema:"required,description=The ID of the alert group to retrieve"`
}
func getAlertGroup(ctx context.Context, args GetAlertGroupParams) (*aapi.AlertGroup, error) {
alertGroupService, err := getAlertGroupServiceFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("getting OnCall alert group service: %w", err)
}
alertGroup, _, err := alertGroupService.GetAlertGroup(args.AlertGroupID)
if err != nil {
return nil, fmt.Errorf("getting OnCall alert group %s: %w", args.AlertGroupID, err)
}
return alertGroup, nil
}
var GetAlertGroup = mcpgrafana.MustTool(
"get_alert_group",
"Get a specific alert group from Grafana OnCall by its ID. Returns the full alert group details.",
getAlertGroup,
mcp.WithTitleAnnotation("Get IRM alert group"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
func AddOnCallTools(mcp *server.MCPServer) {
ListOnCallSchedules.Register(mcp)
GetOnCallShift.Register(mcp)
GetCurrentOnCallUsers.Register(mcp)
ListOnCallTeams.Register(mcp)
ListOnCallUsers.Register(mcp)
ListAlertGroups.Register(mcp)
GetAlertGroup.Register(mcp)
}
```
--------------------------------------------------------------------------------
/tools/alerting_test.go:
--------------------------------------------------------------------------------
```go
// Requires a Grafana instance running on localhost:3000,
// with alert rules configured.
// Run with `go test -tags integration`.
//go:build integration
package tools
import (
"testing"
"github.com/stretchr/testify/require"
)
const (
rule1UID = "test_alert_rule_1"
rule1Title = "Test Alert Rule 1"
rule2UID = "test_alert_rule_2"
rule2Title = "Test Alert Rule 2"
rulePausedUID = "test_alert_rule_paused"
rulePausedTitle = "Test Alert Rule (Paused)"
)
var (
rule1Labels = map[string]string{
"severity": "info",
"type": "test",
"rule": "first",
}
rule2Labels = map[string]string{
"severity": "info",
"type": "test",
"rule": "second",
}
rule3Labels = map[string]string{
"severity": "info",
"type": "test",
"rule": "third",
}
rule1 = alertRuleSummary{
UID: rule1UID,
State: "",
Title: rule1Title,
Labels: rule1Labels,
}
rule2 = alertRuleSummary{
UID: rule2UID,
State: "",
Title: rule2Title,
Labels: rule2Labels,
}
rulePaused = alertRuleSummary{
UID: rulePausedUID,
State: "",
Title: rulePausedTitle,
Labels: rule3Labels,
}
allExpectedRules = []alertRuleSummary{rule1, rule2, rulePaused}
)
// Because the state depends on the evaluation of the alert rules,
// clear it and other variable runtime fields before comparing the results
// to avoid waiting for the alerts to start firing or be in the pending state.
func clearState(rules []alertRuleSummary) []alertRuleSummary {
for i := range rules {
rules[i].State = ""
rules[i].Health = ""
rules[i].FolderUID = ""
rules[i].RuleGroup = ""
rules[i].For = ""
rules[i].LastEvaluation = ""
rules[i].Annotations = nil
}
return rules
}
func TestAlertingTools_ListAlertRules(t *testing.T) {
t.Run("list alert rules", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{})
require.NoError(t, err)
require.ElementsMatch(t, allExpectedRules, clearState(result))
})
t.Run("list alert rules with pagination", func(t *testing.T) {
ctx := newTestContext()
// Get the first page with limit 1
result1, err := listAlertRules(ctx, ListAlertRulesParams{
Limit: 1,
Page: 1,
})
require.NoError(t, err)
require.Len(t, result1, 1)
// Get the second page with limit 1
result2, err := listAlertRules(ctx, ListAlertRulesParams{
Limit: 1,
Page: 2,
})
require.NoError(t, err)
require.Len(t, result2, 1)
// Get the third page with limit 1
result3, err := listAlertRules(ctx, ListAlertRulesParams{
Limit: 1,
Page: 3,
})
require.NoError(t, err)
require.Len(t, result3, 1)
// The next page is empty
result4, err := listAlertRules(ctx, ListAlertRulesParams{
Limit: 1,
Page: 4,
})
require.NoError(t, err)
require.Empty(t, result4)
})
t.Run("list alert rules without the page and limit params", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{})
require.NoError(t, err)
require.ElementsMatch(t, allExpectedRules, clearState(result))
})
t.Run("list alert rules with selectors that match", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
LabelSelectors: []Selector{
{
Filters: []LabelMatcher{
{
Name: "severity",
Value: "info",
Type: "=",
},
},
},
},
})
require.NoError(t, err)
require.ElementsMatch(t, allExpectedRules, clearState(result))
})
t.Run("list alert rules with selectors that don't match", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
LabelSelectors: []Selector{
{
Filters: []LabelMatcher{
{
Name: "severity",
Value: "critical",
Type: "=",
},
},
},
},
})
require.NoError(t, err)
require.Empty(t, result)
})
t.Run("list alert rules with multiple selectors", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
LabelSelectors: []Selector{
{
Filters: []LabelMatcher{
{
Name: "severity",
Value: "info",
Type: "=",
},
},
},
{
Filters: []LabelMatcher{
{
Name: "rule",
Value: "second",
Type: "=",
},
},
},
},
})
require.NoError(t, err)
require.ElementsMatch(t, []alertRuleSummary{rule2}, clearState(result))
})
t.Run("list alert rules with regex matcher", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
LabelSelectors: []Selector{
{
Filters: []LabelMatcher{
{
Name: "rule",
Value: "fi.*",
Type: "=~",
},
},
},
},
})
require.NoError(t, err)
require.ElementsMatch(t, []alertRuleSummary{rule1}, clearState(result))
})
t.Run("list alert rules with selectors and pagination", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
LabelSelectors: []Selector{
{
Filters: []LabelMatcher{
{
Name: "severity",
Value: "info",
Type: "=",
},
},
},
},
Limit: 1,
Page: 1,
})
require.NoError(t, err)
require.Len(t, result, 1)
require.ElementsMatch(t, []alertRuleSummary{rule1}, clearState(result))
// Second page
result, err = listAlertRules(ctx, ListAlertRulesParams{
LabelSelectors: []Selector{
{
Filters: []LabelMatcher{
{
Name: "severity",
Value: "info",
Type: "=",
},
},
},
},
Limit: 1,
Page: 2,
})
require.NoError(t, err)
require.Len(t, result, 1)
require.ElementsMatch(t, []alertRuleSummary{rule2}, clearState(result))
})
t.Run("list alert rules with not equals operator", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
LabelSelectors: []Selector{
{
Filters: []LabelMatcher{
{
Name: "severity",
Value: "critical",
Type: "!=",
},
},
},
},
})
require.NoError(t, err)
require.ElementsMatch(t, allExpectedRules, clearState(result))
})
t.Run("list alert rules with not matches operator", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
LabelSelectors: []Selector{
{
Filters: []LabelMatcher{
{
Name: "severity",
Value: "crit.*",
Type: "!~",
},
},
},
},
})
require.NoError(t, err)
require.ElementsMatch(t, allExpectedRules, clearState(result))
})
t.Run("list alert rules with non-existent label", func(t *testing.T) {
// Equality with non-existent label should return no results
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
LabelSelectors: []Selector{
{
Filters: []LabelMatcher{
{
Name: "nonexistent",
Value: "value",
Type: "=",
},
},
},
},
})
require.NoError(t, err)
require.Empty(t, result)
})
t.Run("list alert rules with non-existent label and inequality", func(t *testing.T) {
// Inequality with non-existent label should return all results
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
LabelSelectors: []Selector{
{
Filters: []LabelMatcher{
{
Name: "nonexistent",
Value: "value",
Type: "!=",
},
},
},
},
})
require.NoError(t, err)
require.ElementsMatch(t, allExpectedRules, clearState(result))
})
t.Run("list alert rules with a limit that is larger than the number of rules", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
Limit: 1000,
Page: 1,
})
require.NoError(t, err)
require.ElementsMatch(t, allExpectedRules, clearState(result))
})
t.Run("list alert rules with a page that doesn't exist", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
Limit: 10,
Page: 1000,
})
require.NoError(t, err)
require.Empty(t, result)
})
t.Run("list alert rules with invalid page parameter", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
Page: -1,
})
require.Error(t, err)
require.Empty(t, result)
})
t.Run("list alert rules with invalid limit parameter", func(t *testing.T) {
ctx := newTestContext()
result, err := listAlertRules(ctx, ListAlertRulesParams{
Limit: -1,
})
require.Error(t, err)
require.Empty(t, result)
})
}
func TestAlertingTools_GetAlertRuleByUID(t *testing.T) {
t.Run("get running alert rule by uid", func(t *testing.T) {
ctx := newTestContext()
result, err := getAlertRuleByUID(ctx, GetAlertRuleByUIDParams{
UID: rule1UID,
})
require.NoError(t, err)
require.Equal(t, rule1UID, result.UID)
require.NotNil(t, result.Title)
require.Equal(t, rule1Title, *result.Title)
require.False(t, result.IsPaused)
})
t.Run("get paused alert rule by uid", func(t *testing.T) {
ctx := newTestContext()
result, err := getAlertRuleByUID(ctx, GetAlertRuleByUIDParams{
UID: "test_alert_rule_paused",
})
require.NoError(t, err)
require.Equal(t, rulePausedUID, result.UID)
require.NotNil(t, result.Title)
require.Equal(t, rulePausedTitle, *result.Title)
require.True(t, result.IsPaused)
})
t.Run("get alert rule with empty UID fails", func(t *testing.T) {
ctx := newTestContext()
result, err := getAlertRuleByUID(ctx, GetAlertRuleByUIDParams{
UID: "",
})
require.Nil(t, result)
require.Error(t, err)
})
t.Run("get non-existing alert rule by uid", func(t *testing.T) {
ctx := newTestContext()
result, err := getAlertRuleByUID(ctx, GetAlertRuleByUIDParams{
UID: "some-non-existing-alert-rule-uid",
})
require.Nil(t, result)
require.Error(t, err)
require.Contains(t, err.Error(), "getAlertRuleNotFound")
})
}
var (
emailType = "email"
contactPoint1 = contactPointSummary{
UID: "email1",
Name: "Email1",
Type: &emailType,
}
contactPoint2 = contactPointSummary{
UID: "email2",
Name: "Email2",
Type: &emailType,
}
contactPoint3 = contactPointSummary{
UID: "",
Name: "email receiver",
Type: &emailType,
}
allExpectedContactPoints = []contactPointSummary{contactPoint1, contactPoint2, contactPoint3}
)
func TestAlertingTools_ListContactPoints(t *testing.T) {
t.Run("list contact points", func(t *testing.T) {
ctx := newTestContext()
result, err := listContactPoints(ctx, ListContactPointsParams{})
require.NoError(t, err)
require.ElementsMatch(t, allExpectedContactPoints, result)
})
t.Run("list one contact point", func(t *testing.T) {
ctx := newTestContext()
// Get the contact points with limit 1
result1, err := listContactPoints(ctx, ListContactPointsParams{
Limit: 1,
})
require.NoError(t, err)
require.Len(t, result1, 1)
})
t.Run("list contact points with name filter", func(t *testing.T) {
ctx := newTestContext()
name := "Email1"
result, err := listContactPoints(ctx, ListContactPointsParams{
Name: &name,
})
require.NoError(t, err)
require.Len(t, result, 1)
require.Equal(t, "Email1", result[0].Name)
})
t.Run("list contact points with invalid limit parameter", func(t *testing.T) {
ctx := newTestContext()
result, err := listContactPoints(ctx, ListContactPointsParams{
Limit: -1,
})
require.Error(t, err)
require.Empty(t, result)
})
t.Run("list contact points with large limit", func(t *testing.T) {
ctx := newTestContext()
result, err := listContactPoints(ctx, ListContactPointsParams{
Limit: 1000,
})
require.NoError(t, err)
require.NotEmpty(t, result)
})
t.Run("list contact points with non-existent name filter", func(t *testing.T) {
ctx := newTestContext()
name := "NonExistentAlert"
result, err := listContactPoints(ctx, ListContactPointsParams{
Name: &name,
})
require.NoError(t, err)
require.Empty(t, result)
})
}
func TestAlertingTools_CreateAlertRule(t *testing.T) {
t.Run("create alert rule with valid parameters", func(t *testing.T) {
ctx := newTestContext()
// Sample query data that matches Grafana's expected format
sampleData := []any{
map[string]any{
"refId": "A",
"queryType": "",
"relativeTimeRange": map[string]any{
"from": 600,
"to": 0,
},
"datasourceUid": "prometheus-uid",
"model": map[string]any{
"expr": "up",
"hide": false,
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": "A",
},
},
map[string]any{
"refId": "B",
"queryType": "",
"relativeTimeRange": map[string]any{
"from": 0,
"to": 0,
},
"datasourceUid": "__expr__",
"model": map[string]any{
"conditions": []any{
map[string]any{
"evaluator": map[string]any{
"params": []any{1},
"type": "gt",
},
"operator": map[string]any{
"type": "and",
},
"query": map[string]any{
"params": []any{"A"},
},
"reducer": map[string]any{
"params": []any{},
"type": "last",
},
"type": "query",
},
},
"datasource": map[string]any{
"type": "__expr__",
"uid": "__expr__",
},
"hide": false,
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": "B",
"type": "classic_conditions",
},
},
}
testUID := "test_create_alert_rule"
params := CreateAlertRuleParams{
Title: "Test Created Alert Rule",
RuleGroup: "test-group",
FolderUID: "tests",
Condition: "B",
Data: sampleData,
NoDataState: "OK",
ExecErrState: "OK",
For: "5m",
Annotations: map[string]string{
"summary": "Test alert rule created via API",
},
Labels: map[string]string{
"team": "test-team",
},
UID: &testUID,
OrgID: 1,
}
result, err := createAlertRule(ctx, params)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, testUID, result.UID)
require.Equal(t, "Test Created Alert Rule", *result.Title)
require.Equal(t, "test-group", *result.RuleGroup)
// Clean up: delete the created rule
_, cleanupErr := deleteAlertRule(ctx, DeleteAlertRuleParams{UID: testUID})
require.NoError(t, cleanupErr)
})
t.Run("create alert rule with missing required fields", func(t *testing.T) {
ctx := newTestContext()
params := CreateAlertRuleParams{
Title: "Incomplete Rule",
// Missing other required fields
}
result, err := createAlertRule(ctx, params)
require.Error(t, err)
require.Nil(t, result)
require.Contains(t, err.Error(), "ruleGroup is required")
})
t.Run("create alert rule with empty title", func(t *testing.T) {
ctx := newTestContext()
params := CreateAlertRuleParams{
Title: "",
}
result, err := createAlertRule(ctx, params)
require.Error(t, err)
require.Nil(t, result)
require.Contains(t, err.Error(), "title is required")
})
}
func TestAlertingTools_UpdateAlertRule(t *testing.T) {
t.Run("update existing alert rule", func(t *testing.T) {
ctx := newTestContext()
// First create a rule to update
sampleData := []any{
map[string]any{
"refId": "A",
"queryType": "",
"relativeTimeRange": map[string]any{
"from": 600,
"to": 0,
},
"datasourceUid": "prometheus-uid",
"model": map[string]any{
"expr": "up",
"hide": false,
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": "A",
},
},
}
testUID := "test_update_alert_rule"
createParams := CreateAlertRuleParams{
Title: "Original Title",
RuleGroup: "test-group",
FolderUID: "tests",
Condition: "A",
Data: sampleData,
NoDataState: "OK",
ExecErrState: "OK",
For: "5m",
UID: &testUID,
OrgID: 1,
}
// Create the rule
created, err := createAlertRule(ctx, createParams)
require.NoError(t, err)
require.NotNil(t, created)
// Now update it
updateParams := UpdateAlertRuleParams{
UID: testUID,
Title: "Updated Title",
RuleGroup: "test-group",
FolderUID: "tests",
Condition: "A",
Data: sampleData,
NoDataState: "Alerting",
ExecErrState: "Alerting",
For: "10m",
Annotations: map[string]string{
"summary": "Updated alert rule",
},
Labels: map[string]string{
"team": "updated-team",
},
OrgID: 1,
}
result, err := updateAlertRule(ctx, updateParams)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, testUID, result.UID)
require.Equal(t, "Updated Title", *result.Title)
require.Equal(t, "Alerting", *result.NoDataState)
// Clean up: delete the rule
_, cleanupErr := deleteAlertRule(ctx, DeleteAlertRuleParams{UID: testUID})
require.NoError(t, cleanupErr)
})
t.Run("update non-existent alert rule", func(t *testing.T) {
ctx := newTestContext()
params := UpdateAlertRuleParams{
UID: "non-existent-uid",
Title: "Updated Title",
RuleGroup: "test-group",
FolderUID: "tests",
Condition: "A",
Data: []any{},
NoDataState: "OK",
ExecErrState: "OK",
For: "5m",
OrgID: 1,
}
result, err := updateAlertRule(ctx, params)
require.Error(t, err)
require.Nil(t, result)
})
t.Run("update alert rule with empty UID", func(t *testing.T) {
ctx := newTestContext()
params := UpdateAlertRuleParams{
UID: "",
}
result, err := updateAlertRule(ctx, params)
require.Error(t, err)
require.Nil(t, result)
require.Contains(t, err.Error(), "uid is required")
})
}
func TestAlertingTools_DeleteAlertRule(t *testing.T) {
t.Run("delete existing alert rule", func(t *testing.T) {
ctx := newTestContext()
// First create a rule to delete
sampleData := []any{
map[string]any{
"refId": "A",
"queryType": "",
"relativeTimeRange": map[string]any{
"from": 600,
"to": 0,
},
"datasourceUid": "prometheus-uid",
"model": map[string]any{
"expr": "up",
"hide": false,
"intervalMs": 1000,
"maxDataPoints": 43200,
"refId": "A",
},
},
}
testUID := "test_delete_alert_rule"
createParams := CreateAlertRuleParams{
Title: "Rule to Delete",
RuleGroup: "test-group",
FolderUID: "tests",
Condition: "A",
Data: sampleData,
NoDataState: "OK",
ExecErrState: "OK",
For: "5m",
UID: &testUID,
OrgID: 1,
}
// Create the rule
created, err := createAlertRule(ctx, createParams)
require.NoError(t, err)
require.NotNil(t, created)
// Now delete it
result, err := deleteAlertRule(ctx, DeleteAlertRuleParams{UID: testUID})
require.NoError(t, err)
require.Contains(t, result, "deleted successfully")
require.Contains(t, result, testUID)
// Verify it's gone by trying to get it
_, getErr := getAlertRuleByUID(ctx, GetAlertRuleByUIDParams{UID: testUID})
require.Error(t, getErr)
})
t.Run("delete non-existent alert rule", func(t *testing.T) {
ctx := newTestContext()
result, err := deleteAlertRule(ctx, DeleteAlertRuleParams{UID: "non-existent-uid"})
require.NoError(t, err) // DELETE is idempotent - success even if rule doesn't exist
require.Contains(t, result, "deleted successfully")
require.Contains(t, result, "non-existent-uid")
})
t.Run("delete alert rule with empty UID", func(t *testing.T) {
ctx := newTestContext()
result, err := deleteAlertRule(ctx, DeleteAlertRuleParams{UID: ""})
require.Error(t, err)
require.Empty(t, result)
require.Contains(t, err.Error(), "uid is required")
})
}
```
--------------------------------------------------------------------------------
/tools/loki.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
mcpgrafana "github.com/grafana/mcp-grafana"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
const (
// DefaultLokiLogLimit is the default number of log lines to return if not specified
DefaultLokiLogLimit = 10
// MaxLokiLogLimit is the maximum number of log lines that can be requested
MaxLokiLogLimit = 100
)
type Client struct {
httpClient *http.Client
baseURL string
}
// LabelResponse represents the http json response to a label query
type LabelResponse struct {
Status string `json:"status"`
Data []string `json:"data,omitempty"`
}
// Stats represents the statistics returned by Loki's index/stats endpoint
type Stats struct {
Streams int `json:"streams"`
Chunks int `json:"chunks"`
Entries int `json:"entries"`
Bytes int `json:"bytes"`
}
func newLokiClient(ctx context.Context, uid string) (*Client, error) {
// First check if the datasource exists
_, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{UID: uid})
if err != nil {
return nil, err
}
cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
url := fmt.Sprintf("%s/api/datasources/proxy/uid/%s", strings.TrimRight(cfg.URL, "/"), uid)
// Create custom transport with TLS configuration if available
var transport = http.DefaultTransport
if tlsConfig := cfg.TLSConfig; tlsConfig != nil {
var err error
transport, err = tlsConfig.HTTPTransport(transport.(*http.Transport))
if err != nil {
return nil, fmt.Errorf("failed to create custom transport: %w", err)
}
}
transport = NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth)
transport = mcpgrafana.NewOrgIDRoundTripper(transport, cfg.OrgID)
client := &http.Client{
Transport: mcpgrafana.NewUserAgentTransport(
transport,
),
}
return &Client{
httpClient: client,
baseURL: url,
}, nil
}
// buildURL constructs a full URL for a Loki API endpoint
func (c *Client) buildURL(urlPath string) string {
fullURL := c.baseURL
if !strings.HasSuffix(fullURL, "/") && !strings.HasPrefix(urlPath, "/") {
fullURL += "/"
} else if strings.HasSuffix(fullURL, "/") && strings.HasPrefix(urlPath, "/") {
// Remove the leading slash from urlPath to avoid double slash
urlPath = strings.TrimPrefix(urlPath, "/")
}
return fullURL + urlPath
}
// makeRequest makes an HTTP request to the Loki API and returns the response body
func (c *Client) makeRequest(ctx context.Context, method, urlPath string, params url.Values) ([]byte, error) {
fullURL := c.buildURL(urlPath)
u, err := url.Parse(fullURL)
if err != nil {
return nil, fmt.Errorf("parsing URL: %w", err)
}
if params != nil {
u.RawQuery = params.Encode()
}
req, err := http.NewRequestWithContext(ctx, method, u.String(), nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer func() {
_ = resp.Body.Close() //nolint:errcheck
}()
// Check for non-200 status code
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("loki API returned status code %d: %s", resp.StatusCode, string(bodyBytes))
}
// Read the response body with a limit to prevent memory issues
body := io.LimitReader(resp.Body, 1024*1024*48)
bodyBytes, err := io.ReadAll(body)
if err != nil {
return nil, fmt.Errorf("reading response body: %w", err)
}
// Check if the response is empty
if len(bodyBytes) == 0 {
return nil, fmt.Errorf("empty response from Loki API")
}
// Trim any whitespace that might cause JSON parsing issues
return bytes.TrimSpace(bodyBytes), nil
}
// fetchData is a generic method to fetch data from Loki API
func (c *Client) fetchData(ctx context.Context, urlPath string, startRFC3339, endRFC3339 string) ([]string, error) {
params := url.Values{}
if startRFC3339 != "" {
params.Add("start", startRFC3339)
}
if endRFC3339 != "" {
params.Add("end", endRFC3339)
}
bodyBytes, err := c.makeRequest(ctx, "GET", urlPath, params)
if err != nil {
return nil, err
}
var labelResponse LabelResponse
err = json.Unmarshal(bodyBytes, &labelResponse)
if err != nil {
return nil, fmt.Errorf("unmarshalling response (content: %s): %w", string(bodyBytes), err)
}
if labelResponse.Status != "success" {
return nil, fmt.Errorf("loki API returned unexpected response format: %s", string(bodyBytes))
}
// Check if Data is nil or empty and handle it explicitly
if labelResponse.Data == nil {
// Return empty slice instead of nil to avoid potential nil pointer issues
return []string{}, nil
}
if len(labelResponse.Data) == 0 {
return []string{}, nil
}
return labelResponse.Data, nil
}
func NewAuthRoundTripper(rt http.RoundTripper, accessToken, idToken, apiKey string, basicAuth *url.Userinfo) *authRoundTripper {
return &authRoundTripper{
accessToken: accessToken,
idToken: idToken,
apiKey: apiKey,
basicAuth: basicAuth,
underlying: rt,
}
}
type authRoundTripper struct {
accessToken string
idToken string
apiKey string
basicAuth *url.Userinfo
underlying http.RoundTripper
}
func (rt *authRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if rt.accessToken != "" && rt.idToken != "" {
req.Header.Set("X-Access-Token", rt.accessToken)
req.Header.Set("X-Grafana-Id", rt.idToken)
} else if rt.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+rt.apiKey)
} else if rt.basicAuth != nil {
password, _ := rt.basicAuth.Password()
req.SetBasicAuth(rt.basicAuth.Username(), password)
}
resp, err := rt.underlying.RoundTrip(req)
if err != nil {
return nil, err
}
return resp, nil
}
// ListLokiLabelNamesParams defines the parameters for listing Loki label names
type ListLokiLabelNamesParams struct {
DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
StartRFC3339 string `json:"startRfc3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format (defaults to 1 hour ago)"`
EndRFC3339 string `json:"endRfc3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format (defaults to now)"`
}
// listLokiLabelNames lists all label names in a Loki datasource
func listLokiLabelNames(ctx context.Context, args ListLokiLabelNamesParams) ([]string, error) {
client, err := newLokiClient(ctx, args.DatasourceUID)
if err != nil {
return nil, fmt.Errorf("creating Loki client: %w", err)
}
result, err := client.fetchData(ctx, "/loki/api/v1/labels", args.StartRFC3339, args.EndRFC3339)
if err != nil {
return nil, err
}
if len(result) == 0 {
return []string{}, nil
}
return result, nil
}
// ListLokiLabelNames is a tool for listing Loki label names
var ListLokiLabelNames = mcpgrafana.MustTool(
"list_loki_label_names",
"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.",
listLokiLabelNames,
mcp.WithTitleAnnotation("List Loki label names"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
// ListLokiLabelValuesParams defines the parameters for listing Loki label values
type ListLokiLabelValuesParams struct {
DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
LabelName string `json:"labelName" jsonschema:"required,description=The name of the label to retrieve values for (e.g. 'app'\\, 'env'\\, 'pod')"`
StartRFC3339 string `json:"startRfc3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format (defaults to 1 hour ago)"`
EndRFC3339 string `json:"endRfc3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format (defaults to now)"`
}
// listLokiLabelValues lists all values for a specific label in a Loki datasource
func listLokiLabelValues(ctx context.Context, args ListLokiLabelValuesParams) ([]string, error) {
client, err := newLokiClient(ctx, args.DatasourceUID)
if err != nil {
return nil, fmt.Errorf("creating Loki client: %w", err)
}
// Use the client's fetchData method
urlPath := fmt.Sprintf("/loki/api/v1/label/%s/values", args.LabelName)
result, err := client.fetchData(ctx, urlPath, args.StartRFC3339, args.EndRFC3339)
if err != nil {
return nil, err
}
if len(result) == 0 {
// Return empty slice instead of nil
return []string{}, nil
}
return result, nil
}
// ListLokiLabelValues is a tool for listing Loki label values
var ListLokiLabelValues = mcpgrafana.MustTool(
"list_loki_label_values",
"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.",
listLokiLabelValues,
mcp.WithTitleAnnotation("List Loki label values"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
// LogStream represents a stream of log entries from Loki
type LogStream struct {
Stream map[string]string `json:"stream"`
Values [][]json.RawMessage `json:"values"` // [timestamp, value] where value can be string or number
}
// QueryRangeResponse represents the response from Loki's query_range API
type QueryRangeResponse struct {
Status string `json:"status"`
Data struct {
ResultType string `json:"resultType"`
Result []LogStream `json:"result"`
} `json:"data"`
}
// addTimeRangeParams adds start and end time parameters to the URL values
// It handles conversion from RFC3339 to Unix nanoseconds
func addTimeRangeParams(params url.Values, startRFC3339, endRFC3339 string) error {
if startRFC3339 != "" {
startTime, err := time.Parse(time.RFC3339, startRFC3339)
if err != nil {
return fmt.Errorf("parsing start time: %w", err)
}
params.Add("start", fmt.Sprintf("%d", startTime.UnixNano()))
}
if endRFC3339 != "" {
endTime, err := time.Parse(time.RFC3339, endRFC3339)
if err != nil {
return fmt.Errorf("parsing end time: %w", err)
}
params.Add("end", fmt.Sprintf("%d", endTime.UnixNano()))
}
return nil
}
// getDefaultTimeRange returns default start and end times if not provided
// Returns start time (1 hour ago) and end time (now) in RFC3339 format
func getDefaultTimeRange(startRFC3339, endRFC3339 string) (string, string) {
if startRFC3339 == "" {
// Default to 1 hour ago if not specified
startRFC3339 = time.Now().Add(-1 * time.Hour).Format(time.RFC3339)
}
if endRFC3339 == "" {
// Default to now if not specified
endRFC3339 = time.Now().Format(time.RFC3339)
}
return startRFC3339, endRFC3339
}
// fetchLogs is a method to fetch logs from Loki API
func (c *Client) fetchLogs(ctx context.Context, query, startRFC3339, endRFC3339 string, limit int, direction string) ([]LogStream, error) {
params := url.Values{}
params.Add("query", query)
// Add time range parameters
if err := addTimeRangeParams(params, startRFC3339, endRFC3339); err != nil {
return nil, err
}
if limit > 0 {
params.Add("limit", fmt.Sprintf("%d", limit))
}
if direction != "" {
params.Add("direction", direction)
}
bodyBytes, err := c.makeRequest(ctx, "GET", "/loki/api/v1/query_range", params)
if err != nil {
return nil, err
}
var queryResponse QueryRangeResponse
err = json.Unmarshal(bodyBytes, &queryResponse)
if err != nil {
return nil, fmt.Errorf("unmarshalling response (content: %s): %w", string(bodyBytes), err)
}
if queryResponse.Status != "success" {
return nil, fmt.Errorf("loki API returned unexpected response format: %s", string(bodyBytes))
}
return queryResponse.Data.Result, nil
}
// QueryLokiLogsParams defines the parameters for querying Loki logs
type QueryLokiLogsParams struct {
DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
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."`
StartRFC3339 string `json:"startRfc3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format"`
EndRFC3339 string `json:"endRfc3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format"`
Limit int `json:"limit,omitempty" jsonschema:"description=Optionally\\, the maximum number of log lines to return (default: 10\\, max: 100)"`
Direction string `json:"direction,omitempty" jsonschema:"description=Optionally\\, the direction of the query: 'forward' (oldest first) or 'backward' (newest first\\, default)"`
}
// LogEntry represents a single log entry or metric sample with metadata
type LogEntry struct {
Timestamp string `json:"timestamp"`
Line string `json:"line,omitempty"` // For log queries
Value *float64 `json:"value,omitempty"` // For metric queries
Labels map[string]string `json:"labels"`
}
// enforceLogLimit ensures a log limit value is within acceptable bounds
func enforceLogLimit(requestedLimit int) int {
if requestedLimit <= 0 {
return DefaultLokiLogLimit
}
if requestedLimit > MaxLokiLogLimit {
return MaxLokiLogLimit
}
return requestedLimit
}
// queryLokiLogs queries logs from a Loki datasource using LogQL
func queryLokiLogs(ctx context.Context, args QueryLokiLogsParams) ([]LogEntry, error) {
client, err := newLokiClient(ctx, args.DatasourceUID)
if err != nil {
return nil, fmt.Errorf("creating Loki client: %w", err)
}
// Get default time range if not provided
startTime, endTime := getDefaultTimeRange(args.StartRFC3339, args.EndRFC3339)
// Apply limit constraints
limit := enforceLogLimit(args.Limit)
// Set default direction if not provided
direction := args.Direction
if direction == "" {
direction = "backward" // Most recent logs first
}
streams, err := client.fetchLogs(ctx, args.LogQL, startTime, endTime, limit, direction)
if err != nil {
return nil, err
}
// Handle empty results
if len(streams) == 0 {
return []LogEntry{}, nil
}
// Convert the streams to a flat list of log entries
var entries []LogEntry
for _, stream := range streams {
for _, value := range stream.Values {
if len(value) >= 2 {
entry := LogEntry{
Timestamp: string(value[0]),
Labels: stream.Stream,
}
// Handle metric queries (numeric values) vs log queries
if stream.Stream["__type__"] == "metrics" {
// For metric queries, parse the value as a number
var numStr string
if err := json.Unmarshal(value[1], &numStr); err == nil {
if v, err := strconv.ParseFloat(numStr, 64); err == nil {
entry.Value = &v
} else {
// Skip invalid numeric values
continue
}
} else {
// Try direct number parsing if string parsing fails
var v float64
if err := json.Unmarshal(value[1], &v); err == nil {
entry.Value = &v
} else {
// Skip invalid values
continue
}
}
} else {
// For log queries, parse the value as a string
var logLine string
if err := json.Unmarshal(value[1], &logLine); err == nil {
entry.Line = logLine
} else {
// Skip invalid log lines
continue
}
}
entries = append(entries, entry)
}
}
}
// If we processed all streams but still have no entries, return an empty slice
if len(entries) == 0 {
return []LogEntry{}, nil
}
return entries, nil
}
// QueryLokiLogs is a tool for querying logs from Loki
var QueryLokiLogs = mcpgrafana.MustTool(
"query_loki_logs",
"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.",
queryLokiLogs,
mcp.WithTitleAnnotation("Query Loki logs"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
// fetchStats is a method to fetch stats data from Loki API
func (c *Client) fetchStats(ctx context.Context, query, startRFC3339, endRFC3339 string) (*Stats, error) {
params := url.Values{}
params.Add("query", query)
// Add time range parameters
if err := addTimeRangeParams(params, startRFC3339, endRFC3339); err != nil {
return nil, err
}
bodyBytes, err := c.makeRequest(ctx, "GET", "/loki/api/v1/index/stats", params)
if err != nil {
return nil, err
}
var stats Stats
err = json.Unmarshal(bodyBytes, &stats)
if err != nil {
return nil, fmt.Errorf("unmarshalling response (content: %s): %w", string(bodyBytes), err)
}
return &stats, nil
}
// QueryLokiStatsParams defines the parameters for querying Loki stats
type QueryLokiStatsParams struct {
DatasourceUID string `json:"datasourceUid" jsonschema:"required,description=The UID of the datasource to query"`
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."`
StartRFC3339 string `json:"startRfc3339,omitempty" jsonschema:"description=Optionally\\, the start time of the query in RFC3339 format"`
EndRFC3339 string `json:"endRfc3339,omitempty" jsonschema:"description=Optionally\\, the end time of the query in RFC3339 format"`
}
// queryLokiStats queries stats from a Loki datasource using LogQL
func queryLokiStats(ctx context.Context, args QueryLokiStatsParams) (*Stats, error) {
client, err := newLokiClient(ctx, args.DatasourceUID)
if err != nil {
return nil, fmt.Errorf("creating Loki client: %w", err)
}
// Get default time range if not provided
startTime, endTime := getDefaultTimeRange(args.StartRFC3339, args.EndRFC3339)
stats, err := client.fetchStats(ctx, args.LogQL, startTime, endTime)
if err != nil {
return nil, err
}
return stats, nil
}
// QueryLokiStats is a tool for querying stats from Loki
var QueryLokiStats = mcpgrafana.MustTool(
"query_loki_stats",
"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.",
queryLokiStats,
mcp.WithTitleAnnotation("Get Loki log statistics"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
// AddLokiTools registers all Loki tools with the MCP server
func AddLokiTools(mcp *server.MCPServer) {
ListLokiLabelNames.Register(mcp)
ListLokiLabelValues.Register(mcp)
QueryLokiStats.Register(mcp)
QueryLokiLogs.Register(mcp)
}
```
--------------------------------------------------------------------------------
/tools/sift.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
mcpgrafana "github.com/grafana/mcp-grafana"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
type investigationStatus string
const (
investigationStatusPending investigationStatus = "pending"
investigationStatusRunning investigationStatus = "running"
investigationStatusFinished investigationStatus = "finished"
investigationStatusFailed investigationStatus = "failed"
)
// errorPatternLogExampleLimit controls how many log examples are fetched per error pattern.
const errorPatternLogExampleLimit = 3
type analysisStatus string
type investigationRequest struct {
AlertLabels map[string]string `json:"alertLabels,omitempty"`
Labels map[string]string `json:"labels"`
Start time.Time `json:"start"`
End time.Time `json:"end"`
QueryURL string `json:"queryUrl"`
Checks []string `json:"checks"`
}
// Interesting: The analysis complete with results that indicate a probable cause for failure.
type analysisResult struct {
Successful bool `json:"successful"`
Interesting bool `json:"interesting"`
Message string `json:"message"`
Details map[string]any `json:"details"`
}
type analysisMeta struct {
Items []analysis `json:"items"`
}
// An analysis struct provides the status and results
// of running a specific type of check.
type analysis struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created"`
UpdatedAt time.Time `json:"modified"`
Status analysisStatus `json:"status"`
StartedAt *time.Time `json:"started"`
// Foreign key to the Investigation that created this Analysis.
InvestigationID uuid.UUID `json:"investigationId"`
// Name is the name of the check that this analysis represents.
Name string `json:"name"`
Title string `json:"title"`
Result analysisResult `json:"result"`
}
type InvestigationDatasources struct {
LokiDatasource struct {
UID string `json:"uid"`
} `json:"lokiDatasource"`
}
type Investigation struct {
ID uuid.UUID `json:"id"`
CreatedAt time.Time `json:"created"`
UpdatedAt time.Time `json:"modified"`
TenantID string `json:"tenantId"`
Name string `json:"name"`
// GrafanaURL is the Grafana URL to be used for datasource queries
// for this investigation.
GrafanaURL string `json:"grafanaUrl"`
// Status describes the state of the investigation (pending, running, failed, or finished).
Status investigationStatus `json:"status"`
// FailureReason is a short human-friendly string that explains the reason that the
// investigation failed.
FailureReason string `json:"failureReason,omitempty"`
Analyses analysisMeta `json:"analyses"`
Datasources InvestigationDatasources `json:"datasources"`
}
// siftClient represents a client for interacting with the Sift API.
type siftClient struct {
client *http.Client
url string
}
func newSiftClient(cfg mcpgrafana.GrafanaConfig) (*siftClient, error) {
// Create custom transport with TLS configuration if available
var transport = http.DefaultTransport
if tlsConfig := cfg.TLSConfig; tlsConfig != nil {
var err error
transport, err = tlsConfig.HTTPTransport(transport.(*http.Transport))
if err != nil {
return nil, fmt.Errorf("failed to create custom transport: %w", err)
}
}
transport = NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth)
transport = mcpgrafana.NewOrgIDRoundTripper(transport, cfg.OrgID)
client := &http.Client{
Transport: transport,
}
return &siftClient{
client: client,
url: cfg.URL,
}, nil
}
func siftClientFromContext(ctx context.Context) (*siftClient, error) {
// Get the standard Grafana URL and API key
cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
client, err := newSiftClient(cfg)
if err != nil {
return nil, fmt.Errorf("creating Sift client: %w", err)
}
return client, nil
}
// checkType represents the type of analysis check to perform.
type checkType string
const (
checkTypeErrorPatternLogs checkType = "ErrorPatternLogs"
checkTypeSlowRequests checkType = "SlowRequests"
)
// GetSiftInvestigationParams defines the parameters for retrieving an investigation
type GetSiftInvestigationParams struct {
ID string `json:"id" jsonschema:"required,description=The UUID of the investigation as a string (e.g. '02adab7c-bf5b-45f2-9459-d71a2c29e11b')"`
}
// getSiftInvestigation retrieves an existing investigation
func getSiftInvestigation(ctx context.Context, args GetSiftInvestigationParams) (*Investigation, error) {
client, err := siftClientFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("creating Sift client: %w", err)
}
// Parse the UUID string
id, err := uuid.Parse(args.ID)
if err != nil {
return nil, fmt.Errorf("invalid investigation ID format: %w", err)
}
investigation, err := client.getSiftInvestigation(ctx, id)
if err != nil {
return nil, fmt.Errorf("getting investigation: %w", err)
}
return investigation, nil
}
// GetSiftInvestigation is a tool for retrieving an existing investigation
var GetSiftInvestigation = mcpgrafana.MustTool(
"get_sift_investigation",
"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').",
getSiftInvestigation,
mcp.WithTitleAnnotation("Get Sift investigation"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
// GetSiftAnalysisParams defines the parameters for retrieving a specific analysis
type GetSiftAnalysisParams struct {
InvestigationID string `json:"investigationId" jsonschema:"required,description=The UUID of the investigation as a string (e.g. '02adab7c-bf5b-45f2-9459-d71a2c29e11b')"`
AnalysisID string `json:"analysisId" jsonschema:"required,description=The UUID of the specific analysis to retrieve"`
}
// getSiftAnalysis retrieves a specific analysis from an investigation
func getSiftAnalysis(ctx context.Context, args GetSiftAnalysisParams) (*analysis, error) {
client, err := siftClientFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("creating Sift client: %w", err)
}
// Parse the UUID strings
investigationID, err := uuid.Parse(args.InvestigationID)
if err != nil {
return nil, fmt.Errorf("invalid investigation ID format: %w", err)
}
analysisID, err := uuid.Parse(args.AnalysisID)
if err != nil {
return nil, fmt.Errorf("invalid analysis ID format: %w", err)
}
analysis, err := client.getSiftAnalysis(ctx, investigationID, analysisID)
if err != nil {
return nil, fmt.Errorf("getting analysis: %w", err)
}
return analysis, nil
}
// GetSiftAnalysis is a tool for retrieving a specific analysis from an investigation
var GetSiftAnalysis = mcpgrafana.MustTool(
"get_sift_analysis",
"Retrieves a specific analysis from an investigation by its UUID. The investigation ID and analysis ID should be provided as strings in UUID format.",
getSiftAnalysis,
mcp.WithTitleAnnotation("Get Sift analysis"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
// ListSiftInvestigationsParams defines the parameters for retrieving investigations
type ListSiftInvestigationsParams struct {
Limit int `json:"limit,omitempty" jsonschema:"description=Maximum number of investigations to return. Defaults to 10 if not specified."`
}
// listSiftInvestigations retrieves a list of investigations with an optional limit
func listSiftInvestigations(ctx context.Context, args ListSiftInvestigationsParams) ([]Investigation, error) {
client, err := siftClientFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("creating Sift client: %w", err)
}
// Set default limit if not provided
if args.Limit <= 0 {
args.Limit = 10
}
investigations, err := client.listSiftInvestigations(ctx, args.Limit)
if err != nil {
return nil, fmt.Errorf("getting investigations: %w", err)
}
return investigations, nil
}
// ListSiftInvestigations is a tool for retrieving a list of investigations
var ListSiftInvestigations = mcpgrafana.MustTool(
"list_sift_investigations",
"Retrieves a list of Sift investigations with an optional limit. If no limit is specified, defaults to 10 investigations.",
listSiftInvestigations,
mcp.WithTitleAnnotation("List Sift investigations"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
// FindErrorPatternLogsParams defines the parameters for running an ErrorPatternLogs check
type FindErrorPatternLogsParams struct {
Name string `json:"name" jsonschema:"required,description=The name of the investigation"`
Labels map[string]string `json:"labels" jsonschema:"required,description=Labels to scope the analysis"`
Start time.Time `json:"start,omitempty" jsonschema:"description=Start time for the investigation. Defaults to 30 minutes ago if not specified."`
End time.Time `json:"end,omitempty" jsonschema:"description=End time for the investigation. Defaults to now if not specified."`
}
// findErrorPatternLogs creates an investigation with ErrorPatternLogs check, waits for it to complete, and returns the analysis
func findErrorPatternLogs(ctx context.Context, args FindErrorPatternLogsParams) (*analysis, error) {
client, err := siftClientFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("creating Sift client: %w", err)
}
// Create the investigation request with ErrorPatternLogs check
requestData := investigationRequest{
Labels: args.Labels,
Start: args.Start,
End: args.End,
Checks: []string{string(checkTypeErrorPatternLogs)},
}
investigation := &Investigation{
Name: args.Name,
GrafanaURL: client.url,
Status: investigationStatusPending,
}
// Create the investigation and wait for it to complete
completedInvestigation, err := client.createSiftInvestigation(ctx, investigation, requestData)
if err != nil {
return nil, fmt.Errorf("creating investigation: %w", err)
}
// Get all analyses from the completed investigation
slog.Debug("Getting analyses", "investigation_id", completedInvestigation.ID)
analyses, err := client.getSiftAnalyses(ctx, completedInvestigation.ID)
if err != nil {
return nil, fmt.Errorf("getting analyses: %w", err)
}
// Find the ErrorPatternLogs analysis
var errorPatternLogsAnalysis *analysis
for i := range analyses {
if analyses[i].Name == string(checkTypeErrorPatternLogs) {
errorPatternLogsAnalysis = &analyses[i]
break
}
}
if errorPatternLogsAnalysis == nil {
return nil, fmt.Errorf("ErrorPatternLogs analysis not found in investigation %s", completedInvestigation.ID)
}
slog.Debug("Found ErrorPatternLogs analysis", "analysis_id", errorPatternLogsAnalysis.ID)
datasourceUID := completedInvestigation.Datasources.LokiDatasource.UID
if errorPatternLogsAnalysis.Result.Details == nil {
// No patterns found, return the analysis without examples
return errorPatternLogsAnalysis, nil
}
for _, pattern := range errorPatternLogsAnalysis.Result.Details["patterns"].([]any) {
patternMap, ok := pattern.(map[string]any)
if !ok {
continue
}
examples, err := fetchErrorPatternLogExamples(ctx, patternMap, datasourceUID)
if err != nil {
return nil, err
}
patternMap["examples"] = examples
}
return errorPatternLogsAnalysis, nil
}
// FindErrorPatternLogs is a tool for running an ErrorPatternLogs check
var FindErrorPatternLogs = mcpgrafana.MustTool(
"find_error_pattern_logs",
"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.",
findErrorPatternLogs,
mcp.WithTitleAnnotation("Find error patterns in logs"),
mcp.WithReadOnlyHintAnnotation(true),
)
// FindSlowRequestsParams defines the parameters for running an SlowRequests check
type FindSlowRequestsParams struct {
Name string `json:"name" jsonschema:"required,description=The name of the investigation"`
Labels map[string]string `json:"labels" jsonschema:"required,description=Labels to scope the analysis"`
Start time.Time `json:"start,omitempty" jsonschema:"description=Start time for the investigation. Defaults to 30 minutes ago if not specified."`
End time.Time `json:"end,omitempty" jsonschema:"description=End time for the investigation. Defaults to now if not specified."`
}
// findSlowRequests creates an investigation with SlowRequests check, waits for it to complete, and returns the analysis
func findSlowRequests(ctx context.Context, args FindSlowRequestsParams) (*analysis, error) {
client, err := siftClientFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("creating Sift client: %w", err)
}
// Create the investigation request with SlowRequests check
requestData := investigationRequest{
Labels: args.Labels,
Start: args.Start,
End: args.End,
Checks: []string{string(checkTypeSlowRequests)},
}
investigation := &Investigation{
Name: args.Name,
GrafanaURL: client.url,
Status: investigationStatusPending,
}
// Create the investigation and wait for it to complete
completedInvestigation, err := client.createSiftInvestigation(ctx, investigation, requestData)
if err != nil {
return nil, fmt.Errorf("creating investigation: %w", err)
}
// Get all analyses from the completed investigation
analyses, err := client.getSiftAnalyses(ctx, completedInvestigation.ID)
if err != nil {
return nil, fmt.Errorf("getting analyses: %w", err)
}
// Find the SlowRequests analysis
var slowRequestsAnalysis *analysis
for i := range analyses {
if analyses[i].Name == string(checkTypeSlowRequests) {
slowRequestsAnalysis = &analyses[i]
break
}
}
if slowRequestsAnalysis == nil {
return nil, fmt.Errorf("SlowRequests analysis not found in investigation %s", completedInvestigation.ID)
}
return slowRequestsAnalysis, nil
}
// FindSlowRequests is a tool for running an SlowRequests check
var FindSlowRequests = mcpgrafana.MustTool(
"find_slow_requests",
"Searches relevant Tempo datasources for slow requests, waits for the analysis to complete, and returns the results.",
findSlowRequests,
mcp.WithTitleAnnotation("Find slow requests"),
mcp.WithReadOnlyHintAnnotation(true),
)
// AddSiftTools registers all Sift tools with the MCP server
func AddSiftTools(mcp *server.MCPServer, enableWriteTools bool) {
GetSiftInvestigation.Register(mcp)
GetSiftAnalysis.Register(mcp)
ListSiftInvestigations.Register(mcp)
if enableWriteTools {
FindErrorPatternLogs.Register(mcp)
FindSlowRequests.Register(mcp)
}
}
// makeRequest is a helper method to make HTTP requests and handle common response patterns
func (c *siftClient) makeRequest(ctx context.Context, method, path string, body []byte) ([]byte, error) {
var req *http.Request
var err error
if body != nil {
req, err = http.NewRequestWithContext(ctx, method, c.url+path, bytes.NewBuffer(body))
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
} else {
req, err = http.NewRequestWithContext(ctx, method, c.url+path, nil)
if err != nil {
return nil, fmt.Errorf("creating request: %w", err)
}
}
response, err := c.client.Do(req)
if err != nil {
return nil, fmt.Errorf("executing request: %w", err)
}
defer func() {
_ = response.Body.Close() //nolint:errcheck
}()
// Check for non-200 status code (matching Loki client's logic)
if response.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(response.Body) // Read full body on error
return nil, fmt.Errorf("API request returned status code %d: %s", response.StatusCode, string(bodyBytes))
}
// Read the response body with a limit to prevent memory issues
reader := io.LimitReader(response.Body, 1024*1024*48) // 48MB limit
buf, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
// Check if the response is empty (matching Loki client's logic)
if len(buf) == 0 {
return nil, fmt.Errorf("empty response from API")
}
// Trim any whitespace that might cause JSON parsing issues (matching Loki client's logic)
return bytes.TrimSpace(buf), nil
}
// getSiftInvestigation is a helper method to get the current status of an investigation
func (c *siftClient) getSiftInvestigation(ctx context.Context, id uuid.UUID) (*Investigation, error) {
buf, err := c.makeRequest(ctx, "GET", fmt.Sprintf("/api/plugins/grafana-ml-app/resources/sift/api/v1/investigations/%s", id), nil)
if err != nil {
return nil, err
}
investigationResponse := struct {
Status string `json:"status"`
Data Investigation `json:"data"`
}{}
if err := json.Unmarshal(buf, &investigationResponse); err != nil {
return nil, fmt.Errorf("failed to unmarshal response body: %w. body: %s", err, buf)
}
return &investigationResponse.Data, nil
}
func (c *siftClient) createSiftInvestigation(ctx context.Context, investigation *Investigation, requestData investigationRequest) (*Investigation, error) {
// Set default time range to last 30 minutes if not provided
if requestData.Start.IsZero() {
requestData.Start = time.Now().Add(-30 * time.Minute)
}
if requestData.End.IsZero() {
requestData.End = time.Now()
}
// Create the payload including the necessary fields for the API
payload := struct {
Investigation
RequestData investigationRequest `json:"requestData"`
}{
Investigation: *investigation,
RequestData: requestData,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("marshaling investigation: %w", err)
}
slog.Debug("Creating investigation", "payload", string(jsonData))
buf, err := c.makeRequest(ctx, "POST", "/api/plugins/grafana-ml-app/resources/sift/api/v1/investigations", jsonData)
if err != nil {
return nil, err
}
slog.Debug("Investigation created", "response", string(buf))
investigationResponse := struct {
Status string `json:"status"`
Data Investigation `json:"data"`
}{}
if err := json.Unmarshal(buf, &investigationResponse); err != nil {
return nil, fmt.Errorf("failed to unmarshal response body: %w. body: %s", err, buf)
}
// Poll for investigation completion
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
timeout := time.After(5 * time.Minute)
for {
select {
case <-ctx.Done():
return nil, fmt.Errorf("context cancelled while waiting for investigation completion")
case <-timeout:
return nil, fmt.Errorf("timeout waiting for investigation completion after 5 minutes")
case <-ticker.C:
slog.Debug("Polling investigation status", "investigation_id", investigationResponse.Data.ID)
investigation, err := c.getSiftInvestigation(ctx, investigationResponse.Data.ID)
if err != nil {
return nil, err
}
if investigation.Status == investigationStatusFailed {
return nil, fmt.Errorf("investigation failed: %s", investigation.FailureReason)
}
if investigation.Status == investigationStatusFinished {
return investigation, nil
}
}
}
}
// getSiftAnalyses is a helper method to get all analyses from an investigation
func (c *siftClient) getSiftAnalyses(ctx context.Context, investigationID uuid.UUID) ([]analysis, error) {
path := fmt.Sprintf("/api/plugins/grafana-ml-app/resources/sift/api/v1/investigations/%s/analyses", investigationID)
buf, err := c.makeRequest(ctx, "GET", path, nil)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
var response struct {
Status string `json:"status"`
Data []analysis `json:"data"`
}
if err := json.Unmarshal(buf, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal response body: %w. body: %s", err, buf)
}
return response.Data, nil
}
// getSiftAnalysis is a helper method to get a specific analysis from an investigation
func (c *siftClient) getSiftAnalysis(ctx context.Context, investigationID, analysisID uuid.UUID) (*analysis, error) {
// First get all analyses to verify the analysis exists
analyses, err := c.getSiftAnalyses(ctx, investigationID)
if err != nil {
return nil, fmt.Errorf("getting analyses: %w", err)
}
// Find the specific analysis
var targetAnalysis *analysis
for _, analysis := range analyses {
if analysis.ID == analysisID {
targetAnalysis = &analysis
break
}
}
if targetAnalysis == nil {
return nil, fmt.Errorf("analysis with ID %s not found in investigation %s", analysisID, investigationID)
}
return targetAnalysis, nil
}
// listSiftInvestigations is a helper method to get a list of investigations
func (c *siftClient) listSiftInvestigations(ctx context.Context, limit int) ([]Investigation, error) {
path := fmt.Sprintf("/api/plugins/grafana-ml-app/resources/sift/api/v1/investigations?limit=%d", limit)
buf, err := c.makeRequest(ctx, "GET", path, nil)
if err != nil {
return nil, fmt.Errorf("making request: %w", err)
}
var response struct {
Status string `json:"status"`
Data []Investigation `json:"data"`
}
if err := json.Unmarshal(buf, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal response body: %w. body: %s", err, buf)
}
return response.Data, nil
}
func fetchErrorPatternLogExamples(ctx context.Context, patternMap map[string]any, datasourceUID string) ([]string, error) {
query, _ := patternMap["query"].(string)
logEntries, err := queryLokiLogs(ctx, QueryLokiLogsParams{
DatasourceUID: datasourceUID,
LogQL: query,
Limit: errorPatternLogExampleLimit,
})
if err != nil {
return nil, fmt.Errorf("querying Loki: %w", err)
}
var examples []string
for _, entry := range logEntries {
if entry.Line != "" {
examples = append(examples, entry.Line)
}
}
return examples, nil
}
```
--------------------------------------------------------------------------------
/tools/alerting.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/go-openapi/strfmt"
"github.com/grafana/grafana-openapi-client-go/client/provisioning"
"github.com/grafana/grafana-openapi-client-go/models"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/prometheus/prometheus/model/labels"
mcpgrafana "github.com/grafana/mcp-grafana"
)
const (
DefaultListAlertRulesLimit = 100
DefaultListContactPointsLimit = 100
)
type ListAlertRulesParams struct {
Limit int `json:"limit,omitempty" jsonschema:"description=The maximum number of results to return. Default is 100."`
Page int `json:"page,omitempty" jsonschema:"description=The page number to return."`
LabelSelectors []Selector `json:"label_selectors,omitempty" jsonschema:"description=Optionally\\, a list of matchers to filter alert rules by labels"`
}
func (p ListAlertRulesParams) validate() error {
if p.Limit < 0 {
return fmt.Errorf("invalid limit: %d, must be greater than 0", p.Limit)
}
if p.Page < 0 {
return fmt.Errorf("invalid page: %d, must be greater than 0", p.Page)
}
return nil
}
type alertRuleSummary struct {
UID string `json:"uid"`
Title string `json:"title"`
// State can be one of: pending, firing, error, recovering, inactive.
// "inactive" means the alert state is normal, not firing.
State string `json:"state"`
Health string `json:"health,omitempty"`
FolderUID string `json:"folderUID,omitempty"`
RuleGroup string `json:"ruleGroup,omitempty"`
For string `json:"for,omitempty"`
LastEvaluation string `json:"lastEvaluation,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
Annotations map[string]string `json:"annotations,omitempty"`
}
func listAlertRules(ctx context.Context, args ListAlertRulesParams) ([]alertRuleSummary, error) {
if err := args.validate(); err != nil {
return nil, fmt.Errorf("list alert rules: %w", err)
}
// Get configuration data from provisioning API (has UIDs, configuration)
c := mcpgrafana.GrafanaClientFromContext(ctx)
provisioningResponse, err := c.Provisioning.GetAlertRules()
if err != nil {
return nil, fmt.Errorf("list alert rules (provisioning): %w", err)
}
// Get runtime state data from alerting client API (has state, health, etc.)
alertingClient, err := newAlertingClientFromContext(ctx)
if err != nil {
return nil, fmt.Errorf("list alert rules (alerting client): %w", err)
}
runtimeResponse, err := alertingClient.GetRules(ctx)
if err != nil {
return nil, fmt.Errorf("list alert rules (runtime): %w", err)
}
// Extract runtime rules from groups
var runtimeRules []alertingRule
for _, group := range runtimeResponse.Data.RuleGroups {
runtimeRules = append(runtimeRules, group.Rules...)
}
// Merge the data from both APIs
mergedRules := mergeAlertRuleData(provisioningResponse.Payload, runtimeRules)
filteredRules, err := filterMergedAlertRules(mergedRules, args.LabelSelectors)
if err != nil {
return nil, fmt.Errorf("list alert rules: %w", err)
}
paginatedRules, err := applyPaginationToMerged(filteredRules, args.Limit, args.Page)
if err != nil {
return nil, fmt.Errorf("list alert rules: %w", err)
}
return summarizeMergedAlertRules(paginatedRules), nil
}
// mergedAlertRule combines data from both provisioning API and runtime API
type mergedAlertRule struct {
// From provisioning API (configuration)
UID string
Title string
FolderUID string
RuleGroup string
Condition string
NoDataState string
ExecErrState string
For string
Labels map[string]string
Annotations map[string]string
// From runtime API (state)
State string
Health string
LastEvaluation string
ActiveAt string
}
// mergeAlertRuleData combines data from provisioning API and runtime API
func mergeAlertRuleData(provisionedRules []*models.ProvisionedAlertRule, runtimeRules []alertingRule) []mergedAlertRule {
var merged []mergedAlertRule
// Create a map of runtime rules by name for quick lookup
runtimeByName := make(map[string]alertingRule)
for _, runtime := range runtimeRules {
runtimeByName[runtime.Name] = runtime
}
// Merge each provisioned rule with its runtime counterpart
for _, provisioned := range provisionedRules {
title := ""
if provisioned.Title != nil {
title = *provisioned.Title
}
mergedRule := mergedAlertRule{
// From provisioning API
UID: provisioned.UID,
Title: title,
Labels: provisioned.Labels,
Annotations: provisioned.Annotations,
}
if provisioned.FolderUID != nil {
mergedRule.FolderUID = *provisioned.FolderUID
}
if provisioned.RuleGroup != nil {
mergedRule.RuleGroup = *provisioned.RuleGroup
}
if provisioned.Condition != nil {
mergedRule.Condition = *provisioned.Condition
}
if provisioned.NoDataState != nil {
mergedRule.NoDataState = *provisioned.NoDataState
}
if provisioned.ExecErrState != nil {
mergedRule.ExecErrState = *provisioned.ExecErrState
}
if provisioned.For != nil {
mergedRule.For = provisioned.For.String()
}
// Try to find matching runtime data by title
if runtime, found := runtimeByName[title]; found {
mergedRule.State = runtime.State
mergedRule.Health = runtime.Health
mergedRule.LastEvaluation = runtime.LastEvaluation.Format(time.RFC3339)
if runtime.ActiveAt != nil {
mergedRule.ActiveAt = runtime.ActiveAt.Format(time.RFC3339)
}
}
merged = append(merged, mergedRule)
}
return merged
}
// filterMergedAlertRules filters a list of merged alert rules based on label selectors
func filterMergedAlertRules(rules []mergedAlertRule, selectors []Selector) ([]mergedAlertRule, error) {
if len(selectors) == 0 {
return rules, nil
}
filteredResult := []mergedAlertRule{}
for _, rule := range rules {
match, err := matchesSelectorsForMerged(rule, selectors)
if err != nil {
return nil, fmt.Errorf("filtering alert rules: %w", err)
}
if match {
filteredResult = append(filteredResult, rule)
}
}
return filteredResult, nil
}
// matchesSelectorsForMerged checks if a merged alert rule matches all provided selectors
func matchesSelectorsForMerged(rule mergedAlertRule, selectors []Selector) (bool, error) {
// Convert map[string]string to labels.Labels for compatibility with selector
lbls := rule.Labels
if lbls == nil {
lbls = make(map[string]string)
}
for _, selector := range selectors {
// Create a labels.Labels from the map for the selector
labelsForSelector := labels.FromMap(lbls)
match, err := selector.Matches(labelsForSelector)
if err != nil {
return false, err
}
if !match {
return false, nil
}
}
return true, nil
}
func summarizeMergedAlertRules(alertRules []mergedAlertRule) []alertRuleSummary {
result := make([]alertRuleSummary, 0, len(alertRules))
for _, r := range alertRules {
result = append(result, alertRuleSummary{
UID: r.UID,
Title: r.Title,
State: r.State,
Health: r.Health,
FolderUID: r.FolderUID,
RuleGroup: r.RuleGroup,
For: r.For,
LastEvaluation: r.LastEvaluation,
Labels: r.Labels,
Annotations: r.Annotations,
})
}
return result
}
// applyPaginationToMerged applies pagination to the list of merged alert rules.
// It doesn't sort the items and relies on the order returned by the API.
func applyPaginationToMerged(items []mergedAlertRule, limit, page int) ([]mergedAlertRule, error) {
if limit == 0 {
limit = DefaultListAlertRulesLimit
}
if page == 0 {
page = 1
}
start := (page - 1) * limit
end := start + limit
if start >= len(items) {
return nil, nil
} else if end > len(items) {
return items[start:], nil
}
return items[start:end], nil
}
var ListAlertRules = mcpgrafana.MustTool(
"list_alert_rules",
"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",
listAlertRules,
mcp.WithTitleAnnotation("List alert rules"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type GetAlertRuleByUIDParams struct {
UID string `json:"uid" jsonschema:"required,description=The uid of the alert rule"`
}
func (p GetAlertRuleByUIDParams) validate() error {
if p.UID == "" {
return fmt.Errorf("uid is required")
}
return nil
}
func getAlertRuleByUID(ctx context.Context, args GetAlertRuleByUIDParams) (*models.ProvisionedAlertRule, error) {
if err := args.validate(); err != nil {
return nil, fmt.Errorf("get alert rule by uid: %w", err)
}
c := mcpgrafana.GrafanaClientFromContext(ctx)
alertRule, err := c.Provisioning.GetAlertRule(args.UID)
if err != nil {
return nil, fmt.Errorf("get alert rule by uid %s: %w", args.UID, err)
}
return alertRule.Payload, nil
}
var GetAlertRuleByUID = mcpgrafana.MustTool(
"get_alert_rule_by_uid",
"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.",
getAlertRuleByUID,
mcp.WithTitleAnnotation("Get alert rule details"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type ListContactPointsParams struct {
Limit int `json:"limit,omitempty" jsonschema:"description=The maximum number of results to return. Default is 100."`
Name *string `json:"name,omitempty" jsonschema:"description=Filter contact points by name"`
}
func (p ListContactPointsParams) validate() error {
if p.Limit < 0 {
return fmt.Errorf("invalid limit: %d, must be greater than 0", p.Limit)
}
return nil
}
type contactPointSummary struct {
UID string `json:"uid"`
Name string `json:"name"`
Type *string `json:"type,omitempty"`
}
func listContactPoints(ctx context.Context, args ListContactPointsParams) ([]contactPointSummary, error) {
if err := args.validate(); err != nil {
return nil, fmt.Errorf("list contact points: %w", err)
}
c := mcpgrafana.GrafanaClientFromContext(ctx)
params := provisioning.NewGetContactpointsParams().WithContext(ctx)
if args.Name != nil {
params.Name = args.Name
}
response, err := c.Provisioning.GetContactpoints(params)
if err != nil {
return nil, fmt.Errorf("list contact points: %w", err)
}
filteredContactPoints, err := applyLimitToContactPoints(response.Payload, args.Limit)
if err != nil {
return nil, fmt.Errorf("list contact points: %w", err)
}
return summarizeContactPoints(filteredContactPoints), nil
}
func summarizeContactPoints(contactPoints []*models.EmbeddedContactPoint) []contactPointSummary {
result := make([]contactPointSummary, 0, len(contactPoints))
for _, cp := range contactPoints {
result = append(result, contactPointSummary{
UID: cp.UID,
Name: cp.Name,
Type: cp.Type,
})
}
return result
}
func applyLimitToContactPoints(items []*models.EmbeddedContactPoint, limit int) ([]*models.EmbeddedContactPoint, error) {
if limit == 0 {
limit = DefaultListContactPointsLimit
}
if limit > len(items) {
return items, nil
}
return items[:limit], nil
}
var ListContactPoints = mcpgrafana.MustTool(
"list_contact_points",
"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.",
listContactPoints,
mcp.WithTitleAnnotation("List notification contact points"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type CreateAlertRuleParams struct {
Title string `json:"title" jsonschema:"required,description=The title of the alert rule"`
RuleGroup string `json:"ruleGroup" jsonschema:"required,description=The rule group name"`
FolderUID string `json:"folderUID" jsonschema:"required,description=The folder UID where the rule will be created"`
Condition string `json:"condition" jsonschema:"required,description=The query condition identifier (e.g. 'A'\\, 'B')"`
Data any `json:"data" jsonschema:"required,description=Array of query data objects"`
NoDataState string `json:"noDataState" jsonschema:"required,description=State when no data (NoData\\, Alerting\\, OK)"`
ExecErrState string `json:"execErrState" jsonschema:"required,description=State on execution error (NoData\\, Alerting\\, OK)"`
For string `json:"for" jsonschema:"required,description=Duration before alert fires (e.g. '5m')"`
Annotations map[string]string `json:"annotations,omitempty" jsonschema:"description=Optional annotations"`
Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Optional labels"`
UID *string `json:"uid,omitempty" jsonschema:"description=Optional UID for the alert rule"`
OrgID int64 `json:"orgID" jsonschema:"required,description=The organization ID"`
}
func (p CreateAlertRuleParams) validate() error {
if p.Title == "" {
return fmt.Errorf("title is required")
}
if p.RuleGroup == "" {
return fmt.Errorf("ruleGroup is required")
}
if p.FolderUID == "" {
return fmt.Errorf("folderUID is required")
}
if p.Condition == "" {
return fmt.Errorf("condition is required")
}
if p.Data == nil {
return fmt.Errorf("data is required")
}
if p.NoDataState == "" {
return fmt.Errorf("noDataState is required")
}
if p.ExecErrState == "" {
return fmt.Errorf("execErrState is required")
}
if p.For == "" {
return fmt.Errorf("for duration is required")
}
if p.OrgID <= 0 {
return fmt.Errorf("orgID is required and must be greater than 0")
}
return nil
}
func createAlertRule(ctx context.Context, args CreateAlertRuleParams) (*models.ProvisionedAlertRule, error) {
if err := args.validate(); err != nil {
return nil, fmt.Errorf("create alert rule: %w", err)
}
c := mcpgrafana.GrafanaClientFromContext(ctx)
// Parse duration string
duration, err := time.ParseDuration(args.For)
if err != nil {
return nil, fmt.Errorf("create alert rule: invalid duration format %q: %w", args.For, err)
}
// Convert Data field to AlertQuery array
var alertQueries []*models.AlertQuery
if args.Data != nil {
// Convert interface{} to JSON and then to AlertQuery structs
dataBytes, err := json.Marshal(args.Data)
if err != nil {
return nil, fmt.Errorf("create alert rule: failed to marshal data: %w", err)
}
if err := json.Unmarshal(dataBytes, &alertQueries); err != nil {
return nil, fmt.Errorf("create alert rule: failed to unmarshal data to AlertQuery: %w", err)
}
}
rule := &models.ProvisionedAlertRule{
Title: &args.Title,
RuleGroup: &args.RuleGroup,
FolderUID: &args.FolderUID,
Condition: &args.Condition,
Data: alertQueries,
NoDataState: &args.NoDataState,
ExecErrState: &args.ExecErrState,
For: func() *strfmt.Duration { d := strfmt.Duration(duration); return &d }(),
Annotations: args.Annotations,
Labels: args.Labels,
OrgID: &args.OrgID,
}
if args.UID != nil {
rule.UID = *args.UID
}
// Validate the rule using the built-in OpenAPI validation
if err := rule.Validate(strfmt.Default); err != nil {
return nil, fmt.Errorf("create alert rule: invalid rule configuration: %w", err)
}
params := provisioning.NewPostAlertRuleParams().WithContext(ctx).WithBody(rule)
response, err := c.Provisioning.PostAlertRule(params)
if err != nil {
return nil, fmt.Errorf("create alert rule: %w", err)
}
return response.Payload, nil
}
var CreateAlertRule = mcpgrafana.MustTool(
"create_alert_rule",
"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.",
createAlertRule,
mcp.WithTitleAnnotation("Create alert rule"),
)
type UpdateAlertRuleParams struct {
UID string `json:"uid" jsonschema:"required,description=The UID of the alert rule to update"`
Title string `json:"title" jsonschema:"required,description=The title of the alert rule"`
RuleGroup string `json:"ruleGroup" jsonschema:"required,description=The rule group name"`
FolderUID string `json:"folderUID" jsonschema:"required,description=The folder UID where the rule will be created"`
Condition string `json:"condition" jsonschema:"required,description=The query condition identifier (e.g. 'A'\\, 'B')"`
Data any `json:"data" jsonschema:"required,description=Array of query data objects"`
NoDataState string `json:"noDataState" jsonschema:"required,description=State when no data (NoData\\, Alerting\\, OK)"`
ExecErrState string `json:"execErrState" jsonschema:"required,description=State on execution error (NoData\\, Alerting\\, OK)"`
For string `json:"for" jsonschema:"required,description=Duration before alert fires (e.g. '5m')"`
Annotations map[string]string `json:"annotations,omitempty" jsonschema:"description=Optional annotations"`
Labels map[string]string `json:"labels,omitempty" jsonschema:"description=Optional labels"`
OrgID int64 `json:"orgID" jsonschema:"required,description=The organization ID"`
}
func (p UpdateAlertRuleParams) validate() error {
if p.UID == "" {
return fmt.Errorf("uid is required")
}
if p.Title == "" {
return fmt.Errorf("title is required")
}
if p.RuleGroup == "" {
return fmt.Errorf("ruleGroup is required")
}
if p.FolderUID == "" {
return fmt.Errorf("folderUID is required")
}
if p.Condition == "" {
return fmt.Errorf("condition is required")
}
if p.Data == nil {
return fmt.Errorf("data is required")
}
if p.NoDataState == "" {
return fmt.Errorf("noDataState is required")
}
if p.ExecErrState == "" {
return fmt.Errorf("execErrState is required")
}
if p.For == "" {
return fmt.Errorf("for duration is required")
}
if p.OrgID <= 0 {
return fmt.Errorf("orgID is required and must be greater than 0")
}
return nil
}
func updateAlertRule(ctx context.Context, args UpdateAlertRuleParams) (*models.ProvisionedAlertRule, error) {
if err := args.validate(); err != nil {
return nil, fmt.Errorf("update alert rule: %w", err)
}
c := mcpgrafana.GrafanaClientFromContext(ctx)
// Parse duration string
duration, err := time.ParseDuration(args.For)
if err != nil {
return nil, fmt.Errorf("update alert rule: invalid duration format %q: %w", args.For, err)
}
// Convert Data field to AlertQuery array
var alertQueries []*models.AlertQuery
if args.Data != nil {
// Convert interface{} to JSON and then to AlertQuery structs
dataBytes, err := json.Marshal(args.Data)
if err != nil {
return nil, fmt.Errorf("update alert rule: failed to marshal data: %w", err)
}
if err := json.Unmarshal(dataBytes, &alertQueries); err != nil {
return nil, fmt.Errorf("update alert rule: failed to unmarshal data to AlertQuery: %w", err)
}
}
rule := &models.ProvisionedAlertRule{
UID: args.UID,
Title: &args.Title,
RuleGroup: &args.RuleGroup,
FolderUID: &args.FolderUID,
Condition: &args.Condition,
Data: alertQueries,
NoDataState: &args.NoDataState,
ExecErrState: &args.ExecErrState,
For: func() *strfmt.Duration { d := strfmt.Duration(duration); return &d }(),
Annotations: args.Annotations,
Labels: args.Labels,
OrgID: &args.OrgID,
}
// Validate the rule using the built-in OpenAPI validation
if err := rule.Validate(strfmt.Default); err != nil {
return nil, fmt.Errorf("update alert rule: invalid rule configuration: %w", err)
}
params := provisioning.NewPutAlertRuleParams().WithContext(ctx).WithUID(args.UID).WithBody(rule)
response, err := c.Provisioning.PutAlertRule(params)
if err != nil {
return nil, fmt.Errorf("update alert rule %s: %w", args.UID, err)
}
return response.Payload, nil
}
var UpdateAlertRule = mcpgrafana.MustTool(
"update_alert_rule",
"Updates an existing Grafana alert rule identified by its UID. Requires all the same parameters as creating a new rule.",
updateAlertRule,
mcp.WithTitleAnnotation("Update alert rule"),
)
type DeleteAlertRuleParams struct {
UID string `json:"uid" jsonschema:"required,description=The UID of the alert rule to delete"`
}
func (p DeleteAlertRuleParams) validate() error {
if p.UID == "" {
return fmt.Errorf("uid is required")
}
return nil
}
func deleteAlertRule(ctx context.Context, args DeleteAlertRuleParams) (string, error) {
if err := args.validate(); err != nil {
return "", fmt.Errorf("delete alert rule: %w", err)
}
c := mcpgrafana.GrafanaClientFromContext(ctx)
params := provisioning.NewDeleteAlertRuleParams().WithContext(ctx).WithUID(args.UID)
_, err := c.Provisioning.DeleteAlertRule(params)
if err != nil {
return "", fmt.Errorf("delete alert rule %s: %w", args.UID, err)
}
return fmt.Sprintf("Alert rule %s deleted successfully", args.UID), nil
}
var DeleteAlertRule = mcpgrafana.MustTool(
"delete_alert_rule",
"Deletes a Grafana alert rule by its UID. This action cannot be undone.",
deleteAlertRule,
mcp.WithTitleAnnotation("Delete alert rule"),
)
func AddAlertingTools(mcp *server.MCPServer, enableWriteTools bool) {
ListAlertRules.Register(mcp)
GetAlertRuleByUID.Register(mcp)
if enableWriteTools {
CreateAlertRule.Register(mcp)
UpdateAlertRule.Register(mcp)
DeleteAlertRule.Register(mcp)
}
ListContactPoints.Register(mcp)
}
```
--------------------------------------------------------------------------------
/tools/dashboard.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"github.com/PaesslerAG/gval"
"github.com/PaesslerAG/jsonpath"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/grafana/grafana-openapi-client-go/models"
mcpgrafana "github.com/grafana/mcp-grafana"
)
type GetDashboardByUIDParams struct {
UID string `json:"uid" jsonschema:"required,description=The UID of the dashboard"`
}
func getDashboardByUID(ctx context.Context, args GetDashboardByUIDParams) (*models.DashboardFullWithMeta, error) {
c := mcpgrafana.GrafanaClientFromContext(ctx)
dashboard, err := c.Dashboards.GetDashboardByUID(args.UID)
if err != nil {
return nil, fmt.Errorf("get dashboard by uid %s: %w", args.UID, err)
}
return dashboard.Payload, nil
}
// PatchOperation represents a single patch operation
type PatchOperation struct {
Op string `json:"op" jsonschema:"required,description=Operation type: 'replace'\\, 'add'\\, 'remove'"`
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)."`
Value interface{} `json:"value,omitempty" jsonschema:"description=New value for replace/add operations"`
}
type UpdateDashboardParams struct {
// For full dashboard updates (creates new dashboards or complete rewrites)
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."`
// For targeted updates using patch operations (preferred for existing dashboards)
UID string `json:"uid,omitempty" jsonschema:"description=UID of existing dashboard to update. Required when using patch operations."`
Operations []PatchOperation `json:"operations,omitempty" jsonschema:"description=Array of patch operations for targeted updates. More efficient than full dashboard JSON for small changes."`
// Common parameters
FolderUID string `json:"folderUid,omitempty" jsonschema:"description=The UID of the dashboard's folder"`
Message string `json:"message,omitempty" jsonschema:"description=Set a commit message for the version history"`
Overwrite bool `json:"overwrite,omitempty" jsonschema:"description=Overwrite the dashboard if it exists. Otherwise create one"`
UserID int64 `json:"userId,omitempty" jsonschema:"description=ID of the user making the change"`
}
// updateDashboard intelligently handles dashboard updates using either full JSON or patch operations.
// It automatically uses the most efficient approach based on the provided parameters.
func updateDashboard(ctx context.Context, args UpdateDashboardParams) (*models.PostDashboardOKBody, error) {
// Determine the update strategy based on provided parameters
if len(args.Operations) > 0 && args.UID != "" {
// Patch-based update: fetch current dashboard and apply operations
return updateDashboardWithPatches(ctx, args)
} else if args.Dashboard != nil {
// Full dashboard update: use the provided JSON
return updateDashboardWithFullJSON(ctx, args)
} else {
return nil, fmt.Errorf("either dashboard JSON or (uid + operations) must be provided")
}
}
// updateDashboardWithPatches applies patch operations to an existing dashboard
func updateDashboardWithPatches(ctx context.Context, args UpdateDashboardParams) (*models.PostDashboardOKBody, error) {
// Get the current dashboard
dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{UID: args.UID})
if err != nil {
return nil, fmt.Errorf("get dashboard by uid: %w", err)
}
// Convert to modifiable map
dashboardMap, ok := dashboard.Dashboard.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("dashboard is not a JSON object")
}
// Apply each patch operation
for i, op := range args.Operations {
switch op.Op {
case "replace", "add":
if err := applyJSONPath(dashboardMap, op.Path, op.Value, false); err != nil {
return nil, fmt.Errorf("operation %d (%s at %s): %w", i, op.Op, op.Path, err)
}
case "remove":
if err := applyJSONPath(dashboardMap, op.Path, nil, true); err != nil {
return nil, fmt.Errorf("operation %d (%s at %s): %w", i, op.Op, op.Path, err)
}
default:
return nil, fmt.Errorf("operation %d: unsupported operation '%s'", i, op.Op)
}
}
// Use the folder UID from the existing dashboard if not provided
folderUID := args.FolderUID
if folderUID == "" && dashboard.Meta != nil {
folderUID = dashboard.Meta.FolderUID
}
// Update with the patched dashboard
return updateDashboardWithFullJSON(ctx, UpdateDashboardParams{
Dashboard: dashboardMap,
FolderUID: folderUID,
Message: args.Message,
Overwrite: true,
UserID: args.UserID,
})
}
// updateDashboardWithFullJSON performs a traditional full dashboard update
func updateDashboardWithFullJSON(ctx context.Context, args UpdateDashboardParams) (*models.PostDashboardOKBody, error) {
c := mcpgrafana.GrafanaClientFromContext(ctx)
cmd := &models.SaveDashboardCommand{
Dashboard: args.Dashboard,
FolderUID: args.FolderUID,
Message: args.Message,
Overwrite: args.Overwrite,
UserID: args.UserID,
}
dashboard, err := c.Dashboards.PostDashboard(cmd)
if err != nil {
return nil, fmt.Errorf("unable to save dashboard: %w", err)
}
return dashboard.Payload, nil
}
var GetDashboardByUID = mcpgrafana.MustTool(
"get_dashboard_by_uid",
"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.",
getDashboardByUID,
mcp.WithTitleAnnotation("Get dashboard details"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
var UpdateDashboard = mcpgrafana.MustTool(
"update_dashboard",
"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.",
updateDashboard,
mcp.WithTitleAnnotation("Create or update dashboard"),
mcp.WithDestructiveHintAnnotation(true),
)
type DashboardPanelQueriesParams struct {
UID string `json:"uid" jsonschema:"required,description=The UID of the dashboard"`
}
type datasourceInfo struct {
UID string `json:"uid"`
Type string `json:"type"`
}
type panelQuery struct {
Title string `json:"title"`
Query string `json:"query"`
Datasource datasourceInfo `json:"datasource"`
}
func GetDashboardPanelQueriesTool(ctx context.Context, args DashboardPanelQueriesParams) ([]panelQuery, error) {
result := make([]panelQuery, 0)
dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams(args))
if err != nil {
return result, fmt.Errorf("get dashboard by uid: %w", err)
}
db, ok := dashboard.Dashboard.(map[string]any)
if !ok {
return result, fmt.Errorf("dashboard is not a JSON object")
}
panels, ok := db["panels"].([]any)
if !ok {
return result, fmt.Errorf("panels is not a JSON array")
}
for _, p := range panels {
panel, ok := p.(map[string]any)
if !ok {
continue
}
title, _ := panel["title"].(string)
var datasourceInfo datasourceInfo
if dsField, dsExists := panel["datasource"]; dsExists && dsField != nil {
if dsMap, ok := dsField.(map[string]any); ok {
if uid, ok := dsMap["uid"].(string); ok {
datasourceInfo.UID = uid
}
if dsType, ok := dsMap["type"].(string); ok {
datasourceInfo.Type = dsType
}
}
}
targets, ok := panel["targets"].([]any)
if !ok {
continue
}
for _, t := range targets {
target, ok := t.(map[string]any)
if !ok {
continue
}
expr, _ := target["expr"].(string)
if expr != "" {
result = append(result, panelQuery{
Title: title,
Query: expr,
Datasource: datasourceInfo,
})
}
}
}
return result, nil
}
var GetDashboardPanelQueries = mcpgrafana.MustTool(
"get_dashboard_panel_queries",
"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).",
GetDashboardPanelQueriesTool,
mcp.WithTitleAnnotation("Get dashboard panel queries"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
// GetDashboardPropertyParams defines parameters for getting specific dashboard properties
type GetDashboardPropertyParams struct {
UID string `json:"uid" jsonschema:"required,description=The UID of the dashboard"`
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)"`
}
// getDashboardProperty retrieves specific parts of a dashboard using JSONPath expressions.
// This helps reduce context window usage by fetching only the needed data.
func getDashboardProperty(ctx context.Context, args GetDashboardPropertyParams) (interface{}, error) {
dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams{UID: args.UID})
if err != nil {
return nil, fmt.Errorf("get dashboard by uid: %w", err)
}
// Convert dashboard to JSON for JSONPath processing
dashboardJSON, err := json.Marshal(dashboard.Dashboard)
if err != nil {
return nil, fmt.Errorf("marshal dashboard to JSON: %w", err)
}
var dashboardData interface{}
if err := json.Unmarshal(dashboardJSON, &dashboardData); err != nil {
return nil, fmt.Errorf("unmarshal dashboard JSON: %w", err)
}
// Apply JSONPath expression
builder := gval.Full(jsonpath.Language())
path, err := builder.NewEvaluable(args.JSONPath)
if err != nil {
return nil, fmt.Errorf("create JSONPath evaluable '%s': %w", args.JSONPath, err)
}
result, err := path(ctx, dashboardData)
if err != nil {
return nil, fmt.Errorf("apply JSONPath '%s': %w", args.JSONPath, err)
}
return result, nil
}
var GetDashboardProperty = mcpgrafana.MustTool(
"get_dashboard_property",
"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.",
getDashboardProperty,
mcp.WithTitleAnnotation("Get dashboard property"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
// GetDashboardSummaryParams defines parameters for getting a dashboard summary
type GetDashboardSummaryParams struct {
UID string `json:"uid" jsonschema:"required,description=The UID of the dashboard"`
}
// DashboardSummary provides a compact overview of a dashboard without the full JSON
type DashboardSummary struct {
UID string `json:"uid"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Tags []string `json:"tags,omitempty"`
PanelCount int `json:"panelCount"`
Panels []PanelSummary `json:"panels"`
Variables []VariableSummary `json:"variables,omitempty"`
TimeRange TimeRangeSummary `json:"timeRange"`
Refresh string `json:"refresh,omitempty"`
Meta *models.DashboardMeta `json:"meta,omitempty"`
}
type PanelSummary struct {
ID int `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Description string `json:"description,omitempty"`
QueryCount int `json:"queryCount"`
}
type VariableSummary struct {
Name string `json:"name"`
Type string `json:"type"`
Label string `json:"label,omitempty"`
}
type TimeRangeSummary struct {
From string `json:"from"`
To string `json:"to"`
}
// getDashboardSummary provides a compact overview of a dashboard to help with context management
func getDashboardSummary(ctx context.Context, args GetDashboardSummaryParams) (*DashboardSummary, error) {
dashboard, err := getDashboardByUID(ctx, GetDashboardByUIDParams(args))
if err != nil {
return nil, fmt.Errorf("get dashboard by uid: %w", err)
}
db, ok := dashboard.Dashboard.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("dashboard is not a JSON object")
}
summary := &DashboardSummary{
UID: args.UID,
Meta: dashboard.Meta,
}
// Extract basic info using helper functions
extractBasicDashboardInfo(db, summary)
// Extract time range
summary.TimeRange = extractTimeRange(db)
// Extract panel summaries
if panels := safeArray(db, "panels"); panels != nil {
summary.PanelCount = len(panels)
for _, p := range panels {
if panelObj, ok := p.(map[string]interface{}); ok {
summary.Panels = append(summary.Panels, extractPanelSummary(panelObj))
}
}
}
// Extract variable summaries
if templating := safeObject(db, "templating"); templating != nil {
if list := safeArray(templating, "list"); list != nil {
for _, v := range list {
if variable, ok := v.(map[string]interface{}); ok {
summary.Variables = append(summary.Variables, extractVariableSummary(variable))
}
}
}
}
return summary, nil
}
var GetDashboardSummary = mcpgrafana.MustTool(
"get_dashboard_summary",
"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.",
getDashboardSummary,
mcp.WithTitleAnnotation("Get dashboard summary"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
// applyJSONPath applies a value to a JSONPath or removes it if remove=true
func applyJSONPath(data map[string]interface{}, path string, value interface{}, remove bool) error {
// Remove the leading "$." if present
if len(path) > 2 && path[:2] == "$." {
path = path[2:]
}
// Split the path into segments
segments := parseJSONPath(path)
if len(segments) == 0 {
return fmt.Errorf("empty JSONPath")
}
// Navigate to the parent of the target
current := data
for i, segment := range segments[:len(segments)-1] {
next, err := navigateSegment(current, segment)
if err != nil {
return fmt.Errorf("at segment %d (%s): %w", i, segment.String(), err)
}
current = next
}
// Apply the final operation
finalSegment := segments[len(segments)-1]
if remove {
return removeAtSegment(current, finalSegment)
}
return setAtSegment(current, finalSegment, value)
}
// JSONPathSegment represents a segment of a JSONPath
type JSONPathSegment struct {
Key string
Index int
IsArray bool
IsAppend bool // true when using /- syntax to append to array
}
func (s JSONPathSegment) String() string {
if s.IsAppend {
return fmt.Sprintf("%s/-", s.Key)
}
if s.IsArray {
return fmt.Sprintf("%s[%d]", s.Key, s.Index)
}
return s.Key
}
// parseJSONPath parses a JSONPath string into segments
// Supports paths like "panels[0].targets[1].expr", "title", "templating.list[0].name"
// Also supports append syntax: "panels/-" or "panels[2]/-"
func parseJSONPath(path string) []JSONPathSegment {
var segments []JSONPathSegment
// Handle empty path
if path == "" {
return segments
}
// Enhanced regex to handle /- append syntax
// Matches: key, key[index], key/-, key[index]/-
re := regexp.MustCompile(`([^.\[\]\/]+)(?:\[(\d+)\])?(?:(\/-))?`)
matches := re.FindAllStringSubmatch(path, -1)
for _, match := range matches {
if len(match) >= 2 && match[1] != "" {
segment := JSONPathSegment{
Key: match[1],
IsArray: len(match) >= 3 && match[2] != "",
IsAppend: len(match) >= 4 && match[3] == "/-",
}
if segment.IsArray && !segment.IsAppend {
if index, err := strconv.Atoi(match[2]); err == nil {
segment.Index = index
}
}
segments = append(segments, segment)
}
}
return segments
}
// validateArrayAccess validates array access for a segment
func validateArrayAccess(current map[string]interface{}, segment JSONPathSegment) ([]interface{}, error) {
arr, ok := current[segment.Key].([]interface{})
if !ok {
return nil, fmt.Errorf("field '%s' is not an array", segment.Key)
}
// For append operations, we don't need to validate index bounds
if segment.IsAppend {
return arr, nil
}
if segment.Index < 0 || segment.Index >= len(arr) {
return nil, fmt.Errorf("index %d out of bounds for array '%s' (length %d)", segment.Index, segment.Key, len(arr))
}
return arr, nil
}
// navigateSegment navigates to the next level in the JSON structure
func navigateSegment(current map[string]interface{}, segment JSONPathSegment) (map[string]interface{}, error) {
// Append operations can only be at the final segment
if segment.IsAppend {
return nil, fmt.Errorf("append operation (/- ) can only be used at the final path segment")
}
if segment.IsArray {
arr, err := validateArrayAccess(current, segment)
if err != nil {
return nil, err
}
// Get the object at the index
obj, ok := arr[segment.Index].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("element at %s[%d] is not an object", segment.Key, segment.Index)
}
return obj, nil
}
// Get the object
obj, ok := current[segment.Key].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("field '%s' is not an object", segment.Key)
}
return obj, nil
}
// setAtSegment sets a value at the final segment
func setAtSegment(current map[string]interface{}, segment JSONPathSegment, value interface{}) error {
if segment.IsAppend {
// Handle append operation: add to the end of the array
arr, err := validateArrayAccess(current, segment)
if err != nil {
return err
}
// Append the value to the array
arr = append(arr, value)
current[segment.Key] = arr
return nil
}
if segment.IsArray {
arr, err := validateArrayAccess(current, segment)
if err != nil {
return err
}
// Set the value in the array
arr[segment.Index] = value
return nil
}
// Set the value directly
current[segment.Key] = value
return nil
}
// removeAtSegment removes a value at the final segment
func removeAtSegment(current map[string]interface{}, segment JSONPathSegment) error {
if segment.IsAppend {
return fmt.Errorf("cannot use remove operation with append syntax (/- ) at %s", segment.Key)
}
if segment.IsArray {
return fmt.Errorf("cannot remove array element %s[%d] (not supported)", segment.Key, segment.Index)
}
delete(current, segment.Key)
return nil
}
// Helper functions for safe type conversions and field extraction
// safeGet safely extracts a value from a map with type conversion
func safeGet[T any](data map[string]interface{}, key string, defaultVal T) T {
if val, ok := data[key]; ok {
if typedVal, ok := val.(T); ok {
return typedVal
}
}
return defaultVal
}
func safeString(data map[string]interface{}, key string) string {
return safeGet(data, key, "")
}
func safeStringSlice(data map[string]interface{}, key string) []string {
var result []string
if arr := safeArray(data, key); arr != nil {
for _, item := range arr {
if str, ok := item.(string); ok {
result = append(result, str)
}
}
}
return result
}
func safeFloat64(data map[string]interface{}, key string) float64 {
return safeGet(data, key, 0.0)
}
func safeInt(data map[string]interface{}, key string) int {
return int(safeFloat64(data, key))
}
func safeObject(data map[string]interface{}, key string) map[string]interface{} {
return safeGet(data, key, map[string]interface{}(nil))
}
func safeArray(data map[string]interface{}, key string) []interface{} {
return safeGet(data, key, []interface{}(nil))
}
// extractBasicDashboardInfo extracts common dashboard fields
func extractBasicDashboardInfo(db map[string]interface{}, summary *DashboardSummary) {
summary.Title = safeString(db, "title")
summary.Description = safeString(db, "description")
summary.Tags = safeStringSlice(db, "tags")
summary.Refresh = safeString(db, "refresh")
}
// extractTimeRange extracts time range information
func extractTimeRange(db map[string]interface{}) TimeRangeSummary {
timeObj := safeObject(db, "time")
if timeObj == nil {
return TimeRangeSummary{}
}
return TimeRangeSummary{
From: safeString(timeObj, "from"),
To: safeString(timeObj, "to"),
}
}
// extractPanelSummary creates a panel summary from panel data
func extractPanelSummary(panel map[string]interface{}) PanelSummary {
summary := PanelSummary{
ID: safeInt(panel, "id"),
Title: safeString(panel, "title"),
Type: safeString(panel, "type"),
Description: safeString(panel, "description"),
}
// Count queries
if targets := safeArray(panel, "targets"); targets != nil {
summary.QueryCount = len(targets)
}
return summary
}
// extractVariableSummary creates a variable summary from variable data
func extractVariableSummary(variable map[string]interface{}) VariableSummary {
return VariableSummary{
Name: safeString(variable, "name"),
Type: safeString(variable, "type"),
Label: safeString(variable, "label"),
}
}
func AddDashboardTools(mcp *server.MCPServer, enableWriteTools bool) {
GetDashboardByUID.Register(mcp)
if enableWriteTools {
UpdateDashboard.Register(mcp)
}
GetDashboardPanelQueries.Register(mcp)
GetDashboardProperty.Register(mcp)
GetDashboardSummary.Register(mcp)
}
```
--------------------------------------------------------------------------------
/mcpgrafana_test.go:
--------------------------------------------------------------------------------
```go
//go:build unit
// +build unit
package mcpgrafana
import (
"context"
"net/http"
"testing"
"github.com/go-openapi/runtime/client"
grafana_client "github.com/grafana/grafana-openapi-client-go/client"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
func TestExtractIncidentClientFromEnv(t *testing.T) {
t.Setenv("GRAFANA_URL", "http://my-test-url.grafana.com/")
ctx := ExtractIncidentClientFromEnv(context.Background())
client := IncidentClientFromContext(ctx)
require.NotNil(t, client)
assert.Equal(t, "http://my-test-url.grafana.com/api/plugins/grafana-irm-app/resources/api/v1/", client.RemoteHost)
}
func TestExtractIncidentClientFromHeaders(t *testing.T) {
t.Run("no headers, no env", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractIncidentClientFromHeaders(context.Background(), req)
client := IncidentClientFromContext(ctx)
require.NotNil(t, client)
assert.Equal(t, "http://localhost:3000/api/plugins/grafana-irm-app/resources/api/v1/", client.RemoteHost)
})
t.Run("no headers, with env", func(t *testing.T) {
t.Setenv("GRAFANA_URL", "http://my-test-url.grafana.com/")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractIncidentClientFromHeaders(context.Background(), req)
client := IncidentClientFromContext(ctx)
require.NotNil(t, client)
assert.Equal(t, "http://my-test-url.grafana.com/api/plugins/grafana-irm-app/resources/api/v1/", client.RemoteHost)
})
t.Run("with headers, no env", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://example.com", nil)
req.Header.Set(grafanaURLHeader, "http://my-test-url.grafana.com")
require.NoError(t, err)
ctx := ExtractIncidentClientFromHeaders(context.Background(), req)
client := IncidentClientFromContext(ctx)
require.NotNil(t, client)
assert.Equal(t, "http://my-test-url.grafana.com/api/plugins/grafana-irm-app/resources/api/v1/", client.RemoteHost)
})
t.Run("with headers, with env", func(t *testing.T) {
t.Setenv("GRAFANA_URL", "will-not-be-used")
req, err := http.NewRequest("GET", "http://example.com", nil)
req.Header.Set(grafanaURLHeader, "http://my-test-url.grafana.com")
require.NoError(t, err)
ctx := ExtractIncidentClientFromHeaders(context.Background(), req)
client := IncidentClientFromContext(ctx)
require.NotNil(t, client)
assert.Equal(t, "http://my-test-url.grafana.com/api/plugins/grafana-irm-app/resources/api/v1/", client.RemoteHost)
})
}
func TestExtractGrafanaInfoFromHeaders(t *testing.T) {
t.Run("no headers, no env", func(t *testing.T) {
// Explicitly clear environment variables to ensure test isolation
t.Setenv("GRAFANA_URL", "")
t.Setenv("GRAFANA_API_KEY", "")
t.Setenv("GRAFANA_SERVICE_ACCOUNT_TOKEN", "")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, defaultGrafanaURL, config.URL)
assert.Equal(t, "", config.APIKey)
assert.Nil(t, config.BasicAuth)
})
t.Run("no headers, with env", func(t *testing.T) {
t.Setenv("GRAFANA_URL", "http://my-test-url.grafana.com")
t.Setenv("GRAFANA_API_KEY", "my-test-api-key")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, "http://my-test-url.grafana.com", config.URL)
assert.Equal(t, "my-test-api-key", config.APIKey)
})
t.Run("no headers, with service account token", func(t *testing.T) {
t.Setenv("GRAFANA_URL", "http://my-test-url.grafana.com")
t.Setenv("GRAFANA_SERVICE_ACCOUNT_TOKEN", "my-service-account-token")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, "http://my-test-url.grafana.com", config.URL)
assert.Equal(t, "my-service-account-token", config.APIKey)
})
t.Run("no headers, service account token takes precedence over api key", func(t *testing.T) {
t.Setenv("GRAFANA_URL", "http://my-test-url.grafana.com")
t.Setenv("GRAFANA_API_KEY", "my-deprecated-api-key")
t.Setenv("GRAFANA_SERVICE_ACCOUNT_TOKEN", "my-service-account-token")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, "http://my-test-url.grafana.com", config.URL)
assert.Equal(t, "my-service-account-token", config.APIKey)
})
t.Run("with headers, no env", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
req.Header.Set(grafanaURLHeader, "http://my-test-url.grafana.com")
req.Header.Set(grafanaAPIKeyHeader, "my-test-api-key")
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, "http://my-test-url.grafana.com", config.URL)
assert.Equal(t, "my-test-api-key", config.APIKey)
})
t.Run("with headers, with env", func(t *testing.T) {
// Env vars should be ignored if headers are present.
t.Setenv("GRAFANA_URL", "will-not-be-used")
t.Setenv("GRAFANA_API_KEY", "will-not-be-used")
t.Setenv("GRAFANA_SERVICE_ACCOUNT_TOKEN", "will-not-be-used")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
req.Header.Set(grafanaURLHeader, "http://my-test-url.grafana.com")
req.Header.Set(grafanaAPIKeyHeader, "my-test-api-key")
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, "http://my-test-url.grafana.com", config.URL)
assert.Equal(t, "my-test-api-key", config.APIKey)
})
t.Run("no headers, with env", func(t *testing.T) {
t.Setenv("GRAFANA_USERNAME", "foo")
t.Setenv("GRAFANA_PASSWORD", "bar")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, "foo", config.BasicAuth.Username())
password, _ := config.BasicAuth.Password()
assert.Equal(t, "bar", password)
})
t.Run("user auth with headers, no env", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://example.com", nil)
req.SetBasicAuth("foo", "bar")
require.NoError(t, err)
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, "foo", config.BasicAuth.Username())
password, _ := config.BasicAuth.Password()
assert.Equal(t, "bar", password)
})
t.Run("user auth with headers, with env", func(t *testing.T) {
t.Setenv("GRAFANA_USERNAME", "will-not-be-used")
t.Setenv("GRAFANA_PASSWORD", "will-not-be-used")
req, err := http.NewRequest("GET", "http://example.com", nil)
req.SetBasicAuth("foo", "bar")
require.NoError(t, err)
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, "foo", config.BasicAuth.Username())
password, _ := config.BasicAuth.Password()
assert.Equal(t, "bar", password)
})
t.Run("orgID from env", func(t *testing.T) {
t.Setenv("GRAFANA_ORG_ID", "123")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, int64(123), config.OrgID)
})
t.Run("orgID from header", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
req.Header.Set("X-Grafana-Org-Id", "456")
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, int64(456), config.OrgID)
})
t.Run("orgID header takes precedence over env", func(t *testing.T) {
t.Setenv("GRAFANA_ORG_ID", "123")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
req.Header.Set("X-Grafana-Org-Id", "456")
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, int64(456), config.OrgID)
})
t.Run("invalid orgID from env ignored", func(t *testing.T) {
t.Setenv("GRAFANA_ORG_ID", "not-a-number")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, int64(0), config.OrgID)
})
t.Run("invalid orgID from header ignored", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
req.Header.Set("X-Grafana-Org-Id", "invalid")
ctx := ExtractGrafanaInfoFromHeaders(context.Background(), req)
config := GrafanaConfigFromContext(ctx)
assert.Equal(t, int64(0), config.OrgID)
})
}
func TestExtractGrafanaClientPath(t *testing.T) {
t.Run("no custom path", func(t *testing.T) {
t.Setenv("GRAFANA_URL", "http://my-test-url.grafana.com/")
ctx := ExtractGrafanaClientFromEnv(context.Background())
c := GrafanaClientFromContext(ctx)
require.NotNil(t, c)
rt := c.Transport.(*client.Runtime)
assert.Equal(t, "/api", rt.BasePath)
})
t.Run("custom path", func(t *testing.T) {
t.Setenv("GRAFANA_URL", "http://my-test-url.grafana.com/grafana")
ctx := ExtractGrafanaClientFromEnv(context.Background())
c := GrafanaClientFromContext(ctx)
require.NotNil(t, c)
rt := c.Transport.(*client.Runtime)
assert.Equal(t, "/grafana/api", rt.BasePath)
})
t.Run("custom path, trailing slash", func(t *testing.T) {
t.Setenv("GRAFANA_URL", "http://my-test-url.grafana.com/grafana/")
ctx := ExtractGrafanaClientFromEnv(context.Background())
c := GrafanaClientFromContext(ctx)
require.NotNil(t, c)
rt := c.Transport.(*client.Runtime)
assert.Equal(t, "/grafana/api", rt.BasePath)
})
}
// minURL is a helper struct representing what we can extract from a constructed
// Grafana client.
type minURL struct {
host, basePath string
}
// minURLFromClient extracts some minimal amount of URL info from a Grafana client.
func minURLFromClient(c *grafana_client.GrafanaHTTPAPI) minURL {
rt := c.Transport.(*client.Runtime)
return minURL{rt.Host, rt.BasePath}
}
func TestExtractGrafanaClientFromHeaders(t *testing.T) {
t.Run("no headers, no env", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractGrafanaClientFromHeaders(context.Background(), req)
c := GrafanaClientFromContext(ctx)
url := minURLFromClient(c)
assert.Equal(t, "localhost:3000", url.host)
assert.Equal(t, "/api", url.basePath)
})
t.Run("no headers, with env", func(t *testing.T) {
t.Setenv("GRAFANA_URL", "http://my-test-url.grafana.com")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
ctx := ExtractGrafanaClientFromHeaders(context.Background(), req)
c := GrafanaClientFromContext(ctx)
url := minURLFromClient(c)
assert.Equal(t, "my-test-url.grafana.com", url.host)
assert.Equal(t, "/api", url.basePath)
})
t.Run("with headers, no env", func(t *testing.T) {
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
req.Header.Set(grafanaURLHeader, "http://my-test-url.grafana.com")
ctx := ExtractGrafanaClientFromHeaders(context.Background(), req)
c := GrafanaClientFromContext(ctx)
url := minURLFromClient(c)
assert.Equal(t, "my-test-url.grafana.com", url.host)
assert.Equal(t, "/api", url.basePath)
})
t.Run("with headers, with env", func(t *testing.T) {
// Env vars should be ignored if headers are present.
t.Setenv("GRAFANA_URL", "will-not-be-used")
req, err := http.NewRequest("GET", "http://example.com", nil)
require.NoError(t, err)
req.Header.Set(grafanaURLHeader, "http://my-test-url.grafana.com")
ctx := ExtractGrafanaClientFromHeaders(context.Background(), req)
c := GrafanaClientFromContext(ctx)
url := minURLFromClient(c)
assert.Equal(t, "my-test-url.grafana.com", url.host)
assert.Equal(t, "/api", url.basePath)
})
}
func TestToolTracingInstrumentation(t *testing.T) {
// Set up in-memory span recorder
spanRecorder := tracetest.NewSpanRecorder()
tracerProvider := sdktrace.NewTracerProvider(
sdktrace.WithSpanProcessor(spanRecorder),
)
originalProvider := otel.GetTracerProvider()
otel.SetTracerProvider(tracerProvider)
defer otel.SetTracerProvider(originalProvider) // Restore original provider
t.Run("successful tool execution creates span with correct attributes", func(t *testing.T) {
// Clear any previous spans
spanRecorder.Reset()
// Define a simple test tool
type TestParams struct {
Message string `json:"message" jsonschema:"description=Test message"`
}
testHandler := func(ctx context.Context, args TestParams) (string, error) {
return "Hello " + args.Message, nil
}
// Create tool using MustTool (this applies our instrumentation)
tool := MustTool("test_tool", "A test tool for tracing", testHandler)
// Create context with argument logging enabled
config := GrafanaConfig{
IncludeArgumentsInSpans: true,
}
ctx := WithGrafanaConfig(context.Background(), config)
// Create a mock MCP request
request := mcp.CallToolRequest{
Params: struct {
Name string `json:"name"`
Arguments any `json:"arguments,omitempty"`
Meta *mcp.Meta `json:"_meta,omitempty"`
}{
Name: "test_tool",
Arguments: map[string]interface{}{
"message": "world",
},
},
}
// Execute the tool
result, err := tool.Handler(ctx, request)
require.NoError(t, err)
require.NotNil(t, result)
// Verify span was created
spans := spanRecorder.Ended()
require.Len(t, spans, 1)
span := spans[0]
assert.Equal(t, "mcp.tool.test_tool", span.Name())
assert.Equal(t, codes.Ok, span.Status().Code)
// Check attributes
attributes := span.Attributes()
assertHasAttribute(t, attributes, "mcp.tool.name", "test_tool")
assertHasAttribute(t, attributes, "mcp.tool.description", "A test tool for tracing")
assertHasAttribute(t, attributes, "mcp.tool.arguments", `{"message":"world"}`)
})
t.Run("tool execution error records error on span", func(t *testing.T) {
// Clear any previous spans
spanRecorder.Reset()
// Define a test tool that returns an error
type TestParams struct {
ShouldFail bool `json:"shouldFail" jsonschema:"description=Whether to fail"`
}
testHandler := func(ctx context.Context, args TestParams) (string, error) {
if args.ShouldFail {
return "", assert.AnError
}
return "success", nil
}
// Create tool
tool := MustTool("failing_tool", "A tool that can fail", testHandler)
// Create context (spans always created)
config := GrafanaConfig{}
ctx := WithGrafanaConfig(context.Background(), config)
// Create a mock MCP request that will cause failure
request := mcp.CallToolRequest{
Params: struct {
Name string `json:"name"`
Arguments any `json:"arguments,omitempty"`
Meta *mcp.Meta `json:"_meta,omitempty"`
}{
Name: "failing_tool",
Arguments: map[string]interface{}{
"shouldFail": true,
},
},
}
// Execute the tool (should fail)
result, err := tool.Handler(ctx, request)
assert.Error(t, err)
assert.Nil(t, result)
// Verify span was created and marked as error
spans := spanRecorder.Ended()
require.Len(t, spans, 1)
span := spans[0]
assert.Equal(t, "mcp.tool.failing_tool", span.Name())
assert.Equal(t, codes.Error, span.Status().Code)
assert.Equal(t, assert.AnError.Error(), span.Status().Description)
// Verify error was recorded (check events for error record)
events := span.Events()
hasErrorEvent := false
for _, event := range events {
if event.Name == "exception" {
hasErrorEvent = true
break
}
}
assert.True(t, hasErrorEvent, "Expected error event to be recorded on span")
})
t.Run("spans always created for context propagation", func(t *testing.T) {
// Clear any previous spans
spanRecorder.Reset()
// Define a simple test tool
type TestParams struct {
Message string `json:"message" jsonschema:"description=Test message"`
}
testHandler := func(ctx context.Context, args TestParams) (string, error) {
return "processed", nil
}
// Create tool
tool := MustTool("context_prop_tool", "A tool for context propagation", testHandler)
// Create context with default config (no special flags)
config := GrafanaConfig{}
ctx := WithGrafanaConfig(context.Background(), config)
// Create a mock MCP request
request := mcp.CallToolRequest{
Params: struct {
Name string `json:"name"`
Arguments any `json:"arguments,omitempty"`
Meta *mcp.Meta `json:"_meta,omitempty"`
}{
Name: "context_prop_tool",
Arguments: map[string]interface{}{
"message": "test",
},
},
}
// Execute the tool (should always create spans for context propagation)
result, err := tool.Handler(ctx, request)
require.NoError(t, err)
require.NotNil(t, result)
// Verify spans ARE always created
spans := spanRecorder.Ended()
require.Len(t, spans, 1)
span := spans[0]
assert.Equal(t, "mcp.tool.context_prop_tool", span.Name())
assert.Equal(t, codes.Ok, span.Status().Code)
})
t.Run("arguments not logged by default (PII safety)", func(t *testing.T) {
// Clear any previous spans
spanRecorder.Reset()
// Define a simple test tool
type TestParams struct {
SensitiveData string `json:"sensitiveData" jsonschema:"description=Potentially sensitive data"`
}
testHandler := func(ctx context.Context, args TestParams) (string, error) {
return "processed", nil
}
// Create tool
tool := MustTool("sensitive_tool", "A tool with sensitive data", testHandler)
// Create context with argument logging disabled (default)
config := GrafanaConfig{
IncludeArgumentsInSpans: false, // Default: safe
}
ctx := WithGrafanaConfig(context.Background(), config)
// Create a mock MCP request with potentially sensitive data
request := mcp.CallToolRequest{
Params: struct {
Name string `json:"name"`
Arguments any `json:"arguments,omitempty"`
Meta *mcp.Meta `json:"_meta,omitempty"`
}{
Name: "sensitive_tool",
Arguments: map[string]interface{}{
"sensitiveData": "[email protected]",
},
},
}
// Execute the tool (arguments should NOT be logged by default)
result, err := tool.Handler(ctx, request)
require.NoError(t, err)
require.NotNil(t, result)
// Verify span was created
spans := spanRecorder.Ended()
require.Len(t, spans, 1)
span := spans[0]
assert.Equal(t, "mcp.tool.sensitive_tool", span.Name())
assert.Equal(t, codes.Ok, span.Status().Code)
// Check that arguments are NOT logged (PII safety)
attributes := span.Attributes()
assertHasAttribute(t, attributes, "mcp.tool.name", "sensitive_tool")
assertHasAttribute(t, attributes, "mcp.tool.description", "A tool with sensitive data")
// Verify arguments are NOT present
for _, attr := range attributes {
assert.NotEqual(t, "mcp.tool.arguments", string(attr.Key), "Arguments should not be logged by default for PII safety")
}
})
t.Run("arguments logged when argument logging enabled", func(t *testing.T) {
// Clear any previous spans
spanRecorder.Reset()
// Define a simple test tool
type TestParams struct {
SafeData string `json:"safeData" jsonschema:"description=Non-sensitive data"`
}
testHandler := func(ctx context.Context, args TestParams) (string, error) {
return "processed", nil
}
// Create tool
tool := MustTool("debug_tool", "A tool for debugging", testHandler)
// Create context with argument logging enabled
config := GrafanaConfig{
IncludeArgumentsInSpans: true,
}
ctx := WithGrafanaConfig(context.Background(), config)
// Create a mock MCP request
request := mcp.CallToolRequest{
Params: struct {
Name string `json:"name"`
Arguments any `json:"arguments,omitempty"`
Meta *mcp.Meta `json:"_meta,omitempty"`
}{
Name: "debug_tool",
Arguments: map[string]interface{}{
"safeData": "debug-value",
},
},
}
// Execute the tool (arguments SHOULD be logged when flag enabled)
result, err := tool.Handler(ctx, request)
require.NoError(t, err)
require.NotNil(t, result)
// Verify span was created
spans := spanRecorder.Ended()
require.Len(t, spans, 1)
span := spans[0]
assert.Equal(t, "mcp.tool.debug_tool", span.Name())
assert.Equal(t, codes.Ok, span.Status().Code)
// Check that arguments ARE logged when flag enabled
attributes := span.Attributes()
assertHasAttribute(t, attributes, "mcp.tool.name", "debug_tool")
assertHasAttribute(t, attributes, "mcp.tool.description", "A tool for debugging")
assertHasAttribute(t, attributes, "mcp.tool.arguments", `{"safeData":"debug-value"}`)
})
}
func TestHTTPTracingConfiguration(t *testing.T) {
t.Run("HTTP tracing always enabled for context propagation", func(t *testing.T) {
// Create context (HTTP tracing always enabled)
config := GrafanaConfig{}
ctx := WithGrafanaConfig(context.Background(), config)
// Create Grafana client
client := NewGrafanaClient(ctx, "http://localhost:3000", "test-api-key", nil, 0)
require.NotNil(t, client)
// Verify the client was created successfully (should not panic)
assert.NotNil(t, client.Transport)
})
t.Run("tracing works gracefully without OpenTelemetry configured", func(t *testing.T) {
// No OpenTelemetry tracer provider configured
// Create context (tracing always enabled for context propagation)
config := GrafanaConfig{}
ctx := WithGrafanaConfig(context.Background(), config)
// Create Grafana client (should not panic even without OTEL configured)
client := NewGrafanaClient(ctx, "http://localhost:3000", "test-api-key", nil, 0)
require.NotNil(t, client)
// Verify the client was created successfully
assert.NotNil(t, client.Transport)
})
}
// Helper function to check if an attribute exists with expected value
func assertHasAttribute(t *testing.T, attributes []attribute.KeyValue, key string, expectedValue string) {
for _, attr := range attributes {
if string(attr.Key) == key {
assert.Equal(t, expectedValue, attr.Value.AsString())
return
}
}
t.Errorf("Expected attribute %s with value %s not found", key, expectedValue)
}
```