#
tokens: 47874/50000 9/96 files (page 3/4)
lines: off (toggle) GitHub
raw markdown copy
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)
}

```
Page 3/4FirstPrevNextLast