#
tokens: 48447/50000 67/96 files (page 1/4)
lines: off (toggle) GitHub
raw markdown copy
This is page 1 of 4. Use http://codebase.md/grafana/mcp-grafana?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

--------------------------------------------------------------------------------
/tests/.gitignore:
--------------------------------------------------------------------------------

```
.env

```

--------------------------------------------------------------------------------
/tests/.python-version:
--------------------------------------------------------------------------------

```
3.13

```

--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------

```yaml
version: "2"
run:
  concurrency: 16
  timeout: 10m
  go: "1.24"
  relative-path-mode: gomod
  allow-parallel-runners: true

```

--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------

```
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
.DS_Store
.vscode/
.env
.cursor/

# Virtual environments
.venv
.envrc

```

--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------

```
# Git
.git
.gitignore
.github/

# Docker
Dockerfile
.dockerignore

# Build artifacts
bin/
dist/
build/
*.exe
*.exe~
*.dll
*.so
*.dylib

# Go specific
vendor/
go.work

# Testing
*_test.go
**/test/
**/tests/
coverage.out
coverage.html

# IDE and editor files
.idea/
.vscode/
*.swp
*.swo
*~

# OS specific
.DS_Store
Thumbs.db

# Temporary files
tmp/
temp/
*.tmp
*.log

# Documentation
docs/
*.md
LICENSE

# Development tools
.air.toml
.golangci.yml
.goreleaser.yml

# Debug files
debug
__debug_bin

```

--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------

```yaml
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json

version: 2

before:
  hooks:
    - go mod tidy

git:
  prerelease_suffix: "-rc"

builds:
  - id: default
    env:
      - CGO_ENABLED=0
    main: ./cmd/mcp-grafana
    goos:
      - linux
      - windows
      - darwin
    ldflags: "-s -w -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser"
  - id: gemini-cli-extension
    env:
      - CGO_ENABLED=0
    main: ./cmd/mcp-grafana
    goos:
      - linux
      - windows
      - darwin
    goarch:
      - amd64
      - arm64
    ldflags: "-s -w -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser"

archives:
  - id: default
    ids:
      - default
    formats: tar.gz
    # this name template makes the OS and Arch compatible with the results of `uname`.
    name_template: >-
      {{ .ProjectName }}_
      {{- title .Os }}_
      {{- if eq .Arch "amd64" }}x86_64
      {{- else if eq .Arch "386" }}i386
      {{- else }}{{ .Arch }}{{ end }}
      {{- if .Arm }}v{{ .Arm }}{{ end }}
    # use zip for windows archives
    format_overrides:
      - goos: windows
        formats: zip

  # The Gemini CLI Extension format.
  # See https://github.com/google-gemini/gemini-cli/blob/main/docs/extension-releasing.md#platform-specific-archives.
  # We'll use platform and architecture specific archive names, which must look like:
  #
  # {platform}.{arch}.{name}.{extension}
  #
  # Where the fields are:
  #
  # - {name}: The name of your extension.
  # - {platform}: The operating system. Supported values are:
  # -     darwin (macOS)
  # -     linux
  # -     win32 (Windows)
  # - {arch}: The architecture. Supported values are:
  # -     x64
  # -     arm64
  # - {extension}: The file extension of the archive (e.g., .tar.gz or .zip).
  #
  # Examples:
  # - darwin.arm64.{project}.tar.gz (specific to Apple Silicon Macs)
  # - linux.x64.{project}.tar.gz
  # - win32.{project}.zip
  - id: gemini-cli-extension
    ids:
      - gemini-cli-extension
    formats: tar.gz
    files:
      - gemini-extension.json
    name_template: >-
      {{ if eq .Os "windows" }}win32
      {{- else }}{{ .Os }}{{ end }}.
      {{- if eq .Arch "amd64" }}x64
      {{- else }}{{ .Arch }}{{ end }}.grafana
    format_overrides:
      - goos: windows
        formats: zip

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"

release:
  footer: >-

    ---

    Released by [GoReleaser](https://github.com/goreleaser/goreleaser).

```

--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------

```markdown
# Tests

This directory contains an e2e test suite for the Grafana MCP server.

The test suite evaluates the LLM's ability to use Grafana MCP tools effectively:

- **Loki tests**: Evaluates how well the LLM can use Grafana tools to:
  - Navigate and use available tools
  - Make appropriate tool calls
  - Process and present the results in a meaningful way
  - Evaluating the LLM responses using `langevals` package, using custom LLM-as-a-Judge approach.

The tests are run against two LLM models:
- GPT-4
- Claude 3.5 Sonnet

Tests are using [`uv`] to manage dependencies. Install uv following the instructions for your platform.

## Prerequisites
- Docker installed and running on your system
- Docker containers for the test environment must be started before running tests

## Setup
1. Create a virtual environment and install the dependencies:
   ```bash
   uv sync --all-groups
   ```

2. Create a `.env` file with your API keys:
   ```env
   OPENAI_API_KEY=sk-...
   ANTHROPIC_API_KEY=sk-ant-...
   ```

3. Start the required Docker containers

4. Start the MCP server in SSE mode; from the root of the project:
   ```bash
   go run ./cmd/mcp-grafana -t sse
   ```

5. Run the tests:
   ```bash
   uv run pytest
   ```

[`uv`]: https://docs.astral.sh/uv/

```

--------------------------------------------------------------------------------
/internal/linter/jsonschema/README.md:
--------------------------------------------------------------------------------

```markdown
# JSONSchema Linter

This linter helps detect and prevent a common issue with Go struct tags in this project. 

## The Problem

In Go struct tags using `jsonschema`, commas in the `description` field need to be escaped using `\\,` syntax. If commas aren't properly escaped, the description is silently truncated at the comma.

For example:

```go
// Problematic (description will be truncated at the first comma):
type Example struct {
    Field string `jsonschema:"description=This is a description, but it will be truncated here"`
}

// Correct (commas properly escaped):
type Example struct {
    Field string `jsonschema:"description=This is a description\\, and it will be fully included"`
}
```

## Usage

You can use this linter by running:

```shell
make lint-jsonschema
```

or directly:

```shell
go run ./cmd/linters/jsonschema --path .
```

### Auto-fixing issues

The linter can automatically fix unescaped commas in jsonschema descriptions by running:

```shell
make lint-jsonschema-fix
```

or directly:

```shell
go run ./cmd/linters/jsonschema --path . --fix
```

This will scan the codebase for unescaped commas and automatically escape them, then report what was fixed.

## Flags

- `--path`: Base directory to scan for Go files (default: ".")
- `--fix`: Automatically fix unescaped commas
- `--help`: Display help information

## Integration

This linter is integrated into the default `make lint` command, ensuring all PRs are checked for this issue.
```

--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------

```markdown
# Grafana MCP server

[![Unit Tests](https://github.com/grafana/mcp-grafana/actions/workflows/unit.yml/badge.svg)](https://github.com/grafana/mcp-grafana/actions/workflows/unit.yml)
[![Integration Tests](https://github.com/grafana/mcp-grafana/actions/workflows/integration.yml/badge.svg)](https://github.com/grafana/mcp-grafana/actions/workflows/integration.yml)
[![E2E Tests](https://github.com/grafana/mcp-grafana/actions/workflows/e2e.yml/badge.svg)](https://github.com/grafana/mcp-grafana/actions/workflows/e2e.yml)
[![Go Reference](https://pkg.go.dev/badge/github.com/grafana/mcp-grafana.svg)](https://pkg.go.dev/github.com/grafana/mcp-grafana)
[![MCP Catalog](https://archestra.ai/mcp-catalog/api/badge/quality/grafana/mcp-grafana)](https://archestra.ai/mcp-catalog/grafana__mcp-grafana)

A [Model Context Protocol][mcp] (MCP) server for Grafana.

This provides access to your Grafana instance and the surrounding ecosystem.

## Requirements

- **Grafana version 9.0 or later** is required for full functionality. Some features, particularly datasource-related operations, may not work correctly with earlier versions due to missing API endpoints.

## Features

_The following features are currently available in MCP server. This list is for informational purposes only and does not represent a roadmap or commitment to future features._

### Dashboards

- **Search for dashboards:** Find dashboards by title or other metadata
- **Get dashboard by UID:** Retrieve full dashboard details using its unique identifier. _Warning: Large dashboards can consume significant context window space._
- **Get dashboard summary:** Get a compact overview of a dashboard including title, panel count, panel types, variables, and metadata without the full JSON to minimize context window usage
- **Get dashboard property:** Extract specific parts of a dashboard using JSONPath expressions (e.g., `$.title`, `$.panels[*].title`) to fetch only needed data and reduce context window consumption
- **Update or create a dashboard:** Modify existing dashboards or create new ones. _Warning: Requires full dashboard JSON which can consume large amounts of context window space._
- **Patch dashboard:** Apply specific changes to a dashboard without requiring the full JSON, significantly reducing context window usage for targeted modifications
- **Get panel queries and datasource info:** Get the title, query string, and datasource information (including UID and type, if available) from every panel in a dashboard

#### Context Window Management

The dashboard tools now include several strategies to manage context window usage effectively ([issue #101](https://github.com/grafana/mcp-grafana/issues/101)):

- **Use `get_dashboard_summary`** for dashboard overview and planning modifications
- **Use `get_dashboard_property`** with JSONPath when you only need specific dashboard parts
- **Avoid `get_dashboard_by_uid`** unless you specifically need the complete dashboard JSON

### Datasources

- **List and fetch datasource information:** View all configured datasources and retrieve detailed information about each.
  - _Supported datasource types: Prometheus, Loki._

### Prometheus Querying

- **Query Prometheus:** Execute PromQL queries (supports both instant and range metric queries) against Prometheus datasources.
- **Query Prometheus metadata:** Retrieve metric metadata, metric names, label names, and label values from Prometheus datasources.

### Loki Querying

- **Query Loki logs and metrics:** Run both log queries and metric queries using LogQL against Loki datasources.
- **Query Loki metadata:** Retrieve label names, label values, and stream statistics from Loki datasources.

### Incidents

- **Search, create, and update incidents:** Manage incidents in Grafana Incident, including searching, creating, and adding activities to incidents.

### Sift Investigations

- **List Sift investigations:** Retrieve a list of Sift investigations, with support for a limit parameter.
- **Get Sift investigation:** Retrieve details of a specific Sift investigation by its UUID.
- **Get Sift analyses:** Retrieve a specific analysis from a Sift investigation.
- **Find error patterns in logs:** Detect elevated error patterns in Loki logs using Sift.
- **Find slow requests:** Detect slow requests using Sift (Tempo).

### Alerting

- **List and fetch alert rule information:** View alert rules and their statuses (firing/normal/error/etc.) in Grafana.
- **List contact points:** View configured notification contact points in Grafana.

### Grafana OnCall

- **List and manage schedules:** View and manage on-call schedules in Grafana OnCall.
- **Get shift details:** Retrieve detailed information about specific on-call shifts.
- **Get current on-call users:** See which users are currently on call for a schedule.
- **List teams and users:** View all OnCall teams and users.
- **List alert groups:** View and filter alert groups from Grafana OnCall by various criteria including state, integration, labels, and time range.
- **Get alert group details:** Retrieve detailed information about a specific alert group by its ID.

### Admin

- **List teams:** View all configured teams in Grafana.
- **List Users:** View all users in an organization in Grafana.

### Navigation

- **Generate deeplinks:** Create accurate deeplink URLs for Grafana resources instead of relying on LLM URL guessing.
  - **Dashboard links:** Generate direct links to dashboards using their UID (e.g., `http://localhost:3000/d/dashboard-uid`)
  - **Panel links:** Create links to specific panels within dashboards with viewPanel parameter (e.g., `http://localhost:3000/d/dashboard-uid?viewPanel=5`)
  - **Explore links:** Generate links to Grafana Explore with pre-configured datasources (e.g., `http://localhost:3000/explore?left={"datasource":"prometheus-uid"}`)
  - **Time range support:** Add time range parameters to links (`from=now-1h&to=now`)
  - **Custom parameters:** Include additional query parameters like dashboard variables or refresh intervals

### Annotations

- **Get Annotations:** Query annotations with filters. Supports time range, dashboard UID, tags, and match mode.
- **Create Annotation:** Create a new annotation on a dashboard or panel.
- **Create Graphite Annotation:** Create annotations using Graphite format (`what`, `when`, `tags`, `data`).
- **Update Annotation:** Replace all fields of an existing annotation (full update).
- **Patch Annotation:** Update only specific fields of an annotation (partial update).
- **Get Annotation Tags:** List available annotation tags with optional filtering.


The list of tools is configurable, so you can choose which tools you want to make available to the MCP client.
This is useful if you don't use certain functionality or if you don't want to take up too much of the context window.
To disable a category of tools, use the `--disable-<category>` flag when starting the server. For example, to disable
the OnCall tools, use `--disable-oncall`, or to disable navigation deeplink generation, use `--disable-navigation`.


#### RBAC Permissions

Each tool requires specific RBAC permissions to function properly. When creating a service account for the MCP server, ensure it has the necessary permissions based on which tools you plan to use. The permissions listed are the minimum required actions - you may also need appropriate scopes (e.g., `datasources:*`, `dashboards:*`, `folders:*`) depending on your use case.

Tip: If you're not familiar with Grafana RBAC or you want a quicker, simpler setup instead of configuring many granular scopes, you can assign a built-in role such as `Editor` to the service account. The `Editor` role grants broad read/write access that will allow most MCP server operations; it is less granular (and therefore less restrictive) than manually-applied scopes, so use it only when convenience is more important than strict least-privilege access.

**Note:** Grafana Incident and Sift tools use basic Grafana roles instead of fine-grained RBAC permissions:
- **Viewer role:** Required for read-only operations (list incidents, get investigations)
- **Editor role:** Required for write operations (create incidents, modify investigations)

For more information about Grafana RBAC, see the [official documentation](https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/access-control/).

#### RBAC Scopes

Scopes define the specific resources that permissions apply to. Each action requires both the appropriate permission and scope combination.

**Common Scope Patterns:**

- **Broad access:** Use `*` wildcards for organization-wide access

  - `datasources:*` - Access to all datasources
  - `dashboards:*` - Access to all dashboards
  - `folders:*` - Access to all folders
  - `teams:*` - Access to all teams

- **Limited access:** Use specific UIDs or IDs to restrict access to individual resources
  - `datasources:uid:prometheus-uid` - Access only to a specific Prometheus datasource
  - `dashboards:uid:abc123` - Access only to dashboard with UID `abc123`
  - `folders:uid:xyz789` - Access only to folder with UID `xyz789`
  - `teams:id:5` - Access only to team with ID `5`
  - `global.users:id:123` - Access only to user with ID `123`

**Examples:**

- **Full MCP server access:** Grant broad permissions for all tools

  ```
  datasources:* (datasources:read, datasources:query)
  dashboards:* (dashboards:read, dashboards:create, dashboards:write)
  folders:* (for dashboard creation and alert rules)
  teams:* (teams:read)
  global.users:* (users:read)
  ```

- **Limited datasource access:** Only query specific Prometheus and Loki instances

  ```
  datasources:uid:prometheus-prod (datasources:query)
  datasources:uid:loki-prod (datasources:query)
  ```

- **Dashboard-specific access:** Read only specific dashboards
  ```
  dashboards:uid:monitoring-dashboard (dashboards:read)
  dashboards:uid:alerts-dashboard (dashboards:read)
  ```

### Tools

| Tool                              | Category    | Description                                                        | Required RBAC Permissions               | Required Scopes                                     |
| --------------------------------- | ----------- | ------------------------------------------------------------------ | --------------------------------------- | --------------------------------------------------- |
| `list_teams`                      | Admin       | List all teams                                                     | `teams:read`                            | `teams:*` or `teams:id:1`                           |
| `list_users_by_org`               | Admin       | List all users in an organization                                  | `users:read`                            | `global.users:*` or `global.users:id:123`           |
| `search_dashboards`               | Search      | Search for dashboards                                              | `dashboards:read`                       | `dashboards:*` or `dashboards:uid:abc123`           |
| `get_dashboard_by_uid`            | Dashboard   | Get a dashboard by uid                                             | `dashboards:read`                       | `dashboards:uid:abc123`                             |
| `update_dashboard`                | Dashboard   | Update or create a new dashboard                                   | `dashboards:create`, `dashboards:write` | `dashboards:*`, `folders:*` or `folders:uid:xyz789` |
| `get_dashboard_panel_queries`     | Dashboard   | Get panel title, queries, datasource UID and type from a dashboard | `dashboards:read`                       | `dashboards:uid:abc123`                             |
| `get_dashboard_property`          | Dashboard   | Extract specific parts of a dashboard using JSONPath expressions   | `dashboards:read`                       | `dashboards:uid:abc123`                             |
| `get_dashboard_summary`           | Dashboard   | Get a compact summary of a dashboard without full JSON             | `dashboards:read`                       | `dashboards:uid:abc123`                             |
| `list_datasources`                | Datasources | List datasources                                                   | `datasources:read`                      | `datasources:*`                                     |
| `get_datasource_by_uid`           | Datasources | Get a datasource by uid                                            | `datasources:read`                      | `datasources:uid:prometheus-uid`                    |
| `get_datasource_by_name`          | Datasources | Get a datasource by name                                           | `datasources:read`                      | `datasources:*` or `datasources:uid:loki-uid`       |
| `query_prometheus`                | Prometheus  | Execute a query against a Prometheus datasource                    | `datasources:query`                     | `datasources:uid:prometheus-uid`                    |
| `list_prometheus_metric_metadata` | Prometheus  | List metric metadata                                               | `datasources:query`                     | `datasources:uid:prometheus-uid`                    |
| `list_prometheus_metric_names`    | Prometheus  | List available metric names                                        | `datasources:query`                     | `datasources:uid:prometheus-uid`                    |
| `list_prometheus_label_names`     | Prometheus  | List label names matching a selector                               | `datasources:query`                     | `datasources:uid:prometheus-uid`                    |
| `list_prometheus_label_values`    | Prometheus  | List values for a specific label                                   | `datasources:query`                     | `datasources:uid:prometheus-uid`                    |
| `list_incidents`                  | Incident    | List incidents in Grafana Incident                                 | Viewer role                             | N/A                                                 |
| `create_incident`                 | Incident    | Create an incident in Grafana Incident                             | Editor role                             | N/A                                                 |
| `add_activity_to_incident`        | Incident    | Add an activity item to an incident in Grafana Incident            | Editor role                             | N/A                                                 |
| `get_incident`                    | Incident    | Get a single incident by ID                                        | Viewer role                             | N/A                                                 |
| `query_loki_logs`                 | Loki        | Query and retrieve logs using LogQL (either log or metric queries) | `datasources:query`                     | `datasources:uid:loki-uid`                          |
| `list_loki_label_names`           | Loki        | List all available label names in logs                             | `datasources:query`                     | `datasources:uid:loki-uid`                          |
| `list_loki_label_values`          | Loki        | List values for a specific log label                               | `datasources:query`                     | `datasources:uid:loki-uid`                          |
| `query_loki_stats`                | Loki        | Get statistics about log streams                                   | `datasources:query`                     | `datasources:uid:loki-uid`                          |
| `list_alert_rules`                | Alerting    | List alert rules                                                   | `alert.rules:read`                      | `folders:*` or `folders:uid:alerts-folder`          |
| `get_alert_rule_by_uid`           | Alerting    | Get alert rule by UID                                              | `alert.rules:read`                      | `folders:uid:alerts-folder`                         |
| `list_contact_points`             | Alerting    | List notification contact points                                   | `alert.notifications:read`              | Global scope                                        |
| `list_oncall_schedules`           | OnCall      | List schedules from Grafana OnCall                                 | `grafana-oncall-app.schedules:read`     | Plugin-specific scopes                              |
| `get_oncall_shift`                | OnCall      | Get details for a specific OnCall shift                            | `grafana-oncall-app.schedules:read`     | Plugin-specific scopes                              |
| `get_current_oncall_users`        | OnCall      | Get users currently on-call for a specific schedule                | `grafana-oncall-app.schedules:read`     | Plugin-specific scopes                              |
| `list_oncall_teams`               | OnCall      | List teams from Grafana OnCall                                     | `grafana-oncall-app.user-settings:read` | Plugin-specific scopes                              |
| `list_oncall_users`               | OnCall      | List users from Grafana OnCall                                     | `grafana-oncall-app.user-settings:read` | Plugin-specific scopes                              |
| `list_alert_groups`               | OnCall      | List alert groups from Grafana OnCall with filtering options       | `grafana-oncall-app.alert-groups:read`  | Plugin-specific scopes                              |
| `get_alert_group`                 | OnCall      | Get a specific alert group from Grafana OnCall by its ID           | `grafana-oncall-app.alert-groups:read`  | Plugin-specific scopes                              |
| `get_sift_investigation`          | Sift        | Retrieve an existing Sift investigation by its UUID                | Viewer role                             | N/A                                                 |
| `get_sift_analysis`               | Sift        | Retrieve a specific analysis from a Sift investigation             | Viewer role                             | N/A                                                 |
| `list_sift_investigations`        | Sift        | Retrieve a list of Sift investigations with an optional limit      | Viewer role                             | N/A                                                 |
| `find_error_pattern_logs`         | Sift        | Finds elevated error patterns in Loki logs.                        | Editor role                             | N/A                                                 |
| `find_slow_requests`              | Sift        | Finds slow requests from the relevant tempo datasources.           | Editor role                             | N/A                                                 |
| `list_pyroscope_label_names`      | Pyroscope   | List label names matching a selector                               | `datasources:query`                     | `datasources:uid:pyroscope-uid`                     |
| `list_pyroscope_label_values`     | Pyroscope   | List label values matching a selector for a label name             | `datasources:query`                     | `datasources:uid:pyroscope-uid`                     |
| `list_pyroscope_profile_types`    | Pyroscope   | List available profile types                                       | `datasources:query`                     | `datasources:uid:pyroscope-uid`                     |
| `fetch_pyroscope_profile`         | Pyroscope   | Fetches a profile in DOT format for analysis                       | `datasources:query`                     | `datasources:uid:pyroscope-uid`                     |
| `get_assertions`                  | Asserts     | Get assertion summary for a given entity                           | Plugin-specific permissions             | Plugin-specific scopes                              |
| `generate_deeplink`               | Navigation  | Generate accurate deeplink URLs for Grafana resources              | None (read-only URL generation)         | N/A
| `get_annotations`                 | Annotations | Fetch annotations with filters                                      | `annotations:read`                      | `annotations:*` or `annotations:id:123`            |
| `create_annotation`               | Annotations | Create a new annotation on a dashboard or panel                     | `annotations:write`                     | `annotations:*`                                    |
| `create_graphite_annotation`      | Annotations | Create an annotation using Graphite format                          | `annotations:write`                     | `annotations:*`                                    |
| `update_annotation`               | Annotations | Replace all fields of an annotation (full update)                   | `annotations:write`                     | `annotations:*`                                    |
| `patch_annotation`                | Annotations | Update only specific fields of an annotation (partial update)       | `annotations:write`                     | `annotations:*`                                    |
| `get_annotation_tags`             | Annotations | List annotation tags with optional filtering                        | `annotations:read`                      | `annotations:*`                                    |
                                              |

## CLI Flags Reference

The `mcp-grafana` binary supports various command-line flags for configuration:

**Transport Options:**
- `-t, --transport`: Transport type (`stdio`, `sse`, or `streamable-http`) - default: `stdio`
- `--address`: The host and port for SSE/streamable-http server - default: `localhost:8000`
- `--base-path`: Base path for the SSE/streamable-http server
- `--endpoint-path`: Endpoint path for the streamable-http server - default: `/`

**Debug and Logging:**
- `--debug`: Enable debug mode for detailed HTTP request/response logging

**Tool Configuration:**
- `--enabled-tools`: Comma-separated list of enabled categories - default: all categories enabled - example: "loki,datasources"
- `--disable-search`: Disable search tools
- `--disable-datasource`: Disable datasource tools
- `--disable-incident`: Disable incident tools
- `--disable-prometheus`: Disable prometheus tools
- `--disable-write`: Disable write tools (create/update operations)
- `--disable-loki`: Disable loki tools
- `--disable-alerting`: Disable alerting tools
- `--disable-dashboard`: Disable dashboard tools
- `--disable-oncall`: Disable oncall tools
- `--disable-asserts`: Disable asserts tools
- `--disable-sift`: Disable sift tools
- `--disable-admin`: Disable admin tools
- `--disable-pyroscope`: Disable pyroscope tools
- `--disable-navigation`: Disable navigation tools

### Read-Only Mode

The `--disable-write` flag provides a way to run the MCP server in read-only mode, preventing any write operations to your Grafana instance. This is useful for scenarios where you want to provide safe, read-only access such as:

- Using service accounts with limited read-only permissions
- Providing AI assistants with observability data without modification capabilities
- Running in production environments where write access should be restricted
- Testing and development scenarios where you want to prevent accidental modifications

When `--disable-write` is enabled, the following write operations are disabled:

**Dashboard Tools:**
- `update_dashboard`

**Folder Tools:**
- `create_folder`

**Incident Tools:**
- `create_incident`
- `add_activity_to_incident`

**Alerting Tools:**
- `create_alert_rule`
- `update_alert_rule`
- `delete_alert_rule`

**Annotation Tools:**
- `create_annotation`
- `create_graphite_annotation`
- `update_annotation`
- `patch_annotation`

**Sift Tools:**
- `find_error_pattern_logs` (creates investigations)
- `find_slow_requests` (creates investigations)

All read operations remain available, allowing you to query dashboards, run PromQL/LogQL queries, list resources, and retrieve data.

**Client TLS Configuration (for Grafana connections):**
- `--tls-cert-file`: Path to TLS certificate file for client authentication
- `--tls-key-file`: Path to TLS private key file for client authentication
- `--tls-ca-file`: Path to TLS CA certificate file for server verification
- `--tls-skip-verify`: Skip TLS certificate verification (insecure)

**Server TLS Configuration (streamable-http transport only):**
- `--server.tls-cert-file`: Path to TLS certificate file for server HTTPS
- `--server.tls-key-file`: Path to TLS private key file for server HTTPS

## Usage

This MCP server works with both local Grafana instances and Grafana Cloud. For Grafana Cloud, use your instance URL (e.g., `https://myinstance.grafana.net`) instead of `http://localhost:3000` in the configuration examples below.

1. If using service account token authentication, create a service account in Grafana with enough permissions to use the tools you want to use,
   generate a service account token, and copy it to the clipboard for use in the configuration file.
   Follow the [Grafana service account documentation][service-account] for details on creating service account tokens.
   Tip: If you're not comfortable configuring fine-grained RBAC scopes, a simpler (but less restrictive) option is to assign the built-in `Editor` role to the service account. This grants broad read/write access that covers most MCP server operations — use it when convenience outweighs strict least-privilege requirements.

   > **Note:** The environment variable `GRAFANA_API_KEY` is deprecated and will be removed in a future version. Please migrate to using `GRAFANA_SERVICE_ACCOUNT_TOKEN` instead. The old variable name will continue to work for backward compatibility but will show deprecation warnings.

### Multi-Organization Support
 
You can specify which organization to interact with using either:

- **Environment variable:** Set `GRAFANA_ORG_ID` to the numeric organization ID
- **HTTP header:** Set `X-Grafana-Org-Id` when using SSE or streamable HTTP transports (header takes precedence over environment variable - meaning you can set a default org as well).

When an organization ID is provided, the MCP server will set the `X-Grafana-Org-Id` header on all requests to Grafana, ensuring that operations are performed within the specified organization context.

**Example with organization ID:**

```json
{
  "mcpServers": {
    "grafana": {
      "command": "mcp-grafana",
      "args": [],
      "env": {
        "GRAFANA_URL": "http://localhost:3000",
        "GRAFANA_USERNAME": "<your username>",
        "GRAFANA_PASSWORD": "<your password>",
        "GRAFANA_ORG_ID": "2"
      }
    }
  }
}
```

2. You have several options to install `mcp-grafana`:

   - **Docker image**: Use the pre-built Docker image from Docker Hub.

     **Important**: The Docker image's entrypoint is configured to run the MCP server in SSE mode by default, but most users will want to use STDIO mode for direct integration with AI assistants like Claude Desktop:

     1. **STDIO Mode**: For stdio mode you must explicitly override the default with `-t stdio` and include the `-i` flag to keep stdin open:

     ```bash
     docker pull mcp/grafana
     # For local Grafana:
     docker run --rm -i -e GRAFANA_URL=http://localhost:3000 -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> mcp/grafana -t stdio
     # For Grafana Cloud:
     docker run --rm -i -e GRAFANA_URL=https://myinstance.grafana.net -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> mcp/grafana -t stdio
     ```

     2. **SSE Mode**: In this mode, the server runs as an HTTP server that clients connect to. You must expose port 8000 using the `-p` flag:

     ```bash
     docker pull mcp/grafana
     docker run --rm -p 8000:8000 -e GRAFANA_URL=http://localhost:3000 -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> mcp/grafana
     ```

     3. **Streamable HTTP Mode**: In this mode, the server operates as an independent process that can handle multiple client connections. You must expose port 8000 using the `-p` flag: For this mode you must explicitly override the default with `-t streamable-http`

     ```bash
     docker pull mcp/grafana
     docker run --rm -p 8000:8000 -e GRAFANA_URL=http://localhost:3000 -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> mcp/grafana -t streamable-http
     ```

     For HTTPS streamable HTTP mode with server TLS certificates:

     ```bash
     docker pull mcp/grafana
     docker run --rm -p 8443:8443 \
       -v /path/to/certs:/certs:ro \
       -e GRAFANA_URL=http://localhost:3000 \
       -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> \
       mcp/grafana \
       -t streamable-http \
       -addr :8443 \
       --server.tls-cert-file /certs/server.crt \
       --server.tls-key-file /certs/server.key
     ```

   - **Download binary**: Download the latest release of `mcp-grafana` from the [releases page](https://github.com/grafana/mcp-grafana/releases) and place it in your `$PATH`.

   - **Build from source**: If you have a Go toolchain installed you can also build and install it from source, using the `GOBIN` environment variable
     to specify the directory where the binary should be installed. This should also be in your `PATH`.

     ```bash
     GOBIN="$HOME/go/bin" go install github.com/grafana/mcp-grafana/cmd/mcp-grafana@latest
     ```

   - **Deploy to Kubernetes using Helm**: use the [Helm chart from the Grafana helm-charts repository](https://github.com/grafana/helm-charts/tree/main/charts/grafana-mcp)

     ```bash
     helm repo add grafana https://grafana.github.io/helm-charts
     helm install --set grafana.apiKey=<Grafana_ApiKey> --set grafana.url=<GrafanaUrl> my-release grafana/grafana-mcp
     ```


3. Add the server configuration to your client configuration file. For example, for Claude Desktop:

   **If using the binary:**

   ```json
   {
     "mcpServers": {
       "grafana": {
         "command": "mcp-grafana",
         "args": [],
         "env": {
           "GRAFANA_URL": "http://localhost:3000",  // Or "https://myinstance.grafana.net" for Grafana Cloud
           "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>",
           // If using username/password authentication
           "GRAFANA_USERNAME": "<your username>",
           "GRAFANA_PASSWORD": "<your password>",
           // Optional: specify organization ID for multi-org support
           "GRAFANA_ORG_ID": "1"
         }
       }
     }
   }
   ```

> Note: if you see `Error: spawn mcp-grafana ENOENT` in Claude Desktop, you need to specify the full path to `mcp-grafana`.

**If using Docker:**

```json
{
  "mcpServers": {
    "grafana": {
      "command": "docker",
      "args": [
        "run",
        "--rm",
        "-i",
        "-e",
        "GRAFANA_URL",
        "-e",
        "GRAFANA_SERVICE_ACCOUNT_TOKEN",
        "mcp/grafana",
        "-t",
        "stdio"
      ],
      "env": {
        "GRAFANA_URL": "http://localhost:3000",  // Or "https://myinstance.grafana.net" for Grafana Cloud
        "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>",
        // If using username/password authentication
        "GRAFANA_USERNAME": "<your username>",
        "GRAFANA_PASSWORD": "<your password>",
        // Optional: specify organization ID for multi-org support
        "GRAFANA_ORG_ID": "1"
      }
    }
  }
}
```

> Note: The `-t stdio` argument is essential here because it overrides the default SSE mode in the Docker image.

**Using VSCode with remote MCP server**

If you're using VSCode and running the MCP server in SSE mode (which is the default when using the Docker image without overriding the transport), make sure your `.vscode/settings.json` includes the following:

```json
"mcp": {
  "servers": {
    "grafana": {
      "type": "sse",
      "url": "http://localhost:8000/sse"
    }
  }
}
```

For HTTPS streamable HTTP mode with server TLS certificates:

```json
"mcp": {
  "servers": {
    "grafana": {
      "type": "sse",
      "url": "https://localhost:8443/sse"
    }
  }
}
```

### Debug Mode

You can enable debug mode for the Grafana transport by adding the `-debug` flag to the command. This will provide detailed logging of HTTP requests and responses between the MCP server and the Grafana API, which can be helpful for troubleshooting.

To use debug mode with the Claude Desktop configuration, update your config as follows:

**If using the binary:**

```json
{
  "mcpServers": {
    "grafana": {
      "command": "mcp-grafana",
      "args": ["-debug"],
      "env": {
        "GRAFANA_URL": "http://localhost:3000",  // Or "https://myinstance.grafana.net" for Grafana Cloud
        "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
      }
    }
  }
}
```

**If using Docker:**

```json
{
  "mcpServers": {
    "grafana": {
      "command": "docker",
      "args": [
        "run",
        "--rm",
        "-i",
        "-e",
        "GRAFANA_URL",
        "-e",
        "GRAFANA_SERVICE_ACCOUNT_TOKEN",
        "mcp/grafana",
        "-t",
        "stdio",
        "-debug"
      ],
      "env": {
        "GRAFANA_URL": "http://localhost:3000",  // Or "https://myinstance.grafana.net" for Grafana Cloud
        "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
      }
    }
  }
}
```

> Note: As with the standard configuration, the `-t stdio` argument is required to override the default SSE mode in the Docker image.

### TLS Configuration

If your Grafana instance is behind mTLS or requires custom TLS certificates, you can configure the MCP server to use custom certificates. The server supports the following TLS configuration options:

- `--tls-cert-file`: Path to TLS certificate file for client authentication
- `--tls-key-file`: Path to TLS private key file for client authentication
- `--tls-ca-file`: Path to TLS CA certificate file for server verification
- `--tls-skip-verify`: Skip TLS certificate verification (insecure, use only for testing)

**Example with client certificate authentication:**

```json
{
  "mcpServers": {
    "grafana": {
      "command": "mcp-grafana",
      "args": [
        "--tls-cert-file",
        "/path/to/client.crt",
        "--tls-key-file",
        "/path/to/client.key",
        "--tls-ca-file",
        "/path/to/ca.crt"
      ],
      "env": {
        "GRAFANA_URL": "https://secure-grafana.example.com",
        "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
      }
    }
  }
}
```

**Example with Docker:**

```json
{
  "mcpServers": {
    "grafana": {
      "command": "docker",
      "args": [
        "run",
        "--rm",
        "-i",
        "-v",
        "/path/to/certs:/certs:ro",
        "-e",
        "GRAFANA_URL",
        "-e",
        "GRAFANA_SERVICE_ACCOUNT_TOKEN",
        "mcp/grafana",
        "-t",
        "stdio",
        "--tls-cert-file",
        "/certs/client.crt",
        "--tls-key-file",
        "/certs/client.key",
        "--tls-ca-file",
        "/certs/ca.crt"
      ],
      "env": {
        "GRAFANA_URL": "https://secure-grafana.example.com",
        "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
      }
    }
  }
}
```

The TLS configuration is applied to all HTTP clients used by the MCP server, including:

- The main Grafana OpenAPI client
- Prometheus datasource clients
- Loki datasource clients
- Incident management clients
- Sift investigation clients
- Alerting clients
- Asserts clients

**Direct CLI Usage Examples:**

For testing with self-signed certificates:

```bash
./mcp-grafana --tls-skip-verify -debug
```

With client certificate authentication:

```bash
./mcp-grafana \
  --tls-cert-file /path/to/client.crt \
  --tls-key-file /path/to/client.key \
  --tls-ca-file /path/to/ca.crt \
  -debug
```

With custom CA certificate only:

```bash
./mcp-grafana --tls-ca-file /path/to/ca.crt
```

**Programmatic Usage:**

If you're using this library programmatically, you can also create TLS-enabled context functions:

```go
// Using struct literals
tlsConfig := &mcpgrafana.TLSConfig{
    CertFile: "/path/to/client.crt",
    KeyFile:  "/path/to/client.key",
    CAFile:   "/path/to/ca.crt",
}
grafanaConfig := mcpgrafana.GrafanaConfig{
    Debug:     true,
    TLSConfig: tlsConfig,
}
contextFunc := mcpgrafana.ComposedStdioContextFunc(grafanaConfig)

// Or inline
grafanaConfig := mcpgrafana.GrafanaConfig{
    Debug: true,
    TLSConfig: &mcpgrafana.TLSConfig{
        CertFile: "/path/to/client.crt",
        KeyFile:  "/path/to/client.key",
        CAFile:   "/path/to/ca.crt",
    },
}
contextFunc := mcpgrafana.ComposedStdioContextFunc(grafanaConfig)
```

### Server TLS Configuration (Streamable HTTP Transport Only)

When using the streamable HTTP transport (`-t streamable-http`), you can configure the MCP server to serve HTTPS instead of HTTP. This is useful when you need to secure the connection between your MCP client and the server itself.

The server supports the following TLS configuration options for the streamable HTTP transport:

- `--server.tls-cert-file`: Path to TLS certificate file for server HTTPS (required for TLS)
- `--server.tls-key-file`: Path to TLS private key file for server HTTPS (required for TLS)

**Note**: These flags are completely separate from the client TLS flags documented above. The client TLS flags configure how the MCP server connects to Grafana, while these server TLS flags configure how clients connect to the MCP server when using streamable HTTP transport.

**Example with HTTPS streamable HTTP server:**

```bash
./mcp-grafana \
  -t streamable-http \
  --server.tls-cert-file /path/to/server.crt \
  --server.tls-key-file /path/to/server.key \
  -addr :8443
```

This would start the MCP server on HTTPS port 8443. Clients would then connect to `https://localhost:8443/` instead of `http://localhost:8000/`.

**Docker example with server TLS:**

```bash
docker run --rm -p 8443:8443 \
  -v /path/to/certs:/certs:ro \
  -e GRAFANA_URL=http://localhost:3000 \
  -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> \
  mcp/grafana \
  -t streamable-http \
  -addr :8443 \
  --server.tls-cert-file /certs/server.crt \
  --server.tls-key-file /certs/server.key
```

### Health Check Endpoint

When using the SSE (`-t sse`) or streamable HTTP (`-t streamable-http`) transports, the MCP server exposes a health check endpoint at `/healthz`. This endpoint can be used by load balancers, monitoring systems, or orchestration platforms to verify that the server is running and accepting connections.

**Endpoint:** `GET /healthz`

**Response:**
- Status Code: `200 OK`
- Body: `ok`

**Example usage:**

```bash
# For streamable HTTP or SSE transport on default port
curl http://localhost:8000/healthz

# With custom address
curl http://localhost:9090/healthz
```

**Note:** The health check endpoint is only available when using SSE or streamable HTTP transports. It is not available when using the stdio transport (`-t stdio`), as stdio does not expose an HTTP server.

## Troubleshooting

### Grafana Version Compatibility

If you encounter the following error when using datasource-related tools:

```
get datasource by uid : [GET /datasources/uid/{uid}][400] getDataSourceByUidBadRequest {"message":"id is invalid"}
```

This typically indicates that you are using a Grafana version earlier than 9.0. The `/datasources/uid/{uid}` API endpoint was introduced in Grafana 9.0, and datasource operations will fail on earlier versions.

**Solution:** Upgrade your Grafana instance to version 9.0 or later to resolve this issue.

## Development

Contributions are welcome! Please open an issue or submit a pull request if you have any suggestions or improvements.

This project is written in Go. Install Go following the instructions for your platform.

To run the server locally in STDIO mode (which is the default for local development), use:

```bash
make run
```

To run the server locally in SSE mode, use:

```bash
go run ./cmd/mcp-grafana --transport sse
```

You can also run the server using the SSE transport inside a custom built Docker image. Just like the published Docker image, this custom image's entrypoint defaults to SSE mode. To build the image, use:

```
make build-image
```

And to run the image in SSE mode (the default), use:

```
docker run -it --rm -p 8000:8000 mcp-grafana:latest
```

If you need to run it in STDIO mode instead, override the transport setting:

```
docker run -it --rm mcp-grafana:latest -t stdio
```

### Testing

There are three types of tests available:

1. Unit Tests (no external dependencies required):

```bash
make test-unit
```

You can also run unit tests with:

```bash
make test
```

2. Integration Tests (requires docker containers to be up and running):

```bash
make test-integration
```

3. Cloud Tests (requires cloud Grafana instance and credentials):

```bash
make test-cloud
```

> Note: Cloud tests are automatically configured in CI. For local development, you'll need to set up your own Grafana Cloud instance and credentials.

More comprehensive integration tests will require a Grafana instance to be running locally on port 3000; you can start one with Docker Compose:

```bash
docker-compose up -d
```

The integration tests can be run with:

```bash
make test-all
```

If you're adding more tools, please add integration tests for them. The existing tests should be a good starting point.

### Linting

To lint the code, run:

```bash
make lint
```

This includes a custom linter that checks for unescaped commas in `jsonschema` struct tags. The commas in `description` fields must be escaped with `\\,` to prevent silent truncation. You can run just this linter with:

```bash
make lint-jsonschema
```

See the [JSONSchema Linter documentation](internal/linter/jsonschema/README.md) for more details.

## License

This project is licensed under the [Apache License, Version 2.0](LICENSE).

[mcp]: https://modelcontextprotocol.io/
[service-account]: https://grafana.com/docs/grafana/latest/administration/service-accounts/#add-a-token-to-a-service-account-in-grafana

```

--------------------------------------------------------------------------------
/testdata/prometheus-seed.yml:
--------------------------------------------------------------------------------

```yaml
groups:
  - name: seed
    rules:
    - record: test
      expr: vector(1)
```

--------------------------------------------------------------------------------
/gemini-extension.json:
--------------------------------------------------------------------------------

```json
{
  "name": "grafana",
  "version": "0.7.0",
  "mcpServers": {
    "grafana": {
      "command": "${extensionPath}${/}mcp-grafana"
    }
  }
}

```

--------------------------------------------------------------------------------
/testdata/prometheus.yml:
--------------------------------------------------------------------------------

```yaml
global:
  scrape_interval:     1s

scrape_configs:
  - job_name: 'prometheus'
    static_configs:
      - targets: ['localhost:9090']

rule_files:
  - prometheus-seed.yml
```

--------------------------------------------------------------------------------
/testdata/provisioning/dashboards/dashboards.yaml:
--------------------------------------------------------------------------------

```yaml
apiVersion: 1

providers:
  - name: "docker-compose"
    orgId: 1
    folder: "Tests"
    folderUid: "tests"
    type: file
    disableDeletion: true
    updateIntervalSeconds: 60
    allowUiUpdates: false
    options:
      # <string, required> path to dashboard files on disk. Required when using the 'file' type
      path: /var/lib/grafana/dashboards

```

--------------------------------------------------------------------------------
/tests/health_test.py:
--------------------------------------------------------------------------------

```python
import httpx
import pytest

pytestmark = pytest.mark.anyio


async def test_healthz(mcp_transport: str, mcp_url: str):
    if mcp_transport == "stdio":
        return
    health_url = f"{mcp_url}/healthz"
    async with httpx.AsyncClient() as client:
        response = await client.get(health_url)
        assert response.status_code == 200
        assert response.text == "ok"

```

--------------------------------------------------------------------------------
/testdata/tempo-config.yaml:
--------------------------------------------------------------------------------

```yaml
server:
  http_listen_port: 3200
  log_level: debug

query_frontend:
  mcp_server:
    enabled: true

distributor:
  receivers:
    otlp:
      protocols:
        http:
        grpc:

ingester:
  max_block_duration: 5m

compactor:
  compaction:
    block_retention: 1h

storage:
  trace:
    backend: local
    local:
      path: /tmp/tempo/blocks
    wal:
      path: /tmp/tempo/wal 

```

--------------------------------------------------------------------------------
/testdata/tempo-config-2.yaml:
--------------------------------------------------------------------------------

```yaml
server:
  http_listen_port: 3201
  log_level: debug

query_frontend:
  mcp_server:
    enabled: true

distributor:
  receivers:
    otlp:
      protocols:
        http:
        grpc:

ingester:
  max_block_duration: 5m

compactor:
  compaction:
    block_retention: 1h

storage:
  trace:
    backend: local
    local:
      path: /tmp/tempo2/blocks
    wal:
      path: /tmp/tempo2/wal

```

--------------------------------------------------------------------------------
/testdata/promtail-config.yml:
--------------------------------------------------------------------------------

```yaml
server:
  http_listen_port: 9080

positions:
  filename: /tmp/positions.yaml

clients:
  - url: http://loki:3100/loki/api/v1/push

scrape_configs:
  - job_name: docker
    docker_sd_configs:
      - host: unix:///var/run/docker.sock
        refresh_interval: 5s
    relabel_configs:
      - source_labels: ['__meta_docker_container_name']
        regex: '/(.*)'
        target_label: 'container'
```

--------------------------------------------------------------------------------
/testdata/provisioning/alerting/contact_points.yaml:
--------------------------------------------------------------------------------

```yaml
apiVersion: 1

contactPoints:
  - name: Email1
    receivers:
      - uid: email1
        type: email
        settings:
          addresses: [email protected]
          singleEmail: false
          message: my optional message1 to include
  - name: Email2
    receivers:
      - uid: email2
        type: email
        settings:
          addresses: [email protected]
          singleEmail: false
          message: my optional message2 to include
```

--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------

```json
{
    "ignorePresets": [
            "github>grafana/grafana-renovate-config//presets/base",
            "github>grafana/grafana-renovate-config//presets/automerge",
            "github>grafana/grafana-renovate-config//presets/labels",
            "github>grafana/grafana-renovate-config//presets/npm"
    ],
    "extends": [
        "config:best-practices",
        ":disableDependencyDashboard",
        ":preserveSemverRanges",
        "github>grafana/grafana-renovate-config//presets/plugin-ci-workflows"
    ],
    "prConcurrentLimit": 5,
    "minimumReleaseAge": "14 days",
    "rebaseWhen": "behind-base-branch"
}
```

--------------------------------------------------------------------------------
/testdata/loki-config.yml:
--------------------------------------------------------------------------------

```yaml
auth_enabled: false

server:
  http_listen_port: 3100

ingester:
  lifecycler:
    address: 127.0.0.1
    ring:
      kvstore:
        store: inmemory
      replication_factor: 1
    final_sleep: 0s
  chunk_idle_period: 5m
  chunk_retain_period: 30s
  wal:
    enabled: true
    dir: /loki/wal

compactor:
  working_directory: /loki/compactor

schema_config:
  configs:
    - from: 2020-10-24
      store: boltdb-shipper
      object_store: filesystem
      schema: v11
      index:
        prefix: index_
        period: 24h

storage_config:
  boltdb_shipper:
    active_index_directory: /loki/boltdb-shipper-active
    cache_location: /loki/boltdb-shipper-cache
    cache_ttl: 24h
    shared_store: filesystem
  filesystem:
    directory: /loki/chunks

```

--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------

```yaml
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file

version: 2
updates:
  - package-ecosystem: "gomod" # See documentation for possible values
    directory: "/" # Location of package manifests
    schedule:
      interval: "weekly"
    groups:
      go-dependencies:
        patterns:
          - "*"
  - package-ecosystem: github-actions
    directory: /
    schedule:
      interval: weekly
    groups:
      github-actions:
        patterns:
          - "*"

```

--------------------------------------------------------------------------------
/tests/pyproject.toml:
--------------------------------------------------------------------------------

```toml
[project]
name = "tests"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
    { name = "Ben Sully", email = "[email protected]" },
    { name = "Ioanna Armouti", email = "[email protected]" },
    { name = "Chris Marchbanks", email = "[email protected]" },
]
requires-python = ">=3.13"
dependencies = []

[dependency-groups]
dev = [
    "anyio>=4.9.0",
    "flaky>=3.8.1",
    "langevals[langevals]>=0.1.8",
    "litellm>=1.63.12",
    "mcp>=1.9.3",
    "pytest>=8.3.5",
    "python-dotenv>=1.0.0",
]

[tool.pytest.ini_options]

[tool.uv.sources]
# Until https://github.com/langwatch/langevals/issues/20.
langevals = { git = "https://github.com/langwatch/langevals", rev = "3a934d1dc4ea95f039cf7bc4969e6bad1543c719" }

```

--------------------------------------------------------------------------------
/testdata/provisioning/datasources/datasources.yaml:
--------------------------------------------------------------------------------

```yaml
apiVersion: 1

datasources:
  - name: Prometheus
    id: 1
    uid: prometheus
    type: prometheus
    access: proxy
    url: http://prometheus:9090
    isDefault: true
  - name: Prometheus Demo
    id: 2
    uid: prometheus-demo
    type: prometheus
    access: proxy
    url: https://prometheus.demo.prometheus.io
  - name: Loki
    id: 3
    uid: loki
    type: loki
    access: proxy
    url: http://loki:3100
    isDefault: false
  - name: pyroscope
    uid: pyroscope
    type: grafana-pyroscope-datasource
    access: proxy
    url: http://pyroscope:4040
    isDefault: false
  - name: Tempo
    id: 4
    uid: tempo
    type: tempo
    access: proxy
    url: http://tempo:3200
    isDefault: false
  - name: Tempo Secondary
    id: 5
    uid: tempo-secondary
    type: tempo
    access: proxy
    url: http://tempo2:3201
    isDefault: false

```

--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------

```yaml
name: goreleaser

on:
  push:
    # run only against tags
    tags:
      - "v*"

permissions:
  contents: write

jobs:
  goreleaser:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Set up Go
        uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
        with:
          go-version: stable
          # Do not use any caches when creating a release.
          cache: false

      - name: Run GoReleaser
        uses: goreleaser/goreleaser-action@a08664b80c0ab417b1babcbf750274aed2018fef
        with:
          distribution: goreleaser
          # 'latest', 'nightly', or a semver
          version: "~> v2"
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

```

--------------------------------------------------------------------------------
/tools/search_test.go:
--------------------------------------------------------------------------------

```go
// Requires a Grafana instance running on localhost:3000,
// with a dashboard named "Demo" provisioned.
// Run with `go test -tags integration`.
//go:build integration

package tools

import (
	"testing"

	"github.com/grafana/grafana-openapi-client-go/models"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestSearchTools(t *testing.T) {
	t.Run("search dashboards", func(t *testing.T) {
		ctx := newTestContext()
		result, err := searchDashboards(ctx, SearchDashboardsParams{
			Query: "Demo",
		})
		require.NoError(t, err)
		assert.Len(t, result, 1)
		assert.Equal(t, models.HitType("dash-db"), result[0].Type)
	})

	t.Run("search folders", func(t *testing.T) {
		ctx := newTestContext()
		result, err := searchFolders(ctx, SearchFoldersParams{
			Query: "Tests",
		})
		require.NoError(t, err)
		assert.NotEmpty(t, result)
		assert.Equal(t, models.HitType("dash-folder"), result[0].Type)
	})
}

```

--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------

```dockerfile
# Build stage
FROM golang:1.24-bullseye@sha256:2cdc80dc25edcb96ada1654f73092f2928045d037581fa4aa7c40d18af7dd85a AS builder

# Set the working directory
WORKDIR /app

# Copy go.mod and go.sum files
COPY go.mod go.sum ./

# Download dependencies
RUN go mod download

# Copy the source code
COPY . .

# Build the application
RUN go build -o mcp-grafana ./cmd/mcp-grafana

# Final stage
FROM debian:bullseye-slim@sha256:52927eff8153b563244f98cdc802ba97918afcdf67f9e4867cbf1f7afb3d147b

LABEL io.modelcontextprotocol.server.name="io.github.grafana/mcp-grafana"

# Install ca-certificates for HTTPS requests
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*

# Create a non-root user
RUN useradd -r -u 1000 -m mcp-grafana

# Set the working directory
WORKDIR /app

# Copy the binary from the builder stage
COPY --from=builder --chown=1000:1000 /app/mcp-grafana /app/

# Use the non-root user
USER mcp-grafana

# Expose the port the app runs on
EXPOSE 8000

# Run the application
ENTRYPOINT ["/app/mcp-grafana", "--transport", "sse", "--address", "0.0.0.0:8000"]

```

--------------------------------------------------------------------------------
/cmd/linters/jsonschema/main.go:
--------------------------------------------------------------------------------

```go
package main

import (
	"flag"
	"fmt"
	"os"
	"path/filepath"

	linter "github.com/grafana/mcp-grafana/internal/linter/jsonschema"
)

func main() {
	var (
		basePath string
		help     bool
		fix      bool
	)

	flag.StringVar(&basePath, "path", ".", "Base directory to scan for Go files")
	flag.BoolVar(&help, "help", false, "Show help message")
	flag.BoolVar(&fix, "fix", false, "Automatically fix unescaped commas")
	flag.Parse()

	if help {
		fmt.Println("jsonschema-linter - A tool to find unescaped commas in jsonschema struct tags")
		fmt.Println("\nUsage:")
		flag.PrintDefaults()
		os.Exit(0)
	}

	// Resolve to absolute path
	absPath, err := filepath.Abs(basePath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err)
		os.Exit(1)
	}

	// Initialize linter
	jsonLinter := &linter.JSONSchemaLinter{
		FixMode: fix,
	}

	// Find unescaped commas
	err = jsonLinter.FindUnescapedCommas(absPath)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error scanning files: %v\n", err)
		os.Exit(1)
	}

	// Print errors
	jsonLinter.PrintErrors()

	// Exit with error code if issues were found
	if len(jsonLinter.Errors) > 0 {
		os.Exit(1)
	}
}

```

--------------------------------------------------------------------------------
/testdata/prometheus-entrypoint.sh:
--------------------------------------------------------------------------------

```bash
#!/bin/sh
# Prometheus entrypoint script to backfill recording rules and start Prometheus

set -e

echo "Starting Prometheus entrypoint script..."


backfill() {
    # Calculate time range for backfilling (5 hours ago from now)
    # Get current time in seconds since epoch
    CURRENT_TIME=$(date -u +%s)
    # Subtract 5 hours (5 * 60 * 60 = 18000 seconds)
    START_TIME=$((CURRENT_TIME - 18000))

    # wait until Prometheus is up and running
    until wget http://localhost:9090/-/healthy -q -O /dev/null; do
        sleep 1
    done

    promtool tsdb create-blocks-from \
        rules \
        --url=http://localhost:9090 \
        --start="${START_TIME}" \
        --end="${CURRENT_TIME}" \
        --eval-interval=30s \
        /etc/prometheus/prometheus-seed.yml
}

# Start Prometheus with the regular configuration, this is needed for backfilling
/bin/prometheus \
    --config.file=/etc/prometheus/prometheus.yml &

backfill

# Restarting Prometheus after backfilling will allow to load the new blocks directly
# without having to wait for the next compaction cycle
kill %1
echo "Starting Prometheus server..."
# Start Prometheus with the regular configuration
/bin/prometheus \
    --config.file=/etc/prometheus/prometheus.yml


```

--------------------------------------------------------------------------------
/tools/asserts_cloud_test.go:
--------------------------------------------------------------------------------

```go
//go:build cloud
// +build cloud

// This file contains cloud integration tests that run against a dedicated test instance
// connected to a Grafana instance at (ASSERTS_GRAFANA_URL, ASSERTS_GRAFANA_SERVICE_ACCOUNT_TOKEN or ASSERTS_GRAFANA_API_KEY).
// These tests expect this configuration to exist and will skip if the required
// environment variables are not set. The ASSERTS_GRAFANA_API_KEY variable is deprecated.

package tools

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestAssertsCloudIntegration(t *testing.T) {
	ctx := createCloudTestContext(t, "Asserts", "ASSERTS_GRAFANA_URL", "ASSERTS_GRAFANA_API_KEY")

	t.Run("get assertions", func(t *testing.T) {
		// Set up time range for the last hour
		endTime := time.Now()
		startTime := endTime.Add(-24 * time.Hour)

		// Test parameters for a known service in the environment
		params := GetAssertionsParams{
			StartTime:  startTime,
			EndTime:    endTime,
			EntityType: "Service", // Adjust these values based on your actual environment
			EntityName: "model-builder",
			Env:        "dev-us-central-0",
			Namespace:  "asserts",
		}

		// Get assertions from the real Grafana instance
		result, err := getAssertions(ctx, params)
		require.NoError(t, err, "Failed to get assertions from Grafana")
		assert.NotEmpty(t, result, "Expected non-empty assertions result")

		// Basic validation of the response structure
		assert.Contains(t, result, "summaries", "Response should contain a summaries field")
	})
}

```

--------------------------------------------------------------------------------
/tools/folder.go:
--------------------------------------------------------------------------------

```go
package tools

import (
	"context"
	"fmt"

	"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 CreateFolderParams struct {
	Title     string `json:"title" jsonschema:"required,description=The title of the folder."`
	UID       string `json:"uid,omitempty" jsonschema:"description=Optional folder UID. If omitted\\, Grafana will generate one."`
	ParentUID string `json:"parentUid,omitempty" jsonschema:"description=Optional parent folder UID. If set\\, the folder will be created under this parent."`
}

func createFolder(ctx context.Context, args CreateFolderParams) (*models.Folder, error) {
	if args.Title == "" {
		return nil, fmt.Errorf("title is required")
	}

	c := mcpgrafana.GrafanaClientFromContext(ctx)
	cmd := &models.CreateFolderCommand{Title: args.Title}
	if args.UID != "" {
		cmd.UID = args.UID
	}
	if args.ParentUID != "" {
		cmd.ParentUID = args.ParentUID
	}

	resp, err := c.Folders.CreateFolder(cmd)
	if err != nil {
		return nil, fmt.Errorf("create folder '%s': %w", args.Title, err)
	}
	return resp.Payload, nil
}

var CreateFolder = mcpgrafana.MustTool(
	"create_folder",
	"Create a Grafana folder. Provide a title and optional UID. Returns the created folder.",
	createFolder,
	mcp.WithTitleAnnotation("Create folder"),
	mcp.WithIdempotentHintAnnotation(false),
	mcp.WithReadOnlyHintAnnotation(false),
)

func AddFolderTools(mcp *server.MCPServer, enableWriteTools bool) {
	if enableWriteTools {
		CreateFolder.Register(mcp)
	}
}

```

--------------------------------------------------------------------------------
/.github/workflows/unit.yml:
--------------------------------------------------------------------------------

```yaml
name: Unit Tests & Linting

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 0 * * *"

permissions:
  contents: read

jobs:
  lint-jsonschema:
    name: Lint JSON Schemas
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false

      - name: Set up Go
        uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
        with:
          go-version: "1.24"
          cache: true
      - name: Run linter
        run: make lint-jsonschema

  lint-go:
    name: Lint Go
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false
      - name: Set up Go
        uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
        with:
          go-version: "1.24"
          cache: true
      - name: Run golangci-lint
        uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
        with:
          version: v2.3.0

  test-unit:
    name: Test Unit
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false

      - name: Set up Go
        uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
        with:
          go-version: "1.24"
          cache: true

      - name: Run unit tests
        run: make test-unit

```

--------------------------------------------------------------------------------
/tools/incident_test.go:
--------------------------------------------------------------------------------

```go
//go:build unit
// +build unit

package tools

import (
	"context"
	"testing"

	"github.com/grafana/incident-go"
	mcpgrafana "github.com/grafana/mcp-grafana"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func newIncidentTestContext() context.Context {
	client := incident.NewTestClient()
	return mcpgrafana.WithIncidentClient(context.Background(), client)
}

func TestIncidentTools(t *testing.T) {
	t.Run("list incidents", func(t *testing.T) {
		ctx := newIncidentTestContext()
		result, err := listIncidents(ctx, ListIncidentsParams{
			Limit: 2,
		})
		require.NoError(t, err)
		assert.Len(t, result.IncidentPreviews, 2)
	})

	t.Run("create incident", func(t *testing.T) {
		ctx := newIncidentTestContext()
		result, err := createIncident(ctx, CreateIncidentParams{
			Title:         "high latency in web requests",
			Severity:      "minor",
			RoomPrefix:    "test",
			IsDrill:       true,
			Status:        "active",
			AttachCaption: "Test attachment",
			AttachURL:     "https://grafana.com",
		})
		require.NoError(t, err)
		assert.Equal(t, "high latency in web requests", result.Title)
		assert.Equal(t, "minor", result.Severity)
		assert.True(t, result.IsDrill)
		assert.Equal(t, "active", result.Status)
	})

	t.Run("add activity to incident", func(t *testing.T) {
		ctx := newIncidentTestContext()
		result, err := addActivityToIncident(ctx, AddActivityToIncidentParams{
			IncidentID: "123",
			Body:       "The incident was created by user-123",
			EventTime:  "2021-08-07T11:58:23Z",
		})
		require.NoError(t, err)
		assert.Equal(t, "The incident was created by user-123", result.Body)
		assert.Equal(t, "2021-08-07T11:58:23Z", result.EventTime)
	})
}

```

--------------------------------------------------------------------------------
/tools/testcontext_test.go:
--------------------------------------------------------------------------------

```go
//go:build integration

package tools

import (
	"context"
	"fmt"
	"net/url"
	"os"

	"github.com/go-openapi/strfmt"
	"github.com/grafana/grafana-openapi-client-go/client"
	mcpgrafana "github.com/grafana/mcp-grafana"
)

// newTestContext creates a new context with the Grafana URL and service account token
// from the environment variables GRAFANA_URL and GRAFANA_SERVICE_ACCOUNT_TOKEN (or deprecated GRAFANA_API_KEY).
func newTestContext() context.Context {
	cfg := client.DefaultTransportConfig()
	cfg.Host = "localhost:3000"
	cfg.Schemes = []string{"http"}
	// Extract transport config from env vars, and set it on the context.
	if u, ok := os.LookupEnv("GRAFANA_URL"); ok {
		url, err := url.Parse(u)
		if err != nil {
			panic(fmt.Errorf("invalid %s: %w", "GRAFANA_URL", err))
		}
		cfg.Host = url.Host
		// The Grafana client will always prefer HTTPS even if the URL is HTTP,
		// so we need to limit the schemes to HTTP if the URL is HTTP.
		if url.Scheme == "http" {
			cfg.Schemes = []string{"http"}
		}
	}

	// Check for the new service account token environment variable first
	if apiKey := os.Getenv("GRAFANA_SERVICE_ACCOUNT_TOKEN"); apiKey != "" {
		cfg.APIKey = apiKey
	} else if apiKey := os.Getenv("GRAFANA_API_KEY"); apiKey != "" {
		// Fall back to the deprecated API key environment variable
		cfg.APIKey = apiKey
	} else {
		cfg.BasicAuth = url.UserPassword("admin", "admin")
	}

	client := client.NewHTTPClientWithConfig(strfmt.Default, cfg)

	grafanaCfg := mcpgrafana.GrafanaConfig{
		Debug:     true,
		URL:       "http://localhost:3000",
		APIKey:    cfg.APIKey,
		BasicAuth: cfg.BasicAuth,
	}

	ctx := mcpgrafana.WithGrafanaConfig(context.Background(), grafanaCfg)
	return mcpgrafana.WithGrafanaClient(ctx, client)
}

```

--------------------------------------------------------------------------------
/tools/datasources_test.go:
--------------------------------------------------------------------------------

```go
// Requires a Grafana instance running on localhost:3000,
// with a Prometheus datasource provisioned.
// Run with `go test -tags integration`.
//go:build integration

package tools

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestDatasourcesTools(t *testing.T) {
	t.Run("list datasources", func(t *testing.T) {
		ctx := newTestContext()
		result, err := listDatasources(ctx, ListDatasourcesParams{})
		require.NoError(t, err)
		// Six datasources are provisioned in the test environment (Prometheus, Prometheus Demo, Loki, Pyroscope, Tempo, and Tempo Secondary).
		assert.Len(t, result, 6)
	})

	t.Run("list datasources for type", func(t *testing.T) {
		ctx := newTestContext()
		result, err := listDatasources(ctx, ListDatasourcesParams{Type: "Prometheus"})
		require.NoError(t, err)
		// Only two Prometheus datasources are provisioned in the test environment.
		assert.Len(t, result, 2)
	})

	t.Run("get datasource by uid", func(t *testing.T) {
		ctx := newTestContext()
		result, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{
			UID: "prometheus",
		})
		require.NoError(t, err)
		assert.Equal(t, "Prometheus", result.Name)
	})

	t.Run("get datasource by uid - not found", func(t *testing.T) {
		ctx := newTestContext()
		result, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{
			UID: "non-existent-datasource",
		})
		require.Error(t, err)
		require.Nil(t, result)
		assert.Contains(t, err.Error(), "not found")
	})

	t.Run("get datasource by name", func(t *testing.T) {
		ctx := newTestContext()
		result, err := getDatasourceByName(ctx, GetDatasourceByNameParams{
			Name: "Prometheus",
		})
		require.NoError(t, err)
		assert.Equal(t, "Prometheus", result.Name)
	})
}

```

--------------------------------------------------------------------------------
/server.json:
--------------------------------------------------------------------------------

```json
{
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
  "name": "io.github.grafana/mcp-grafana",
  "description": "An MCP server giving access to Grafana dashboards, data and more.",
  "repository": {
    "url": "https://github.com/grafana/mcp-grafana",
    "source": "github"
  },
  "version": "$VERSION",
  "packages": [
    {
      "registryType": "oci",
      "identifier": "docker.io/grafana/mcp-grafana:$VERSION",
      "transport": {
        "type": "stdio"
      },
      "environmentVariables": [
        {
          "description": "URL to your Grafana instance",
          "isRequired": true,
          "format": "string",
          "isSecret": false,
          "name": "GRAFANA_URL"
        },
        {
          "description": "Service account token used to authenticate with your Grafana instance",
          "isRequired": false,
          "format": "string",
          "isSecret": true,
          "name": "GRAFANA_SERVICE_ACCOUNT_TOKEN"
        },
        {
          "description": "Username to authenticate with your Grafana instance",
          "isRequired": false,
          "format": "string",
          "isSecret": false,
          "name": "GRAFANA_USERNAME"
        },
        {
          "description": "Password to authenticate with your Grafana instance",
          "isRequired": false,
          "format": "string",
          "isSecret": true,
          "name": "GRAFANA_PASSWORD"
        },
        {
          "description": "Organization ID for multi-org support. Can also be set via X-Grafana-Org-Id header in SSE/streamable HTTP transports.",
          "isRequired": false,
          "format": "string",
          "isSecret": false,
          "name": "GRAFANA_ORG_ID"
        }
      ]
    }
  ]
}

```

--------------------------------------------------------------------------------
/tools/cloud_testing_utils.go:
--------------------------------------------------------------------------------

```go
//go:build cloud
// +build cloud

package tools

import (
	"context"
	"os"
	"strings"
	"testing"

	mcpgrafana "github.com/grafana/mcp-grafana"
)

// createCloudTestContext creates a context with a Grafana URL, Grafana service account token and
// Grafana client for cloud integration tests.
// The test will be skipped if required environment variables are not set.
// testName is used to customize the skip message (e.g. "OnCall", "Sift", "Incident")
// urlEnv and apiKeyEnv specify the environment variable names for the Grafana URL and API key (deprecated).
// The function will automatically try the new SERVICE_ACCOUNT_TOKEN pattern first, then fall back to API_KEY.
func createCloudTestContext(t *testing.T, testName, urlEnv, apiKeyEnv string) context.Context {
	ctx := context.Background()

	grafanaURL := os.Getenv(urlEnv)
	if grafanaURL == "" {
		t.Skipf("%s environment variable not set, skipping cloud %s integration tests", urlEnv, testName)
	}

	// Try the new service account token environment variable first
	serviceAccountTokenEnv := strings.Replace(apiKeyEnv, "API_KEY", "SERVICE_ACCOUNT_TOKEN", 1)
	grafanaApiKey := os.Getenv(serviceAccountTokenEnv)

	if grafanaApiKey == "" {
		// Fall back to the deprecated API key environment variable
		grafanaApiKey = os.Getenv(apiKeyEnv)
		if grafanaApiKey != "" {
			t.Logf("Warning: %s is deprecated, please use %s instead", apiKeyEnv, serviceAccountTokenEnv)
		}
	}

	if grafanaApiKey == "" {
		t.Skipf("Neither %s nor %s environment variables are set, skipping cloud %s integration tests", serviceAccountTokenEnv, apiKeyEnv, testName)
	}

	client := mcpgrafana.NewGrafanaClient(ctx, grafanaURL, grafanaApiKey, nil, 0)

	config := mcpgrafana.GrafanaConfig{
		URL:    grafanaURL,
		APIKey: grafanaApiKey,
	}
	ctx = mcpgrafana.WithGrafanaConfig(ctx, config)
	ctx = mcpgrafana.WithGrafanaClient(ctx, client)

	return ctx
}

```

--------------------------------------------------------------------------------
/tools/incident_integration_test.go:
--------------------------------------------------------------------------------

```go
// Requires a Cloud or other Grafana instance with Grafana Incident available,
// with a Prometheus datasource provisioned.
//go:build cloud
// +build cloud

// This file contains cloud integration tests that run against a dedicated test instance
// at mcptests.grafana-dev.net. This instance is configured with a minimal setup on the Incident side
// with two incidents created, one minor and one major, and both of them resolved.
// These tests expect this configuration to exist and will skip if the required
// environment variables (GRAFANA_URL, GRAFANA_SERVICE_ACCOUNT_TOKEN or GRAFANA_API_KEY) are not set.
// The GRAFANA_API_KEY variable is deprecated.

package tools

import (
	"testing"

	mcpgrafana "github.com/grafana/mcp-grafana"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestCloudIncidentTools(t *testing.T) {
	t.Run("list incidents", func(t *testing.T) {
		ctx := createCloudTestContext(t, "Incident", "GRAFANA_URL", "GRAFANA_API_KEY")
		ctx = mcpgrafana.ExtractIncidentClientFromEnv(ctx)

		result, err := listIncidents(ctx, ListIncidentsParams{
			Limit: 1,
		})
		require.NoError(t, err)
		assert.NotNil(t, result, "Result should not be nil")
		assert.NotNil(t, result.IncidentPreviews, "IncidentPreviews should not be nil")
		assert.LessOrEqual(t, len(result.IncidentPreviews), 1, "Should not return more incidents than the limit")
	})

	t.Run("get incident by ID", func(t *testing.T) {
		ctx := createCloudTestContext(t, "Incident", "GRAFANA_URL", "GRAFANA_API_KEY")
		ctx = mcpgrafana.ExtractIncidentClientFromEnv(ctx)
		result, err := getIncident(ctx, GetIncidentParams{
			ID: "1",
		})
		require.NoError(t, err)
		assert.NotNil(t, result, "Result should not be nil")
		assert.Equal(t, "1", result.IncidentID, "Should return the requested incident ID")
		assert.NotEmpty(t, result.Title, "Incident should have a title")
		assert.NotEmpty(t, result.Status, "Incident should have a status")
	})
}

```

--------------------------------------------------------------------------------
/tools/admin.go:
--------------------------------------------------------------------------------

```go
package tools

import (
	"context"
	"fmt"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"

	"github.com/grafana/grafana-openapi-client-go/client/org"
	"github.com/grafana/grafana-openapi-client-go/client/teams"
	"github.com/grafana/grafana-openapi-client-go/models"
	mcpgrafana "github.com/grafana/mcp-grafana"
)

type ListTeamsParams struct {
	Query string `json:"query" jsonschema:"description=The query to search for teams. Can be left empty to fetch all teams"`
}

func listTeams(ctx context.Context, args ListTeamsParams) (*models.SearchTeamQueryResult, error) {
	c := mcpgrafana.GrafanaClientFromContext(ctx)
	params := teams.NewSearchTeamsParamsWithContext(ctx)
	if args.Query != "" {
		params.SetQuery(&args.Query)
	}
	search, err := c.Teams.SearchTeams(params)
	if err != nil {
		return nil, fmt.Errorf("search teams for %+v: %w", c, err)
	}
	return search.Payload, nil
}

var ListTeams = mcpgrafana.MustTool(
	"list_teams",
	"Search for Grafana teams by a query string. Returns a list of matching teams with details like name, ID, and URL.",
	listTeams,
	mcp.WithTitleAnnotation("List teams"),
	mcp.WithIdempotentHintAnnotation(true),
	mcp.WithReadOnlyHintAnnotation(true),
)

type ListUsersByOrgParams struct{}

func listUsersByOrg(ctx context.Context, args ListUsersByOrgParams) ([]*models.OrgUserDTO, error) {
	c := mcpgrafana.GrafanaClientFromContext(ctx)

	params := org.NewGetOrgUsersForCurrentOrgParamsWithContext(ctx)
	search, err := c.Org.GetOrgUsersForCurrentOrg(params)
	if err != nil {
		return nil, fmt.Errorf("search users: %w", err)
	}
	return search.Payload, nil
}

var ListUsersByOrg = mcpgrafana.MustTool(
	"list_users_by_org",
	"List users by organization. Returns a list of users with details like userid, email, role etc.",
	listUsersByOrg,
	mcp.WithTitleAnnotation("List users by org"),
	mcp.WithIdempotentHintAnnotation(true),
	mcp.WithReadOnlyHintAnnotation(true),
)

func AddAdminTools(mcp *server.MCPServer) {
	ListTeams.Register(mcp)
	ListUsersByOrg.Register(mcp)
}

```

--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------

```yaml
services:
  grafana:
    image: grafana/grafana@sha256:35c41e0fd0295f5d0ee5db7e780cf33506abfaf47686196f825364889dee878b
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "false"
      GF_LOG_LEVEL: debug
      GF_SERVER_ROUTER_LOGGING: "true"
    ports:
      - 3000:3000/tcp
    volumes:
      - ./testdata/provisioning:/etc/grafana/provisioning
      - ./testdata/dashboards:/var/lib/grafana/dashboards

  prometheus:
    image: prom/prometheus@sha256:ff7e389acbe064a4823212a500393d40a28a8f362e4b05cbf6742a9a3ef736b2
    ports:
      - "9090:9090"
    entrypoint: /etc/prometheus/entrypoint.sh
    volumes:
      - ./testdata/prometheus.yml:/etc/prometheus/prometheus.yml
      - ./testdata/prometheus-seed.yml:/etc/prometheus/prometheus-seed.yml
      - ./testdata/prometheus-entrypoint.sh:/etc/prometheus/entrypoint.sh

  loki:
    image: grafana/loki:2.9.15@sha256:2fde6baaa4743a6870acb9ab5f15633de35adced3c0e3d61effd2a5f1008f1c3
    ports:
      - "3100:3100"
    command: -config.file=/etc/loki/loki-config.yml
    volumes:
      - ./testdata/loki-config.yml:/etc/loki/loki-config.yml

  promtail:
    image: grafana/promtail:2.9.15@sha256:466ba2fac4448ed2dc509b267995a3c13511d69f6bba01800ca7b38d9953f899
    volumes:
      - ./testdata/promtail-config.yml:/etc/promtail/config.yml
      - /var/log:/var/log
      - /var/run/docker.sock:/var/run/docker.sock
    command: -config.file=/etc/promtail/config.yml
    depends_on:
      - loki

  pyroscope:
    image: grafana/pyroscope:1.13.4@sha256:7e8f1911cbe9353f5c2433b81ff494d5c728c773e76ae9e886d2c009b0a28ada
    ports:
      - 4040:4040

  tempo:
    image: grafana/tempo:2.9.0-rc.0@sha256:5517ee34d335dedb9ad43028bd8f72edd0bb98b744ea5847a7572755d93d9866
    command: ["-config.file=/etc/tempo/tempo-config.yaml"]
    volumes:
      - ./testdata/tempo-config.yaml:/etc/tempo/tempo-config.yaml
    ports:
      - "3200:3200" # tempo

  tempo2:
    image: grafana/tempo:2.9.0-rc.0@sha256:5517ee34d335dedb9ad43028bd8f72edd0bb98b744ea5847a7572755d93d9866
    command: ["-config.file=/etc/tempo/tempo-config.yaml"]
    volumes:
      - ./testdata/tempo-config-2.yaml:/etc/tempo/tempo-config.yaml
    ports:
      - "3201:3201" # tempo instance 2

```

--------------------------------------------------------------------------------
/tools/search.go:
--------------------------------------------------------------------------------

```go
package tools

import (
	"context"
	"fmt"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"

	"github.com/grafana/grafana-openapi-client-go/client/search"
	"github.com/grafana/grafana-openapi-client-go/models"
	mcpgrafana "github.com/grafana/mcp-grafana"
)

var dashboardTypeStr = "dash-db"
var folderTypeStr = "dash-folder"

type SearchDashboardsParams struct {
	Query string `json:"query" jsonschema:"description=The query to search for"`
}

func searchDashboards(ctx context.Context, args SearchDashboardsParams) (models.HitList, error) {
	c := mcpgrafana.GrafanaClientFromContext(ctx)
	params := search.NewSearchParamsWithContext(ctx)
	if args.Query != "" {
		params.SetQuery(&args.Query)
		params.SetType(&dashboardTypeStr)
	}
	search, err := c.Search.Search(params)
	if err != nil {
		return nil, fmt.Errorf("search dashboards for %+v: %w", c, err)
	}
	return search.Payload, nil
}

var SearchDashboards = mcpgrafana.MustTool(
	"search_dashboards",
	"Search for Grafana dashboards by a query string. Returns a list of matching dashboards with details like title, UID, folder, tags, and URL.",
	searchDashboards,
	mcp.WithTitleAnnotation("Search dashboards"),
	mcp.WithIdempotentHintAnnotation(true),
	mcp.WithReadOnlyHintAnnotation(true),
)

type SearchFoldersParams struct {
	Query string `json:"query" jsonschema:"description=The query to search for"`
}

func searchFolders(ctx context.Context, args SearchFoldersParams) (models.HitList, error) {
	c := mcpgrafana.GrafanaClientFromContext(ctx)
	params := search.NewSearchParamsWithContext(ctx)
	if args.Query != "" {
		params.SetQuery(&args.Query)
	}
	params.SetType(&folderTypeStr)
	search, err := c.Search.Search(params)
	if err != nil {
		return nil, fmt.Errorf("search folders for %+v: %w", c, err)
	}
	return search.Payload, nil
}

var SearchFolders = mcpgrafana.MustTool(
	"search_folders",
	"Search for Grafana folders by a query string. Returns matching folders with details like title, UID, and URL.",
	searchFolders,
	mcp.WithTitleAnnotation("Search folders"),
	mcp.WithIdempotentHintAnnotation(true),
	mcp.WithReadOnlyHintAnnotation(true),
)

func AddSearchTools(mcp *server.MCPServer) {
	SearchDashboards.Register(mcp)
	SearchFolders.Register(mcp)
}

```

--------------------------------------------------------------------------------
/.github/workflows/integration.yml:
--------------------------------------------------------------------------------

```yaml
name: Integration Tests

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 0 * * *"

permissions:
  contents: read

jobs:
  test-integration:
    name: Test Integration
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false

      # Start the Grafana server.
      # Do this early so that it can start up in time for the tests to run.
      # We may need to add a wait here.
      - name: Start docker-compose services
        uses: hoverkraft-tech/compose-action@ccd64b05f85e42d4fa426d34ecb5884c99537eb4
        with:
          compose-file: "docker-compose.yaml"

      - name: Set up Go
        uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
        with:
          go-version: "1.24"
          cache: true

      - name: Wait for Grafana server and Prometheus server to start and scrape
        run: sleep 30

      - name: Run integration tests
        run: make test-integration

  test-cloud:
    name: Test Cloud
    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - name: Checkout code
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
        with:
          persist-credentials: false

      - name: Set up Go
        uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
        with:
          go-version: "1.24"
          cache: true

      - id: get-secrets
        uses: grafana/shared-workflows/actions/get-vault-secrets@5d7e361bc7e0a183cde8afe9899fb7b596d2659b # get-vault-secrets-v1.2.0
        with:
          # Secrets placed in the ci/repo/grafana/mcp-grafana/<path> path in Vault
          repo_secrets: |
            GRAFANA_SERVICE_ACCOUNT_TOKEN=mcptests-grafana:api-key
            ASSERTS_GRAFANA_SERVICE_ACCOUNT_TOKEN=dev-grafana:api-key

      - name: Run cloud tests
        env:
          GRAFANA_URL: ${{ vars.CLOUD_GRAFANA_URL }}
          ASSERTS_GRAFANA_URL: ${{ vars.ASSERTS_GRAFANA_URL }}
        run: make test-cloud

```

--------------------------------------------------------------------------------
/.github/workflows/e2e.yml:
--------------------------------------------------------------------------------

```yaml
name: E2E Tests

on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: "0 0 * * *"

permissions:
  contents: read

jobs:
  test-python-e2e:
    name: Python E2E Tests (${{ matrix.transport }})
    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    strategy:
      matrix:
        transport: [stdio, sse, streamable-http]
    permissions:
      id-token: write
      contents: read
    env:
      # Set auth here so stdio transport and background process pick them up
      GRAFANA_USERNAME: admin
      GRAFANA_PASSWORD: admin
    steps:
      - name: Checkout code
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0

      - name: Install uv
        uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2

      - name: Set up Go
        uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
        with:
          go-version: "1.24"
          cache: true

      - name: Install Python dependencies
        run: |
          cd tests
          uv sync --all-groups

      - id: get-secrets
        uses: grafana/shared-workflows/actions/get-vault-secrets@5d7e361bc7e0a183cde8afe9899fb7b596d2659b # get-vault-secrets-v1.2.0
        with:
          # Secrets placed in the ci/repo/grafana/mcp-grafana/<path> path in Vault
          repo_secrets: |
            ANTHROPIC_API_KEY=anthropic:api-key
            OPENAI_API_KEY=openai:api-key

      - name: Start docker-compose services
        uses: hoverkraft-tech/compose-action@ccd64b05f85e42d4fa426d34ecb5884c99537eb4
        with:
          compose-file: "docker-compose.yaml"

      - name: Wait for Grafana server and Prometheus server to start and scrape
        run: sleep 30

      - name: Build mcp-grafana
        run: go build -o dist/mcp-grafana ./cmd/mcp-grafana

      - name: Start MCP server in background
        if: matrix.transport != 'stdio'
        run: nohup ./dist/mcp-grafana -t ${{ matrix.transport }} > mcp.log 2>&1 &

      - name: Run Python e2e tests
        env:
          MCP_GRAFANA_PATH: ../dist/mcp-grafana
          MCP_TRANSPORT: ${{ matrix.transport }}
        run: |
          cd tests
          uv run pytest

      - if: failure() && matrix.transport != 'stdio'
        name: Print MCP logs
        run: cat mcp.log

```

--------------------------------------------------------------------------------
/tools/admin_test.go:
--------------------------------------------------------------------------------

```go
//go:build unit
// +build unit

package tools

import (
	"context"
	"testing"

	mcpgrafana "github.com/grafana/mcp-grafana"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestAdminToolsUnit(t *testing.T) {
	t.Run("tool definitions", func(t *testing.T) {
		// Test that the tools are properly defined with correct metadata
		require.NotNil(t, ListUsersByOrg, "ListUsersByOrg tool should be defined")
		require.NotNil(t, ListTeams, "ListTeams tool should be defined")

		// Verify tool metadata
		assert.Equal(t, "list_users_by_org", ListUsersByOrg.Tool.Name)
		assert.Equal(t, "list_teams", ListTeams.Tool.Name)
		assert.Contains(t, ListUsersByOrg.Tool.Description, "List users by organization")
		assert.Contains(t, ListTeams.Tool.Description, "Search for Grafana teams")
	})

	t.Run("parameter structures", func(t *testing.T) {
		// Test parameter types are correctly defined
		userParams := ListUsersByOrgParams{}
		teamParams := ListTeamsParams{Query: "test-query"}

		// ListUsersByOrgParams should be an empty struct (no parameters required)
		assert.IsType(t, ListUsersByOrgParams{}, userParams)

		// ListTeamsParams should have a Query field
		assert.Equal(t, "test-query", teamParams.Query)
	})

	t.Run("nil client handling", func(t *testing.T) {
		// Test that functions handle missing client gracefully
		ctx := context.Background() // No client in context

		// Both functions should return nil when client is not available
		// (they will panic on nil pointer dereference, which is the current behavior)
		assert.Panics(t, func() {
			listUsersByOrg(ctx, ListUsersByOrgParams{})
		}, "Should panic when no Grafana client in context")

		assert.Panics(t, func() {
			listTeams(ctx, ListTeamsParams{})
		}, "Should panic when no Grafana client in context")
	})

	t.Run("function signatures", func(t *testing.T) {
		// Verify that function signatures follow the expected pattern
		// This test ensures the API migration was done correctly

		// Create context with configuration but no client
		ctx := mcpgrafana.WithGrafanaConfig(context.Background(), mcpgrafana.GrafanaConfig{
			URL:    "http://test.grafana.com",
			APIKey: "test-key",
		})

		// Test that both functions can be called with correct parameter types
		// They will fail due to no client, but this validates the signature
		assert.Panics(t, func() {
			listUsersByOrg(ctx, ListUsersByOrgParams{})
		})

		assert.Panics(t, func() {
			listTeams(ctx, ListTeamsParams{Query: "test"})
		})
	})
}

```

--------------------------------------------------------------------------------
/tools/annotations_integration_test.go:
--------------------------------------------------------------------------------

```go
// Requires a Grafana instance running on localhost:3000,
// Run with `go test -tags integration`.
//go:build integration

package tools

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestAnnotationTools(t *testing.T) {
	ctx := newTestContext()

	// get existing provisioned dashboard.
	orig := getExistingTestDashboard(t, ctx, "")
	origMap := getTestDashboardJSON(t, ctx, orig)

	// remove identifiers so grafana treats it as a new dashboard
	delete(origMap, "uid")
	delete(origMap, "id")
	origMap["title"] = "Integration Test for Annotations"

	// create new dashboard.
	result, err := updateDashboard(ctx, UpdateDashboardParams{
		Dashboard: origMap,
		Message:   "creating new dashboard for Annotations Tool Test",
		Overwrite: false,
		UserID:    1,
	})

	require.NoError(t, err)

	// new UID for the test dashboard.
	newUID := result.UID

	// create, update and patch.
	t.Run("create, update and patch annotation", func(t *testing.T) {
		// 1. create annotation.
		created, err := createAnnotation(ctx, CreateAnnotationInput{
			DashboardUID: *newUID,
			Time:         time.Now().UnixMilli(),
			Text:         "integration-test-update-initial",
			Tags:         []string{"init"},
		})
		require.NoError(t, err)
		require.NotNil(t, created)

		id := created.Payload.ID // *int64

		// 2. update annotation (PUT).
		_, err = updateAnnotation(ctx, UpdateAnnotationInput{
			ID:   *id,
			Time: time.Now().UnixMilli(),
			Text: "integration-test-updated",
			Tags: []string{"updated"},
		})
		require.NoError(t, err)

		// 3. patch annotation (PATCH).
		newText := "patched"
		_, err = patchAnnotation(ctx, PatchAnnotationInput{
			ID:   *id,
			Text: &newText,
		})
		require.NoError(t, err)
	})

	// create graphite annotation.
	t.Run("create graphite annotation", func(t *testing.T) {
		resp, err := createAnnotationGraphiteFormat(ctx, CreateGraphiteAnnotationInput{
			What: "integration-test-graphite",
			When: time.Now().UnixMilli(),
			Tags: []string{"mcp", "graphite"},
		})
		require.NoError(t, err)
		require.NotNil(t, resp)
	})

	// list all annotations.
	t.Run("list annotations", func(t *testing.T) {
		limit := int64(1)
		out, err := getAnnotations(ctx, GetAnnotationsInput{
			DashboardUID: newUID,
			Limit:        &limit,
		})
		require.NoError(t, err)
		assert.NotNil(t, out)
	})

	// list all tags.
	t.Run("list annotation tags", func(t *testing.T) {
		out, err := getAnnotationTags(ctx, GetAnnotationTagsInput{})
		require.NoError(t, err)
		assert.NotNil(t, out)
	})
}

```

--------------------------------------------------------------------------------
/tools/loki_test.go:
--------------------------------------------------------------------------------

```go
//go:build integration

package tools

import (
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestLokiTools(t *testing.T) {
	t.Run("list loki label names", func(t *testing.T) {
		ctx := newTestContext()
		result, err := listLokiLabelNames(ctx, ListLokiLabelNamesParams{
			DatasourceUID: "loki",
		})
		require.NoError(t, err)
		assert.Len(t, result, 1)
	})

	t.Run("get loki label values", func(t *testing.T) {
		ctx := newTestContext()
		result, err := listLokiLabelValues(ctx, ListLokiLabelValuesParams{
			DatasourceUID: "loki",
			LabelName:     "container",
		})
		require.NoError(t, err)
		assert.NotEmpty(t, result, "Should have at least one container label value")
	})

	t.Run("query loki stats", func(t *testing.T) {
		ctx := newTestContext()
		result, err := queryLokiStats(ctx, QueryLokiStatsParams{
			DatasourceUID: "loki",
			LogQL:         `{container="grafana"}`,
		})
		require.NoError(t, err)
		assert.NotNil(t, result, "Should return a result")

		// We can't assert on specific values as they will vary,
		// but we can check that the structure is correct
		assert.GreaterOrEqual(t, result.Streams, 0, "Should have a valid streams count")
		assert.GreaterOrEqual(t, result.Chunks, 0, "Should have a valid chunks count")
		assert.GreaterOrEqual(t, result.Entries, 0, "Should have a valid entries count")
		assert.GreaterOrEqual(t, result.Bytes, 0, "Should have a valid bytes count")
	})

	t.Run("query loki logs", func(t *testing.T) {
		ctx := newTestContext()
		result, err := queryLokiLogs(ctx, QueryLokiLogsParams{
			DatasourceUID: "loki",
			LogQL:         `{container=~".+"}`,
			Limit:         10,
		})
		require.NoError(t, err)

		// We can't assert on specific log content as it will vary,
		// but we can check that the structure is correct
		// If we got logs, check that they have the expected structure
		for _, entry := range result {
			assert.NotEmpty(t, entry.Timestamp, "Log entry should have a timestamp")
			assert.NotNil(t, entry.Labels, "Log entry should have labels")
		}
	})

	t.Run("query loki logs with no results", func(t *testing.T) {
		ctx := newTestContext()
		// Use a query that's unlikely to match any logs
		result, err := queryLokiLogs(ctx, QueryLokiLogsParams{
			DatasourceUID: "loki",
			LogQL:         `{container="non-existent-container-name-123456789"}`,
			Limit:         10,
		})
		require.NoError(t, err)

		// Should return an empty slice, not nil
		assert.NotNil(t, result, "Empty results should be an empty slice, not nil")
		assert.Equal(t, 0, len(result), "Empty results should have length 0")
	})
}

```

--------------------------------------------------------------------------------
/tools/pyroscope_test.go:
--------------------------------------------------------------------------------

```go
//go:build integration

package tools

import (
	"testing"

	"github.com/stretchr/testify/require"
)

func TestPyroscopeTools(t *testing.T) {
	t.Run("list Pyroscope label names", func(t *testing.T) {
		ctx := newTestContext()
		names, err := listPyroscopeLabelNames(ctx, ListPyroscopeLabelNamesParams{
			DataSourceUID: "pyroscope",
			Matchers:      `{service_name="pyroscope"}`,
		})
		require.NoError(t, err)
		require.ElementsMatch(t, names, []string{
			"__name__",
			"__period_type__",
			"__period_unit__",
			"__profile_type__",
			"__service_name__",
			"__type__",
			"__unit__",
			"hostname",
			"pyroscope_spy",
			"service_git_ref",
			"service_name",
			"service_repository",
			"target",
		})
	})

	t.Run("get Pyroscope label values", func(t *testing.T) {
		ctx := newTestContext()
		values, err := listPyroscopeLabelValues(ctx, ListPyroscopeLabelValuesParams{
			DataSourceUID: "pyroscope",
			Name:          "target",
			Matchers:      `{service_name="pyroscope"}`,
		})
		require.NoError(t, err)
		require.ElementsMatch(t, values, []string{"all"})
	})

	t.Run("get Pyroscope profile types", func(t *testing.T) {
		ctx := newTestContext()
		types, err := listPyroscopeProfileTypes(ctx, ListPyroscopeProfileTypesParams{
			DataSourceUID: "pyroscope",
		})
		require.NoError(t, err)
		require.ElementsMatch(t, types, []string{
			"block:contentions:count:contentions:count",
			"block:delay:nanoseconds:contentions:count",
			"goroutines:goroutine:count:goroutine:count",
			"memory:alloc_objects:count:space:bytes",
			"memory:alloc_space:bytes:space:bytes",
			"memory:inuse_objects:count:space:bytes",
			"memory:inuse_space:bytes:space:bytes",
			"mutex:contentions:count:contentions:count",
			"mutex:delay:nanoseconds:contentions:count",
			"process_cpu:cpu:nanoseconds:cpu:nanoseconds",
			"process_cpu:samples:count:cpu:nanoseconds",
		})
	})

	t.Run("fetch Pyroscope profile", func(t *testing.T) {
		ctx := newTestContext()
		profile, err := fetchPyroscopeProfile(ctx, FetchPyroscopeProfileParams{
			DataSourceUID: "pyroscope",
			ProfileType:   "process_cpu:cpu:nanoseconds:cpu:nanoseconds",
			Matchers:      `{service_name="pyroscope"}`,
		})
		require.NoError(t, err)
		require.NotEmpty(t, profile)
	})

	t.Run("fetch empty Pyroscope profile", func(t *testing.T) {
		ctx := newTestContext()
		_, err := fetchPyroscopeProfile(ctx, FetchPyroscopeProfileParams{
			DataSourceUID: "pyroscope",
			ProfileType:   "process_cpu:cpu:nanoseconds:cpu:nanoseconds",
			Matchers:      `{service_name="pyroscope", label_does_not_exit="missing"}`,
		})
		require.EqualError(t, err, "failed to call Pyroscope API: pyroscope API returned a empty profile")
	})
}

```

--------------------------------------------------------------------------------
/proxied_handler.go:
--------------------------------------------------------------------------------

```go
package mcpgrafana

import (
	"context"
	"fmt"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
)

// ProxiedToolHandler implements the CallToolHandler interface for proxied tools
type ProxiedToolHandler struct {
	sessionManager *SessionManager
	toolManager    *ToolManager
	toolName       string
}

// NewProxiedToolHandler creates a new handler for a proxied tool
func NewProxiedToolHandler(sm *SessionManager, tm *ToolManager, toolName string) *ProxiedToolHandler {
	return &ProxiedToolHandler{
		sessionManager: sm,
		toolManager:    tm,
		toolName:       toolName,
	}
}

// Handle forwards the tool call to the appropriate remote MCP server
func (h *ProxiedToolHandler) Handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
	// Check if session is in context
	session := server.ClientSessionFromContext(ctx)
	if session == nil {
		return nil, fmt.Errorf("session not found in context")
	}

	// Extract arguments
	args, ok := request.Params.Arguments.(map[string]any)
	if !ok {
		return nil, fmt.Errorf("invalid arguments type")
	}

	// Extract required datasourceUid parameter
	datasourceUidRaw, ok := args["datasourceUid"]
	if !ok {
		return nil, fmt.Errorf("datasourceUid parameter is required")
	}
	datasourceUID, ok := datasourceUidRaw.(string)
	if !ok {
		return nil, fmt.Errorf("datasourceUid must be a string")
	}

	// Parse the tool name to get datasource type and original tool name
	// Format: datasourceType_originalToolName (e.g., "tempo_traceql-search")
	datasourceType, originalToolName, err := parseProxiedToolName(h.toolName)
	if err != nil {
		return nil, fmt.Errorf("failed to parse tool name: %w", err)
	}

	// Get the proxied client for this datasource
	var client *ProxiedClient

	if h.toolManager.serverMode {
		// Server mode (stdio): clients stored at manager level
		client, err = h.toolManager.GetServerClient(datasourceType, datasourceUID)
	} else {
		// Session mode (HTTP/SSE): clients stored per-session
		client, err = h.sessionManager.GetProxiedClient(ctx, datasourceType, datasourceUID)
		if err != nil {
			// Fallback to server-level in case of mixed mode
			client, err = h.toolManager.GetServerClient(datasourceType, datasourceUID)
		}
	}

	if err != nil {
		return nil, fmt.Errorf("datasource '%s' not found or not accessible. Ensure the datasource exists and you have permission to access it", datasourceUID)
	}

	// Remove datasourceUid from args before forwarding to remote server
	forwardArgs := make(map[string]any)
	for k, v := range args {
		if k != "datasourceUid" {
			forwardArgs[k] = v
		}
	}

	// Forward the call to the remote MCP server
	return client.CallTool(ctx, originalToolName, forwardArgs)
}

```

--------------------------------------------------------------------------------
/tools/prometheus_unit_test.go:
--------------------------------------------------------------------------------

```go
package tools

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestParseRelativeTime(t *testing.T) {
	const day = 24 * time.Hour
	const week = 7 * day

	testCases := []struct {
		name          string
		input         string
		expectedError bool
		expectedDelta time.Duration // Expected time difference from now
		isMonthCase   bool          // Special handling for month arithmetic
		isYearCase    bool          // Special handling for year arithmetic
	}{
		{
			name:          "now",
			input:         "now",
			expectedError: false,
			expectedDelta: 0,
		},
		{
			name:          "now-1h",
			input:         "now-1h",
			expectedError: false,
			expectedDelta: -1 * time.Hour,
		},
		{
			name:          "now-30m",
			input:         "now-30m",
			expectedError: false,
			expectedDelta: -30 * time.Minute,
		},
		{
			name:          "now-1d",
			input:         "now-1d",
			expectedError: false,
			expectedDelta: -24 * time.Hour,
		},
		{
			name:          "now-1w",
			input:         "now-1w",
			expectedError: false,
			expectedDelta: -week,
		},
		{
			name:          "now-1M",
			input:         "now-1M",
			expectedError: false,
			isMonthCase:   true,
		},
		{
			name:          "now-1y",
			input:         "now-1y",
			expectedError: false,
			isYearCase:    true,
		},
		{
			name:          "now-1.5h",
			input:         "now-1.5h",
			expectedError: true,
		},
		{
			name:          "invalid format",
			input:         "yesterday",
			expectedError: true,
		},
		{
			name:          "empty string",
			input:         "",
			expectedError: true,
		},
	}

	for _, tc := range testCases {
		t.Run(tc.name, func(t *testing.T) {
			now := time.Now()
			result, err := parseTime(tc.input)

			if tc.expectedError {
				assert.Error(t, err)
				return
			}

			require.NoError(t, err)

			if tc.input == "now" {
				// For "now", the result should be very close to the current time
				// Allow a small tolerance for execution time
				diff := result.Sub(now)
				assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
			} else if tc.isMonthCase {
				// For month calculations, use proper calendar arithmetic
				expected := now.AddDate(0, -1, 0)
				diff := result.Sub(expected)
				assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
			} else if tc.isYearCase {
				// For year calculations, use proper calendar arithmetic
				expected := now.AddDate(-1, 0, 0)
				diff := result.Sub(expected)
				assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
			} else {
				// For other relative times, compare with the expected delta from now
				expected := now.Add(tc.expectedDelta)
				diff := result.Sub(expected)
				assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
			}
		})
	}
}

```

--------------------------------------------------------------------------------
/testdata/dashboards/demo.json:
--------------------------------------------------------------------------------

```json
{
  "annotations": {
    "list": [
      {
        "builtIn": 1,
        "datasource": {
          "type": "grafana",
          "uid": "-- Grafana --"
        },
        "enable": true,
        "hide": true,
        "iconColor": "rgba(0, 211, 255, 1)",
        "name": "Annotations & Alerts",
        "type": "dashboard"
      }
    ]
  },
  "editable": true,
  "fiscalYearStartMonth": 0,
  "graphTooltip": 0,
  "id": 1,
  "isStarred": true,
  "links": [],
  "panels": [
    {
      "datasource": {
        "default": true,
        "type": "prometheus",
        "uid": "robustperception"
      },
      "fieldConfig": {
        "defaults": {
          "color": {
            "mode": "palette-classic"
          },
          "custom": {
            "axisBorderShow": false,
            "axisCenteredZero": false,
            "axisColorMode": "text",
            "axisLabel": "",
            "axisPlacement": "auto",
            "barAlignment": 0,
            "barWidthFactor": 0.6,
            "drawStyle": "line",
            "fillOpacity": 0,
            "gradientMode": "none",
            "hideFrom": {
              "legend": false,
              "tooltip": false,
              "viz": false
            },
            "insertNulls": false,
            "lineInterpolation": "linear",
            "lineWidth": 1,
            "pointSize": 5,
            "scaleDistribution": {
              "type": "linear"
            },
            "showPoints": "auto",
            "spanNulls": false,
            "stacking": {
              "group": "A",
              "mode": "none"
            },
            "thresholdsStyle": {
              "mode": "off"
            }
          },
          "mappings": [],
          "thresholds": {
            "mode": "absolute",
            "steps": [
              {
                "color": "green",
                "value": null
              },
              {
                "color": "red",
                "value": 80
              }
            ]
          }
        },
        "overrides": []
      },
      "gridPos": {
        "h": 8,
        "w": 12,
        "x": 0,
        "y": 0
      },
      "id": 1,
      "options": {
        "legend": {
          "calcs": [],
          "displayMode": "list",
          "placement": "bottom",
          "showLegend": true
        },
        "tooltip": {
          "mode": "single",
          "sort": "none"
        }
      },
      "targets": [
        {
          "datasource": {
            "type": "prometheus",
            "uid": "robustperception"
          },
          "editorMode": "code",
          "expr": "node_load1",
          "instant": false,
          "legendFormat": "__auto",
          "range": true,
          "refId": "A"
        }
      ],
      "title": "Node Load",
      "type": "timeseries"
    }
  ],
  "schemaVersion": 39,
  "tags": [
    "demo"
  ],
  "templating": {
    "list": []
  },
  "time": {
    "from": "now-6h",
    "to": "now"
  },
  "timepicker": {},
  "timezone": "browser",
  "title": "Demo",
  "uid": "fe9gm6guyzi0wd",
  "version": 2,
  "weekStart": ""
}

```

--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------

```yaml
name: Build and Push Docker Image

on:
  push:
    tags:
      - "v*.*.*"
  release:
    types: [published]

permissions:
  contents: read

jobs:
  docker:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
    steps:
      - uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493

      - name: Process tag name
        id: tag
        run: |
          VERSION=${GITHUB_REF_NAME#v}
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          # Check if this is a stable release (no rc, alpha, beta, etc.)
          if [[ ! "$VERSION" =~ (rc|alpha|beta|pre|dev) ]]; then
            echo "is_stable=true" >> $GITHUB_OUTPUT
          else
            echo "is_stable=false" >> $GITHUB_OUTPUT
          fi

      - name: Build and Push to Docker Hub
        uses: grafana/shared-workflows/actions/build-push-to-dockerhub@60fadd1458bb20b97f00618568c22ed1c7d485bd
        with:
          context: .
          file: ./Dockerfile
          repository: grafana/mcp-grafana
          platforms: linux/amd64,linux/arm64
          tags: |
            ${{ steps.tag.outputs.is_stable == 'true' && 'latest' || '' }}
            ${{ steps.tag.outputs.version }}
          push: true

  mcp-registry:
    runs-on: ubuntu-latest
    needs: docker
    permissions:
      contents: read
      id-token: write
    steps:
      - name: Checkout code
        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
        with:
          fetch-depth: 0

      - name: Extract version from tag
        id: version
        run: |
          # Get the tag from the triggering workflow
          TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "")
          if [ -z "$TAG" ]; then
            echo "No tag found at HEAD"
            exit 1
          fi
          echo "VERSION=$TAG" >> $GITHUB_OUTPUT

      - name: Extract image tag from version
        id: image-tag
        run: |
          # Extract the image tag from the version
          VERSION="${{ steps.version.outputs.VERSION }}"
          echo "IMAGE_TAG=${VERSION#v}" >> $GITHUB_OUTPUT

      - name: Install dependencies
        run: |
          sudo apt-get update && sudo apt-get install -y jq
          curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.3.0/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher
          chmod +x mcp-publisher
          sudo mv mcp-publisher /usr/local/bin/

      - name: Update server.json with Docker image
        run: |
          # Update the server.json with the correct Docker image reference
          # (note the image tag does not include the "v" prefix)
          jq --arg version "${{ steps.version.outputs.VERSION }}" \
            --arg image "docker.io/grafana/mcp-grafana:${{ steps.image-tag.outputs.IMAGE_TAG }}" \
             '.version = $version | .packages[0].identifier = $image' server.json > server.json.tmp
          mv server.json.tmp server.json

      - name: Login to MCP Registry
        run: mcp-publisher login github-oidc

      - name: Publish to MCP Registry
        run: mcp-publisher publish

```

--------------------------------------------------------------------------------
/session.go:
--------------------------------------------------------------------------------

```go
package mcpgrafana

import (
	"context"
	"fmt"
	"log/slog"
	"sync"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
)

// SessionState holds the state for a single client session
type SessionState struct {
	// Proxied tools state
	initOnce                sync.Once
	proxiedToolsInitialized bool
	proxiedTools            []mcp.Tool
	proxiedClients          map[string]*ProxiedClient // key: datasourceType_datasourceUID
	toolToDatasources       map[string][]string       // key: toolName, value: list of datasource keys that support it
	mutex                   sync.RWMutex
}

func newSessionState() *SessionState {
	return &SessionState{
		proxiedClients:    make(map[string]*ProxiedClient),
		toolToDatasources: make(map[string][]string),
	}
}

// SessionManager manages client sessions and their state
type SessionManager struct {
	sessions map[string]*SessionState
	mutex    sync.RWMutex
}

func NewSessionManager() *SessionManager {
	return &SessionManager{
		sessions: make(map[string]*SessionState),
	}
}

func (sm *SessionManager) CreateSession(ctx context.Context, session server.ClientSession) {
	sm.mutex.Lock()
	defer sm.mutex.Unlock()

	sessionID := session.SessionID()
	if _, exists := sm.sessions[sessionID]; !exists {
		sm.sessions[sessionID] = newSessionState()
	}
}

func (sm *SessionManager) GetSession(sessionID string) (*SessionState, bool) {
	sm.mutex.RLock()
	defer sm.mutex.RUnlock()

	session, exists := sm.sessions[sessionID]
	return session, exists
}

func (sm *SessionManager) RemoveSession(ctx context.Context, session server.ClientSession) {
	sm.mutex.Lock()
	sessionID := session.SessionID()
	state, exists := sm.sessions[sessionID]
	delete(sm.sessions, sessionID)
	sm.mutex.Unlock()

	if !exists {
		return
	}

	// Clean up proxied clients outside of the main lock
	state.mutex.Lock()
	defer state.mutex.Unlock()

	for key, client := range state.proxiedClients {
		if err := client.Close(); err != nil {
			slog.Error("failed to close proxied client", "key", key, "error", err)
		}
	}
}

// GetProxiedClient retrieves a proxied client for the given datasource
func (sm *SessionManager) GetProxiedClient(ctx context.Context, datasourceType, datasourceUID string) (*ProxiedClient, error) {
	session := server.ClientSessionFromContext(ctx)
	if session == nil {
		return nil, fmt.Errorf("session not found in context")
	}

	state, exists := sm.GetSession(session.SessionID())
	if !exists {
		return nil, fmt.Errorf("session not found")
	}

	state.mutex.RLock()
	defer state.mutex.RUnlock()

	key := datasourceType + "_" + datasourceUID
	client, exists := state.proxiedClients[key]
	if !exists {
		// List available datasources to help with debugging
		var availableUIDs []string
		for _, c := range state.proxiedClients {
			if c.DatasourceType == datasourceType {
				availableUIDs = append(availableUIDs, c.DatasourceUID)
			}
		}
		if len(availableUIDs) > 0 {
			return nil, fmt.Errorf("datasource '%s' not found. Available %s datasources: %v", datasourceUID, datasourceType, availableUIDs)
		}
		return nil, fmt.Errorf("datasource '%s' not found. No %s datasources with MCP support are configured", datasourceUID, datasourceType)
	}

	return client, nil
}

```

--------------------------------------------------------------------------------
/tools/alerting_client_test.go:
--------------------------------------------------------------------------------

```go
package tools

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"net/url"
	"testing"

	"github.com/prometheus/prometheus/model/labels"
	"github.com/stretchr/testify/require"

	mcpgrafana "github.com/grafana/mcp-grafana"
)

var (
	fakeruleGroup = ruleGroup{
		Name:      "TestGroup",
		FolderUID: "test-folder",
		Rules: []alertingRule{
			{
				State:     "firing",
				Name:      "Test Alert Rule",
				UID:       "test-rule-uid",
				FolderUID: "test-folder",
				Labels:    labels.New(labels.Label{Name: "severity", Value: "critical"}),
				Alerts: []alert{
					{
						Labels:      labels.New(labels.Label{Name: "instance", Value: "test-instance"}),
						Annotations: labels.New(labels.Label{Name: "summary", Value: "Test alert firing"}),
						State:       "firing",
						Value:       "1",
					},
				},
			},
		},
	}
)

func setupMockServer(handler http.HandlerFunc) (*httptest.Server, *alertingClient) {
	server := httptest.NewServer(handler)
	baseURL, _ := url.Parse(server.URL)
	client := &alertingClient{
		baseURL:    baseURL,
		apiKey:     "test-api-key",
		httpClient: &http.Client{},
	}
	return server, client
}

func mockrulesResponse() rulesResponse {
	resp := rulesResponse{}
	resp.Data.RuleGroups = []ruleGroup{fakeruleGroup}
	return resp
}

func TestAlertingClient_GetRules(t *testing.T) {
	server, client := setupMockServer(func(w http.ResponseWriter, r *http.Request) {
		require.Equal(t, "/api/prometheus/grafana/api/v1/rules", r.URL.Path)
		require.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))

		resp := mockrulesResponse()
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		err := json.NewEncoder(w).Encode(resp)
		require.NoError(t, err)
	})
	defer server.Close()

	rules, err := client.GetRules(context.Background())
	require.NoError(t, err)
	require.NotNil(t, rules)
	require.ElementsMatch(t, rules.Data.RuleGroups, []ruleGroup{fakeruleGroup})
}

func TestAlertingClient_GetRules_Error(t *testing.T) {
	t.Run("internal server error", func(t *testing.T) {
		server, client := setupMockServer(func(w http.ResponseWriter, r *http.Request) {
			w.WriteHeader(http.StatusInternalServerError)
			_, err := w.Write([]byte("internal server error"))
			require.NoError(t, err)
		})
		defer server.Close()

		rules, err := client.GetRules(context.Background())
		require.Error(t, err)
		require.Nil(t, rules)
		require.ErrorContains(t, err, "grafana API returned status code 500: internal server error")
	})

	t.Run("network error", func(t *testing.T) {
		server, client := setupMockServer(func(w http.ResponseWriter, r *http.Request) {})
		server.Close()

		rules, err := client.GetRules(context.Background())

		require.Error(t, err)
		require.Nil(t, rules)
		require.ErrorContains(t, err, "failed to execute request")
	})
}

func TestNewAlertingClientFromContext(t *testing.T) {
	config := mcpgrafana.GrafanaConfig{
		URL:    "http://localhost:3000/",
		APIKey: "test-api-key",
	}
	ctx := mcpgrafana.WithGrafanaConfig(context.Background(), config)

	client, err := newAlertingClientFromContext(ctx)
	require.NoError(t, err)

	require.Equal(t, "http://localhost:3000", client.baseURL.String())
	require.Equal(t, "test-api-key", client.apiKey)
	require.NotNil(t, client.httpClient)
}

```

--------------------------------------------------------------------------------
/tools/navigation.go:
--------------------------------------------------------------------------------

```go
package tools

import (
	"context"
	"fmt"
	"net/url"
	"strings"

	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"

	mcpgrafana "github.com/grafana/mcp-grafana"
)

type GenerateDeeplinkParams struct {
	ResourceType  string            `json:"resourceType" jsonschema:"required,description=Type of resource: dashboard\\, panel\\, or explore"`
	DashboardUID  *string           `json:"dashboardUid,omitempty" jsonschema:"description=Dashboard UID (required for dashboard and panel types)"`
	DatasourceUID *string           `json:"datasourceUid,omitempty" jsonschema:"description=Datasource UID (required for explore type)"`
	PanelID       *int              `json:"panelId,omitempty" jsonschema:"description=Panel ID (required for panel type)"`
	QueryParams   map[string]string `json:"queryParams,omitempty" jsonschema:"description=Additional query parameters"`
	TimeRange     *TimeRange        `json:"timeRange,omitempty" jsonschema:"description=Time range for the link"`
}

type TimeRange struct {
	From string `json:"from" jsonschema:"description=Start time (e.g.\\, 'now-1h')"`
	To   string `json:"to" jsonschema:"description=End time (e.g.\\, 'now')"`
}

func generateDeeplink(ctx context.Context, args GenerateDeeplinkParams) (string, error) {
	config := mcpgrafana.GrafanaConfigFromContext(ctx)
	baseURL := strings.TrimRight(config.URL, "/")

	if baseURL == "" {
		return "", fmt.Errorf("grafana url not configured. Please set GRAFANA_URL environment variable or X-Grafana-URL header")
	}

	var deeplink string

	switch strings.ToLower(args.ResourceType) {
	case "dashboard":
		if args.DashboardUID == nil {
			return "", fmt.Errorf("dashboardUid is required for dashboard links")
		}
		deeplink = fmt.Sprintf("%s/d/%s", baseURL, *args.DashboardUID)
	case "panel":
		if args.DashboardUID == nil {
			return "", fmt.Errorf("dashboardUid is required for panel links")
		}
		if args.PanelID == nil {
			return "", fmt.Errorf("panelId is required for panel links")
		}
		deeplink = fmt.Sprintf("%s/d/%s?viewPanel=%d", baseURL, *args.DashboardUID, *args.PanelID)
	case "explore":
		if args.DatasourceUID == nil {
			return "", fmt.Errorf("datasourceUid is required for explore links")
		}
		params := url.Values{}
		exploreState := fmt.Sprintf(`{"datasource":"%s"}`, *args.DatasourceUID)
		params.Set("left", exploreState)
		deeplink = fmt.Sprintf("%s/explore?%s", baseURL, params.Encode())
	default:
		return "", fmt.Errorf("unsupported resource type: %s. Supported types are: dashboard, panel, explore", args.ResourceType)
	}

	if args.TimeRange != nil {
		separator := "?"
		if strings.Contains(deeplink, "?") {
			separator = "&"
		}
		timeParams := url.Values{}
		if args.TimeRange.From != "" {
			timeParams.Set("from", args.TimeRange.From)
		}
		if args.TimeRange.To != "" {
			timeParams.Set("to", args.TimeRange.To)
		}
		if len(timeParams) > 0 {
			deeplink = fmt.Sprintf("%s%s%s", deeplink, separator, timeParams.Encode())
		}
	}

	if len(args.QueryParams) > 0 {
		separator := "?"
		if strings.Contains(deeplink, "?") {
			separator = "&"
		}
		additionalParams := url.Values{}
		for key, value := range args.QueryParams {
			additionalParams.Set(key, value)
		}
		deeplink = fmt.Sprintf("%s%s%s", deeplink, separator, additionalParams.Encode())
	}

	return deeplink, nil
}

var GenerateDeeplink = mcpgrafana.MustTool(
	"generate_deeplink",
	"Generate deeplink URLs for Grafana resources. Supports dashboards (requires dashboardUid), panels (requires dashboardUid and panelId), and Explore queries (requires datasourceUid). Optionally accepts time range and additional query parameters.",
	generateDeeplink,
	mcp.WithTitleAnnotation("Generate navigation deeplink"),
	mcp.WithIdempotentHintAnnotation(true),
	mcp.WithReadOnlyHintAnnotation(true),
)

func AddNavigationTools(mcp *server.MCPServer) {
	GenerateDeeplink.Register(mcp)
}

```

--------------------------------------------------------------------------------
/tools/navigation_test.go:
--------------------------------------------------------------------------------

```go
package tools

import (
	"context"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	mcpgrafana "github.com/grafana/mcp-grafana"
)

// Helper function to create string pointers
func stringPtr(s string) *string {
	return &s
}

func TestGenerateDeeplink(t *testing.T) {
	grafanaCfg := mcpgrafana.GrafanaConfig{
		URL: "http://localhost:3000",
	}
	ctx := mcpgrafana.WithGrafanaConfig(context.Background(), grafanaCfg)

	t.Run("Dashboard deeplink", func(t *testing.T) {
		params := GenerateDeeplinkParams{
			ResourceType: "dashboard",
			DashboardUID: stringPtr("abc123"),
		}

		result, err := generateDeeplink(ctx, params)
		require.NoError(t, err)
		assert.Equal(t, "http://localhost:3000/d/abc123", result)
	})

	t.Run("Panel deeplink", func(t *testing.T) {
		panelID := 5
		params := GenerateDeeplinkParams{
			ResourceType: "panel",
			DashboardUID: stringPtr("dash-123"),
			PanelID:      &panelID,
		}

		result, err := generateDeeplink(ctx, params)
		require.NoError(t, err)
		assert.Equal(t, "http://localhost:3000/d/dash-123?viewPanel=5", result)
	})

	t.Run("Explore deeplink", func(t *testing.T) {
		params := GenerateDeeplinkParams{
			ResourceType:  "explore",
			DatasourceUID: stringPtr("prometheus-uid"),
		}

		result, err := generateDeeplink(ctx, params)
		require.NoError(t, err)
		assert.Contains(t, result, "http://localhost:3000/explore?left=")
		assert.Contains(t, result, "prometheus-uid")
	})

	t.Run("With time range", func(t *testing.T) {
		params := GenerateDeeplinkParams{
			ResourceType: "dashboard",
			DashboardUID: stringPtr("abc123"),
			TimeRange: &TimeRange{
				From: "now-1h",
				To:   "now",
			},
		}

		result, err := generateDeeplink(ctx, params)
		require.NoError(t, err)
		assert.Contains(t, result, "http://localhost:3000/d/abc123")
		assert.Contains(t, result, "from=now-1h")
		assert.Contains(t, result, "to=now")
	})

	t.Run("With additional query params", func(t *testing.T) {
		params := GenerateDeeplinkParams{
			ResourceType: "dashboard",
			DashboardUID: stringPtr("abc123"),
			QueryParams: map[string]string{
				"var-datasource": "prometheus",
				"refresh":        "30s",
			},
		}

		result, err := generateDeeplink(ctx, params)
		require.NoError(t, err)
		assert.Contains(t, result, "http://localhost:3000/d/abc123")
		assert.Contains(t, result, "var-datasource=prometheus")
		assert.Contains(t, result, "refresh=30s")
	})

	t.Run("Error cases", func(t *testing.T) {
		emptyGrafanaCfg := mcpgrafana.GrafanaConfig{
			URL: "",
		}
		emptyCtx := mcpgrafana.WithGrafanaConfig(context.Background(), emptyGrafanaCfg)
		params := GenerateDeeplinkParams{
			ResourceType: "dashboard",
			DashboardUID: stringPtr("abc123"),
		}
		_, err := generateDeeplink(emptyCtx, params)
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "grafana url not configured")

		params.ResourceType = "unsupported"
		_, err = generateDeeplink(ctx, params)
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "unsupported resource type")

		// Test missing dashboardUid for dashboard
		params = GenerateDeeplinkParams{
			ResourceType: "dashboard",
		}
		_, err = generateDeeplink(ctx, params)
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "dashboardUid is required")

		// Test missing dashboardUid for panel
		params = GenerateDeeplinkParams{
			ResourceType: "panel",
		}
		_, err = generateDeeplink(ctx, params)
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "dashboardUid is required")

		// Test missing panelId for panel
		params = GenerateDeeplinkParams{
			ResourceType: "panel",
			DashboardUID: stringPtr("dash-123"),
		}
		_, err = generateDeeplink(ctx, params)
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "panelId is required")

		// Test missing datasourceUid for explore
		params = GenerateDeeplinkParams{
			ResourceType: "explore",
		}
		_, err = generateDeeplink(ctx, params)
		assert.Error(t, err)
		assert.Contains(t, err.Error(), "datasourceUid is required")
	})
}

```

--------------------------------------------------------------------------------
/tools/asserts_test.go:
--------------------------------------------------------------------------------

```go
//go:build unit
// +build unit

package tools

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	mcpgrafana "github.com/grafana/mcp-grafana"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func setupMockAssertsServer(handler http.HandlerFunc) (*httptest.Server, context.Context) {
	server := httptest.NewServer(handler)
	config := mcpgrafana.GrafanaConfig{
		URL:    server.URL,
		APIKey: "test-api-key",
	}
	ctx := mcpgrafana.WithGrafanaConfig(context.Background(), config)
	return server, ctx
}

func TestAssertTools(t *testing.T) {
	t.Run("get assertions", func(t *testing.T) {
		startTime := time.Date(2025, 4, 23, 10, 0, 0, 0, time.UTC)
		endTime := time.Date(2025, 4, 23, 11, 0, 0, 0, time.UTC)
		server, ctx := setupMockAssertsServer(func(w http.ResponseWriter, r *http.Request) {
			require.Equal(t, "/api/plugins/grafana-asserts-app/resources/asserts/api-server/v1/assertions/llm-summary", r.URL.Path)
			require.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))

			var requestBody map[string]interface{}
			err := json.NewDecoder(r.Body).Decode(&requestBody)
			require.NoError(t, err)

			expectedBody := map[string]interface{}{
				"startTime": float64(startTime.UnixMilli()),
				"endTime":   float64(endTime.UnixMilli()),
				"entityKeys": []interface{}{
					map[string]interface{}{
						"type": "Service",
						"name": "mongodb",
						"scope": map[string]interface{}{
							"env":       "asserts-demo",
							"site":      "app",
							"namespace": "robot-shop",
						},
					},
				},
				"suggestionSrcEntities": []interface{}{},
				"alertCategories":       []interface{}{"saturation", "amend", "anomaly", "failure", "error"},
			}
			require.Equal(t, expectedBody, requestBody)

			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusOK)
			_, err = w.Write([]byte(`{"summary": "test summary"}`))
			require.NoError(t, err)
		})
		defer server.Close()

		result, err := getAssertions(ctx, GetAssertionsParams{
			StartTime:  startTime,
			EndTime:    endTime,
			EntityType: "Service",
			EntityName: "mongodb",
			Env:        "asserts-demo",
			Site:       "app",
			Namespace:  "robot-shop",
		})
		require.NoError(t, err)
		assert.NotNil(t, result)
		assert.Equal(t, `{"summary": "test summary"}`, result)
	})

	t.Run("get assertions with no site and namespace", func(t *testing.T) {
		startTime := time.Date(2025, 4, 23, 10, 0, 0, 0, time.UTC)
		endTime := time.Date(2025, 4, 23, 11, 0, 0, 0, time.UTC)
		server, ctx := setupMockAssertsServer(func(w http.ResponseWriter, r *http.Request) {
			require.Equal(t, "/api/plugins/grafana-asserts-app/resources/asserts/api-server/v1/assertions/llm-summary", r.URL.Path)
			require.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))

			var requestBody map[string]interface{}
			err := json.NewDecoder(r.Body).Decode(&requestBody)
			require.NoError(t, err)

			expectedBody := map[string]interface{}{
				"startTime": float64(startTime.UnixMilli()),
				"endTime":   float64(endTime.UnixMilli()),
				"entityKeys": []interface{}{
					map[string]interface{}{
						"type": "Service",
						"name": "mongodb",
						"scope": map[string]interface{}{
							"env": "asserts-demo",
						},
					},
				},
				"suggestionSrcEntities": []interface{}{},
				"alertCategories":       []interface{}{"saturation", "amend", "anomaly", "failure", "error"},
			}
			require.Equal(t, expectedBody, requestBody)

			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusOK)
			_, err = w.Write([]byte(`{"summary": "test summary"}`))
			require.NoError(t, err)
		})
		defer server.Close()

		result, err := getAssertions(ctx, GetAssertionsParams{
			StartTime:  startTime,
			EndTime:    endTime,
			EntityType: "Service",
			EntityName: "mongodb",
			Env:        "asserts-demo",
		})
		require.NoError(t, err)
		assert.NotNil(t, result)
		assert.Equal(t, `{"summary": "test summary"}`, result)
	})
}

```

--------------------------------------------------------------------------------
/proxied_client.go:
--------------------------------------------------------------------------------

```go
package mcpgrafana

import (
	"context"
	"encoding/base64"
	"fmt"
	"log/slog"
	"sync"

	mcp_client "github.com/mark3labs/mcp-go/client"
	"github.com/mark3labs/mcp-go/client/transport"
	"github.com/mark3labs/mcp-go/mcp"
)

// ProxiedClient represents a connection to a remote MCP server (e.g., Tempo datasource)
type ProxiedClient struct {
	DatasourceUID  string
	DatasourceName string
	DatasourceType string
	Client         *mcp_client.Client
	Tools          []mcp.Tool
	mutex          sync.RWMutex
}

// NewProxiedClient creates a new connection to a remote MCP server
func NewProxiedClient(ctx context.Context, datasourceUID, datasourceName, datasourceType, mcpEndpoint string) (*ProxiedClient, error) {
	// Get Grafana config for authentication
	config := GrafanaConfigFromContext(ctx)

	// Build headers for authentication
	headers := make(map[string]string)
	if config.APIKey != "" {
		headers["Authorization"] = "Bearer " + config.APIKey
	} else if config.BasicAuth != nil {
		auth := config.BasicAuth.String()
		headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
	}

	// Create HTTP transport with authentication headers
	slog.DebugContext(ctx, "connecting to MCP server", "datasource", datasourceUID, "url", mcpEndpoint)
	httpTransport, err := transport.NewStreamableHTTP(
		mcpEndpoint,
		transport.WithHTTPHeaders(headers),
	)
	if err != nil {
		return nil, fmt.Errorf("failed to create HTTP transport: %w", err)
	}

	// Create MCP client
	mcpClient := mcp_client.NewClient(httpTransport)

	// Initialize the connection
	initReq := mcp.InitializeRequest{}
	initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
	initReq.Params.ClientInfo = mcp.Implementation{
		Name:    "mcp-grafana-proxy",
		Version: Version(),
	}

	_, err = mcpClient.Initialize(ctx, initReq)
	if err != nil {
		_ = mcpClient.Close()
		return nil, fmt.Errorf("failed to initialize MCP client: %w", err)
	}

	// List available tools from the remote server
	listReq := mcp.ListToolsRequest{}
	toolsResult, err := mcpClient.ListTools(ctx, listReq)
	if err != nil {
		_ = mcpClient.Close()
		return nil, fmt.Errorf("failed to list tools from remote MCP server: %w", err)
	}

	slog.DebugContext(ctx, "connected to proxied MCP server",
		"datasource", datasourceUID,
		"type", datasourceType,
		"tools", len(toolsResult.Tools))

	return &ProxiedClient{
		DatasourceUID:  datasourceUID,
		DatasourceName: datasourceName,
		DatasourceType: datasourceType,
		Client:         mcpClient,
		Tools:          toolsResult.Tools,
	}, nil
}

// CallTool forwards a tool call to the remote MCP server
func (pc *ProxiedClient) CallTool(ctx context.Context, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
	pc.mutex.RLock()
	defer pc.mutex.RUnlock()

	// Validate the tool exists
	var toolExists bool
	for _, tool := range pc.Tools {
		if tool.Name == toolName {
			toolExists = true
			break
		}
	}
	if !toolExists {
		return nil, fmt.Errorf("tool %s not found in remote MCP server", toolName)
	}

	// Create the call tool request
	req := mcp.CallToolRequest{}
	req.Params.Name = toolName
	req.Params.Arguments = arguments

	// Forward the call to the remote server
	result, err := pc.Client.CallTool(ctx, req)
	if err != nil {
		return nil, fmt.Errorf("failed to call tool on remote MCP server: %w", err)
	}

	return result, nil
}

// ListTools returns the tools available from this remote server
// Note: This method doesn't take a context parameter as the tools are cached locally
func (pc *ProxiedClient) ListTools() []mcp.Tool {
	pc.mutex.RLock()
	defer pc.mutex.RUnlock()

	// Return a copy to prevent external modification
	result := make([]mcp.Tool, len(pc.Tools))
	copy(result, pc.Tools)
	return result
}

// Close closes the connection to the remote MCP server
func (pc *ProxiedClient) Close() error {
	pc.mutex.Lock()
	defer pc.mutex.Unlock()

	if pc.Client != nil {
		if err := pc.Client.Close(); err != nil {
			return fmt.Errorf("failed to close MCP client: %w", err)
		}
	}

	return nil
}

```

--------------------------------------------------------------------------------
/tests/dashboards_test.py:
--------------------------------------------------------------------------------

```python
import json
import pytest
from langevals import expect
from langevals_langevals.llm_boolean import (
    CustomLLMBooleanEvaluator,
    CustomLLMBooleanSettings,
)
from litellm import Message, acompletion
from mcp import ClientSession

from conftest import models
from utils import (
    get_converted_tools,
    llm_tool_call_sequence,
)

pytestmark = pytest.mark.anyio

@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_dashboard_panel_queries_tool(model: str, mcp_client: ClientSession):
    tools = await get_converted_tools(mcp_client)
    prompt = "Can you list the panel queries for the dashboard with UID fe9gm6guyzi0wd?"

    messages = [
        Message(role="system", content="You are a helpful assistant."),
        Message(role="user", content=prompt),
    ]

    # 1. Call the dashboard panel queries tool
    messages = await llm_tool_call_sequence(
        model, messages, tools, mcp_client, "get_dashboard_panel_queries",
        {"uid": "fe9gm6guyzi0wd"}
    )

    # 2. Final LLM response
    response = await acompletion(model=model, messages=messages, tools=tools)
    content = response.choices[0].message.content
    panel_queries_checker = CustomLLMBooleanEvaluator(
        settings=CustomLLMBooleanSettings(
            prompt="Does the response contain specific information about the panel queries and titles for a grafana dashboard?",
        )
    )
    print("content", content)
    expect(input=prompt, output=content).to_pass(panel_queries_checker)


@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_dashboard_update_with_patch_operations(model: str, mcp_client: ClientSession):
    """Test that LLMs naturally use patch operations for dashboard updates"""
    tools = await get_converted_tools(mcp_client)

    # First, create a non-provisioned test dashboard by copying the demo dashboard
    # 1. Get the demo dashboard JSON
    demo_result = await mcp_client.call_tool("get_dashboard_by_uid", {"uid": "fe9gm6guyzi0wd"})
    demo_data = json.loads(demo_result.content[0].text)
    dashboard_json = demo_data["dashboard"]

    # 2. Remove uid and id to create a new dashboard
    if "uid" in dashboard_json:
        del dashboard_json["uid"]
    if "id" in dashboard_json:
        del dashboard_json["id"]

    # 3. Set a new title
    title = f"Test Dashboard"
    dashboard_json["title"] = title
    dashboard_json["tags"] = ["python-integration-test"]

    # 4. Create the dashboard in Grafana
    create_result = await mcp_client.call_tool("update_dashboard", {
        "dashboard": dashboard_json,
        "folderUid": "",
        "overwrite": False
    })
    create_data = json.loads(create_result.content[0].text)
    created_dashboard_uid = create_data["uid"]

    # 5. Update the dashboard title
    updated_title = f"Updated {title}"
    title_prompt = f"Update the title of the Test Dashboard to {updated_title}. Search for the dashboard by title first."

    messages = [
        Message(role="system", content="You are a helpful assistant"),
        Message(role="user", content=title_prompt),
    ]

    # 6. Search for the test dashboard
    messages = await llm_tool_call_sequence(
        model, messages, tools, mcp_client, "search_dashboards",
        {"query": title}
    )

    # 7. Update the dashboard using patch operations
    messages = await llm_tool_call_sequence(
        model, messages, tools, mcp_client, "update_dashboard",
        {
            "uid": created_dashboard_uid,
            "operations": [
                {
                    "op": "replace",
                    "path": "$.title",
                    "value": updated_title
                }
            ]
        }
    )

    # 8. Final LLM response - just verify it completes successfully
    response = await acompletion(model=model, messages=messages, tools=tools)
    content = response.choices[0].message.content

    # Test passes if we get here - the tool call sequence worked correctly
    assert len(content) > 0, "LLM should provide a response after updating the dashboard"


```

--------------------------------------------------------------------------------
/tests/loki_test.py:
--------------------------------------------------------------------------------

```python
import json

import pytest
from langevals import expect
from langevals_langevals.llm_boolean import (
    CustomLLMBooleanEvaluator,
    CustomLLMBooleanSettings,
)
from litellm import Message, acompletion
from mcp import ClientSession

from conftest import models
from utils import (
    get_converted_tools,
    flexible_tool_call,
)

pytestmark = pytest.mark.anyio


@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_loki_logs_tool(model: str, mcp_client: ClientSession):
    tools = await get_converted_tools(mcp_client)
    prompt = "Can you list the last 10 log lines from container 'mcp-grafana-grafana-1' using any available Loki datasource? Give me the raw log lines. Please use only the necessary tools to get this information."

    messages = [
        Message(role="system", content="You are a helpful assistant."),
        Message(role="user", content=prompt),
    ]

    # 1. List datasources
    messages = await flexible_tool_call(
        model, messages, tools, mcp_client, "list_datasources"
    )
    datasources_response = messages[-1].content
    datasources_data = json.loads(datasources_response)
    loki_ds = get_first_loki_datasource(datasources_data)
    print(f"\nFound Loki datasource: {loki_ds['name']} (uid: {loki_ds['uid']})")

    # 2. Query logs
    messages = await flexible_tool_call(
        model, messages, tools, mcp_client, "query_loki_logs",
        required_params={"datasourceUid": loki_ds["uid"]}
    )

    # 3. Final LLM response
    response = await acompletion(model=model, messages=messages, tools=tools)
    content = response.choices[0].message.content
    log_lines_checker = CustomLLMBooleanEvaluator(
        settings=CustomLLMBooleanSettings(
            prompt="Does the response contain specific information that could only come from a Loki datasource? This could be actual log lines with timestamps, container names, or a summary that references specific log data. The response should show evidence of real data rather than generic statements.",
        )
    )
    expect(input=prompt, output=content).to_pass(log_lines_checker)


@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_loki_container_labels(model: str, mcp_client: ClientSession):
    tools = await get_converted_tools(mcp_client)
    prompt = "Can you list the values for the label container in any available loki datasource? Please use only the necessary tools to get this information."

    messages = [
        Message(role="system", content="You are a helpful assistant."),
        Message(role="user", content=prompt),
    ]

    # 1. List datasources
    messages = await flexible_tool_call(
        model, messages, tools, mcp_client, "list_datasources"
    )
    datasources_response = messages[-1].content
    datasources_data = json.loads(datasources_response)
    loki_ds = get_first_loki_datasource(datasources_data)
    print(f"\nFound Loki datasource: {loki_ds['name']} (uid: {loki_ds['uid']})")

    # 2. List label values for 'container'
    messages = await flexible_tool_call(
        model, messages, tools, mcp_client, "list_loki_label_values",
        required_params={"datasourceUid": loki_ds["uid"], "labelName": "container"}
    )

    # 3. Final LLM response
    response = await acompletion(model=model, messages=messages, tools=tools)
    content = response.choices[0].message.content
    label_checker = CustomLLMBooleanEvaluator(
        settings=CustomLLMBooleanSettings(
            prompt="Does the response provide a clear and organized list of container names found in the logs? It should present the container names in a readable format and may include additional context about their usage.",
        )
    )
    expect(input=prompt, output=content).to_pass(label_checker)

def get_first_loki_datasource(datasources_data):
    """
    Returns the first datasource with type 'loki' from a list of datasources.
    Raises an AssertionError if none are found.
    """
    loki_datasources = [ds for ds in datasources_data if ds.get("type") == "loki"]
    assert len(loki_datasources) > 0, "No Loki datasource found"
    return loki_datasources[0]

```

--------------------------------------------------------------------------------
/tools/datasources.go:
--------------------------------------------------------------------------------

```go
package tools

import (
	"context"
	"fmt"
	"strings"

	"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 ListDatasourcesParams struct {
	Type string `json:"type,omitempty" jsonschema:"description=The type of datasources to search for. For example\\, 'prometheus'\\, 'loki'\\, 'tempo'\\, etc..."`
}

type dataSourceSummary struct {
	ID        int64  `json:"id"`
	UID       string `json:"uid"`
	Name      string `json:"name"`
	Type      string `json:"type"`
	IsDefault bool   `json:"isDefault"`
}

func listDatasources(ctx context.Context, args ListDatasourcesParams) ([]dataSourceSummary, error) {
	c := mcpgrafana.GrafanaClientFromContext(ctx)
	resp, err := c.Datasources.GetDataSources()
	if err != nil {
		return nil, fmt.Errorf("list datasources: %w", err)
	}
	datasources := filterDatasources(resp.Payload, args.Type)
	return summarizeDatasources(datasources), nil
}

// filterDatasources returns only datasources of the specified type `t`. If `t`
// is an empty string no filtering is done.
func filterDatasources(datasources models.DataSourceList, t string) models.DataSourceList {
	if t == "" {
		return datasources
	}
	filtered := models.DataSourceList{}
	t = strings.ToLower(t)
	for _, ds := range datasources {
		if strings.Contains(strings.ToLower(ds.Type), t) {
			filtered = append(filtered, ds)
		}
	}
	return filtered
}

func summarizeDatasources(dataSources models.DataSourceList) []dataSourceSummary {
	result := make([]dataSourceSummary, 0, len(dataSources))
	for _, ds := range dataSources {
		result = append(result, dataSourceSummary{
			ID:        ds.ID,
			UID:       ds.UID,
			Name:      ds.Name,
			Type:      ds.Type,
			IsDefault: ds.IsDefault,
		})
	}
	return result
}

var ListDatasources = mcpgrafana.MustTool(
	"list_datasources",
	"List available Grafana datasources. Optionally filter by datasource type (e.g., 'prometheus', 'loki'). Returns a summary list including ID, UID, name, type, and default status.",
	listDatasources,
	mcp.WithTitleAnnotation("List datasources"),
	mcp.WithIdempotentHintAnnotation(true),
	mcp.WithReadOnlyHintAnnotation(true),
)

type GetDatasourceByUIDParams struct {
	UID string `json:"uid" jsonschema:"required,description=The uid of the datasource"`
}

func getDatasourceByUID(ctx context.Context, args GetDatasourceByUIDParams) (*models.DataSource, error) {
	c := mcpgrafana.GrafanaClientFromContext(ctx)
	datasource, err := c.Datasources.GetDataSourceByUID(args.UID)
	if err != nil {
		// Check if it's a 404 Not Found Error
		if strings.Contains(err.Error(), "404") {
			return nil, fmt.Errorf("datasource with UID '%s' not found. Please check if the datasource exists and is accessible", args.UID)
		}
		return nil, fmt.Errorf("get datasource by uid %s: %w", args.UID, err)
	}
	return datasource.Payload, nil
}

var GetDatasourceByUID = mcpgrafana.MustTool(
	"get_datasource_by_uid",
	"Retrieves detailed information about a specific datasource using its UID. Returns the full datasource model, including name, type, URL, access settings, JSON data, and secure JSON field status.",
	getDatasourceByUID,
	mcp.WithTitleAnnotation("Get datasource by UID"),
	mcp.WithIdempotentHintAnnotation(true),
	mcp.WithReadOnlyHintAnnotation(true),
)

type GetDatasourceByNameParams struct {
	Name string `json:"name" jsonschema:"required,description=The name of the datasource"`
}

func getDatasourceByName(ctx context.Context, args GetDatasourceByNameParams) (*models.DataSource, error) {
	c := mcpgrafana.GrafanaClientFromContext(ctx)
	datasource, err := c.Datasources.GetDataSourceByName(args.Name)
	if err != nil {
		return nil, fmt.Errorf("get datasource by name %s: %w", args.Name, err)
	}
	return datasource.Payload, nil
}

var GetDatasourceByName = mcpgrafana.MustTool(
	"get_datasource_by_name",
	"Retrieves detailed information about a specific datasource using its name. Returns the full datasource model, including UID, type, URL, access settings, JSON data, and secure JSON field status.",
	getDatasourceByName,
	mcp.WithTitleAnnotation("Get datasource by name"),
	mcp.WithIdempotentHintAnnotation(true),
	mcp.WithReadOnlyHintAnnotation(true),
)

func AddDatasourceTools(mcp *server.MCPServer) {
	ListDatasources.Register(mcp)
	GetDatasourceByUID.Register(mcp)
	GetDatasourceByName.Register(mcp)
}

```

--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------

```python
import pytest
import os
import asyncio
import gc
import base64
from dotenv import load_dotenv
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession, StdioServerParameters

load_dotenv()

DEFAULT_GRAFANA_URL = "http://localhost:3000"
DEFAULT_MCP_URL = "http://localhost:8000"
DEFAULT_MCP_TRANSPORT = "sse"

models = ["gpt-4o", "claude-3-5-sonnet-20240620"]

pytestmark = pytest.mark.anyio


@pytest.fixture
def anyio_backend():
    return "asyncio"


@pytest.fixture(autouse=True)
async def cleanup_sessions():
    """Clean up any lingering HTTP sessions after each test."""
    yield
    # Force garbage collection to clean up any unclosed sessions
    gc.collect()
    # Give a brief moment for cleanup
    await asyncio.sleep(0.01)


@pytest.fixture
def mcp_transport():
    return os.environ.get("MCP_TRANSPORT", DEFAULT_MCP_TRANSPORT)


@pytest.fixture
def mcp_url():
    return os.environ.get("MCP_GRAFANA_URL", DEFAULT_MCP_URL)


@pytest.fixture
def grafana_env():
    env = {"GRAFANA_URL": os.environ.get("GRAFANA_URL", DEFAULT_GRAFANA_URL)}
    # Check for the new service account token environment variable first
    if key := os.environ.get("GRAFANA_SERVICE_ACCOUNT_TOKEN"):
        env["GRAFANA_SERVICE_ACCOUNT_TOKEN"] = key
    elif key := os.environ.get("GRAFANA_API_KEY"):
        env["GRAFANA_API_KEY"] = key
        import warnings

        warnings.warn(
            "GRAFANA_API_KEY is deprecated, please use GRAFANA_SERVICE_ACCOUNT_TOKEN instead. See https://grafana.com/docs/grafana/latest/administration/service-accounts/#add-a-token-to-a-service-account-in-grafana for details on creating service account tokens.",
            DeprecationWarning,
        )
    elif (username := os.environ.get("GRAFANA_USERNAME")) and (
        password := os.environ.get("GRAFANA_PASSWORD")
    ):
        env["GRAFANA_USERNAME"] = username
        env["GRAFANA_PASSWORD"] = password
    return env


@pytest.fixture
def grafana_headers():
    headers = {
        "X-Grafana-URL": os.environ.get("GRAFANA_URL", DEFAULT_GRAFANA_URL),
    }
    # Check for the new service account token environment variable first
    if key := os.environ.get("GRAFANA_SERVICE_ACCOUNT_TOKEN"):
        headers["X-Grafana-API-Key"] = key
    elif key := os.environ.get("GRAFANA_API_KEY"):
        headers["X-Grafana-API-Key"] = key
        import warnings

        warnings.warn(
            "GRAFANA_API_KEY is deprecated, please use GRAFANA_SERVICE_ACCOUNT_TOKEN instead. See https://grafana.com/docs/grafana/latest/administration/service-accounts/#add-a-token-to-a-service-account-in-grafana for details on creating service account tokens.",
            DeprecationWarning,
        )
    elif (username := os.environ.get("GRAFANA_USERNAME")) and (
        password := os.environ.get("GRAFANA_PASSWORD")
    ):
        credentials = f"{username}:{password}"
        headers["Authorization"] = (
            "Basic " + base64.b64encode(credentials.encode("utf-8")).decode()
        )
    return headers


@pytest.fixture
async def mcp_client(mcp_transport, mcp_url, grafana_env, grafana_headers):
    if mcp_transport == "stdio":
        params = StdioServerParameters(
            command=os.environ.get("MCP_GRAFANA_PATH", "../dist/mcp-grafana"),
            args=["--debug", "--log-level", "debug"],
            env=grafana_env,
        )
        async with stdio_client(params) as (read, write):
            async with ClientSession(read, write) as session:
                await session.initialize()
                yield session
    elif mcp_transport == "sse":
        url = f"{mcp_url}/sse"
        async with sse_client(url, headers=grafana_headers) as (read, write):
            async with ClientSession(read, write) as session:
                await session.initialize()
                yield session
    elif mcp_transport == "streamable-http":
        # Use HTTP client for streamable-http transport
        url = f"{mcp_url}/mcp"
        async with streamablehttp_client(url, headers=grafana_headers) as (
            read,
            write,
            _,
        ):
            async with ClientSession(read, write) as session:
                await session.initialize()
                yield session
    else:
        raise ValueError(f"Unsupported transport: {mcp_transport}")

```

--------------------------------------------------------------------------------
/tests/disable_write_test.py:
--------------------------------------------------------------------------------

```python
import pytest
import os
from mcp.client.stdio import stdio_client
from mcp import ClientSession, StdioServerParameters

pytestmark = pytest.mark.anyio


@pytest.fixture
def grafana_env():
    env = {"GRAFANA_URL": os.environ.get("GRAFANA_URL", "http://localhost:3000")}
    # Check for the new service account token environment variable first
    if key := os.environ.get("GRAFANA_SERVICE_ACCOUNT_TOKEN"):
        env["GRAFANA_SERVICE_ACCOUNT_TOKEN"] = key
    elif key := os.environ.get("GRAFANA_API_KEY"):
        env["GRAFANA_API_KEY"] = key
    return env


async def test_disable_write_flag_disables_write_tools(grafana_env):
    """Test that --disable-write flag disables write tools."""
    params = StdioServerParameters(
        command=os.environ.get("MCP_GRAFANA_PATH", "../dist/mcp-grafana"),
        args=["--disable-write"],
        env=grafana_env,
    )
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            
            # List all available tools
            tools_result = await session.list_tools()
            tool_names = [tool.name for tool in tools_result.tools]
            
            # Verify write tools are NOT present
            write_tools = [
                "update_dashboard",
                "create_folder",
                "create_incident",
                "add_activity_to_incident",
                "create_alert_rule",
                "update_alert_rule",
                "delete_alert_rule",
                "create_annotation",
                "create_graphite_annotation",
                "update_annotation",
                "patch_annotation",
                "find_error_pattern_logs",
                "find_slow_requests",
            ]
            
            for tool in write_tools:
                assert tool not in tool_names, f"Write tool '{tool}' should not be available with --disable-write flag"
            
            # Verify read tools ARE still present
            read_tools = [
                "get_dashboard_by_uid",
                "list_alert_rules",
                "get_alert_rule_by_uid",
                "list_contact_points",
                "list_incidents",
                "get_incident",
                "get_sift_investigation",
                "get_annotations",
                "get_annotation_tags",
            ]
            
            for tool in read_tools:
                assert tool in tool_names, f"Read tool '{tool}' should still be available with --disable-write flag"


async def test_without_disable_write_flag_enables_write_tools(grafana_env):
    """Test that without --disable-write flag, write tools are enabled."""
    params = StdioServerParameters(
        command=os.environ.get("MCP_GRAFANA_PATH", "../dist/mcp-grafana"),
        args=[],  # No --disable-write flag
        env=grafana_env,
    )
    async with stdio_client(params) as (read, write):
        async with ClientSession(read, write) as session:
            await session.initialize()
            
            # List all available tools
            tools_result = await session.list_tools()
            tool_names = [tool.name for tool in tools_result.tools]
            
            # Verify write tools ARE present
            write_tools = [
                "update_dashboard",
                "create_folder",
                "create_incident",
                "add_activity_to_incident",
                "create_alert_rule",
                "update_alert_rule",
                "delete_alert_rule",
                "create_annotation",
                "create_graphite_annotation",
                "update_annotation",
                "patch_annotation",
                "find_error_pattern_logs",
                "find_slow_requests",
            ]
            
            for tool in write_tools:
                assert tool in tool_names, f"Write tool '{tool}' should be available without --disable-write flag"
            
            # Verify read tools are also present
            read_tools = [
                "get_dashboard_by_uid",
                "list_alert_rules",
                "get_alert_rule_by_uid",
                "list_contact_points",
                "list_incidents",
                "get_incident",
                "get_sift_investigation",
                "get_annotations",
                "get_annotation_tags",
            ]
            
            for tool in read_tools:
                assert tool in tool_names, f"Read tool '{tool}' should be available without --disable-write flag"


```

--------------------------------------------------------------------------------
/testdata/provisioning/alerting/alert_rules.yaml:
--------------------------------------------------------------------------------

```yaml
apiVersion: 1

groups:
  - orgId: 1
    name: Test Alert Rules
    folder: Tests
    interval: 1m
    rules:
      - uid: test_alert_rule_1
        title: Test Alert Rule 1
        condition: B
        data:
          - refId: A
            relativeTimeRange:
              from: 600
              to: 0
            datasourceUid: prometheus
            model:
              datasource:
                type: prometheus
                uid: prometheus
              editorMode: code
              expr: vector(1)
              hide: false
              instant: true
              legendFormat: __auto
              range: false
              refId: A
          - refId: B
            datasourceUid: __expr__
            model:
              conditions:
                - evaluator:
                    params:
                      - 0
                      - 0
                    type: gt
                  operator:
                    type: and
                  query:
                    params: []
                  reducer:
                    params: []
                    type: avg
                  type: query
              datasource:
                name: Expression
                type: __expr__
                uid: __expr__
              expression: A
              hide: false
              refId: B
              type: threshold
        noDataState: NoData
        execErrState: Error
        for: 1m
        keepFiringFor: 0s
        annotations:
          description: This is a test alert rule that is always firing
        labels:
          severity: info
          type: test
          rule: first
        isPaused: false

      - uid: test_alert_rule_2
        title: Test Alert Rule 2
        condition: B
        data:
          - refId: A
            relativeTimeRange:
              from: 600
              to: 0
            datasourceUid: prometheus
            model:
              datasource:
                type: prometheus
                uid: prometheus
              editorMode: code
              expr: vector(0)
              hide: false
              instant: true
              legendFormat: __auto
              range: false
              refId: A
          - refId: B
            datasourceUid: __expr__
            model:
              conditions:
                - evaluator:
                    params:
                      - 0
                      - 0
                    type: gt
                  operator:
                    type: and
                  query:
                    params: []
                  reducer:
                    params: []
                    type: avg
                  type: query
              datasource:
                name: Expression
                type: __expr__
                uid: __expr__
              expression: A
              hide: false
              refId: B
              type: threshold
        noDataState: NoData
        execErrState: Error
        for: 1m
        keepFiringFor: 0s
        annotations:
          description: This is a test alert rule that is always normal
        labels:
          severity: info
          type: test
          rule: second
        isPaused: false

      - uid: test_alert_rule_paused
        title: Test Alert Rule (Paused)
        condition: B
        data:
          - refId: A
            relativeTimeRange:
              from: 600
              to: 0
            datasourceUid: prometheus
            model:
              datasource:
                type: prometheus
                uid: prometheus
              editorMode: code
              expr: vector(1)
              hide: false
              instant: true
              legendFormat: __auto
              range: false
              refId: A
          - refId: B
            datasourceUid: __expr__
            model:
              conditions:
                - evaluator:
                    params:
                      - 0
                      - 0
                    type: gt
                  operator:
                    type: and
                  query:
                    params: []
                  reducer:
                    params: []
                    type: avg
                  type: query
              datasource:
                name: Expression
                type: __expr__
                uid: __expr__
              expression: A
              hide: false
              refId: B
              type: threshold
        noDataState: NoData
        execErrState: Error
        for: 1m
        keepFiringFor: 0s
        annotations:
          description: This is a paused alert rule
        labels:
          severity: info
          type: test
          rule: third
        isPaused: true

```

--------------------------------------------------------------------------------
/tools/asserts.go:
--------------------------------------------------------------------------------

```go
package tools

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"strings"
	"time"

	mcpgrafana "github.com/grafana/mcp-grafana"
	"github.com/mark3labs/mcp-go/mcp"
	"github.com/mark3labs/mcp-go/server"
)

func newAssertsClient(ctx context.Context) (*Client, error) {
	cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
	url := fmt.Sprintf("%s/api/plugins/grafana-asserts-app/resources/asserts/api-server", strings.TrimRight(cfg.URL, "/"))

	// 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
}

type GetAssertionsParams struct {
	StartTime  time.Time `json:"startTime" jsonschema:"required,description=The start time in RFC3339 format"`
	EndTime    time.Time `json:"endTime" jsonschema:"required,description=The end time in RFC3339 format"`
	EntityType string    `json:"entityType" jsonschema:"description=The type of the entity to list (e.g. Service\\, Node\\, Pod\\, etc.)"`
	EntityName string    `json:"entityName" jsonschema:"description=The name of the entity to list"`
	Env        string    `json:"env,omitempty" jsonschema:"description=The env of the entity to list"`
	Site       string    `json:"site,omitempty" jsonschema:"description=The site of the entity to list"`
	Namespace  string    `json:"namespace,omitempty" jsonschema:"description=The namespace of the entity to list"`
}

type scope struct {
	Env       string `json:"env,omitempty"`
	Site      string `json:"site,omitempty"`
	Namespace string `json:"namespace,omitempty"`
}

type entity struct {
	Name  string `json:"name"`
	Type  string `json:"type"`
	Scope scope  `json:"scope"`
}

type requestBody struct {
	StartTime             int64    `json:"startTime"`
	EndTime               int64    `json:"endTime"`
	EntityKeys            []entity `json:"entityKeys"`
	SuggestionSrcEntities []entity `json:"suggestionSrcEntities"`
	AlertCategories       []string `json:"alertCategories"`
}

func (c *Client) fetchAssertsData(ctx context.Context, urlPath string, method string, reqBody any) (string, error) {
	jsonData, err := json.Marshal(reqBody)
	if err != nil {
		return "", fmt.Errorf("failed to marshal request body: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, method, c.baseURL+urlPath, bytes.NewBuffer(jsonData))
	if err != nil {
		return "", fmt.Errorf("failed to create request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return "", fmt.Errorf("failed to execute request: %w", err)
	}
	defer func() {
		_ = resp.Body.Close() //nolint:errcheck
	}()

	body, err := io.ReadAll(resp.Body)
	if err != nil {
		return "", fmt.Errorf("failed to read response body: %w", err)
	}

	if resp.StatusCode != http.StatusOK {
		return "", fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
	}

	return string(body), nil
}

func getAssertions(ctx context.Context, args GetAssertionsParams) (string, error) {
	client, err := newAssertsClient(ctx)
	if err != nil {
		return "", fmt.Errorf("failed to create Asserts client: %w", err)
	}

	// Create request body
	reqBody := requestBody{
		StartTime: args.StartTime.UnixMilli(),
		EndTime:   args.EndTime.UnixMilli(),
		EntityKeys: []entity{
			{
				Name:  args.EntityName,
				Type:  args.EntityType,
				Scope: scope{},
			},
		},
		SuggestionSrcEntities: []entity{},
		AlertCategories:       []string{"saturation", "amend", "anomaly", "failure", "error"},
	}

	if args.Env != "" {
		reqBody.EntityKeys[0].Scope.Env = args.Env
	}
	if args.Site != "" {
		reqBody.EntityKeys[0].Scope.Site = args.Site
	}
	if args.Namespace != "" {
		reqBody.EntityKeys[0].Scope.Namespace = args.Namespace
	}

	data, err := client.fetchAssertsData(ctx, "/v1/assertions/llm-summary", "POST", reqBody)
	if err != nil {
		return "", fmt.Errorf("failed to fetch data: %w", err)
	}

	return data, nil
}

var GetAssertions = mcpgrafana.MustTool(
	"get_assertions",
	"Get assertion summary for a given entity with its type, name, env, site, namespace, and a time range",
	getAssertions,
	mcp.WithTitleAnnotation("Get assertions summary"),
	mcp.WithIdempotentHintAnnotation(true),
	mcp.WithReadOnlyHintAnnotation(true),
)

func AddAssertsTools(mcp *server.MCPServer) {
	GetAssertions.Register(mcp)
}

```

--------------------------------------------------------------------------------
/tests/admin_test.py:
--------------------------------------------------------------------------------

```python
from typing import Dict
import pytest
from langevals import expect
from langevals_langevals.llm_boolean import (
    CustomLLMBooleanEvaluator,
    CustomLLMBooleanSettings,
)
from litellm import Message, acompletion
from mcp import ClientSession
import aiohttp
import uuid
import os
from conftest import DEFAULT_GRAFANA_URL

from conftest import models
from utils import (
    get_converted_tools,
    llm_tool_call_sequence,
)

pytestmark = pytest.mark.anyio


@pytest.fixture
async def grafana_team():
    """Create a temporary test team and clean it up after the test is done."""
    # Generate a unique team name to avoid conflicts
    team_name = f"test-team-{uuid.uuid4().hex[:8]}"

    # Get Grafana URL and service account token from environment
    grafana_url = os.environ.get("GRAFANA_URL", DEFAULT_GRAFANA_URL)

    auth_header = None
    # Check for the new service account token environment variable first
    if api_key := os.environ.get("GRAFANA_SERVICE_ACCOUNT_TOKEN"):
        auth_header = {"Authorization": f"Bearer {api_key}"}
    elif api_key := os.environ.get("GRAFANA_API_KEY"):
        auth_header = {"Authorization": f"Bearer {api_key}"}
        import warnings

        warnings.warn(
            "GRAFANA_API_KEY is deprecated, please use GRAFANA_SERVICE_ACCOUNT_TOKEN instead. See https://grafana.com/docs/grafana/latest/administration/service-accounts/#add-a-token-to-a-service-account-in-grafana for details on creating service account tokens.",
            DeprecationWarning,
        )

    if not auth_header:
        pytest.skip("No authentication credentials available to create team")

    # Create the team using Grafana API
    team_id = None
    async with aiohttp.ClientSession() as session:
        create_url = f"{grafana_url}/api/teams"
        async with session.post(
            create_url,
            headers=auth_header,
            json={"name": team_name, "email": f"{team_name}@example.com"},
        ) as response:
            if response.status != 200:
                resp_text = await response.text()
                pytest.skip(f"Failed to create team: {resp_text}")
            resp_data = await response.json()
            team_id = resp_data.get("teamId")

    # Yield the team info for the test to use
    yield {"id": team_id, "name": team_name}

    # Clean up after the test
    if team_id:
        async with aiohttp.ClientSession() as session:
            delete_url = f"{grafana_url}/api/teams/{team_id}"
            async with session.delete(delete_url, headers=auth_header) as response:
                if response.status != 200:
                    resp_text = await response.text()
                    print(f"Warning: Failed to delete team: {resp_text}")


@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_list_teams_tool(
    model: str, mcp_client: ClientSession, grafana_team: Dict[str, str]
):
    tools = await get_converted_tools(mcp_client)
    team_name = grafana_team["name"]
    prompt = "Can you list the teams in Grafana?"

    messages = [
        Message(role="system", content="You are a helpful assistant."),
        Message(role="user", content=prompt),
    ]

    # 1. Call the list teams tool
    messages = await llm_tool_call_sequence(
        model,
        messages,
        tools,
        mcp_client,
        "list_teams",
    )

    # 2. Final LLM response
    response = await acompletion(model=model, messages=messages, tools=tools)
    content = response.choices[0].message.content
    panel_queries_checker = CustomLLMBooleanEvaluator(
        settings=CustomLLMBooleanSettings(
            prompt=(
                "Does the response contain specific information about "
                "the teams in Grafana?"
                f"There should be a team named {team_name}. "
            ),
        )
    )
    expect(input=prompt, output=content).to_pass(panel_queries_checker)


@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_list_users_by_org_tool(model: str, mcp_client: ClientSession):
    tools = await get_converted_tools(mcp_client)
    prompt = "Can you list the users in Grafana?"

    messages = [
        Message(role="system", content="You are a helpful assistant."),
        Message(role="user", content=prompt),
    ]

    # 1. Call the list_users_by_org tool
    messages = await llm_tool_call_sequence(
        model, messages, tools, mcp_client, "list_users_by_org"
    )

    # 2. Final LLM response
    response = await acompletion(model=model, messages=messages, tools=tools)
    content = response.choices[0].message.content
    user_checker = CustomLLMBooleanEvaluator(
        settings=CustomLLMBooleanSettings(
            prompt="Does the response contain specific information about users in Grafana, such as usernames, emails, or roles?",
        )
    )
    expect(input=prompt, output=content).to_pass(user_checker)

```

--------------------------------------------------------------------------------
/tools/alerting_client.go:
--------------------------------------------------------------------------------

```go
package tools

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"time"

	"github.com/prometheus/prometheus/model/labels"

	mcpgrafana "github.com/grafana/mcp-grafana"
)

const (
	defaultTimeout    = 30 * time.Second
	rulesEndpointPath = "/api/prometheus/grafana/api/v1/rules"
)

type alertingClient struct {
	baseURL     *url.URL
	accessToken string
	idToken     string
	apiKey      string
	basicAuth   *url.Userinfo
	orgID       int64
	httpClient  *http.Client
}

func newAlertingClientFromContext(ctx context.Context) (*alertingClient, error) {
	cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
	baseURL := strings.TrimRight(cfg.URL, "/")
	parsedBaseURL, err := url.Parse(baseURL)
	if err != nil {
		return nil, fmt.Errorf("invalid Grafana base URL %q: %w", baseURL, err)
	}

	client := &alertingClient{
		baseURL:     parsedBaseURL,
		accessToken: cfg.AccessToken,
		idToken:     cfg.IDToken,
		apiKey:      cfg.APIKey,
		basicAuth:   cfg.BasicAuth,
		orgID:       cfg.OrgID,
		httpClient: &http.Client{
			Timeout: defaultTimeout,
		},
	}

	// Create custom transport with TLS configuration if available
	if tlsConfig := mcpgrafana.GrafanaConfigFromContext(ctx).TLSConfig; tlsConfig != nil {
		client.httpClient.Transport, err = tlsConfig.HTTPTransport(http.DefaultTransport.(*http.Transport))
		if err != nil {
			return nil, fmt.Errorf("failed to create custom transport: %w", err)
		}
		// Wrap with user agent
		client.httpClient.Transport = mcpgrafana.NewUserAgentTransport(
			client.httpClient.Transport,
		)
	} else {
		// No custom TLS, but still add user agent
		client.httpClient.Transport = mcpgrafana.NewUserAgentTransport(
			http.DefaultTransport,
		)
	}

	return client, nil
}

func (c *alertingClient) makeRequest(ctx context.Context, path string) (*http.Response, error) {
	p := c.baseURL.JoinPath(path).String()

	req, err := http.NewRequestWithContext(ctx, http.MethodGet, p, nil)
	if err != nil {
		return nil, fmt.Errorf("failed to create request to %s: %w", p, err)
	}

	req.Header.Set("Accept", "application/json")
	req.Header.Set("Content-Type", "application/json")

	// If accessToken is set we use that first and fall back to normal Authorization.
	if c.accessToken != "" && c.idToken != "" {
		req.Header.Set("X-Access-Token", c.accessToken)
		req.Header.Set("X-Grafana-Id", c.idToken)
	} else if c.apiKey != "" {
		req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
	} else if c.basicAuth != nil {
		password, _ := c.basicAuth.Password()
		req.SetBasicAuth(c.basicAuth.Username(), password)
	}

	// Add org ID header for multi-org support
	if c.orgID > 0 {
		req.Header.Set("X-Scope-OrgId", strconv.FormatInt(c.orgID, 10))
	}

	resp, err := c.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("failed to execute request to %s: %w", p, err)
	}
	if resp.StatusCode != http.StatusOK {
		bodyBytes, _ := io.ReadAll(resp.Body)
		_ = resp.Body.Close() //nolint:errcheck
		return nil, fmt.Errorf("grafana API returned status code %d: %s", resp.StatusCode, string(bodyBytes))
	}

	return resp, nil
}

func (c *alertingClient) GetRules(ctx context.Context) (*rulesResponse, error) {
	resp, err := c.makeRequest(ctx, rulesEndpointPath)
	if err != nil {
		return nil, fmt.Errorf("failed to get alert rules from Grafana API: %w", err)
	}
	defer func() {
		_ = resp.Body.Close() //nolint:errcheck
	}()

	var rulesResponse rulesResponse
	decoder := json.NewDecoder(resp.Body)
	if err := decoder.Decode(&rulesResponse); err != nil {
		return nil, fmt.Errorf("failed to decode rules response from %s: %w", rulesEndpointPath, err)
	}

	return &rulesResponse, nil
}

type rulesResponse struct {
	Data struct {
		RuleGroups []ruleGroup      `json:"groups"`
		NextToken  string           `json:"groupNextToken,omitempty"`
		Totals     map[string]int64 `json:"totals,omitempty"`
	} `json:"data"`
}

type ruleGroup struct {
	Name           string         `json:"name"`
	FolderUID      string         `json:"folderUid"`
	Rules          []alertingRule `json:"rules"`
	Interval       float64        `json:"interval"`
	LastEvaluation time.Time      `json:"lastEvaluation"`
	EvaluationTime float64        `json:"evaluationTime"`
}

type alertingRule struct {
	State          string           `json:"state,omitempty"`
	Name           string           `json:"name,omitempty"`
	Query          string           `json:"query,omitempty"`
	Duration       float64          `json:"duration,omitempty"`
	KeepFiringFor  float64          `json:"keepFiringFor,omitempty"`
	Annotations    labels.Labels    `json:"annotations,omitempty"`
	ActiveAt       *time.Time       `json:"activeAt,omitempty"`
	Alerts         []alert          `json:"alerts,omitempty"`
	Totals         map[string]int64 `json:"totals,omitempty"`
	TotalsFiltered map[string]int64 `json:"totalsFiltered,omitempty"`
	UID            string           `json:"uid"`
	FolderUID      string           `json:"folderUid"`
	Labels         labels.Labels    `json:"labels,omitempty"`
	Health         string           `json:"health"`
	LastError      string           `json:"lastError,omitempty"`
	Type           string           `json:"type"`
	LastEvaluation time.Time        `json:"lastEvaluation"`
	EvaluationTime float64          `json:"evaluationTime"`
}

type alert struct {
	Labels      labels.Labels `json:"labels"`
	Annotations labels.Labels `json:"annotations"`
	State       string        `json:"state"`
	ActiveAt    *time.Time    `json:"activeAt"`
	Value       string        `json:"value"`
}

```

--------------------------------------------------------------------------------
/internal/linter/jsonschema/jsonschema_lint_test.go:
--------------------------------------------------------------------------------

```go
package linter

import (
	"os"
	"path/filepath"
	"testing"
)

func TestFindUnescapedCommas(t *testing.T) {
	// Create a temporary directory for test files
	tmpDir, err := os.MkdirTemp("", "jsonschema-linter-test")
	if err != nil {
		t.Fatalf("Failed to create temp dir: %v", err)
	}
	defer func() {
		if err := os.RemoveAll(tmpDir); err != nil {
			t.Logf("Failed to remove temp dir: %v", err)
		}
	}()

	// Create test files
	testFiles := map[string]string{
		"valid.go": `package test

// Valid has properly escaped commas
type Valid struct {
	Name string ` + "`json:\"name\" jsonschema:\"description=A valid field\\, with escaped comma\"`" + `
	Age  int    ` + "`json:\"age\" jsonschema:\"description=Another valid field\"`" + `
}
`,
		"invalid.go": `package test

// Invalid has unescaped commas
type Invalid struct {
	Name string ` + "`json:\"name\" jsonschema:\"description=An invalid field, with unescaped comma\"`" + `
	Age  int    ` + "`json:\"age\" jsonschema:\"description=Another valid field\"`" + `
}
`,
		"mixed.go": `package test

// Mixed has both valid and invalid fields
type Mixed struct {
	Valid   string ` + "`json:\"valid\" jsonschema:\"description=A valid field\\, with escaped comma\"`" + `
	Invalid string ` + "`json:\"invalid\" jsonschema:\"description=An invalid field, with unescaped comma\"`" + `
}
`,
	}

	for filename, content := range testFiles {
		filePath := filepath.Join(tmpDir, filename)
		if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
			t.Fatalf("Failed to write test file %s: %v", filename, err)
		}
	}

	// Run the linter
	linter := &JSONSchemaLinter{}
	err = linter.FindUnescapedCommas(tmpDir)
	if err != nil {
		t.Fatalf("Linter failed: %v", err)
	}

	// Check if we found the expected errors
	if len(linter.Errors) != 2 {
		t.Errorf("Expected 2 errors, got %d", len(linter.Errors))
	}

	// Check if the errors are in the expected files
	fileErrors := make(map[string]int)
	for _, e := range linter.Errors {
		fileName := filepath.Base(e.FilePath)
		fileErrors[fileName]++
	}

	if fileErrors["invalid.go"] != 1 {
		t.Errorf("Expected 1 error in invalid.go, got %d", fileErrors["invalid.go"])
	}

	if fileErrors["mixed.go"] != 1 {
		t.Errorf("Expected 1 error in mixed.go, got %d", fileErrors["mixed.go"])
	}

	if fileErrors["valid.go"] != 0 {
		t.Errorf("Expected 0 errors in valid.go, got %d", fileErrors["valid.go"])
	}
}

// TestEscapedQuotesWithComma tests if the regex correctly identifies unescaped commas
// in jsonschema tags that contain escaped quotes
func TestEscapedQuotesWithComma(t *testing.T) {
	testCases := []struct {
		tag         string
		shouldMatch bool
		description string
	}{
		{`jsonschema:"description=This has an unescaped, comma"`, true, "Simple unescaped comma"},
		{`jsonschema:"description=This has escaped quote \"followed by, comma"`, true, "Escaped quote then unescaped comma"},
		{`jsonschema:"description=This has escaped quote \", comma"`, true, "Escaped quote, comma with space"},
		{`jsonschema:"description=This has escaped quote \\\"and escaped\\, comma"`, false, "Properly escaped quote and comma"},
		{`jsonschema:"description=No comma here"`, false, "No comma at all"},
	}

	for _, tc := range testCases {
		t.Run(tc.description, func(t *testing.T) {
			matches := tagPattern.FindStringSubmatch(tc.tag)
			hasMatch := len(matches) > 0
			if hasMatch != tc.shouldMatch {
				t.Fatalf("Test failed for %s: expected match=%v, got=%v\n", tc.description, tc.shouldMatch, hasMatch)
			}
		})
	}
}

func TestFixUnescapedCommas(t *testing.T) {
	// Create a temporary directory for test files
	tmpDir, err := os.MkdirTemp("", "jsonschema-linter-test")
	if err != nil {
		t.Fatalf("Failed to create temp dir: %v", err)
	}
	defer func() {
		if err := os.RemoveAll(tmpDir); err != nil {
			t.Logf("Failed to remove temp dir: %v", err)
		}
	}()

	// Create a test file with unescaped commas
	invalidContent := `package test

// Invalid has unescaped commas
type Invalid struct {
	Name string ` + "`json:\"name\" jsonschema:\"description=An invalid field, with unescaped comma\"`" + `
	Age  int    ` + "`json:\"age\" jsonschema:\"description=Another field, also with unescaped comma\"`" + `
}
`

	// Expected content after fixing
	// Note: We need double backslashes in the actual file, so we use double escaped backslashes here
	expectedContent := `package test

// Invalid has unescaped commas
type Invalid struct {
	Name string ` + "`json:\"name\" jsonschema:\"description=An invalid field\\\\, with unescaped comma\"`" + `
	Age  int    ` + "`json:\"age\" jsonschema:\"description=Another field\\\\, also with unescaped comma\"`" + `
}
`

	filePath := filepath.Join(tmpDir, "invalid.go")
	if err := os.WriteFile(filePath, []byte(invalidContent), 0644); err != nil {
		t.Fatalf("Failed to write test file: %v", err)
	}

	// Run the linter with fix mode enabled
	linter := &JSONSchemaLinter{FixMode: true}
	err = linter.FindUnescapedCommas(tmpDir)
	if err != nil {
		t.Fatalf("Linter failed: %v", err)
	}

	// Check if we found the expected errors
	if len(linter.Errors) != 2 {
		t.Errorf("Expected 2 errors, got %d", len(linter.Errors))
	}

	// Verify the file was fixed
	fixedContent, err := os.ReadFile(filePath)
	if err != nil {
		t.Fatalf("Failed to read fixed file: %v", err)
	}

	if string(fixedContent) != expectedContent {
		t.Errorf("File not fixed correctly.\nExpected:\n%s\n\nGot:\n%s", expectedContent, string(fixedContent))
	}

	// Verify the fixed field was correctly tracked
	if !linter.Fixed[filePath] {
		t.Errorf("Fixed file not tracked in linter.Fixed")
	}
}

```

--------------------------------------------------------------------------------
/tools/sift_cloud_test.go:
--------------------------------------------------------------------------------

```go
//go:build cloud
// +build cloud

// This file contains cloud integration tests that run against a dedicated test instance
// at mcptests.grafana-dev.net. This instance is configured with a minimal setup on the Sift side:
//   - 2 test investigations
// These tests expect this configuration to exist and will skip if the required
// environment variables (GRAFANA_URL, GRAFANA_SERVICE_ACCOUNT_TOKEN or GRAFANA_API_KEY) are not set.
// The GRAFANA_API_KEY variable is deprecated.

package tools

import (
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestCloudSiftInvestigations(t *testing.T) {
	ctx := createCloudTestContext(t, "Sift", "GRAFANA_URL", "GRAFANA_API_KEY")

	// Test listing all investigations
	t.Run("list all investigations", func(t *testing.T) {
		result, err := listSiftInvestigations(ctx, ListSiftInvestigationsParams{})
		require.NoError(t, err, "Should not error when listing investigations")
		assert.NotNil(t, result, "Result should not be nil")
		assert.GreaterOrEqual(t, len(result), 1, "Should have at least one investigation")
	})

	// Test listing investigations with a limit
	t.Run("list investigations with limit", func(t *testing.T) {
		// Get the client
		client, err := siftClientFromContext(ctx)
		require.NoError(t, err, "Should not error when getting Sift client")

		// List investigations with a limit of 1
		investigations, err := client.listSiftInvestigations(ctx, 1)
		require.NoError(t, err, "Should not error when listing investigations with limit")
		assert.NotNil(t, investigations, "Investigations should not be nil")
		assert.LessOrEqual(t, len(investigations), 1, "Should have at most one investigation")

		// If there are investigations, verify their structure
		if len(investigations) > 0 {
			investigation := investigations[0]
			assert.NotEmpty(t, investigation.ID, "Investigation should have an ID")
			assert.NotEmpty(t, investigation.Name, "Investigation should have a name")
			assert.NotEmpty(t, investigation.TenantID, "Investigation should have a tenant ID")
		}
	})

	// Get an investigation ID from the list to test getting a specific investigation
	investigations, err := listSiftInvestigations(ctx, ListSiftInvestigationsParams{Limit: 10})
	require.NoError(t, err, "Should not error when listing investigations")
	require.NotEmpty(t, investigations, "Should have at least one investigation to test with")

	// Find an investigation with at least one analysis.
	var investigationID string
	for _, investigation := range investigations {
		if len(investigation.Analyses.Items) > 0 {
			investigationID = investigation.ID.String()
			break
		}
	}
	require.NotEmpty(t, investigationID, "Should have at least one investigation with at least one analysis")

	// Test getting a specific investigation
	t.Run("get specific investigation", func(t *testing.T) {
		result, err := getSiftInvestigation(ctx, GetSiftInvestigationParams{
			ID: investigationID,
		})
		require.NoError(t, err, "Should not error when getting specific investigation")
		assert.NotNil(t, result, "Result should not be nil")
		assert.Equal(t, investigationID, result.ID.String(), "Should return the correct investigation")

		// Verify all required fields are present
		assert.NotEmpty(t, result.Name, "Investigation should have a name")
		assert.NotEmpty(t, result.TenantID, "Investigation should have a tenant ID")
		assert.NotNil(t, result.GrafanaURL, "Investigation should have a Grafana URL")
		assert.NotNil(t, result.Status, "Investigation should have a status")
		assert.NotNil(t, result.FailureReason, "Investigation should have a failure reason")
	})

	// Test getting a non-existent investigation
	t.Run("get non-existent investigation", func(t *testing.T) {
		_, err := getSiftInvestigation(ctx, GetSiftInvestigationParams{
			ID: "00000000-0000-0000-0000-000000000000",
		})
		assert.NoError(t, err, "Should not error when getting non-existent investigation")
	})

	// Test getting analyses for an investigation
	t.Run("get analyses for investigation", func(t *testing.T) {
		// Get the investigation
		result, err := getSiftInvestigation(ctx, GetSiftInvestigationParams{
			ID: investigationID,
		})
		require.NoError(t, err, "Should not error when getting specific investigation")
		assert.NotNil(t, result, "Result should not be nil")

		// Get an analysis ID
		analysisID := result.Analyses.Items[0].ID

		// Get the analysis
		analysis, err := getSiftAnalysis(ctx, GetSiftAnalysisParams{
			InvestigationID: investigationID,
			AnalysisID:      analysisID.String(),
		})
		require.NoError(t, err, "Should not error when getting specific analysis")
		assert.NotNil(t, analysis, "Analysis should not be nil")

		// Verify all required fields are present
		assert.NotEmpty(t, analysis.Name, "Analysis should have a name")
		assert.NotEmpty(t, analysis.InvestigationID, "Analysis should have an investigation ID")
		assert.NotNil(t, analysis.Result, "Analysis should have a result")
	})

	t.Run("find error patterns", func(t *testing.T) {
		// Find error patterns
		analysis, err := findErrorPatternLogs(ctx, FindErrorPatternLogsParams{
			Name: "Test Sift",
			Labels: map[string]string{
				"namespace": "hosted-grafana",
				"cluster":   "dev-eu-west-2",
				"slug":      "mcptests",
			},
			Start: time.Now().Add(-5 * time.Minute),
			End:   time.Now(),
		})
		require.NoError(t, err, "Should not error when finding error patterns")
		assert.NotNil(t, analysis, "Result should not be nil")

		// Verify all required fields are present
		assert.NotEmpty(t, analysis.Name, "Analysis should have a name")
		assert.NotEmpty(t, analysis.InvestigationID, "Analysis should have an investigation ID")
		assert.NotEmpty(t, analysis.Result.Message, "Analysis  should have a message")
	})
}

```
Page 1/4FirstPrevNextLast