#
tokens: 49329/50000 58/96 files (page 1/5)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 1 of 5. Use http://codebase.md/grafana/mcp-grafana?lines=true&page={x} to view the full context.

# Directory Structure

```
├── .dockerignore
├── .github
│   ├── dependabot.yml
│   └── workflows
│       ├── docker.yml
│       ├── e2e.yml
│       ├── integration.yml
│       ├── release.yml
│       └── unit.yml
├── .gitignore
├── .golangci.yaml
├── .goreleaser.yaml
├── cmd
│   ├── linters
│   │   └── jsonschema
│   │       └── main.go
│   └── mcp-grafana
│       └── main.go
├── CODEOWNERS
├── docker-compose.yaml
├── Dockerfile
├── examples
│   └── tls_example.go
├── gemini-extension.json
├── go.mod
├── go.sum
├── image-tag
├── internal
│   └── linter
│       └── jsonschema
│           ├── jsonschema_lint_test.go
│           ├── jsonschema_lint.go
│           └── README.md
├── LICENSE
├── Makefile
├── mcpgrafana_test.go
├── mcpgrafana.go
├── proxied_client.go
├── proxied_handler.go
├── proxied_tools_test.go
├── proxied_tools.go
├── README.md
├── renovate.json
├── server.json
├── session_test.go
├── session.go
├── testdata
│   ├── dashboards
│   │   └── demo.json
│   ├── loki-config.yml
│   ├── prometheus-entrypoint.sh
│   ├── prometheus-seed.yml
│   ├── prometheus.yml
│   ├── promtail-config.yml
│   ├── provisioning
│   │   ├── alerting
│   │   │   ├── alert_rules.yaml
│   │   │   └── contact_points.yaml
│   │   ├── dashboards
│   │   │   └── dashboards.yaml
│   │   └── datasources
│   │       └── datasources.yaml
│   ├── tempo-config-2.yaml
│   └── tempo-config.yaml
├── tests
│   ├── .gitignore
│   ├── .python-version
│   ├── admin_test.py
│   ├── conftest.py
│   ├── dashboards_test.py
│   ├── disable_write_test.py
│   ├── health_test.py
│   ├── loki_test.py
│   ├── navigation_test.py
│   ├── pyproject.toml
│   ├── README.md
│   ├── tempo_test.py
│   ├── utils.py
│   └── uv.lock
├── tls_test.go
├── tools
│   ├── admin_test.go
│   ├── admin.go
│   ├── alerting_client_test.go
│   ├── alerting_client.go
│   ├── alerting_test.go
│   ├── alerting_unit_test.go
│   ├── alerting.go
│   ├── annotations_integration_test.go
│   ├── annotations_unit_test.go
│   ├── annotations.go
│   ├── asserts_cloud_test.go
│   ├── asserts_test.go
│   ├── asserts.go
│   ├── cloud_testing_utils.go
│   ├── dashboard_test.go
│   ├── dashboard.go
│   ├── datasources_test.go
│   ├── datasources.go
│   ├── folder.go
│   ├── incident_integration_test.go
│   ├── incident_test.go
│   ├── incident.go
│   ├── loki_test.go
│   ├── loki.go
│   ├── navigation_test.go
│   ├── navigation.go
│   ├── oncall_cloud_test.go
│   ├── oncall.go
│   ├── prometheus_test.go
│   ├── prometheus_unit_test.go
│   ├── prometheus.go
│   ├── pyroscope_test.go
│   ├── pyroscope.go
│   ├── search_test.go
│   ├── search.go
│   ├── sift_cloud_test.go
│   ├── sift.go
│   └── testcontext_test.go
├── tools_test.go
└── tools.go
```

# Files

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

```
1 | .env
2 | 
```

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

```
1 | 3.13
2 | 
```

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

```yaml
1 | version: "2"
2 | run:
3 |   concurrency: 16
4 |   timeout: 10m
5 |   go: "1.24"
6 |   relative-path-mode: gomod
7 |   allow-parallel-runners: true
8 | 
```

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

```
 1 | # Python-generated files
 2 | __pycache__/
 3 | *.py[oc]
 4 | build/
 5 | dist/
 6 | wheels/
 7 | *.egg-info
 8 | .DS_Store
 9 | .vscode/
10 | .env
11 | .cursor/
12 | 
13 | # Virtual environments
14 | .venv
15 | .envrc
16 | 
```

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

```
 1 | # Git
 2 | .git
 3 | .gitignore
 4 | .github/
 5 | 
 6 | # Docker
 7 | Dockerfile
 8 | .dockerignore
 9 | 
10 | # Build artifacts
11 | bin/
12 | dist/
13 | build/
14 | *.exe
15 | *.exe~
16 | *.dll
17 | *.so
18 | *.dylib
19 | 
20 | # Go specific
21 | vendor/
22 | go.work
23 | 
24 | # Testing
25 | *_test.go
26 | **/test/
27 | **/tests/
28 | coverage.out
29 | coverage.html
30 | 
31 | # IDE and editor files
32 | .idea/
33 | .vscode/
34 | *.swp
35 | *.swo
36 | *~
37 | 
38 | # OS specific
39 | .DS_Store
40 | Thumbs.db
41 | 
42 | # Temporary files
43 | tmp/
44 | temp/
45 | *.tmp
46 | *.log
47 | 
48 | # Documentation
49 | docs/
50 | *.md
51 | LICENSE
52 | 
53 | # Development tools
54 | .air.toml
55 | .golangci.yml
56 | .goreleaser.yml
57 | 
58 | # Debug files
59 | debug
60 | __debug_bin
61 | 
```

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

```yaml
  1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
  2 | 
  3 | version: 2
  4 | 
  5 | before:
  6 |   hooks:
  7 |     - go mod tidy
  8 | 
  9 | git:
 10 |   prerelease_suffix: "-rc"
 11 | 
 12 | builds:
 13 |   - id: default
 14 |     env:
 15 |       - CGO_ENABLED=0
 16 |     main: ./cmd/mcp-grafana
 17 |     goos:
 18 |       - linux
 19 |       - windows
 20 |       - darwin
 21 |     ldflags: "-s -w -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser"
 22 |   - id: gemini-cli-extension
 23 |     env:
 24 |       - CGO_ENABLED=0
 25 |     main: ./cmd/mcp-grafana
 26 |     goos:
 27 |       - linux
 28 |       - windows
 29 |       - darwin
 30 |     goarch:
 31 |       - amd64
 32 |       - arm64
 33 |     ldflags: "-s -w -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser"
 34 | 
 35 | archives:
 36 |   - id: default
 37 |     ids:
 38 |       - default
 39 |     formats: tar.gz
 40 |     # this name template makes the OS and Arch compatible with the results of `uname`.
 41 |     name_template: >-
 42 |       {{ .ProjectName }}_
 43 |       {{- title .Os }}_
 44 |       {{- if eq .Arch "amd64" }}x86_64
 45 |       {{- else if eq .Arch "386" }}i386
 46 |       {{- else }}{{ .Arch }}{{ end }}
 47 |       {{- if .Arm }}v{{ .Arm }}{{ end }}
 48 |     # use zip for windows archives
 49 |     format_overrides:
 50 |       - goos: windows
 51 |         formats: zip
 52 | 
 53 |   # The Gemini CLI Extension format.
 54 |   # See https://github.com/google-gemini/gemini-cli/blob/main/docs/extension-releasing.md#platform-specific-archives.
 55 |   # We'll use platform and architecture specific archive names, which must look like:
 56 |   #
 57 |   # {platform}.{arch}.{name}.{extension}
 58 |   #
 59 |   # Where the fields are:
 60 |   #
 61 |   # - {name}: The name of your extension.
 62 |   # - {platform}: The operating system. Supported values are:
 63 |   # -     darwin (macOS)
 64 |   # -     linux
 65 |   # -     win32 (Windows)
 66 |   # - {arch}: The architecture. Supported values are:
 67 |   # -     x64
 68 |   # -     arm64
 69 |   # - {extension}: The file extension of the archive (e.g., .tar.gz or .zip).
 70 |   #
 71 |   # Examples:
 72 |   # - darwin.arm64.{project}.tar.gz (specific to Apple Silicon Macs)
 73 |   # - linux.x64.{project}.tar.gz
 74 |   # - win32.{project}.zip
 75 |   - id: gemini-cli-extension
 76 |     ids:
 77 |       - gemini-cli-extension
 78 |     formats: tar.gz
 79 |     files:
 80 |       - gemini-extension.json
 81 |     name_template: >-
 82 |       {{ if eq .Os "windows" }}win32
 83 |       {{- else }}{{ .Os }}{{ end }}.
 84 |       {{- if eq .Arch "amd64" }}x64
 85 |       {{- else }}{{ .Arch }}{{ end }}.grafana
 86 |     format_overrides:
 87 |       - goos: windows
 88 |         formats: zip
 89 | 
 90 | changelog:
 91 |   sort: asc
 92 |   filters:
 93 |     exclude:
 94 |       - "^docs:"
 95 |       - "^test:"
 96 | 
 97 | release:
 98 |   footer: >-
 99 | 
100 |     ---
101 | 
102 |     Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
103 | 
```

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

```markdown
 1 | # Tests
 2 | 
 3 | This directory contains an e2e test suite for the Grafana MCP server.
 4 | 
 5 | The test suite evaluates the LLM's ability to use Grafana MCP tools effectively:
 6 | 
 7 | - **Loki tests**: Evaluates how well the LLM can use Grafana tools to:
 8 |   - Navigate and use available tools
 9 |   - Make appropriate tool calls
10 |   - Process and present the results in a meaningful way
11 |   - Evaluating the LLM responses using `langevals` package, using custom LLM-as-a-Judge approach.
12 | 
13 | The tests are run against two LLM models:
14 | - GPT-4
15 | - Claude 3.5 Sonnet
16 | 
17 | Tests are using [`uv`] to manage dependencies. Install uv following the instructions for your platform.
18 | 
19 | ## Prerequisites
20 | - Docker installed and running on your system
21 | - Docker containers for the test environment must be started before running tests
22 | 
23 | ## Setup
24 | 1. Create a virtual environment and install the dependencies:
25 |    ```bash
26 |    uv sync --all-groups
27 |    ```
28 | 
29 | 2. Create a `.env` file with your API keys:
30 |    ```env
31 |    OPENAI_API_KEY=sk-...
32 |    ANTHROPIC_API_KEY=sk-ant-...
33 |    ```
34 | 
35 | 3. Start the required Docker containers
36 | 
37 | 4. Start the MCP server in SSE mode; from the root of the project:
38 |    ```bash
39 |    go run ./cmd/mcp-grafana -t sse
40 |    ```
41 | 
42 | 5. Run the tests:
43 |    ```bash
44 |    uv run pytest
45 |    ```
46 | 
47 | [`uv`]: https://docs.astral.sh/uv/
48 | 
```

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

```markdown
 1 | # JSONSchema Linter
 2 | 
 3 | This linter helps detect and prevent a common issue with Go struct tags in this project. 
 4 | 
 5 | ## The Problem
 6 | 
 7 | 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.
 8 | 
 9 | For example:
10 | 
11 | ```go
12 | // Problematic (description will be truncated at the first comma):
13 | type Example struct {
14 |     Field string `jsonschema:"description=This is a description, but it will be truncated here"`
15 | }
16 | 
17 | // Correct (commas properly escaped):
18 | type Example struct {
19 |     Field string `jsonschema:"description=This is a description\\, and it will be fully included"`
20 | }
21 | ```
22 | 
23 | ## Usage
24 | 
25 | You can use this linter by running:
26 | 
27 | ```shell
28 | make lint-jsonschema
29 | ```
30 | 
31 | or directly:
32 | 
33 | ```shell
34 | go run ./cmd/linters/jsonschema --path .
35 | ```
36 | 
37 | ### Auto-fixing issues
38 | 
39 | The linter can automatically fix unescaped commas in jsonschema descriptions by running:
40 | 
41 | ```shell
42 | make lint-jsonschema-fix
43 | ```
44 | 
45 | or directly:
46 | 
47 | ```shell
48 | go run ./cmd/linters/jsonschema --path . --fix
49 | ```
50 | 
51 | This will scan the codebase for unescaped commas and automatically escape them, then report what was fixed.
52 | 
53 | ## Flags
54 | 
55 | - `--path`: Base directory to scan for Go files (default: ".")
56 | - `--fix`: Automatically fix unescaped commas
57 | - `--help`: Display help information
58 | 
59 | ## Integration
60 | 
61 | This linter is integrated into the default `make lint` command, ensuring all PRs are checked for this issue.
```

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

```markdown
  1 | # Grafana MCP server
  2 | 
  3 | [![Unit Tests](https://github.com/grafana/mcp-grafana/actions/workflows/unit.yml/badge.svg)](https://github.com/grafana/mcp-grafana/actions/workflows/unit.yml)
  4 | [![Integration Tests](https://github.com/grafana/mcp-grafana/actions/workflows/integration.yml/badge.svg)](https://github.com/grafana/mcp-grafana/actions/workflows/integration.yml)
  5 | [![E2E Tests](https://github.com/grafana/mcp-grafana/actions/workflows/e2e.yml/badge.svg)](https://github.com/grafana/mcp-grafana/actions/workflows/e2e.yml)
  6 | [![Go Reference](https://pkg.go.dev/badge/github.com/grafana/mcp-grafana.svg)](https://pkg.go.dev/github.com/grafana/mcp-grafana)
  7 | [![MCP Catalog](https://archestra.ai/mcp-catalog/api/badge/quality/grafana/mcp-grafana)](https://archestra.ai/mcp-catalog/grafana__mcp-grafana)
  8 | 
  9 | A [Model Context Protocol][mcp] (MCP) server for Grafana.
 10 | 
 11 | This provides access to your Grafana instance and the surrounding ecosystem.
 12 | 
 13 | ## Requirements
 14 | 
 15 | - **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.
 16 | 
 17 | ## Features
 18 | 
 19 | _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._
 20 | 
 21 | ### Dashboards
 22 | 
 23 | - **Search for dashboards:** Find dashboards by title or other metadata
 24 | - **Get dashboard by UID:** Retrieve full dashboard details using its unique identifier. _Warning: Large dashboards can consume significant context window space._
 25 | - **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
 26 | - **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
 27 | - **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._
 28 | - **Patch dashboard:** Apply specific changes to a dashboard without requiring the full JSON, significantly reducing context window usage for targeted modifications
 29 | - **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
 30 | 
 31 | #### Context Window Management
 32 | 
 33 | The dashboard tools now include several strategies to manage context window usage effectively ([issue #101](https://github.com/grafana/mcp-grafana/issues/101)):
 34 | 
 35 | - **Use `get_dashboard_summary`** for dashboard overview and planning modifications
 36 | - **Use `get_dashboard_property`** with JSONPath when you only need specific dashboard parts
 37 | - **Avoid `get_dashboard_by_uid`** unless you specifically need the complete dashboard JSON
 38 | 
 39 | ### Datasources
 40 | 
 41 | - **List and fetch datasource information:** View all configured datasources and retrieve detailed information about each.
 42 |   - _Supported datasource types: Prometheus, Loki._
 43 | 
 44 | ### Prometheus Querying
 45 | 
 46 | - **Query Prometheus:** Execute PromQL queries (supports both instant and range metric queries) against Prometheus datasources.
 47 | - **Query Prometheus metadata:** Retrieve metric metadata, metric names, label names, and label values from Prometheus datasources.
 48 | 
 49 | ### Loki Querying
 50 | 
 51 | - **Query Loki logs and metrics:** Run both log queries and metric queries using LogQL against Loki datasources.
 52 | - **Query Loki metadata:** Retrieve label names, label values, and stream statistics from Loki datasources.
 53 | 
 54 | ### Incidents
 55 | 
 56 | - **Search, create, and update incidents:** Manage incidents in Grafana Incident, including searching, creating, and adding activities to incidents.
 57 | 
 58 | ### Sift Investigations
 59 | 
 60 | - **List Sift investigations:** Retrieve a list of Sift investigations, with support for a limit parameter.
 61 | - **Get Sift investigation:** Retrieve details of a specific Sift investigation by its UUID.
 62 | - **Get Sift analyses:** Retrieve a specific analysis from a Sift investigation.
 63 | - **Find error patterns in logs:** Detect elevated error patterns in Loki logs using Sift.
 64 | - **Find slow requests:** Detect slow requests using Sift (Tempo).
 65 | 
 66 | ### Alerting
 67 | 
 68 | - **List and fetch alert rule information:** View alert rules and their statuses (firing/normal/error/etc.) in Grafana.
 69 | - **List contact points:** View configured notification contact points in Grafana.
 70 | 
 71 | ### Grafana OnCall
 72 | 
 73 | - **List and manage schedules:** View and manage on-call schedules in Grafana OnCall.
 74 | - **Get shift details:** Retrieve detailed information about specific on-call shifts.
 75 | - **Get current on-call users:** See which users are currently on call for a schedule.
 76 | - **List teams and users:** View all OnCall teams and users.
 77 | - **List alert groups:** View and filter alert groups from Grafana OnCall by various criteria including state, integration, labels, and time range.
 78 | - **Get alert group details:** Retrieve detailed information about a specific alert group by its ID.
 79 | 
 80 | ### Admin
 81 | 
 82 | - **List teams:** View all configured teams in Grafana.
 83 | - **List Users:** View all users in an organization in Grafana.
 84 | 
 85 | ### Navigation
 86 | 
 87 | - **Generate deeplinks:** Create accurate deeplink URLs for Grafana resources instead of relying on LLM URL guessing.
 88 |   - **Dashboard links:** Generate direct links to dashboards using their UID (e.g., `http://localhost:3000/d/dashboard-uid`)
 89 |   - **Panel links:** Create links to specific panels within dashboards with viewPanel parameter (e.g., `http://localhost:3000/d/dashboard-uid?viewPanel=5`)
 90 |   - **Explore links:** Generate links to Grafana Explore with pre-configured datasources (e.g., `http://localhost:3000/explore?left={"datasource":"prometheus-uid"}`)
 91 |   - **Time range support:** Add time range parameters to links (`from=now-1h&to=now`)
 92 |   - **Custom parameters:** Include additional query parameters like dashboard variables or refresh intervals
 93 | 
 94 | ### Annotations
 95 | 
 96 | - **Get Annotations:** Query annotations with filters. Supports time range, dashboard UID, tags, and match mode.
 97 | - **Create Annotation:** Create a new annotation on a dashboard or panel.
 98 | - **Create Graphite Annotation:** Create annotations using Graphite format (`what`, `when`, `tags`, `data`).
 99 | - **Update Annotation:** Replace all fields of an existing annotation (full update).
100 | - **Patch Annotation:** Update only specific fields of an annotation (partial update).
101 | - **Get Annotation Tags:** List available annotation tags with optional filtering.
102 | 
103 | 
104 | The list of tools is configurable, so you can choose which tools you want to make available to the MCP client.
105 | 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.
106 | To disable a category of tools, use the `--disable-<category>` flag when starting the server. For example, to disable
107 | the OnCall tools, use `--disable-oncall`, or to disable navigation deeplink generation, use `--disable-navigation`.
108 | 
109 | 
110 | #### RBAC Permissions
111 | 
112 | 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.
113 | 
114 | 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.
115 | 
116 | **Note:** Grafana Incident and Sift tools use basic Grafana roles instead of fine-grained RBAC permissions:
117 | - **Viewer role:** Required for read-only operations (list incidents, get investigations)
118 | - **Editor role:** Required for write operations (create incidents, modify investigations)
119 | 
120 | For more information about Grafana RBAC, see the [official documentation](https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/access-control/).
121 | 
122 | #### RBAC Scopes
123 | 
124 | Scopes define the specific resources that permissions apply to. Each action requires both the appropriate permission and scope combination.
125 | 
126 | **Common Scope Patterns:**
127 | 
128 | - **Broad access:** Use `*` wildcards for organization-wide access
129 | 
130 |   - `datasources:*` - Access to all datasources
131 |   - `dashboards:*` - Access to all dashboards
132 |   - `folders:*` - Access to all folders
133 |   - `teams:*` - Access to all teams
134 | 
135 | - **Limited access:** Use specific UIDs or IDs to restrict access to individual resources
136 |   - `datasources:uid:prometheus-uid` - Access only to a specific Prometheus datasource
137 |   - `dashboards:uid:abc123` - Access only to dashboard with UID `abc123`
138 |   - `folders:uid:xyz789` - Access only to folder with UID `xyz789`
139 |   - `teams:id:5` - Access only to team with ID `5`
140 |   - `global.users:id:123` - Access only to user with ID `123`
141 | 
142 | **Examples:**
143 | 
144 | - **Full MCP server access:** Grant broad permissions for all tools
145 | 
146 |   ```
147 |   datasources:* (datasources:read, datasources:query)
148 |   dashboards:* (dashboards:read, dashboards:create, dashboards:write)
149 |   folders:* (for dashboard creation and alert rules)
150 |   teams:* (teams:read)
151 |   global.users:* (users:read)
152 |   ```
153 | 
154 | - **Limited datasource access:** Only query specific Prometheus and Loki instances
155 | 
156 |   ```
157 |   datasources:uid:prometheus-prod (datasources:query)
158 |   datasources:uid:loki-prod (datasources:query)
159 |   ```
160 | 
161 | - **Dashboard-specific access:** Read only specific dashboards
162 |   ```
163 |   dashboards:uid:monitoring-dashboard (dashboards:read)
164 |   dashboards:uid:alerts-dashboard (dashboards:read)
165 |   ```
166 | 
167 | ### Tools
168 | 
169 | | Tool                              | Category    | Description                                                        | Required RBAC Permissions               | Required Scopes                                     |
170 | | --------------------------------- | ----------- | ------------------------------------------------------------------ | --------------------------------------- | --------------------------------------------------- |
171 | | `list_teams`                      | Admin       | List all teams                                                     | `teams:read`                            | `teams:*` or `teams:id:1`                           |
172 | | `list_users_by_org`               | Admin       | List all users in an organization                                  | `users:read`                            | `global.users:*` or `global.users:id:123`           |
173 | | `search_dashboards`               | Search      | Search for dashboards                                              | `dashboards:read`                       | `dashboards:*` or `dashboards:uid:abc123`           |
174 | | `get_dashboard_by_uid`            | Dashboard   | Get a dashboard by uid                                             | `dashboards:read`                       | `dashboards:uid:abc123`                             |
175 | | `update_dashboard`                | Dashboard   | Update or create a new dashboard                                   | `dashboards:create`, `dashboards:write` | `dashboards:*`, `folders:*` or `folders:uid:xyz789` |
176 | | `get_dashboard_panel_queries`     | Dashboard   | Get panel title, queries, datasource UID and type from a dashboard | `dashboards:read`                       | `dashboards:uid:abc123`                             |
177 | | `get_dashboard_property`          | Dashboard   | Extract specific parts of a dashboard using JSONPath expressions   | `dashboards:read`                       | `dashboards:uid:abc123`                             |
178 | | `get_dashboard_summary`           | Dashboard   | Get a compact summary of a dashboard without full JSON             | `dashboards:read`                       | `dashboards:uid:abc123`                             |
179 | | `list_datasources`                | Datasources | List datasources                                                   | `datasources:read`                      | `datasources:*`                                     |
180 | | `get_datasource_by_uid`           | Datasources | Get a datasource by uid                                            | `datasources:read`                      | `datasources:uid:prometheus-uid`                    |
181 | | `get_datasource_by_name`          | Datasources | Get a datasource by name                                           | `datasources:read`                      | `datasources:*` or `datasources:uid:loki-uid`       |
182 | | `query_prometheus`                | Prometheus  | Execute a query against a Prometheus datasource                    | `datasources:query`                     | `datasources:uid:prometheus-uid`                    |
183 | | `list_prometheus_metric_metadata` | Prometheus  | List metric metadata                                               | `datasources:query`                     | `datasources:uid:prometheus-uid`                    |
184 | | `list_prometheus_metric_names`    | Prometheus  | List available metric names                                        | `datasources:query`                     | `datasources:uid:prometheus-uid`                    |
185 | | `list_prometheus_label_names`     | Prometheus  | List label names matching a selector                               | `datasources:query`                     | `datasources:uid:prometheus-uid`                    |
186 | | `list_prometheus_label_values`    | Prometheus  | List values for a specific label                                   | `datasources:query`                     | `datasources:uid:prometheus-uid`                    |
187 | | `list_incidents`                  | Incident    | List incidents in Grafana Incident                                 | Viewer role                             | N/A                                                 |
188 | | `create_incident`                 | Incident    | Create an incident in Grafana Incident                             | Editor role                             | N/A                                                 |
189 | | `add_activity_to_incident`        | Incident    | Add an activity item to an incident in Grafana Incident            | Editor role                             | N/A                                                 |
190 | | `get_incident`                    | Incident    | Get a single incident by ID                                        | Viewer role                             | N/A                                                 |
191 | | `query_loki_logs`                 | Loki        | Query and retrieve logs using LogQL (either log or metric queries) | `datasources:query`                     | `datasources:uid:loki-uid`                          |
192 | | `list_loki_label_names`           | Loki        | List all available label names in logs                             | `datasources:query`                     | `datasources:uid:loki-uid`                          |
193 | | `list_loki_label_values`          | Loki        | List values for a specific log label                               | `datasources:query`                     | `datasources:uid:loki-uid`                          |
194 | | `query_loki_stats`                | Loki        | Get statistics about log streams                                   | `datasources:query`                     | `datasources:uid:loki-uid`                          |
195 | | `list_alert_rules`                | Alerting    | List alert rules                                                   | `alert.rules:read`                      | `folders:*` or `folders:uid:alerts-folder`          |
196 | | `get_alert_rule_by_uid`           | Alerting    | Get alert rule by UID                                              | `alert.rules:read`                      | `folders:uid:alerts-folder`                         |
197 | | `list_contact_points`             | Alerting    | List notification contact points                                   | `alert.notifications:read`              | Global scope                                        |
198 | | `list_oncall_schedules`           | OnCall      | List schedules from Grafana OnCall                                 | `grafana-oncall-app.schedules:read`     | Plugin-specific scopes                              |
199 | | `get_oncall_shift`                | OnCall      | Get details for a specific OnCall shift                            | `grafana-oncall-app.schedules:read`     | Plugin-specific scopes                              |
200 | | `get_current_oncall_users`        | OnCall      | Get users currently on-call for a specific schedule                | `grafana-oncall-app.schedules:read`     | Plugin-specific scopes                              |
201 | | `list_oncall_teams`               | OnCall      | List teams from Grafana OnCall                                     | `grafana-oncall-app.user-settings:read` | Plugin-specific scopes                              |
202 | | `list_oncall_users`               | OnCall      | List users from Grafana OnCall                                     | `grafana-oncall-app.user-settings:read` | Plugin-specific scopes                              |
203 | | `list_alert_groups`               | OnCall      | List alert groups from Grafana OnCall with filtering options       | `grafana-oncall-app.alert-groups:read`  | Plugin-specific scopes                              |
204 | | `get_alert_group`                 | OnCall      | Get a specific alert group from Grafana OnCall by its ID           | `grafana-oncall-app.alert-groups:read`  | Plugin-specific scopes                              |
205 | | `get_sift_investigation`          | Sift        | Retrieve an existing Sift investigation by its UUID                | Viewer role                             | N/A                                                 |
206 | | `get_sift_analysis`               | Sift        | Retrieve a specific analysis from a Sift investigation             | Viewer role                             | N/A                                                 |
207 | | `list_sift_investigations`        | Sift        | Retrieve a list of Sift investigations with an optional limit      | Viewer role                             | N/A                                                 |
208 | | `find_error_pattern_logs`         | Sift        | Finds elevated error patterns in Loki logs.                        | Editor role                             | N/A                                                 |
209 | | `find_slow_requests`              | Sift        | Finds slow requests from the relevant tempo datasources.           | Editor role                             | N/A                                                 |
210 | | `list_pyroscope_label_names`      | Pyroscope   | List label names matching a selector                               | `datasources:query`                     | `datasources:uid:pyroscope-uid`                     |
211 | | `list_pyroscope_label_values`     | Pyroscope   | List label values matching a selector for a label name             | `datasources:query`                     | `datasources:uid:pyroscope-uid`                     |
212 | | `list_pyroscope_profile_types`    | Pyroscope   | List available profile types                                       | `datasources:query`                     | `datasources:uid:pyroscope-uid`                     |
213 | | `fetch_pyroscope_profile`         | Pyroscope   | Fetches a profile in DOT format for analysis                       | `datasources:query`                     | `datasources:uid:pyroscope-uid`                     |
214 | | `get_assertions`                  | Asserts     | Get assertion summary for a given entity                           | Plugin-specific permissions             | Plugin-specific scopes                              |
215 | | `generate_deeplink`               | Navigation  | Generate accurate deeplink URLs for Grafana resources              | None (read-only URL generation)         | N/A
216 | | `get_annotations`                 | Annotations | Fetch annotations with filters                                      | `annotations:read`                      | `annotations:*` or `annotations:id:123`            |
217 | | `create_annotation`               | Annotations | Create a new annotation on a dashboard or panel                     | `annotations:write`                     | `annotations:*`                                    |
218 | | `create_graphite_annotation`      | Annotations | Create an annotation using Graphite format                          | `annotations:write`                     | `annotations:*`                                    |
219 | | `update_annotation`               | Annotations | Replace all fields of an annotation (full update)                   | `annotations:write`                     | `annotations:*`                                    |
220 | | `patch_annotation`                | Annotations | Update only specific fields of an annotation (partial update)       | `annotations:write`                     | `annotations:*`                                    |
221 | | `get_annotation_tags`             | Annotations | List annotation tags with optional filtering                        | `annotations:read`                      | `annotations:*`                                    |
222 |                                               |
223 | 
224 | ## CLI Flags Reference
225 | 
226 | The `mcp-grafana` binary supports various command-line flags for configuration:
227 | 
228 | **Transport Options:**
229 | - `-t, --transport`: Transport type (`stdio`, `sse`, or `streamable-http`) - default: `stdio`
230 | - `--address`: The host and port for SSE/streamable-http server - default: `localhost:8000`
231 | - `--base-path`: Base path for the SSE/streamable-http server
232 | - `--endpoint-path`: Endpoint path for the streamable-http server - default: `/`
233 | 
234 | **Debug and Logging:**
235 | - `--debug`: Enable debug mode for detailed HTTP request/response logging
236 | 
237 | **Tool Configuration:**
238 | - `--enabled-tools`: Comma-separated list of enabled categories - default: all categories enabled - example: "loki,datasources"
239 | - `--disable-search`: Disable search tools
240 | - `--disable-datasource`: Disable datasource tools
241 | - `--disable-incident`: Disable incident tools
242 | - `--disable-prometheus`: Disable prometheus tools
243 | - `--disable-write`: Disable write tools (create/update operations)
244 | - `--disable-loki`: Disable loki tools
245 | - `--disable-alerting`: Disable alerting tools
246 | - `--disable-dashboard`: Disable dashboard tools
247 | - `--disable-oncall`: Disable oncall tools
248 | - `--disable-asserts`: Disable asserts tools
249 | - `--disable-sift`: Disable sift tools
250 | - `--disable-admin`: Disable admin tools
251 | - `--disable-pyroscope`: Disable pyroscope tools
252 | - `--disable-navigation`: Disable navigation tools
253 | 
254 | ### Read-Only Mode
255 | 
256 | 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:
257 | 
258 | - Using service accounts with limited read-only permissions
259 | - Providing AI assistants with observability data without modification capabilities
260 | - Running in production environments where write access should be restricted
261 | - Testing and development scenarios where you want to prevent accidental modifications
262 | 
263 | When `--disable-write` is enabled, the following write operations are disabled:
264 | 
265 | **Dashboard Tools:**
266 | - `update_dashboard`
267 | 
268 | **Folder Tools:**
269 | - `create_folder`
270 | 
271 | **Incident Tools:**
272 | - `create_incident`
273 | - `add_activity_to_incident`
274 | 
275 | **Alerting Tools:**
276 | - `create_alert_rule`
277 | - `update_alert_rule`
278 | - `delete_alert_rule`
279 | 
280 | **Annotation Tools:**
281 | - `create_annotation`
282 | - `create_graphite_annotation`
283 | - `update_annotation`
284 | - `patch_annotation`
285 | 
286 | **Sift Tools:**
287 | - `find_error_pattern_logs` (creates investigations)
288 | - `find_slow_requests` (creates investigations)
289 | 
290 | All read operations remain available, allowing you to query dashboards, run PromQL/LogQL queries, list resources, and retrieve data.
291 | 
292 | **Client TLS Configuration (for Grafana connections):**
293 | - `--tls-cert-file`: Path to TLS certificate file for client authentication
294 | - `--tls-key-file`: Path to TLS private key file for client authentication
295 | - `--tls-ca-file`: Path to TLS CA certificate file for server verification
296 | - `--tls-skip-verify`: Skip TLS certificate verification (insecure)
297 | 
298 | **Server TLS Configuration (streamable-http transport only):**
299 | - `--server.tls-cert-file`: Path to TLS certificate file for server HTTPS
300 | - `--server.tls-key-file`: Path to TLS private key file for server HTTPS
301 | 
302 | ## Usage
303 | 
304 | 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.
305 | 
306 | 1. If using service account token authentication, create a service account in Grafana with enough permissions to use the tools you want to use,
307 |    generate a service account token, and copy it to the clipboard for use in the configuration file.
308 |    Follow the [Grafana service account documentation][service-account] for details on creating service account tokens.
309 |    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.
310 | 
311 |    > **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.
312 | 
313 | ### Multi-Organization Support
314 |  
315 | You can specify which organization to interact with using either:
316 | 
317 | - **Environment variable:** Set `GRAFANA_ORG_ID` to the numeric organization ID
318 | - **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).
319 | 
320 | 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.
321 | 
322 | **Example with organization ID:**
323 | 
324 | ```json
325 | {
326 |   "mcpServers": {
327 |     "grafana": {
328 |       "command": "mcp-grafana",
329 |       "args": [],
330 |       "env": {
331 |         "GRAFANA_URL": "http://localhost:3000",
332 |         "GRAFANA_USERNAME": "<your username>",
333 |         "GRAFANA_PASSWORD": "<your password>",
334 |         "GRAFANA_ORG_ID": "2"
335 |       }
336 |     }
337 |   }
338 | }
339 | ```
340 | 
341 | 2. You have several options to install `mcp-grafana`:
342 | 
343 |    - **Docker image**: Use the pre-built Docker image from Docker Hub.
344 | 
345 |      **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:
346 | 
347 |      1. **STDIO Mode**: For stdio mode you must explicitly override the default with `-t stdio` and include the `-i` flag to keep stdin open:
348 | 
349 |      ```bash
350 |      docker pull mcp/grafana
351 |      # For local Grafana:
352 |      docker run --rm -i -e GRAFANA_URL=http://localhost:3000 -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> mcp/grafana -t stdio
353 |      # For Grafana Cloud:
354 |      docker run --rm -i -e GRAFANA_URL=https://myinstance.grafana.net -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> mcp/grafana -t stdio
355 |      ```
356 | 
357 |      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:
358 | 
359 |      ```bash
360 |      docker pull mcp/grafana
361 |      docker run --rm -p 8000:8000 -e GRAFANA_URL=http://localhost:3000 -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> mcp/grafana
362 |      ```
363 | 
364 |      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`
365 | 
366 |      ```bash
367 |      docker pull mcp/grafana
368 |      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
369 |      ```
370 | 
371 |      For HTTPS streamable HTTP mode with server TLS certificates:
372 | 
373 |      ```bash
374 |      docker pull mcp/grafana
375 |      docker run --rm -p 8443:8443 \
376 |        -v /path/to/certs:/certs:ro \
377 |        -e GRAFANA_URL=http://localhost:3000 \
378 |        -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> \
379 |        mcp/grafana \
380 |        -t streamable-http \
381 |        -addr :8443 \
382 |        --server.tls-cert-file /certs/server.crt \
383 |        --server.tls-key-file /certs/server.key
384 |      ```
385 | 
386 |    - **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`.
387 | 
388 |    - **Build from source**: If you have a Go toolchain installed you can also build and install it from source, using the `GOBIN` environment variable
389 |      to specify the directory where the binary should be installed. This should also be in your `PATH`.
390 | 
391 |      ```bash
392 |      GOBIN="$HOME/go/bin" go install github.com/grafana/mcp-grafana/cmd/mcp-grafana@latest
393 |      ```
394 | 
395 |    - **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)
396 | 
397 |      ```bash
398 |      helm repo add grafana https://grafana.github.io/helm-charts
399 |      helm install --set grafana.apiKey=<Grafana_ApiKey> --set grafana.url=<GrafanaUrl> my-release grafana/grafana-mcp
400 |      ```
401 | 
402 | 
403 | 3. Add the server configuration to your client configuration file. For example, for Claude Desktop:
404 | 
405 |    **If using the binary:**
406 | 
407 |    ```json
408 |    {
409 |      "mcpServers": {
410 |        "grafana": {
411 |          "command": "mcp-grafana",
412 |          "args": [],
413 |          "env": {
414 |            "GRAFANA_URL": "http://localhost:3000",  // Or "https://myinstance.grafana.net" for Grafana Cloud
415 |            "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>",
416 |            // If using username/password authentication
417 |            "GRAFANA_USERNAME": "<your username>",
418 |            "GRAFANA_PASSWORD": "<your password>",
419 |            // Optional: specify organization ID for multi-org support
420 |            "GRAFANA_ORG_ID": "1"
421 |          }
422 |        }
423 |      }
424 |    }
425 |    ```
426 | 
427 | > Note: if you see `Error: spawn mcp-grafana ENOENT` in Claude Desktop, you need to specify the full path to `mcp-grafana`.
428 | 
429 | **If using Docker:**
430 | 
431 | ```json
432 | {
433 |   "mcpServers": {
434 |     "grafana": {
435 |       "command": "docker",
436 |       "args": [
437 |         "run",
438 |         "--rm",
439 |         "-i",
440 |         "-e",
441 |         "GRAFANA_URL",
442 |         "-e",
443 |         "GRAFANA_SERVICE_ACCOUNT_TOKEN",
444 |         "mcp/grafana",
445 |         "-t",
446 |         "stdio"
447 |       ],
448 |       "env": {
449 |         "GRAFANA_URL": "http://localhost:3000",  // Or "https://myinstance.grafana.net" for Grafana Cloud
450 |         "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>",
451 |         // If using username/password authentication
452 |         "GRAFANA_USERNAME": "<your username>",
453 |         "GRAFANA_PASSWORD": "<your password>",
454 |         // Optional: specify organization ID for multi-org support
455 |         "GRAFANA_ORG_ID": "1"
456 |       }
457 |     }
458 |   }
459 | }
460 | ```
461 | 
462 | > Note: The `-t stdio` argument is essential here because it overrides the default SSE mode in the Docker image.
463 | 
464 | **Using VSCode with remote MCP server**
465 | 
466 | 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:
467 | 
468 | ```json
469 | "mcp": {
470 |   "servers": {
471 |     "grafana": {
472 |       "type": "sse",
473 |       "url": "http://localhost:8000/sse"
474 |     }
475 |   }
476 | }
477 | ```
478 | 
479 | For HTTPS streamable HTTP mode with server TLS certificates:
480 | 
481 | ```json
482 | "mcp": {
483 |   "servers": {
484 |     "grafana": {
485 |       "type": "sse",
486 |       "url": "https://localhost:8443/sse"
487 |     }
488 |   }
489 | }
490 | ```
491 | 
492 | ### Debug Mode
493 | 
494 | 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.
495 | 
496 | To use debug mode with the Claude Desktop configuration, update your config as follows:
497 | 
498 | **If using the binary:**
499 | 
500 | ```json
501 | {
502 |   "mcpServers": {
503 |     "grafana": {
504 |       "command": "mcp-grafana",
505 |       "args": ["-debug"],
506 |       "env": {
507 |         "GRAFANA_URL": "http://localhost:3000",  // Or "https://myinstance.grafana.net" for Grafana Cloud
508 |         "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
509 |       }
510 |     }
511 |   }
512 | }
513 | ```
514 | 
515 | **If using Docker:**
516 | 
517 | ```json
518 | {
519 |   "mcpServers": {
520 |     "grafana": {
521 |       "command": "docker",
522 |       "args": [
523 |         "run",
524 |         "--rm",
525 |         "-i",
526 |         "-e",
527 |         "GRAFANA_URL",
528 |         "-e",
529 |         "GRAFANA_SERVICE_ACCOUNT_TOKEN",
530 |         "mcp/grafana",
531 |         "-t",
532 |         "stdio",
533 |         "-debug"
534 |       ],
535 |       "env": {
536 |         "GRAFANA_URL": "http://localhost:3000",  // Or "https://myinstance.grafana.net" for Grafana Cloud
537 |         "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
538 |       }
539 |     }
540 |   }
541 | }
542 | ```
543 | 
544 | > Note: As with the standard configuration, the `-t stdio` argument is required to override the default SSE mode in the Docker image.
545 | 
546 | ### TLS Configuration
547 | 
548 | 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:
549 | 
550 | - `--tls-cert-file`: Path to TLS certificate file for client authentication
551 | - `--tls-key-file`: Path to TLS private key file for client authentication
552 | - `--tls-ca-file`: Path to TLS CA certificate file for server verification
553 | - `--tls-skip-verify`: Skip TLS certificate verification (insecure, use only for testing)
554 | 
555 | **Example with client certificate authentication:**
556 | 
557 | ```json
558 | {
559 |   "mcpServers": {
560 |     "grafana": {
561 |       "command": "mcp-grafana",
562 |       "args": [
563 |         "--tls-cert-file",
564 |         "/path/to/client.crt",
565 |         "--tls-key-file",
566 |         "/path/to/client.key",
567 |         "--tls-ca-file",
568 |         "/path/to/ca.crt"
569 |       ],
570 |       "env": {
571 |         "GRAFANA_URL": "https://secure-grafana.example.com",
572 |         "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
573 |       }
574 |     }
575 |   }
576 | }
577 | ```
578 | 
579 | **Example with Docker:**
580 | 
581 | ```json
582 | {
583 |   "mcpServers": {
584 |     "grafana": {
585 |       "command": "docker",
586 |       "args": [
587 |         "run",
588 |         "--rm",
589 |         "-i",
590 |         "-v",
591 |         "/path/to/certs:/certs:ro",
592 |         "-e",
593 |         "GRAFANA_URL",
594 |         "-e",
595 |         "GRAFANA_SERVICE_ACCOUNT_TOKEN",
596 |         "mcp/grafana",
597 |         "-t",
598 |         "stdio",
599 |         "--tls-cert-file",
600 |         "/certs/client.crt",
601 |         "--tls-key-file",
602 |         "/certs/client.key",
603 |         "--tls-ca-file",
604 |         "/certs/ca.crt"
605 |       ],
606 |       "env": {
607 |         "GRAFANA_URL": "https://secure-grafana.example.com",
608 |         "GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
609 |       }
610 |     }
611 |   }
612 | }
613 | ```
614 | 
615 | The TLS configuration is applied to all HTTP clients used by the MCP server, including:
616 | 
617 | - The main Grafana OpenAPI client
618 | - Prometheus datasource clients
619 | - Loki datasource clients
620 | - Incident management clients
621 | - Sift investigation clients
622 | - Alerting clients
623 | - Asserts clients
624 | 
625 | **Direct CLI Usage Examples:**
626 | 
627 | For testing with self-signed certificates:
628 | 
629 | ```bash
630 | ./mcp-grafana --tls-skip-verify -debug
631 | ```
632 | 
633 | With client certificate authentication:
634 | 
635 | ```bash
636 | ./mcp-grafana \
637 |   --tls-cert-file /path/to/client.crt \
638 |   --tls-key-file /path/to/client.key \
639 |   --tls-ca-file /path/to/ca.crt \
640 |   -debug
641 | ```
642 | 
643 | With custom CA certificate only:
644 | 
645 | ```bash
646 | ./mcp-grafana --tls-ca-file /path/to/ca.crt
647 | ```
648 | 
649 | **Programmatic Usage:**
650 | 
651 | If you're using this library programmatically, you can also create TLS-enabled context functions:
652 | 
653 | ```go
654 | // Using struct literals
655 | tlsConfig := &mcpgrafana.TLSConfig{
656 |     CertFile: "/path/to/client.crt",
657 |     KeyFile:  "/path/to/client.key",
658 |     CAFile:   "/path/to/ca.crt",
659 | }
660 | grafanaConfig := mcpgrafana.GrafanaConfig{
661 |     Debug:     true,
662 |     TLSConfig: tlsConfig,
663 | }
664 | contextFunc := mcpgrafana.ComposedStdioContextFunc(grafanaConfig)
665 | 
666 | // Or inline
667 | grafanaConfig := mcpgrafana.GrafanaConfig{
668 |     Debug: true,
669 |     TLSConfig: &mcpgrafana.TLSConfig{
670 |         CertFile: "/path/to/client.crt",
671 |         KeyFile:  "/path/to/client.key",
672 |         CAFile:   "/path/to/ca.crt",
673 |     },
674 | }
675 | contextFunc := mcpgrafana.ComposedStdioContextFunc(grafanaConfig)
676 | ```
677 | 
678 | ### Server TLS Configuration (Streamable HTTP Transport Only)
679 | 
680 | 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.
681 | 
682 | The server supports the following TLS configuration options for the streamable HTTP transport:
683 | 
684 | - `--server.tls-cert-file`: Path to TLS certificate file for server HTTPS (required for TLS)
685 | - `--server.tls-key-file`: Path to TLS private key file for server HTTPS (required for TLS)
686 | 
687 | **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.
688 | 
689 | **Example with HTTPS streamable HTTP server:**
690 | 
691 | ```bash
692 | ./mcp-grafana \
693 |   -t streamable-http \
694 |   --server.tls-cert-file /path/to/server.crt \
695 |   --server.tls-key-file /path/to/server.key \
696 |   -addr :8443
697 | ```
698 | 
699 | This would start the MCP server on HTTPS port 8443. Clients would then connect to `https://localhost:8443/` instead of `http://localhost:8000/`.
700 | 
701 | **Docker example with server TLS:**
702 | 
703 | ```bash
704 | docker run --rm -p 8443:8443 \
705 |   -v /path/to/certs:/certs:ro \
706 |   -e GRAFANA_URL=http://localhost:3000 \
707 |   -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> \
708 |   mcp/grafana \
709 |   -t streamable-http \
710 |   -addr :8443 \
711 |   --server.tls-cert-file /certs/server.crt \
712 |   --server.tls-key-file /certs/server.key
713 | ```
714 | 
715 | ### Health Check Endpoint
716 | 
717 | 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.
718 | 
719 | **Endpoint:** `GET /healthz`
720 | 
721 | **Response:**
722 | - Status Code: `200 OK`
723 | - Body: `ok`
724 | 
725 | **Example usage:**
726 | 
727 | ```bash
728 | # For streamable HTTP or SSE transport on default port
729 | curl http://localhost:8000/healthz
730 | 
731 | # With custom address
732 | curl http://localhost:9090/healthz
733 | ```
734 | 
735 | **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.
736 | 
737 | ## Troubleshooting
738 | 
739 | ### Grafana Version Compatibility
740 | 
741 | If you encounter the following error when using datasource-related tools:
742 | 
743 | ```
744 | get datasource by uid : [GET /datasources/uid/{uid}][400] getDataSourceByUidBadRequest {"message":"id is invalid"}
745 | ```
746 | 
747 | 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.
748 | 
749 | **Solution:** Upgrade your Grafana instance to version 9.0 or later to resolve this issue.
750 | 
751 | ## Development
752 | 
753 | Contributions are welcome! Please open an issue or submit a pull request if you have any suggestions or improvements.
754 | 
755 | This project is written in Go. Install Go following the instructions for your platform.
756 | 
757 | To run the server locally in STDIO mode (which is the default for local development), use:
758 | 
759 | ```bash
760 | make run
761 | ```
762 | 
763 | To run the server locally in SSE mode, use:
764 | 
765 | ```bash
766 | go run ./cmd/mcp-grafana --transport sse
767 | ```
768 | 
769 | 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:
770 | 
771 | ```
772 | make build-image
773 | ```
774 | 
775 | And to run the image in SSE mode (the default), use:
776 | 
777 | ```
778 | docker run -it --rm -p 8000:8000 mcp-grafana:latest
779 | ```
780 | 
781 | If you need to run it in STDIO mode instead, override the transport setting:
782 | 
783 | ```
784 | docker run -it --rm mcp-grafana:latest -t stdio
785 | ```
786 | 
787 | ### Testing
788 | 
789 | There are three types of tests available:
790 | 
791 | 1. Unit Tests (no external dependencies required):
792 | 
793 | ```bash
794 | make test-unit
795 | ```
796 | 
797 | You can also run unit tests with:
798 | 
799 | ```bash
800 | make test
801 | ```
802 | 
803 | 2. Integration Tests (requires docker containers to be up and running):
804 | 
805 | ```bash
806 | make test-integration
807 | ```
808 | 
809 | 3. Cloud Tests (requires cloud Grafana instance and credentials):
810 | 
811 | ```bash
812 | make test-cloud
813 | ```
814 | 
815 | > Note: Cloud tests are automatically configured in CI. For local development, you'll need to set up your own Grafana Cloud instance and credentials.
816 | 
817 | More comprehensive integration tests will require a Grafana instance to be running locally on port 3000; you can start one with Docker Compose:
818 | 
819 | ```bash
820 | docker-compose up -d
821 | ```
822 | 
823 | The integration tests can be run with:
824 | 
825 | ```bash
826 | make test-all
827 | ```
828 | 
829 | If you're adding more tools, please add integration tests for them. The existing tests should be a good starting point.
830 | 
831 | ### Linting
832 | 
833 | To lint the code, run:
834 | 
835 | ```bash
836 | make lint
837 | ```
838 | 
839 | 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:
840 | 
841 | ```bash
842 | make lint-jsonschema
843 | ```
844 | 
845 | See the [JSONSchema Linter documentation](internal/linter/jsonschema/README.md) for more details.
846 | 
847 | ## License
848 | 
849 | This project is licensed under the [Apache License, Version 2.0](LICENSE).
850 | 
851 | [mcp]: https://modelcontextprotocol.io/
852 | [service-account]: https://grafana.com/docs/grafana/latest/administration/service-accounts/#add-a-token-to-a-service-account-in-grafana
853 | 
```

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

```yaml
1 | groups:
2 |   - name: seed
3 |     rules:
4 |     - record: test
5 |       expr: vector(1)
```

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

```json
 1 | {
 2 |   "name": "grafana",
 3 |   "version": "0.7.0",
 4 |   "mcpServers": {
 5 |     "grafana": {
 6 |       "command": "${extensionPath}${/}mcp-grafana"
 7 |     }
 8 |   }
 9 | }
10 | 
```

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

```yaml
 1 | global:
 2 |   scrape_interval:     1s
 3 | 
 4 | scrape_configs:
 5 |   - job_name: 'prometheus'
 6 |     static_configs:
 7 |       - targets: ['localhost:9090']
 8 | 
 9 | rule_files:
10 |   - prometheus-seed.yml
```

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

```yaml
 1 | apiVersion: 1
 2 | 
 3 | providers:
 4 |   - name: "docker-compose"
 5 |     orgId: 1
 6 |     folder: "Tests"
 7 |     folderUid: "tests"
 8 |     type: file
 9 |     disableDeletion: true
10 |     updateIntervalSeconds: 60
11 |     allowUiUpdates: false
12 |     options:
13 |       # <string, required> path to dashboard files on disk. Required when using the 'file' type
14 |       path: /var/lib/grafana/dashboards
15 | 
```

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

```python
 1 | import httpx
 2 | import pytest
 3 | 
 4 | pytestmark = pytest.mark.anyio
 5 | 
 6 | 
 7 | async def test_healthz(mcp_transport: str, mcp_url: str):
 8 |     if mcp_transport == "stdio":
 9 |         return
10 |     health_url = f"{mcp_url}/healthz"
11 |     async with httpx.AsyncClient() as client:
12 |         response = await client.get(health_url)
13 |         assert response.status_code == 200
14 |         assert response.text == "ok"
15 | 
```

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

```yaml
 1 | server:
 2 |   http_listen_port: 3200
 3 |   log_level: debug
 4 | 
 5 | query_frontend:
 6 |   mcp_server:
 7 |     enabled: true
 8 | 
 9 | distributor:
10 |   receivers:
11 |     otlp:
12 |       protocols:
13 |         http:
14 |         grpc:
15 | 
16 | ingester:
17 |   max_block_duration: 5m
18 | 
19 | compactor:
20 |   compaction:
21 |     block_retention: 1h
22 | 
23 | storage:
24 |   trace:
25 |     backend: local
26 |     local:
27 |       path: /tmp/tempo/blocks
28 |     wal:
29 |       path: /tmp/tempo/wal 
30 | 
```

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

```yaml
 1 | server:
 2 |   http_listen_port: 3201
 3 |   log_level: debug
 4 | 
 5 | query_frontend:
 6 |   mcp_server:
 7 |     enabled: true
 8 | 
 9 | distributor:
10 |   receivers:
11 |     otlp:
12 |       protocols:
13 |         http:
14 |         grpc:
15 | 
16 | ingester:
17 |   max_block_duration: 5m
18 | 
19 | compactor:
20 |   compaction:
21 |     block_retention: 1h
22 | 
23 | storage:
24 |   trace:
25 |     backend: local
26 |     local:
27 |       path: /tmp/tempo2/blocks
28 |     wal:
29 |       path: /tmp/tempo2/wal
30 | 
```

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

```yaml
 1 | server:
 2 |   http_listen_port: 9080
 3 | 
 4 | positions:
 5 |   filename: /tmp/positions.yaml
 6 | 
 7 | clients:
 8 |   - url: http://loki:3100/loki/api/v1/push
 9 | 
10 | scrape_configs:
11 |   - job_name: docker
12 |     docker_sd_configs:
13 |       - host: unix:///var/run/docker.sock
14 |         refresh_interval: 5s
15 |     relabel_configs:
16 |       - source_labels: ['__meta_docker_container_name']
17 |         regex: '/(.*)'
18 |         target_label: 'container'
```

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

```yaml
 1 | apiVersion: 1
 2 | 
 3 | contactPoints:
 4 |   - name: Email1
 5 |     receivers:
 6 |       - uid: email1
 7 |         type: email
 8 |         settings:
 9 |           addresses: [email protected]
10 |           singleEmail: false
11 |           message: my optional message1 to include
12 |   - name: Email2
13 |     receivers:
14 |       - uid: email2
15 |         type: email
16 |         settings:
17 |           addresses: [email protected]
18 |           singleEmail: false
19 |           message: my optional message2 to include
```

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

```json
 1 | {
 2 |     "ignorePresets": [
 3 |             "github>grafana/grafana-renovate-config//presets/base",
 4 |             "github>grafana/grafana-renovate-config//presets/automerge",
 5 |             "github>grafana/grafana-renovate-config//presets/labels",
 6 |             "github>grafana/grafana-renovate-config//presets/npm"
 7 |     ],
 8 |     "extends": [
 9 |         "config:best-practices",
10 |         ":disableDependencyDashboard",
11 |         ":preserveSemverRanges",
12 |         "github>grafana/grafana-renovate-config//presets/plugin-ci-workflows"
13 |     ],
14 |     "prConcurrentLimit": 5,
15 |     "minimumReleaseAge": "14 days",
16 |     "rebaseWhen": "behind-base-branch"
17 | }
```

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

```yaml
 1 | auth_enabled: false
 2 | 
 3 | server:
 4 |   http_listen_port: 3100
 5 | 
 6 | ingester:
 7 |   lifecycler:
 8 |     address: 127.0.0.1
 9 |     ring:
10 |       kvstore:
11 |         store: inmemory
12 |       replication_factor: 1
13 |     final_sleep: 0s
14 |   chunk_idle_period: 5m
15 |   chunk_retain_period: 30s
16 |   wal:
17 |     enabled: true
18 |     dir: /loki/wal
19 | 
20 | compactor:
21 |   working_directory: /loki/compactor
22 | 
23 | schema_config:
24 |   configs:
25 |     - from: 2020-10-24
26 |       store: boltdb-shipper
27 |       object_store: filesystem
28 |       schema: v11
29 |       index:
30 |         prefix: index_
31 |         period: 24h
32 | 
33 | storage_config:
34 |   boltdb_shipper:
35 |     active_index_directory: /loki/boltdb-shipper-active
36 |     cache_location: /loki/boltdb-shipper-cache
37 |     cache_ttl: 24h
38 |     shared_store: filesystem
39 |   filesystem:
40 |     directory: /loki/chunks
41 | 
```

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

```yaml
 1 | # To get started with Dependabot version updates, you'll need to specify which
 2 | # package ecosystems to update and where the package manifests are located.
 3 | # Please see the documentation for all configuration options:
 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
 5 | 
 6 | version: 2
 7 | updates:
 8 |   - package-ecosystem: "gomod" # See documentation for possible values
 9 |     directory: "/" # Location of package manifests
10 |     schedule:
11 |       interval: "weekly"
12 |     groups:
13 |       go-dependencies:
14 |         patterns:
15 |           - "*"
16 |   - package-ecosystem: github-actions
17 |     directory: /
18 |     schedule:
19 |       interval: weekly
20 |     groups:
21 |       github-actions:
22 |         patterns:
23 |           - "*"
24 | 
```

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

```toml
 1 | [project]
 2 | name = "tests"
 3 | version = "0.1.0"
 4 | description = "Add your description here"
 5 | readme = "README.md"
 6 | authors = [
 7 |     { name = "Ben Sully", email = "[email protected]" },
 8 |     { name = "Ioanna Armouti", email = "[email protected]" },
 9 |     { name = "Chris Marchbanks", email = "[email protected]" },
10 | ]
11 | requires-python = ">=3.13"
12 | dependencies = []
13 | 
14 | [dependency-groups]
15 | dev = [
16 |     "anyio>=4.9.0",
17 |     "flaky>=3.8.1",
18 |     "langevals[langevals]>=0.1.8",
19 |     "litellm>=1.63.12",
20 |     "mcp>=1.9.3",
21 |     "pytest>=8.3.5",
22 |     "python-dotenv>=1.0.0",
23 | ]
24 | 
25 | [tool.pytest.ini_options]
26 | 
27 | [tool.uv.sources]
28 | # Until https://github.com/langwatch/langevals/issues/20.
29 | langevals = { git = "https://github.com/langwatch/langevals", rev = "3a934d1dc4ea95f039cf7bc4969e6bad1543c719" }
30 | 
```

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

```yaml
 1 | apiVersion: 1
 2 | 
 3 | datasources:
 4 |   - name: Prometheus
 5 |     id: 1
 6 |     uid: prometheus
 7 |     type: prometheus
 8 |     access: proxy
 9 |     url: http://prometheus:9090
10 |     isDefault: true
11 |   - name: Prometheus Demo
12 |     id: 2
13 |     uid: prometheus-demo
14 |     type: prometheus
15 |     access: proxy
16 |     url: https://prometheus.demo.prometheus.io
17 |   - name: Loki
18 |     id: 3
19 |     uid: loki
20 |     type: loki
21 |     access: proxy
22 |     url: http://loki:3100
23 |     isDefault: false
24 |   - name: pyroscope
25 |     uid: pyroscope
26 |     type: grafana-pyroscope-datasource
27 |     access: proxy
28 |     url: http://pyroscope:4040
29 |     isDefault: false
30 |   - name: Tempo
31 |     id: 4
32 |     uid: tempo
33 |     type: tempo
34 |     access: proxy
35 |     url: http://tempo:3200
36 |     isDefault: false
37 |   - name: Tempo Secondary
38 |     id: 5
39 |     uid: tempo-secondary
40 |     type: tempo
41 |     access: proxy
42 |     url: http://tempo2:3201
43 |     isDefault: false
44 | 
```

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

```yaml
 1 | name: goreleaser
 2 | 
 3 | on:
 4 |   push:
 5 |     # run only against tags
 6 |     tags:
 7 |       - "v*"
 8 | 
 9 | permissions:
10 |   contents: write
11 | 
12 | jobs:
13 |   goreleaser:
14 |     runs-on: ubuntu-latest
15 |     steps:
16 |       - name: Checkout
17 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
18 |         with:
19 |           fetch-depth: 0
20 |           persist-credentials: false
21 | 
22 |       - name: Set up Go
23 |         uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
24 |         with:
25 |           go-version: stable
26 |           # Do not use any caches when creating a release.
27 |           cache: false
28 | 
29 |       - name: Run GoReleaser
30 |         uses: goreleaser/goreleaser-action@a08664b80c0ab417b1babcbf750274aed2018fef
31 |         with:
32 |           distribution: goreleaser
33 |           # 'latest', 'nightly', or a semver
34 |           version: "~> v2"
35 |           args: release --clean
36 |         env:
37 |           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 | 
```

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

```go
 1 | // Requires a Grafana instance running on localhost:3000,
 2 | // with a dashboard named "Demo" provisioned.
 3 | // Run with `go test -tags integration`.
 4 | //go:build integration
 5 | 
 6 | package tools
 7 | 
 8 | import (
 9 | 	"testing"
10 | 
11 | 	"github.com/grafana/grafana-openapi-client-go/models"
12 | 	"github.com/stretchr/testify/assert"
13 | 	"github.com/stretchr/testify/require"
14 | )
15 | 
16 | func TestSearchTools(t *testing.T) {
17 | 	t.Run("search dashboards", func(t *testing.T) {
18 | 		ctx := newTestContext()
19 | 		result, err := searchDashboards(ctx, SearchDashboardsParams{
20 | 			Query: "Demo",
21 | 		})
22 | 		require.NoError(t, err)
23 | 		assert.Len(t, result, 1)
24 | 		assert.Equal(t, models.HitType("dash-db"), result[0].Type)
25 | 	})
26 | 
27 | 	t.Run("search folders", func(t *testing.T) {
28 | 		ctx := newTestContext()
29 | 		result, err := searchFolders(ctx, SearchFoldersParams{
30 | 			Query: "Tests",
31 | 		})
32 | 		require.NoError(t, err)
33 | 		assert.NotEmpty(t, result)
34 | 		assert.Equal(t, models.HitType("dash-folder"), result[0].Type)
35 | 	})
36 | }
37 | 
```

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

```dockerfile
 1 | # Build stage
 2 | FROM golang:1.24-bullseye@sha256:2cdc80dc25edcb96ada1654f73092f2928045d037581fa4aa7c40d18af7dd85a AS builder
 3 | 
 4 | # Set the working directory
 5 | WORKDIR /app
 6 | 
 7 | # Copy go.mod and go.sum files
 8 | COPY go.mod go.sum ./
 9 | 
10 | # Download dependencies
11 | RUN go mod download
12 | 
13 | # Copy the source code
14 | COPY . .
15 | 
16 | # Build the application
17 | RUN go build -o mcp-grafana ./cmd/mcp-grafana
18 | 
19 | # Final stage
20 | FROM debian:bullseye-slim@sha256:52927eff8153b563244f98cdc802ba97918afcdf67f9e4867cbf1f7afb3d147b
21 | 
22 | LABEL io.modelcontextprotocol.server.name="io.github.grafana/mcp-grafana"
23 | 
24 | # Install ca-certificates for HTTPS requests
25 | RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
26 | 
27 | # Create a non-root user
28 | RUN useradd -r -u 1000 -m mcp-grafana
29 | 
30 | # Set the working directory
31 | WORKDIR /app
32 | 
33 | # Copy the binary from the builder stage
34 | COPY --from=builder --chown=1000:1000 /app/mcp-grafana /app/
35 | 
36 | # Use the non-root user
37 | USER mcp-grafana
38 | 
39 | # Expose the port the app runs on
40 | EXPOSE 8000
41 | 
42 | # Run the application
43 | ENTRYPOINT ["/app/mcp-grafana", "--transport", "sse", "--address", "0.0.0.0:8000"]
44 | 
```

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

```go
 1 | package main
 2 | 
 3 | import (
 4 | 	"flag"
 5 | 	"fmt"
 6 | 	"os"
 7 | 	"path/filepath"
 8 | 
 9 | 	linter "github.com/grafana/mcp-grafana/internal/linter/jsonschema"
10 | )
11 | 
12 | func main() {
13 | 	var (
14 | 		basePath string
15 | 		help     bool
16 | 		fix      bool
17 | 	)
18 | 
19 | 	flag.StringVar(&basePath, "path", ".", "Base directory to scan for Go files")
20 | 	flag.BoolVar(&help, "help", false, "Show help message")
21 | 	flag.BoolVar(&fix, "fix", false, "Automatically fix unescaped commas")
22 | 	flag.Parse()
23 | 
24 | 	if help {
25 | 		fmt.Println("jsonschema-linter - A tool to find unescaped commas in jsonschema struct tags")
26 | 		fmt.Println("\nUsage:")
27 | 		flag.PrintDefaults()
28 | 		os.Exit(0)
29 | 	}
30 | 
31 | 	// Resolve to absolute path
32 | 	absPath, err := filepath.Abs(basePath)
33 | 	if err != nil {
34 | 		fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err)
35 | 		os.Exit(1)
36 | 	}
37 | 
38 | 	// Initialize linter
39 | 	jsonLinter := &linter.JSONSchemaLinter{
40 | 		FixMode: fix,
41 | 	}
42 | 
43 | 	// Find unescaped commas
44 | 	err = jsonLinter.FindUnescapedCommas(absPath)
45 | 	if err != nil {
46 | 		fmt.Fprintf(os.Stderr, "Error scanning files: %v\n", err)
47 | 		os.Exit(1)
48 | 	}
49 | 
50 | 	// Print errors
51 | 	jsonLinter.PrintErrors()
52 | 
53 | 	// Exit with error code if issues were found
54 | 	if len(jsonLinter.Errors) > 0 {
55 | 		os.Exit(1)
56 | 	}
57 | }
58 | 
```

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

```bash
 1 | #!/bin/sh
 2 | # Prometheus entrypoint script to backfill recording rules and start Prometheus
 3 | 
 4 | set -e
 5 | 
 6 | echo "Starting Prometheus entrypoint script..."
 7 | 
 8 | 
 9 | backfill() {
10 |     # Calculate time range for backfilling (5 hours ago from now)
11 |     # Get current time in seconds since epoch
12 |     CURRENT_TIME=$(date -u +%s)
13 |     # Subtract 5 hours (5 * 60 * 60 = 18000 seconds)
14 |     START_TIME=$((CURRENT_TIME - 18000))
15 | 
16 |     # wait until Prometheus is up and running
17 |     until wget http://localhost:9090/-/healthy -q -O /dev/null; do
18 |         sleep 1
19 |     done
20 | 
21 |     promtool tsdb create-blocks-from \
22 |         rules \
23 |         --url=http://localhost:9090 \
24 |         --start="${START_TIME}" \
25 |         --end="${CURRENT_TIME}" \
26 |         --eval-interval=30s \
27 |         /etc/prometheus/prometheus-seed.yml
28 | }
29 | 
30 | # Start Prometheus with the regular configuration, this is needed for backfilling
31 | /bin/prometheus \
32 |     --config.file=/etc/prometheus/prometheus.yml &
33 | 
34 | backfill
35 | 
36 | # Restarting Prometheus after backfilling will allow to load the new blocks directly
37 | # without having to wait for the next compaction cycle
38 | kill %1
39 | echo "Starting Prometheus server..."
40 | # Start Prometheus with the regular configuration
41 | /bin/prometheus \
42 |     --config.file=/etc/prometheus/prometheus.yml
43 | 
44 | 
```

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

```go
 1 | //go:build cloud
 2 | // +build cloud
 3 | 
 4 | // This file contains cloud integration tests that run against a dedicated test instance
 5 | // connected to a Grafana instance at (ASSERTS_GRAFANA_URL, ASSERTS_GRAFANA_SERVICE_ACCOUNT_TOKEN or ASSERTS_GRAFANA_API_KEY).
 6 | // These tests expect this configuration to exist and will skip if the required
 7 | // environment variables are not set. The ASSERTS_GRAFANA_API_KEY variable is deprecated.
 8 | 
 9 | package tools
10 | 
11 | import (
12 | 	"testing"
13 | 	"time"
14 | 
15 | 	"github.com/stretchr/testify/assert"
16 | 	"github.com/stretchr/testify/require"
17 | )
18 | 
19 | func TestAssertsCloudIntegration(t *testing.T) {
20 | 	ctx := createCloudTestContext(t, "Asserts", "ASSERTS_GRAFANA_URL", "ASSERTS_GRAFANA_API_KEY")
21 | 
22 | 	t.Run("get assertions", func(t *testing.T) {
23 | 		// Set up time range for the last hour
24 | 		endTime := time.Now()
25 | 		startTime := endTime.Add(-24 * time.Hour)
26 | 
27 | 		// Test parameters for a known service in the environment
28 | 		params := GetAssertionsParams{
29 | 			StartTime:  startTime,
30 | 			EndTime:    endTime,
31 | 			EntityType: "Service", // Adjust these values based on your actual environment
32 | 			EntityName: "model-builder",
33 | 			Env:        "dev-us-central-0",
34 | 			Namespace:  "asserts",
35 | 		}
36 | 
37 | 		// Get assertions from the real Grafana instance
38 | 		result, err := getAssertions(ctx, params)
39 | 		require.NoError(t, err, "Failed to get assertions from Grafana")
40 | 		assert.NotEmpty(t, result, "Expected non-empty assertions result")
41 | 
42 | 		// Basic validation of the response structure
43 | 		assert.Contains(t, result, "summaries", "Response should contain a summaries field")
44 | 	})
45 | }
46 | 
```

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

```go
 1 | package tools
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"fmt"
 6 | 
 7 | 	"github.com/mark3labs/mcp-go/mcp"
 8 | 	"github.com/mark3labs/mcp-go/server"
 9 | 
10 | 	"github.com/grafana/grafana-openapi-client-go/models"
11 | 	mcpgrafana "github.com/grafana/mcp-grafana"
12 | )
13 | 
14 | type CreateFolderParams struct {
15 | 	Title     string `json:"title" jsonschema:"required,description=The title of the folder."`
16 | 	UID       string `json:"uid,omitempty" jsonschema:"description=Optional folder UID. If omitted\\, Grafana will generate one."`
17 | 	ParentUID string `json:"parentUid,omitempty" jsonschema:"description=Optional parent folder UID. If set\\, the folder will be created under this parent."`
18 | }
19 | 
20 | func createFolder(ctx context.Context, args CreateFolderParams) (*models.Folder, error) {
21 | 	if args.Title == "" {
22 | 		return nil, fmt.Errorf("title is required")
23 | 	}
24 | 
25 | 	c := mcpgrafana.GrafanaClientFromContext(ctx)
26 | 	cmd := &models.CreateFolderCommand{Title: args.Title}
27 | 	if args.UID != "" {
28 | 		cmd.UID = args.UID
29 | 	}
30 | 	if args.ParentUID != "" {
31 | 		cmd.ParentUID = args.ParentUID
32 | 	}
33 | 
34 | 	resp, err := c.Folders.CreateFolder(cmd)
35 | 	if err != nil {
36 | 		return nil, fmt.Errorf("create folder '%s': %w", args.Title, err)
37 | 	}
38 | 	return resp.Payload, nil
39 | }
40 | 
41 | var CreateFolder = mcpgrafana.MustTool(
42 | 	"create_folder",
43 | 	"Create a Grafana folder. Provide a title and optional UID. Returns the created folder.",
44 | 	createFolder,
45 | 	mcp.WithTitleAnnotation("Create folder"),
46 | 	mcp.WithIdempotentHintAnnotation(false),
47 | 	mcp.WithReadOnlyHintAnnotation(false),
48 | )
49 | 
50 | func AddFolderTools(mcp *server.MCPServer, enableWriteTools bool) {
51 | 	if enableWriteTools {
52 | 		CreateFolder.Register(mcp)
53 | 	}
54 | }
55 | 
```

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

```yaml
 1 | name: Unit Tests & Linting
 2 | 
 3 | on:
 4 |   push:
 5 |     branches: [main]
 6 |   pull_request:
 7 |   schedule:
 8 |     - cron: "0 0 * * *"
 9 | 
10 | permissions:
11 |   contents: read
12 | 
13 | jobs:
14 |   lint-jsonschema:
15 |     name: Lint JSON Schemas
16 |     runs-on: ubuntu-latest
17 |     steps:
18 |       - name: Checkout code
19 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
20 |         with:
21 |           persist-credentials: false
22 | 
23 |       - name: Set up Go
24 |         uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
25 |         with:
26 |           go-version: "1.24"
27 |           cache: true
28 |       - name: Run linter
29 |         run: make lint-jsonschema
30 | 
31 |   lint-go:
32 |     name: Lint Go
33 |     runs-on: ubuntu-latest
34 |     steps:
35 |       - name: Checkout code
36 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
37 |         with:
38 |           persist-credentials: false
39 |       - name: Set up Go
40 |         uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
41 |         with:
42 |           go-version: "1.24"
43 |           cache: true
44 |       - name: Run golangci-lint
45 |         uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
46 |         with:
47 |           version: v2.3.0
48 | 
49 |   test-unit:
50 |     name: Test Unit
51 |     runs-on: ubuntu-latest
52 |     steps:
53 |       - name: Checkout code
54 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
55 |         with:
56 |           persist-credentials: false
57 | 
58 |       - name: Set up Go
59 |         uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
60 |         with:
61 |           go-version: "1.24"
62 |           cache: true
63 | 
64 |       - name: Run unit tests
65 |         run: make test-unit
66 | 
```

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

```go
 1 | //go:build unit
 2 | // +build unit
 3 | 
 4 | package tools
 5 | 
 6 | import (
 7 | 	"context"
 8 | 	"testing"
 9 | 
10 | 	"github.com/grafana/incident-go"
11 | 	mcpgrafana "github.com/grafana/mcp-grafana"
12 | 	"github.com/stretchr/testify/assert"
13 | 	"github.com/stretchr/testify/require"
14 | )
15 | 
16 | func newIncidentTestContext() context.Context {
17 | 	client := incident.NewTestClient()
18 | 	return mcpgrafana.WithIncidentClient(context.Background(), client)
19 | }
20 | 
21 | func TestIncidentTools(t *testing.T) {
22 | 	t.Run("list incidents", func(t *testing.T) {
23 | 		ctx := newIncidentTestContext()
24 | 		result, err := listIncidents(ctx, ListIncidentsParams{
25 | 			Limit: 2,
26 | 		})
27 | 		require.NoError(t, err)
28 | 		assert.Len(t, result.IncidentPreviews, 2)
29 | 	})
30 | 
31 | 	t.Run("create incident", func(t *testing.T) {
32 | 		ctx := newIncidentTestContext()
33 | 		result, err := createIncident(ctx, CreateIncidentParams{
34 | 			Title:         "high latency in web requests",
35 | 			Severity:      "minor",
36 | 			RoomPrefix:    "test",
37 | 			IsDrill:       true,
38 | 			Status:        "active",
39 | 			AttachCaption: "Test attachment",
40 | 			AttachURL:     "https://grafana.com",
41 | 		})
42 | 		require.NoError(t, err)
43 | 		assert.Equal(t, "high latency in web requests", result.Title)
44 | 		assert.Equal(t, "minor", result.Severity)
45 | 		assert.True(t, result.IsDrill)
46 | 		assert.Equal(t, "active", result.Status)
47 | 	})
48 | 
49 | 	t.Run("add activity to incident", func(t *testing.T) {
50 | 		ctx := newIncidentTestContext()
51 | 		result, err := addActivityToIncident(ctx, AddActivityToIncidentParams{
52 | 			IncidentID: "123",
53 | 			Body:       "The incident was created by user-123",
54 | 			EventTime:  "2021-08-07T11:58:23Z",
55 | 		})
56 | 		require.NoError(t, err)
57 | 		assert.Equal(t, "The incident was created by user-123", result.Body)
58 | 		assert.Equal(t, "2021-08-07T11:58:23Z", result.EventTime)
59 | 	})
60 | }
61 | 
```

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

```go
 1 | //go:build integration
 2 | 
 3 | package tools
 4 | 
 5 | import (
 6 | 	"context"
 7 | 	"fmt"
 8 | 	"net/url"
 9 | 	"os"
10 | 
11 | 	"github.com/go-openapi/strfmt"
12 | 	"github.com/grafana/grafana-openapi-client-go/client"
13 | 	mcpgrafana "github.com/grafana/mcp-grafana"
14 | )
15 | 
16 | // newTestContext creates a new context with the Grafana URL and service account token
17 | // from the environment variables GRAFANA_URL and GRAFANA_SERVICE_ACCOUNT_TOKEN (or deprecated GRAFANA_API_KEY).
18 | func newTestContext() context.Context {
19 | 	cfg := client.DefaultTransportConfig()
20 | 	cfg.Host = "localhost:3000"
21 | 	cfg.Schemes = []string{"http"}
22 | 	// Extract transport config from env vars, and set it on the context.
23 | 	if u, ok := os.LookupEnv("GRAFANA_URL"); ok {
24 | 		url, err := url.Parse(u)
25 | 		if err != nil {
26 | 			panic(fmt.Errorf("invalid %s: %w", "GRAFANA_URL", err))
27 | 		}
28 | 		cfg.Host = url.Host
29 | 		// The Grafana client will always prefer HTTPS even if the URL is HTTP,
30 | 		// so we need to limit the schemes to HTTP if the URL is HTTP.
31 | 		if url.Scheme == "http" {
32 | 			cfg.Schemes = []string{"http"}
33 | 		}
34 | 	}
35 | 
36 | 	// Check for the new service account token environment variable first
37 | 	if apiKey := os.Getenv("GRAFANA_SERVICE_ACCOUNT_TOKEN"); apiKey != "" {
38 | 		cfg.APIKey = apiKey
39 | 	} else if apiKey := os.Getenv("GRAFANA_API_KEY"); apiKey != "" {
40 | 		// Fall back to the deprecated API key environment variable
41 | 		cfg.APIKey = apiKey
42 | 	} else {
43 | 		cfg.BasicAuth = url.UserPassword("admin", "admin")
44 | 	}
45 | 
46 | 	client := client.NewHTTPClientWithConfig(strfmt.Default, cfg)
47 | 
48 | 	grafanaCfg := mcpgrafana.GrafanaConfig{
49 | 		Debug:     true,
50 | 		URL:       "http://localhost:3000",
51 | 		APIKey:    cfg.APIKey,
52 | 		BasicAuth: cfg.BasicAuth,
53 | 	}
54 | 
55 | 	ctx := mcpgrafana.WithGrafanaConfig(context.Background(), grafanaCfg)
56 | 	return mcpgrafana.WithGrafanaClient(ctx, client)
57 | }
58 | 
```

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

```go
 1 | // Requires a Grafana instance running on localhost:3000,
 2 | // with a Prometheus datasource provisioned.
 3 | // Run with `go test -tags integration`.
 4 | //go:build integration
 5 | 
 6 | package tools
 7 | 
 8 | import (
 9 | 	"testing"
10 | 
11 | 	"github.com/stretchr/testify/assert"
12 | 	"github.com/stretchr/testify/require"
13 | )
14 | 
15 | func TestDatasourcesTools(t *testing.T) {
16 | 	t.Run("list datasources", func(t *testing.T) {
17 | 		ctx := newTestContext()
18 | 		result, err := listDatasources(ctx, ListDatasourcesParams{})
19 | 		require.NoError(t, err)
20 | 		// Six datasources are provisioned in the test environment (Prometheus, Prometheus Demo, Loki, Pyroscope, Tempo, and Tempo Secondary).
21 | 		assert.Len(t, result, 6)
22 | 	})
23 | 
24 | 	t.Run("list datasources for type", func(t *testing.T) {
25 | 		ctx := newTestContext()
26 | 		result, err := listDatasources(ctx, ListDatasourcesParams{Type: "Prometheus"})
27 | 		require.NoError(t, err)
28 | 		// Only two Prometheus datasources are provisioned in the test environment.
29 | 		assert.Len(t, result, 2)
30 | 	})
31 | 
32 | 	t.Run("get datasource by uid", func(t *testing.T) {
33 | 		ctx := newTestContext()
34 | 		result, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{
35 | 			UID: "prometheus",
36 | 		})
37 | 		require.NoError(t, err)
38 | 		assert.Equal(t, "Prometheus", result.Name)
39 | 	})
40 | 
41 | 	t.Run("get datasource by uid - not found", func(t *testing.T) {
42 | 		ctx := newTestContext()
43 | 		result, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{
44 | 			UID: "non-existent-datasource",
45 | 		})
46 | 		require.Error(t, err)
47 | 		require.Nil(t, result)
48 | 		assert.Contains(t, err.Error(), "not found")
49 | 	})
50 | 
51 | 	t.Run("get datasource by name", func(t *testing.T) {
52 | 		ctx := newTestContext()
53 | 		result, err := getDatasourceByName(ctx, GetDatasourceByNameParams{
54 | 			Name: "Prometheus",
55 | 		})
56 | 		require.NoError(t, err)
57 | 		assert.Equal(t, "Prometheus", result.Name)
58 | 	})
59 | }
60 | 
```

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

```json
 1 | {
 2 |   "$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
 3 |   "name": "io.github.grafana/mcp-grafana",
 4 |   "description": "An MCP server giving access to Grafana dashboards, data and more.",
 5 |   "repository": {
 6 |     "url": "https://github.com/grafana/mcp-grafana",
 7 |     "source": "github"
 8 |   },
 9 |   "version": "$VERSION",
10 |   "packages": [
11 |     {
12 |       "registryType": "oci",
13 |       "identifier": "docker.io/grafana/mcp-grafana:$VERSION",
14 |       "transport": {
15 |         "type": "stdio"
16 |       },
17 |       "environmentVariables": [
18 |         {
19 |           "description": "URL to your Grafana instance",
20 |           "isRequired": true,
21 |           "format": "string",
22 |           "isSecret": false,
23 |           "name": "GRAFANA_URL"
24 |         },
25 |         {
26 |           "description": "Service account token used to authenticate with your Grafana instance",
27 |           "isRequired": false,
28 |           "format": "string",
29 |           "isSecret": true,
30 |           "name": "GRAFANA_SERVICE_ACCOUNT_TOKEN"
31 |         },
32 |         {
33 |           "description": "Username to authenticate with your Grafana instance",
34 |           "isRequired": false,
35 |           "format": "string",
36 |           "isSecret": false,
37 |           "name": "GRAFANA_USERNAME"
38 |         },
39 |         {
40 |           "description": "Password to authenticate with your Grafana instance",
41 |           "isRequired": false,
42 |           "format": "string",
43 |           "isSecret": true,
44 |           "name": "GRAFANA_PASSWORD"
45 |         },
46 |         {
47 |           "description": "Organization ID for multi-org support. Can also be set via X-Grafana-Org-Id header in SSE/streamable HTTP transports.",
48 |           "isRequired": false,
49 |           "format": "string",
50 |           "isSecret": false,
51 |           "name": "GRAFANA_ORG_ID"
52 |         }
53 |       ]
54 |     }
55 |   ]
56 | }
57 | 
```

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

```go
 1 | //go:build cloud
 2 | // +build cloud
 3 | 
 4 | package tools
 5 | 
 6 | import (
 7 | 	"context"
 8 | 	"os"
 9 | 	"strings"
10 | 	"testing"
11 | 
12 | 	mcpgrafana "github.com/grafana/mcp-grafana"
13 | )
14 | 
15 | // createCloudTestContext creates a context with a Grafana URL, Grafana service account token and
16 | // Grafana client for cloud integration tests.
17 | // The test will be skipped if required environment variables are not set.
18 | // testName is used to customize the skip message (e.g. "OnCall", "Sift", "Incident")
19 | // urlEnv and apiKeyEnv specify the environment variable names for the Grafana URL and API key (deprecated).
20 | // The function will automatically try the new SERVICE_ACCOUNT_TOKEN pattern first, then fall back to API_KEY.
21 | func createCloudTestContext(t *testing.T, testName, urlEnv, apiKeyEnv string) context.Context {
22 | 	ctx := context.Background()
23 | 
24 | 	grafanaURL := os.Getenv(urlEnv)
25 | 	if grafanaURL == "" {
26 | 		t.Skipf("%s environment variable not set, skipping cloud %s integration tests", urlEnv, testName)
27 | 	}
28 | 
29 | 	// Try the new service account token environment variable first
30 | 	serviceAccountTokenEnv := strings.Replace(apiKeyEnv, "API_KEY", "SERVICE_ACCOUNT_TOKEN", 1)
31 | 	grafanaApiKey := os.Getenv(serviceAccountTokenEnv)
32 | 
33 | 	if grafanaApiKey == "" {
34 | 		// Fall back to the deprecated API key environment variable
35 | 		grafanaApiKey = os.Getenv(apiKeyEnv)
36 | 		if grafanaApiKey != "" {
37 | 			t.Logf("Warning: %s is deprecated, please use %s instead", apiKeyEnv, serviceAccountTokenEnv)
38 | 		}
39 | 	}
40 | 
41 | 	if grafanaApiKey == "" {
42 | 		t.Skipf("Neither %s nor %s environment variables are set, skipping cloud %s integration tests", serviceAccountTokenEnv, apiKeyEnv, testName)
43 | 	}
44 | 
45 | 	client := mcpgrafana.NewGrafanaClient(ctx, grafanaURL, grafanaApiKey, nil, 0)
46 | 
47 | 	config := mcpgrafana.GrafanaConfig{
48 | 		URL:    grafanaURL,
49 | 		APIKey: grafanaApiKey,
50 | 	}
51 | 	ctx = mcpgrafana.WithGrafanaConfig(ctx, config)
52 | 	ctx = mcpgrafana.WithGrafanaClient(ctx, client)
53 | 
54 | 	return ctx
55 | }
56 | 
```

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

```go
 1 | // Requires a Cloud or other Grafana instance with Grafana Incident available,
 2 | // with a Prometheus datasource provisioned.
 3 | //go:build cloud
 4 | // +build cloud
 5 | 
 6 | // This file contains cloud integration tests that run against a dedicated test instance
 7 | // at mcptests.grafana-dev.net. This instance is configured with a minimal setup on the Incident side
 8 | // with two incidents created, one minor and one major, and both of them resolved.
 9 | // These tests expect this configuration to exist and will skip if the required
10 | // environment variables (GRAFANA_URL, GRAFANA_SERVICE_ACCOUNT_TOKEN or GRAFANA_API_KEY) are not set.
11 | // The GRAFANA_API_KEY variable is deprecated.
12 | 
13 | package tools
14 | 
15 | import (
16 | 	"testing"
17 | 
18 | 	mcpgrafana "github.com/grafana/mcp-grafana"
19 | 	"github.com/stretchr/testify/assert"
20 | 	"github.com/stretchr/testify/require"
21 | )
22 | 
23 | func TestCloudIncidentTools(t *testing.T) {
24 | 	t.Run("list incidents", func(t *testing.T) {
25 | 		ctx := createCloudTestContext(t, "Incident", "GRAFANA_URL", "GRAFANA_API_KEY")
26 | 		ctx = mcpgrafana.ExtractIncidentClientFromEnv(ctx)
27 | 
28 | 		result, err := listIncidents(ctx, ListIncidentsParams{
29 | 			Limit: 1,
30 | 		})
31 | 		require.NoError(t, err)
32 | 		assert.NotNil(t, result, "Result should not be nil")
33 | 		assert.NotNil(t, result.IncidentPreviews, "IncidentPreviews should not be nil")
34 | 		assert.LessOrEqual(t, len(result.IncidentPreviews), 1, "Should not return more incidents than the limit")
35 | 	})
36 | 
37 | 	t.Run("get incident by ID", func(t *testing.T) {
38 | 		ctx := createCloudTestContext(t, "Incident", "GRAFANA_URL", "GRAFANA_API_KEY")
39 | 		ctx = mcpgrafana.ExtractIncidentClientFromEnv(ctx)
40 | 		result, err := getIncident(ctx, GetIncidentParams{
41 | 			ID: "1",
42 | 		})
43 | 		require.NoError(t, err)
44 | 		assert.NotNil(t, result, "Result should not be nil")
45 | 		assert.Equal(t, "1", result.IncidentID, "Should return the requested incident ID")
46 | 		assert.NotEmpty(t, result.Title, "Incident should have a title")
47 | 		assert.NotEmpty(t, result.Status, "Incident should have a status")
48 | 	})
49 | }
50 | 
```

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

```go
 1 | package tools
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"fmt"
 6 | 
 7 | 	"github.com/mark3labs/mcp-go/mcp"
 8 | 	"github.com/mark3labs/mcp-go/server"
 9 | 
10 | 	"github.com/grafana/grafana-openapi-client-go/client/org"
11 | 	"github.com/grafana/grafana-openapi-client-go/client/teams"
12 | 	"github.com/grafana/grafana-openapi-client-go/models"
13 | 	mcpgrafana "github.com/grafana/mcp-grafana"
14 | )
15 | 
16 | type ListTeamsParams struct {
17 | 	Query string `json:"query" jsonschema:"description=The query to search for teams. Can be left empty to fetch all teams"`
18 | }
19 | 
20 | func listTeams(ctx context.Context, args ListTeamsParams) (*models.SearchTeamQueryResult, error) {
21 | 	c := mcpgrafana.GrafanaClientFromContext(ctx)
22 | 	params := teams.NewSearchTeamsParamsWithContext(ctx)
23 | 	if args.Query != "" {
24 | 		params.SetQuery(&args.Query)
25 | 	}
26 | 	search, err := c.Teams.SearchTeams(params)
27 | 	if err != nil {
28 | 		return nil, fmt.Errorf("search teams for %+v: %w", c, err)
29 | 	}
30 | 	return search.Payload, nil
31 | }
32 | 
33 | var ListTeams = mcpgrafana.MustTool(
34 | 	"list_teams",
35 | 	"Search for Grafana teams by a query string. Returns a list of matching teams with details like name, ID, and URL.",
36 | 	listTeams,
37 | 	mcp.WithTitleAnnotation("List teams"),
38 | 	mcp.WithIdempotentHintAnnotation(true),
39 | 	mcp.WithReadOnlyHintAnnotation(true),
40 | )
41 | 
42 | type ListUsersByOrgParams struct{}
43 | 
44 | func listUsersByOrg(ctx context.Context, args ListUsersByOrgParams) ([]*models.OrgUserDTO, error) {
45 | 	c := mcpgrafana.GrafanaClientFromContext(ctx)
46 | 
47 | 	params := org.NewGetOrgUsersForCurrentOrgParamsWithContext(ctx)
48 | 	search, err := c.Org.GetOrgUsersForCurrentOrg(params)
49 | 	if err != nil {
50 | 		return nil, fmt.Errorf("search users: %w", err)
51 | 	}
52 | 	return search.Payload, nil
53 | }
54 | 
55 | var ListUsersByOrg = mcpgrafana.MustTool(
56 | 	"list_users_by_org",
57 | 	"List users by organization. Returns a list of users with details like userid, email, role etc.",
58 | 	listUsersByOrg,
59 | 	mcp.WithTitleAnnotation("List users by org"),
60 | 	mcp.WithIdempotentHintAnnotation(true),
61 | 	mcp.WithReadOnlyHintAnnotation(true),
62 | )
63 | 
64 | func AddAdminTools(mcp *server.MCPServer) {
65 | 	ListTeams.Register(mcp)
66 | 	ListUsersByOrg.Register(mcp)
67 | }
68 | 
```

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

```yaml
 1 | services:
 2 |   grafana:
 3 |     image: grafana/grafana@sha256:35c41e0fd0295f5d0ee5db7e780cf33506abfaf47686196f825364889dee878b
 4 |     environment:
 5 |       GF_AUTH_ANONYMOUS_ENABLED: "false"
 6 |       GF_LOG_LEVEL: debug
 7 |       GF_SERVER_ROUTER_LOGGING: "true"
 8 |     ports:
 9 |       - 3000:3000/tcp
10 |     volumes:
11 |       - ./testdata/provisioning:/etc/grafana/provisioning
12 |       - ./testdata/dashboards:/var/lib/grafana/dashboards
13 | 
14 |   prometheus:
15 |     image: prom/prometheus@sha256:ff7e389acbe064a4823212a500393d40a28a8f362e4b05cbf6742a9a3ef736b2
16 |     ports:
17 |       - "9090:9090"
18 |     entrypoint: /etc/prometheus/entrypoint.sh
19 |     volumes:
20 |       - ./testdata/prometheus.yml:/etc/prometheus/prometheus.yml
21 |       - ./testdata/prometheus-seed.yml:/etc/prometheus/prometheus-seed.yml
22 |       - ./testdata/prometheus-entrypoint.sh:/etc/prometheus/entrypoint.sh
23 | 
24 |   loki:
25 |     image: grafana/loki:2.9.15@sha256:2fde6baaa4743a6870acb9ab5f15633de35adced3c0e3d61effd2a5f1008f1c3
26 |     ports:
27 |       - "3100:3100"
28 |     command: -config.file=/etc/loki/loki-config.yml
29 |     volumes:
30 |       - ./testdata/loki-config.yml:/etc/loki/loki-config.yml
31 | 
32 |   promtail:
33 |     image: grafana/promtail:2.9.15@sha256:466ba2fac4448ed2dc509b267995a3c13511d69f6bba01800ca7b38d9953f899
34 |     volumes:
35 |       - ./testdata/promtail-config.yml:/etc/promtail/config.yml
36 |       - /var/log:/var/log
37 |       - /var/run/docker.sock:/var/run/docker.sock
38 |     command: -config.file=/etc/promtail/config.yml
39 |     depends_on:
40 |       - loki
41 | 
42 |   pyroscope:
43 |     image: grafana/pyroscope:1.13.4@sha256:7e8f1911cbe9353f5c2433b81ff494d5c728c773e76ae9e886d2c009b0a28ada
44 |     ports:
45 |       - 4040:4040
46 | 
47 |   tempo:
48 |     image: grafana/tempo:2.9.0-rc.0@sha256:5517ee34d335dedb9ad43028bd8f72edd0bb98b744ea5847a7572755d93d9866
49 |     command: ["-config.file=/etc/tempo/tempo-config.yaml"]
50 |     volumes:
51 |       - ./testdata/tempo-config.yaml:/etc/tempo/tempo-config.yaml
52 |     ports:
53 |       - "3200:3200" # tempo
54 | 
55 |   tempo2:
56 |     image: grafana/tempo:2.9.0-rc.0@sha256:5517ee34d335dedb9ad43028bd8f72edd0bb98b744ea5847a7572755d93d9866
57 |     command: ["-config.file=/etc/tempo/tempo-config.yaml"]
58 |     volumes:
59 |       - ./testdata/tempo-config-2.yaml:/etc/tempo/tempo-config.yaml
60 |     ports:
61 |       - "3201:3201" # tempo instance 2
62 | 
```

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

```go
 1 | package tools
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"fmt"
 6 | 
 7 | 	"github.com/mark3labs/mcp-go/mcp"
 8 | 	"github.com/mark3labs/mcp-go/server"
 9 | 
10 | 	"github.com/grafana/grafana-openapi-client-go/client/search"
11 | 	"github.com/grafana/grafana-openapi-client-go/models"
12 | 	mcpgrafana "github.com/grafana/mcp-grafana"
13 | )
14 | 
15 | var dashboardTypeStr = "dash-db"
16 | var folderTypeStr = "dash-folder"
17 | 
18 | type SearchDashboardsParams struct {
19 | 	Query string `json:"query" jsonschema:"description=The query to search for"`
20 | }
21 | 
22 | func searchDashboards(ctx context.Context, args SearchDashboardsParams) (models.HitList, error) {
23 | 	c := mcpgrafana.GrafanaClientFromContext(ctx)
24 | 	params := search.NewSearchParamsWithContext(ctx)
25 | 	if args.Query != "" {
26 | 		params.SetQuery(&args.Query)
27 | 		params.SetType(&dashboardTypeStr)
28 | 	}
29 | 	search, err := c.Search.Search(params)
30 | 	if err != nil {
31 | 		return nil, fmt.Errorf("search dashboards for %+v: %w", c, err)
32 | 	}
33 | 	return search.Payload, nil
34 | }
35 | 
36 | var SearchDashboards = mcpgrafana.MustTool(
37 | 	"search_dashboards",
38 | 	"Search for Grafana dashboards by a query string. Returns a list of matching dashboards with details like title, UID, folder, tags, and URL.",
39 | 	searchDashboards,
40 | 	mcp.WithTitleAnnotation("Search dashboards"),
41 | 	mcp.WithIdempotentHintAnnotation(true),
42 | 	mcp.WithReadOnlyHintAnnotation(true),
43 | )
44 | 
45 | type SearchFoldersParams struct {
46 | 	Query string `json:"query" jsonschema:"description=The query to search for"`
47 | }
48 | 
49 | func searchFolders(ctx context.Context, args SearchFoldersParams) (models.HitList, error) {
50 | 	c := mcpgrafana.GrafanaClientFromContext(ctx)
51 | 	params := search.NewSearchParamsWithContext(ctx)
52 | 	if args.Query != "" {
53 | 		params.SetQuery(&args.Query)
54 | 	}
55 | 	params.SetType(&folderTypeStr)
56 | 	search, err := c.Search.Search(params)
57 | 	if err != nil {
58 | 		return nil, fmt.Errorf("search folders for %+v: %w", c, err)
59 | 	}
60 | 	return search.Payload, nil
61 | }
62 | 
63 | var SearchFolders = mcpgrafana.MustTool(
64 | 	"search_folders",
65 | 	"Search for Grafana folders by a query string. Returns matching folders with details like title, UID, and URL.",
66 | 	searchFolders,
67 | 	mcp.WithTitleAnnotation("Search folders"),
68 | 	mcp.WithIdempotentHintAnnotation(true),
69 | 	mcp.WithReadOnlyHintAnnotation(true),
70 | )
71 | 
72 | func AddSearchTools(mcp *server.MCPServer) {
73 | 	SearchDashboards.Register(mcp)
74 | 	SearchFolders.Register(mcp)
75 | }
76 | 
```

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

```yaml
 1 | name: Integration Tests
 2 | 
 3 | on:
 4 |   push:
 5 |     branches: [main]
 6 |   pull_request:
 7 |   schedule:
 8 |     - cron: "0 0 * * *"
 9 | 
10 | permissions:
11 |   contents: read
12 | 
13 | jobs:
14 |   test-integration:
15 |     name: Test Integration
16 |     runs-on: ubuntu-latest
17 |     steps:
18 |       - name: Checkout code
19 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
20 |         with:
21 |           persist-credentials: false
22 | 
23 |       # Start the Grafana server.
24 |       # Do this early so that it can start up in time for the tests to run.
25 |       # We may need to add a wait here.
26 |       - name: Start docker-compose services
27 |         uses: hoverkraft-tech/compose-action@ccd64b05f85e42d4fa426d34ecb5884c99537eb4
28 |         with:
29 |           compose-file: "docker-compose.yaml"
30 | 
31 |       - name: Set up Go
32 |         uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
33 |         with:
34 |           go-version: "1.24"
35 |           cache: true
36 | 
37 |       - name: Wait for Grafana server and Prometheus server to start and scrape
38 |         run: sleep 30
39 | 
40 |       - name: Run integration tests
41 |         run: make test-integration
42 | 
43 |   test-cloud:
44 |     name: Test Cloud
45 |     if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
46 |     runs-on: ubuntu-latest
47 |     permissions:
48 |       id-token: write
49 |       contents: read
50 |     steps:
51 |       - name: Checkout code
52 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
53 |         with:
54 |           persist-credentials: false
55 | 
56 |       - name: Set up Go
57 |         uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
58 |         with:
59 |           go-version: "1.24"
60 |           cache: true
61 | 
62 |       - id: get-secrets
63 |         uses: grafana/shared-workflows/actions/get-vault-secrets@5d7e361bc7e0a183cde8afe9899fb7b596d2659b # get-vault-secrets-v1.2.0
64 |         with:
65 |           # Secrets placed in the ci/repo/grafana/mcp-grafana/<path> path in Vault
66 |           repo_secrets: |
67 |             GRAFANA_SERVICE_ACCOUNT_TOKEN=mcptests-grafana:api-key
68 |             ASSERTS_GRAFANA_SERVICE_ACCOUNT_TOKEN=dev-grafana:api-key
69 | 
70 |       - name: Run cloud tests
71 |         env:
72 |           GRAFANA_URL: ${{ vars.CLOUD_GRAFANA_URL }}
73 |           ASSERTS_GRAFANA_URL: ${{ vars.ASSERTS_GRAFANA_URL }}
74 |         run: make test-cloud
75 | 
```

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

```yaml
 1 | name: E2E Tests
 2 | 
 3 | on:
 4 |   push:
 5 |     branches: [main]
 6 |   pull_request:
 7 |   schedule:
 8 |     - cron: "0 0 * * *"
 9 | 
10 | permissions:
11 |   contents: read
12 | 
13 | jobs:
14 |   test-python-e2e:
15 |     name: Python E2E Tests (${{ matrix.transport }})
16 |     if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
17 |     runs-on: ubuntu-latest
18 |     strategy:
19 |       matrix:
20 |         transport: [stdio, sse, streamable-http]
21 |     permissions:
22 |       id-token: write
23 |       contents: read
24 |     env:
25 |       # Set auth here so stdio transport and background process pick them up
26 |       GRAFANA_USERNAME: admin
27 |       GRAFANA_PASSWORD: admin
28 |     steps:
29 |       - name: Checkout code
30 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
31 | 
32 |       - name: Install uv
33 |         uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
34 | 
35 |       - name: Set up Go
36 |         uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
37 |         with:
38 |           go-version: "1.24"
39 |           cache: true
40 | 
41 |       - name: Install Python dependencies
42 |         run: |
43 |           cd tests
44 |           uv sync --all-groups
45 | 
46 |       - id: get-secrets
47 |         uses: grafana/shared-workflows/actions/get-vault-secrets@5d7e361bc7e0a183cde8afe9899fb7b596d2659b # get-vault-secrets-v1.2.0
48 |         with:
49 |           # Secrets placed in the ci/repo/grafana/mcp-grafana/<path> path in Vault
50 |           repo_secrets: |
51 |             ANTHROPIC_API_KEY=anthropic:api-key
52 |             OPENAI_API_KEY=openai:api-key
53 | 
54 |       - name: Start docker-compose services
55 |         uses: hoverkraft-tech/compose-action@ccd64b05f85e42d4fa426d34ecb5884c99537eb4
56 |         with:
57 |           compose-file: "docker-compose.yaml"
58 | 
59 |       - name: Wait for Grafana server and Prometheus server to start and scrape
60 |         run: sleep 30
61 | 
62 |       - name: Build mcp-grafana
63 |         run: go build -o dist/mcp-grafana ./cmd/mcp-grafana
64 | 
65 |       - name: Start MCP server in background
66 |         if: matrix.transport != 'stdio'
67 |         run: nohup ./dist/mcp-grafana -t ${{ matrix.transport }} > mcp.log 2>&1 &
68 | 
69 |       - name: Run Python e2e tests
70 |         env:
71 |           MCP_GRAFANA_PATH: ../dist/mcp-grafana
72 |           MCP_TRANSPORT: ${{ matrix.transport }}
73 |         run: |
74 |           cd tests
75 |           uv run pytest
76 | 
77 |       - if: failure() && matrix.transport != 'stdio'
78 |         name: Print MCP logs
79 |         run: cat mcp.log
80 | 
```

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

```go
 1 | //go:build unit
 2 | // +build unit
 3 | 
 4 | package tools
 5 | 
 6 | import (
 7 | 	"context"
 8 | 	"testing"
 9 | 
10 | 	mcpgrafana "github.com/grafana/mcp-grafana"
11 | 	"github.com/stretchr/testify/assert"
12 | 	"github.com/stretchr/testify/require"
13 | )
14 | 
15 | func TestAdminToolsUnit(t *testing.T) {
16 | 	t.Run("tool definitions", func(t *testing.T) {
17 | 		// Test that the tools are properly defined with correct metadata
18 | 		require.NotNil(t, ListUsersByOrg, "ListUsersByOrg tool should be defined")
19 | 		require.NotNil(t, ListTeams, "ListTeams tool should be defined")
20 | 
21 | 		// Verify tool metadata
22 | 		assert.Equal(t, "list_users_by_org", ListUsersByOrg.Tool.Name)
23 | 		assert.Equal(t, "list_teams", ListTeams.Tool.Name)
24 | 		assert.Contains(t, ListUsersByOrg.Tool.Description, "List users by organization")
25 | 		assert.Contains(t, ListTeams.Tool.Description, "Search for Grafana teams")
26 | 	})
27 | 
28 | 	t.Run("parameter structures", func(t *testing.T) {
29 | 		// Test parameter types are correctly defined
30 | 		userParams := ListUsersByOrgParams{}
31 | 		teamParams := ListTeamsParams{Query: "test-query"}
32 | 
33 | 		// ListUsersByOrgParams should be an empty struct (no parameters required)
34 | 		assert.IsType(t, ListUsersByOrgParams{}, userParams)
35 | 
36 | 		// ListTeamsParams should have a Query field
37 | 		assert.Equal(t, "test-query", teamParams.Query)
38 | 	})
39 | 
40 | 	t.Run("nil client handling", func(t *testing.T) {
41 | 		// Test that functions handle missing client gracefully
42 | 		ctx := context.Background() // No client in context
43 | 
44 | 		// Both functions should return nil when client is not available
45 | 		// (they will panic on nil pointer dereference, which is the current behavior)
46 | 		assert.Panics(t, func() {
47 | 			listUsersByOrg(ctx, ListUsersByOrgParams{})
48 | 		}, "Should panic when no Grafana client in context")
49 | 
50 | 		assert.Panics(t, func() {
51 | 			listTeams(ctx, ListTeamsParams{})
52 | 		}, "Should panic when no Grafana client in context")
53 | 	})
54 | 
55 | 	t.Run("function signatures", func(t *testing.T) {
56 | 		// Verify that function signatures follow the expected pattern
57 | 		// This test ensures the API migration was done correctly
58 | 
59 | 		// Create context with configuration but no client
60 | 		ctx := mcpgrafana.WithGrafanaConfig(context.Background(), mcpgrafana.GrafanaConfig{
61 | 			URL:    "http://test.grafana.com",
62 | 			APIKey: "test-key",
63 | 		})
64 | 
65 | 		// Test that both functions can be called with correct parameter types
66 | 		// They will fail due to no client, but this validates the signature
67 | 		assert.Panics(t, func() {
68 | 			listUsersByOrg(ctx, ListUsersByOrgParams{})
69 | 		})
70 | 
71 | 		assert.Panics(t, func() {
72 | 			listTeams(ctx, ListTeamsParams{Query: "test"})
73 | 		})
74 | 	})
75 | }
76 | 
```

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

```go
  1 | // Requires a Grafana instance running on localhost:3000,
  2 | // Run with `go test -tags integration`.
  3 | //go:build integration
  4 | 
  5 | package tools
  6 | 
  7 | import (
  8 | 	"testing"
  9 | 	"time"
 10 | 
 11 | 	"github.com/stretchr/testify/assert"
 12 | 	"github.com/stretchr/testify/require"
 13 | )
 14 | 
 15 | func TestAnnotationTools(t *testing.T) {
 16 | 	ctx := newTestContext()
 17 | 
 18 | 	// get existing provisioned dashboard.
 19 | 	orig := getExistingTestDashboard(t, ctx, "")
 20 | 	origMap := getTestDashboardJSON(t, ctx, orig)
 21 | 
 22 | 	// remove identifiers so grafana treats it as a new dashboard
 23 | 	delete(origMap, "uid")
 24 | 	delete(origMap, "id")
 25 | 	origMap["title"] = "Integration Test for Annotations"
 26 | 
 27 | 	// create new dashboard.
 28 | 	result, err := updateDashboard(ctx, UpdateDashboardParams{
 29 | 		Dashboard: origMap,
 30 | 		Message:   "creating new dashboard for Annotations Tool Test",
 31 | 		Overwrite: false,
 32 | 		UserID:    1,
 33 | 	})
 34 | 
 35 | 	require.NoError(t, err)
 36 | 
 37 | 	// new UID for the test dashboard.
 38 | 	newUID := result.UID
 39 | 
 40 | 	// create, update and patch.
 41 | 	t.Run("create, update and patch annotation", func(t *testing.T) {
 42 | 		// 1. create annotation.
 43 | 		created, err := createAnnotation(ctx, CreateAnnotationInput{
 44 | 			DashboardUID: *newUID,
 45 | 			Time:         time.Now().UnixMilli(),
 46 | 			Text:         "integration-test-update-initial",
 47 | 			Tags:         []string{"init"},
 48 | 		})
 49 | 		require.NoError(t, err)
 50 | 		require.NotNil(t, created)
 51 | 
 52 | 		id := created.Payload.ID // *int64
 53 | 
 54 | 		// 2. update annotation (PUT).
 55 | 		_, err = updateAnnotation(ctx, UpdateAnnotationInput{
 56 | 			ID:   *id,
 57 | 			Time: time.Now().UnixMilli(),
 58 | 			Text: "integration-test-updated",
 59 | 			Tags: []string{"updated"},
 60 | 		})
 61 | 		require.NoError(t, err)
 62 | 
 63 | 		// 3. patch annotation (PATCH).
 64 | 		newText := "patched"
 65 | 		_, err = patchAnnotation(ctx, PatchAnnotationInput{
 66 | 			ID:   *id,
 67 | 			Text: &newText,
 68 | 		})
 69 | 		require.NoError(t, err)
 70 | 	})
 71 | 
 72 | 	// create graphite annotation.
 73 | 	t.Run("create graphite annotation", func(t *testing.T) {
 74 | 		resp, err := createAnnotationGraphiteFormat(ctx, CreateGraphiteAnnotationInput{
 75 | 			What: "integration-test-graphite",
 76 | 			When: time.Now().UnixMilli(),
 77 | 			Tags: []string{"mcp", "graphite"},
 78 | 		})
 79 | 		require.NoError(t, err)
 80 | 		require.NotNil(t, resp)
 81 | 	})
 82 | 
 83 | 	// list all annotations.
 84 | 	t.Run("list annotations", func(t *testing.T) {
 85 | 		limit := int64(1)
 86 | 		out, err := getAnnotations(ctx, GetAnnotationsInput{
 87 | 			DashboardUID: newUID,
 88 | 			Limit:        &limit,
 89 | 		})
 90 | 		require.NoError(t, err)
 91 | 		assert.NotNil(t, out)
 92 | 	})
 93 | 
 94 | 	// list all tags.
 95 | 	t.Run("list annotation tags", func(t *testing.T) {
 96 | 		out, err := getAnnotationTags(ctx, GetAnnotationTagsInput{})
 97 | 		require.NoError(t, err)
 98 | 		assert.NotNil(t, out)
 99 | 	})
100 | }
101 | 
```

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

```go
 1 | //go:build integration
 2 | 
 3 | package tools
 4 | 
 5 | import (
 6 | 	"testing"
 7 | 
 8 | 	"github.com/stretchr/testify/assert"
 9 | 	"github.com/stretchr/testify/require"
10 | )
11 | 
12 | func TestLokiTools(t *testing.T) {
13 | 	t.Run("list loki label names", func(t *testing.T) {
14 | 		ctx := newTestContext()
15 | 		result, err := listLokiLabelNames(ctx, ListLokiLabelNamesParams{
16 | 			DatasourceUID: "loki",
17 | 		})
18 | 		require.NoError(t, err)
19 | 		assert.Len(t, result, 1)
20 | 	})
21 | 
22 | 	t.Run("get loki label values", func(t *testing.T) {
23 | 		ctx := newTestContext()
24 | 		result, err := listLokiLabelValues(ctx, ListLokiLabelValuesParams{
25 | 			DatasourceUID: "loki",
26 | 			LabelName:     "container",
27 | 		})
28 | 		require.NoError(t, err)
29 | 		assert.NotEmpty(t, result, "Should have at least one container label value")
30 | 	})
31 | 
32 | 	t.Run("query loki stats", func(t *testing.T) {
33 | 		ctx := newTestContext()
34 | 		result, err := queryLokiStats(ctx, QueryLokiStatsParams{
35 | 			DatasourceUID: "loki",
36 | 			LogQL:         `{container="grafana"}`,
37 | 		})
38 | 		require.NoError(t, err)
39 | 		assert.NotNil(t, result, "Should return a result")
40 | 
41 | 		// We can't assert on specific values as they will vary,
42 | 		// but we can check that the structure is correct
43 | 		assert.GreaterOrEqual(t, result.Streams, 0, "Should have a valid streams count")
44 | 		assert.GreaterOrEqual(t, result.Chunks, 0, "Should have a valid chunks count")
45 | 		assert.GreaterOrEqual(t, result.Entries, 0, "Should have a valid entries count")
46 | 		assert.GreaterOrEqual(t, result.Bytes, 0, "Should have a valid bytes count")
47 | 	})
48 | 
49 | 	t.Run("query loki logs", func(t *testing.T) {
50 | 		ctx := newTestContext()
51 | 		result, err := queryLokiLogs(ctx, QueryLokiLogsParams{
52 | 			DatasourceUID: "loki",
53 | 			LogQL:         `{container=~".+"}`,
54 | 			Limit:         10,
55 | 		})
56 | 		require.NoError(t, err)
57 | 
58 | 		// We can't assert on specific log content as it will vary,
59 | 		// but we can check that the structure is correct
60 | 		// If we got logs, check that they have the expected structure
61 | 		for _, entry := range result {
62 | 			assert.NotEmpty(t, entry.Timestamp, "Log entry should have a timestamp")
63 | 			assert.NotNil(t, entry.Labels, "Log entry should have labels")
64 | 		}
65 | 	})
66 | 
67 | 	t.Run("query loki logs with no results", func(t *testing.T) {
68 | 		ctx := newTestContext()
69 | 		// Use a query that's unlikely to match any logs
70 | 		result, err := queryLokiLogs(ctx, QueryLokiLogsParams{
71 | 			DatasourceUID: "loki",
72 | 			LogQL:         `{container="non-existent-container-name-123456789"}`,
73 | 			Limit:         10,
74 | 		})
75 | 		require.NoError(t, err)
76 | 
77 | 		// Should return an empty slice, not nil
78 | 		assert.NotNil(t, result, "Empty results should be an empty slice, not nil")
79 | 		assert.Equal(t, 0, len(result), "Empty results should have length 0")
80 | 	})
81 | }
82 | 
```

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

```go
 1 | //go:build integration
 2 | 
 3 | package tools
 4 | 
 5 | import (
 6 | 	"testing"
 7 | 
 8 | 	"github.com/stretchr/testify/require"
 9 | )
10 | 
11 | func TestPyroscopeTools(t *testing.T) {
12 | 	t.Run("list Pyroscope label names", func(t *testing.T) {
13 | 		ctx := newTestContext()
14 | 		names, err := listPyroscopeLabelNames(ctx, ListPyroscopeLabelNamesParams{
15 | 			DataSourceUID: "pyroscope",
16 | 			Matchers:      `{service_name="pyroscope"}`,
17 | 		})
18 | 		require.NoError(t, err)
19 | 		require.ElementsMatch(t, names, []string{
20 | 			"__name__",
21 | 			"__period_type__",
22 | 			"__period_unit__",
23 | 			"__profile_type__",
24 | 			"__service_name__",
25 | 			"__type__",
26 | 			"__unit__",
27 | 			"hostname",
28 | 			"pyroscope_spy",
29 | 			"service_git_ref",
30 | 			"service_name",
31 | 			"service_repository",
32 | 			"target",
33 | 		})
34 | 	})
35 | 
36 | 	t.Run("get Pyroscope label values", func(t *testing.T) {
37 | 		ctx := newTestContext()
38 | 		values, err := listPyroscopeLabelValues(ctx, ListPyroscopeLabelValuesParams{
39 | 			DataSourceUID: "pyroscope",
40 | 			Name:          "target",
41 | 			Matchers:      `{service_name="pyroscope"}`,
42 | 		})
43 | 		require.NoError(t, err)
44 | 		require.ElementsMatch(t, values, []string{"all"})
45 | 	})
46 | 
47 | 	t.Run("get Pyroscope profile types", func(t *testing.T) {
48 | 		ctx := newTestContext()
49 | 		types, err := listPyroscopeProfileTypes(ctx, ListPyroscopeProfileTypesParams{
50 | 			DataSourceUID: "pyroscope",
51 | 		})
52 | 		require.NoError(t, err)
53 | 		require.ElementsMatch(t, types, []string{
54 | 			"block:contentions:count:contentions:count",
55 | 			"block:delay:nanoseconds:contentions:count",
56 | 			"goroutines:goroutine:count:goroutine:count",
57 | 			"memory:alloc_objects:count:space:bytes",
58 | 			"memory:alloc_space:bytes:space:bytes",
59 | 			"memory:inuse_objects:count:space:bytes",
60 | 			"memory:inuse_space:bytes:space:bytes",
61 | 			"mutex:contentions:count:contentions:count",
62 | 			"mutex:delay:nanoseconds:contentions:count",
63 | 			"process_cpu:cpu:nanoseconds:cpu:nanoseconds",
64 | 			"process_cpu:samples:count:cpu:nanoseconds",
65 | 		})
66 | 	})
67 | 
68 | 	t.Run("fetch Pyroscope profile", func(t *testing.T) {
69 | 		ctx := newTestContext()
70 | 		profile, err := fetchPyroscopeProfile(ctx, FetchPyroscopeProfileParams{
71 | 			DataSourceUID: "pyroscope",
72 | 			ProfileType:   "process_cpu:cpu:nanoseconds:cpu:nanoseconds",
73 | 			Matchers:      `{service_name="pyroscope"}`,
74 | 		})
75 | 		require.NoError(t, err)
76 | 		require.NotEmpty(t, profile)
77 | 	})
78 | 
79 | 	t.Run("fetch empty Pyroscope profile", func(t *testing.T) {
80 | 		ctx := newTestContext()
81 | 		_, err := fetchPyroscopeProfile(ctx, FetchPyroscopeProfileParams{
82 | 			DataSourceUID: "pyroscope",
83 | 			ProfileType:   "process_cpu:cpu:nanoseconds:cpu:nanoseconds",
84 | 			Matchers:      `{service_name="pyroscope", label_does_not_exit="missing"}`,
85 | 		})
86 | 		require.EqualError(t, err, "failed to call Pyroscope API: pyroscope API returned a empty profile")
87 | 	})
88 | }
89 | 
```

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

```go
 1 | package mcpgrafana
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"fmt"
 6 | 
 7 | 	"github.com/mark3labs/mcp-go/mcp"
 8 | 	"github.com/mark3labs/mcp-go/server"
 9 | )
10 | 
11 | // ProxiedToolHandler implements the CallToolHandler interface for proxied tools
12 | type ProxiedToolHandler struct {
13 | 	sessionManager *SessionManager
14 | 	toolManager    *ToolManager
15 | 	toolName       string
16 | }
17 | 
18 | // NewProxiedToolHandler creates a new handler for a proxied tool
19 | func NewProxiedToolHandler(sm *SessionManager, tm *ToolManager, toolName string) *ProxiedToolHandler {
20 | 	return &ProxiedToolHandler{
21 | 		sessionManager: sm,
22 | 		toolManager:    tm,
23 | 		toolName:       toolName,
24 | 	}
25 | }
26 | 
27 | // Handle forwards the tool call to the appropriate remote MCP server
28 | func (h *ProxiedToolHandler) Handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
29 | 	// Check if session is in context
30 | 	session := server.ClientSessionFromContext(ctx)
31 | 	if session == nil {
32 | 		return nil, fmt.Errorf("session not found in context")
33 | 	}
34 | 
35 | 	// Extract arguments
36 | 	args, ok := request.Params.Arguments.(map[string]any)
37 | 	if !ok {
38 | 		return nil, fmt.Errorf("invalid arguments type")
39 | 	}
40 | 
41 | 	// Extract required datasourceUid parameter
42 | 	datasourceUidRaw, ok := args["datasourceUid"]
43 | 	if !ok {
44 | 		return nil, fmt.Errorf("datasourceUid parameter is required")
45 | 	}
46 | 	datasourceUID, ok := datasourceUidRaw.(string)
47 | 	if !ok {
48 | 		return nil, fmt.Errorf("datasourceUid must be a string")
49 | 	}
50 | 
51 | 	// Parse the tool name to get datasource type and original tool name
52 | 	// Format: datasourceType_originalToolName (e.g., "tempo_traceql-search")
53 | 	datasourceType, originalToolName, err := parseProxiedToolName(h.toolName)
54 | 	if err != nil {
55 | 		return nil, fmt.Errorf("failed to parse tool name: %w", err)
56 | 	}
57 | 
58 | 	// Get the proxied client for this datasource
59 | 	var client *ProxiedClient
60 | 
61 | 	if h.toolManager.serverMode {
62 | 		// Server mode (stdio): clients stored at manager level
63 | 		client, err = h.toolManager.GetServerClient(datasourceType, datasourceUID)
64 | 	} else {
65 | 		// Session mode (HTTP/SSE): clients stored per-session
66 | 		client, err = h.sessionManager.GetProxiedClient(ctx, datasourceType, datasourceUID)
67 | 		if err != nil {
68 | 			// Fallback to server-level in case of mixed mode
69 | 			client, err = h.toolManager.GetServerClient(datasourceType, datasourceUID)
70 | 		}
71 | 	}
72 | 
73 | 	if err != nil {
74 | 		return nil, fmt.Errorf("datasource '%s' not found or not accessible. Ensure the datasource exists and you have permission to access it", datasourceUID)
75 | 	}
76 | 
77 | 	// Remove datasourceUid from args before forwarding to remote server
78 | 	forwardArgs := make(map[string]any)
79 | 	for k, v := range args {
80 | 		if k != "datasourceUid" {
81 | 			forwardArgs[k] = v
82 | 		}
83 | 	}
84 | 
85 | 	// Forward the call to the remote MCP server
86 | 	return client.CallTool(ctx, originalToolName, forwardArgs)
87 | }
88 | 
```

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

```go
  1 | package tools
  2 | 
  3 | import (
  4 | 	"testing"
  5 | 	"time"
  6 | 
  7 | 	"github.com/stretchr/testify/assert"
  8 | 	"github.com/stretchr/testify/require"
  9 | )
 10 | 
 11 | func TestParseRelativeTime(t *testing.T) {
 12 | 	const day = 24 * time.Hour
 13 | 	const week = 7 * day
 14 | 
 15 | 	testCases := []struct {
 16 | 		name          string
 17 | 		input         string
 18 | 		expectedError bool
 19 | 		expectedDelta time.Duration // Expected time difference from now
 20 | 		isMonthCase   bool          // Special handling for month arithmetic
 21 | 		isYearCase    bool          // Special handling for year arithmetic
 22 | 	}{
 23 | 		{
 24 | 			name:          "now",
 25 | 			input:         "now",
 26 | 			expectedError: false,
 27 | 			expectedDelta: 0,
 28 | 		},
 29 | 		{
 30 | 			name:          "now-1h",
 31 | 			input:         "now-1h",
 32 | 			expectedError: false,
 33 | 			expectedDelta: -1 * time.Hour,
 34 | 		},
 35 | 		{
 36 | 			name:          "now-30m",
 37 | 			input:         "now-30m",
 38 | 			expectedError: false,
 39 | 			expectedDelta: -30 * time.Minute,
 40 | 		},
 41 | 		{
 42 | 			name:          "now-1d",
 43 | 			input:         "now-1d",
 44 | 			expectedError: false,
 45 | 			expectedDelta: -24 * time.Hour,
 46 | 		},
 47 | 		{
 48 | 			name:          "now-1w",
 49 | 			input:         "now-1w",
 50 | 			expectedError: false,
 51 | 			expectedDelta: -week,
 52 | 		},
 53 | 		{
 54 | 			name:          "now-1M",
 55 | 			input:         "now-1M",
 56 | 			expectedError: false,
 57 | 			isMonthCase:   true,
 58 | 		},
 59 | 		{
 60 | 			name:          "now-1y",
 61 | 			input:         "now-1y",
 62 | 			expectedError: false,
 63 | 			isYearCase:    true,
 64 | 		},
 65 | 		{
 66 | 			name:          "now-1.5h",
 67 | 			input:         "now-1.5h",
 68 | 			expectedError: true,
 69 | 		},
 70 | 		{
 71 | 			name:          "invalid format",
 72 | 			input:         "yesterday",
 73 | 			expectedError: true,
 74 | 		},
 75 | 		{
 76 | 			name:          "empty string",
 77 | 			input:         "",
 78 | 			expectedError: true,
 79 | 		},
 80 | 	}
 81 | 
 82 | 	for _, tc := range testCases {
 83 | 		t.Run(tc.name, func(t *testing.T) {
 84 | 			now := time.Now()
 85 | 			result, err := parseTime(tc.input)
 86 | 
 87 | 			if tc.expectedError {
 88 | 				assert.Error(t, err)
 89 | 				return
 90 | 			}
 91 | 
 92 | 			require.NoError(t, err)
 93 | 
 94 | 			if tc.input == "now" {
 95 | 				// For "now", the result should be very close to the current time
 96 | 				// Allow a small tolerance for execution time
 97 | 				diff := result.Sub(now)
 98 | 				assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
 99 | 			} else if tc.isMonthCase {
100 | 				// For month calculations, use proper calendar arithmetic
101 | 				expected := now.AddDate(0, -1, 0)
102 | 				diff := result.Sub(expected)
103 | 				assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
104 | 			} else if tc.isYearCase {
105 | 				// For year calculations, use proper calendar arithmetic
106 | 				expected := now.AddDate(-1, 0, 0)
107 | 				diff := result.Sub(expected)
108 | 				assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
109 | 			} else {
110 | 				// For other relative times, compare with the expected delta from now
111 | 				expected := now.Add(tc.expectedDelta)
112 | 				diff := result.Sub(expected)
113 | 				assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
114 | 			}
115 | 		})
116 | 	}
117 | }
118 | 
```

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

```json
  1 | {
  2 |   "annotations": {
  3 |     "list": [
  4 |       {
  5 |         "builtIn": 1,
  6 |         "datasource": {
  7 |           "type": "grafana",
  8 |           "uid": "-- Grafana --"
  9 |         },
 10 |         "enable": true,
 11 |         "hide": true,
 12 |         "iconColor": "rgba(0, 211, 255, 1)",
 13 |         "name": "Annotations & Alerts",
 14 |         "type": "dashboard"
 15 |       }
 16 |     ]
 17 |   },
 18 |   "editable": true,
 19 |   "fiscalYearStartMonth": 0,
 20 |   "graphTooltip": 0,
 21 |   "id": 1,
 22 |   "isStarred": true,
 23 |   "links": [],
 24 |   "panels": [
 25 |     {
 26 |       "datasource": {
 27 |         "default": true,
 28 |         "type": "prometheus",
 29 |         "uid": "robustperception"
 30 |       },
 31 |       "fieldConfig": {
 32 |         "defaults": {
 33 |           "color": {
 34 |             "mode": "palette-classic"
 35 |           },
 36 |           "custom": {
 37 |             "axisBorderShow": false,
 38 |             "axisCenteredZero": false,
 39 |             "axisColorMode": "text",
 40 |             "axisLabel": "",
 41 |             "axisPlacement": "auto",
 42 |             "barAlignment": 0,
 43 |             "barWidthFactor": 0.6,
 44 |             "drawStyle": "line",
 45 |             "fillOpacity": 0,
 46 |             "gradientMode": "none",
 47 |             "hideFrom": {
 48 |               "legend": false,
 49 |               "tooltip": false,
 50 |               "viz": false
 51 |             },
 52 |             "insertNulls": false,
 53 |             "lineInterpolation": "linear",
 54 |             "lineWidth": 1,
 55 |             "pointSize": 5,
 56 |             "scaleDistribution": {
 57 |               "type": "linear"
 58 |             },
 59 |             "showPoints": "auto",
 60 |             "spanNulls": false,
 61 |             "stacking": {
 62 |               "group": "A",
 63 |               "mode": "none"
 64 |             },
 65 |             "thresholdsStyle": {
 66 |               "mode": "off"
 67 |             }
 68 |           },
 69 |           "mappings": [],
 70 |           "thresholds": {
 71 |             "mode": "absolute",
 72 |             "steps": [
 73 |               {
 74 |                 "color": "green",
 75 |                 "value": null
 76 |               },
 77 |               {
 78 |                 "color": "red",
 79 |                 "value": 80
 80 |               }
 81 |             ]
 82 |           }
 83 |         },
 84 |         "overrides": []
 85 |       },
 86 |       "gridPos": {
 87 |         "h": 8,
 88 |         "w": 12,
 89 |         "x": 0,
 90 |         "y": 0
 91 |       },
 92 |       "id": 1,
 93 |       "options": {
 94 |         "legend": {
 95 |           "calcs": [],
 96 |           "displayMode": "list",
 97 |           "placement": "bottom",
 98 |           "showLegend": true
 99 |         },
100 |         "tooltip": {
101 |           "mode": "single",
102 |           "sort": "none"
103 |         }
104 |       },
105 |       "targets": [
106 |         {
107 |           "datasource": {
108 |             "type": "prometheus",
109 |             "uid": "robustperception"
110 |           },
111 |           "editorMode": "code",
112 |           "expr": "node_load1",
113 |           "instant": false,
114 |           "legendFormat": "__auto",
115 |           "range": true,
116 |           "refId": "A"
117 |         }
118 |       ],
119 |       "title": "Node Load",
120 |       "type": "timeseries"
121 |     }
122 |   ],
123 |   "schemaVersion": 39,
124 |   "tags": [
125 |     "demo"
126 |   ],
127 |   "templating": {
128 |     "list": []
129 |   },
130 |   "time": {
131 |     "from": "now-6h",
132 |     "to": "now"
133 |   },
134 |   "timepicker": {},
135 |   "timezone": "browser",
136 |   "title": "Demo",
137 |   "uid": "fe9gm6guyzi0wd",
138 |   "version": 2,
139 |   "weekStart": ""
140 | }
141 | 
```

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

```yaml
 1 | name: Build and Push Docker Image
 2 | 
 3 | on:
 4 |   push:
 5 |     tags:
 6 |       - "v*.*.*"
 7 |   release:
 8 |     types: [published]
 9 | 
10 | permissions:
11 |   contents: read
12 | 
13 | jobs:
14 |   docker:
15 |     runs-on: ubuntu-latest
16 |     permissions:
17 |       id-token: write
18 |     steps:
19 |       - uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493
20 | 
21 |       - name: Process tag name
22 |         id: tag
23 |         run: |
24 |           VERSION=${GITHUB_REF_NAME#v}
25 |           echo "version=$VERSION" >> $GITHUB_OUTPUT
26 |           # Check if this is a stable release (no rc, alpha, beta, etc.)
27 |           if [[ ! "$VERSION" =~ (rc|alpha|beta|pre|dev) ]]; then
28 |             echo "is_stable=true" >> $GITHUB_OUTPUT
29 |           else
30 |             echo "is_stable=false" >> $GITHUB_OUTPUT
31 |           fi
32 | 
33 |       - name: Build and Push to Docker Hub
34 |         uses: grafana/shared-workflows/actions/build-push-to-dockerhub@60fadd1458bb20b97f00618568c22ed1c7d485bd
35 |         with:
36 |           context: .
37 |           file: ./Dockerfile
38 |           repository: grafana/mcp-grafana
39 |           platforms: linux/amd64,linux/arm64
40 |           tags: |
41 |             ${{ steps.tag.outputs.is_stable == 'true' && 'latest' || '' }}
42 |             ${{ steps.tag.outputs.version }}
43 |           push: true
44 | 
45 |   mcp-registry:
46 |     runs-on: ubuntu-latest
47 |     needs: docker
48 |     permissions:
49 |       contents: read
50 |       id-token: write
51 |     steps:
52 |       - name: Checkout code
53 |         uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
54 |         with:
55 |           fetch-depth: 0
56 | 
57 |       - name: Extract version from tag
58 |         id: version
59 |         run: |
60 |           # Get the tag from the triggering workflow
61 |           TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "")
62 |           if [ -z "$TAG" ]; then
63 |             echo "No tag found at HEAD"
64 |             exit 1
65 |           fi
66 |           echo "VERSION=$TAG" >> $GITHUB_OUTPUT
67 | 
68 |       - name: Extract image tag from version
69 |         id: image-tag
70 |         run: |
71 |           # Extract the image tag from the version
72 |           VERSION="${{ steps.version.outputs.VERSION }}"
73 |           echo "IMAGE_TAG=${VERSION#v}" >> $GITHUB_OUTPUT
74 | 
75 |       - name: Install dependencies
76 |         run: |
77 |           sudo apt-get update && sudo apt-get install -y jq
78 |           curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.3.0/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher
79 |           chmod +x mcp-publisher
80 |           sudo mv mcp-publisher /usr/local/bin/
81 | 
82 |       - name: Update server.json with Docker image
83 |         run: |
84 |           # Update the server.json with the correct Docker image reference
85 |           # (note the image tag does not include the "v" prefix)
86 |           jq --arg version "${{ steps.version.outputs.VERSION }}" \
87 |             --arg image "docker.io/grafana/mcp-grafana:${{ steps.image-tag.outputs.IMAGE_TAG }}" \
88 |              '.version = $version | .packages[0].identifier = $image' server.json > server.json.tmp
89 |           mv server.json.tmp server.json
90 | 
91 |       - name: Login to MCP Registry
92 |         run: mcp-publisher login github-oidc
93 | 
94 |       - name: Publish to MCP Registry
95 |         run: mcp-publisher publish
96 | 
```

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

```go
  1 | package mcpgrafana
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"log/slog"
  7 | 	"sync"
  8 | 
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/mark3labs/mcp-go/server"
 11 | )
 12 | 
 13 | // SessionState holds the state for a single client session
 14 | type SessionState struct {
 15 | 	// Proxied tools state
 16 | 	initOnce                sync.Once
 17 | 	proxiedToolsInitialized bool
 18 | 	proxiedTools            []mcp.Tool
 19 | 	proxiedClients          map[string]*ProxiedClient // key: datasourceType_datasourceUID
 20 | 	toolToDatasources       map[string][]string       // key: toolName, value: list of datasource keys that support it
 21 | 	mutex                   sync.RWMutex
 22 | }
 23 | 
 24 | func newSessionState() *SessionState {
 25 | 	return &SessionState{
 26 | 		proxiedClients:    make(map[string]*ProxiedClient),
 27 | 		toolToDatasources: make(map[string][]string),
 28 | 	}
 29 | }
 30 | 
 31 | // SessionManager manages client sessions and their state
 32 | type SessionManager struct {
 33 | 	sessions map[string]*SessionState
 34 | 	mutex    sync.RWMutex
 35 | }
 36 | 
 37 | func NewSessionManager() *SessionManager {
 38 | 	return &SessionManager{
 39 | 		sessions: make(map[string]*SessionState),
 40 | 	}
 41 | }
 42 | 
 43 | func (sm *SessionManager) CreateSession(ctx context.Context, session server.ClientSession) {
 44 | 	sm.mutex.Lock()
 45 | 	defer sm.mutex.Unlock()
 46 | 
 47 | 	sessionID := session.SessionID()
 48 | 	if _, exists := sm.sessions[sessionID]; !exists {
 49 | 		sm.sessions[sessionID] = newSessionState()
 50 | 	}
 51 | }
 52 | 
 53 | func (sm *SessionManager) GetSession(sessionID string) (*SessionState, bool) {
 54 | 	sm.mutex.RLock()
 55 | 	defer sm.mutex.RUnlock()
 56 | 
 57 | 	session, exists := sm.sessions[sessionID]
 58 | 	return session, exists
 59 | }
 60 | 
 61 | func (sm *SessionManager) RemoveSession(ctx context.Context, session server.ClientSession) {
 62 | 	sm.mutex.Lock()
 63 | 	sessionID := session.SessionID()
 64 | 	state, exists := sm.sessions[sessionID]
 65 | 	delete(sm.sessions, sessionID)
 66 | 	sm.mutex.Unlock()
 67 | 
 68 | 	if !exists {
 69 | 		return
 70 | 	}
 71 | 
 72 | 	// Clean up proxied clients outside of the main lock
 73 | 	state.mutex.Lock()
 74 | 	defer state.mutex.Unlock()
 75 | 
 76 | 	for key, client := range state.proxiedClients {
 77 | 		if err := client.Close(); err != nil {
 78 | 			slog.Error("failed to close proxied client", "key", key, "error", err)
 79 | 		}
 80 | 	}
 81 | }
 82 | 
 83 | // GetProxiedClient retrieves a proxied client for the given datasource
 84 | func (sm *SessionManager) GetProxiedClient(ctx context.Context, datasourceType, datasourceUID string) (*ProxiedClient, error) {
 85 | 	session := server.ClientSessionFromContext(ctx)
 86 | 	if session == nil {
 87 | 		return nil, fmt.Errorf("session not found in context")
 88 | 	}
 89 | 
 90 | 	state, exists := sm.GetSession(session.SessionID())
 91 | 	if !exists {
 92 | 		return nil, fmt.Errorf("session not found")
 93 | 	}
 94 | 
 95 | 	state.mutex.RLock()
 96 | 	defer state.mutex.RUnlock()
 97 | 
 98 | 	key := datasourceType + "_" + datasourceUID
 99 | 	client, exists := state.proxiedClients[key]
100 | 	if !exists {
101 | 		// List available datasources to help with debugging
102 | 		var availableUIDs []string
103 | 		for _, c := range state.proxiedClients {
104 | 			if c.DatasourceType == datasourceType {
105 | 				availableUIDs = append(availableUIDs, c.DatasourceUID)
106 | 			}
107 | 		}
108 | 		if len(availableUIDs) > 0 {
109 | 			return nil, fmt.Errorf("datasource '%s' not found. Available %s datasources: %v", datasourceUID, datasourceType, availableUIDs)
110 | 		}
111 | 		return nil, fmt.Errorf("datasource '%s' not found. No %s datasources with MCP support are configured", datasourceUID, datasourceType)
112 | 	}
113 | 
114 | 	return client, nil
115 | }
116 | 
```

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

```go
  1 | package tools
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/json"
  6 | 	"net/http"
  7 | 	"net/http/httptest"
  8 | 	"net/url"
  9 | 	"testing"
 10 | 
 11 | 	"github.com/prometheus/prometheus/model/labels"
 12 | 	"github.com/stretchr/testify/require"
 13 | 
 14 | 	mcpgrafana "github.com/grafana/mcp-grafana"
 15 | )
 16 | 
 17 | var (
 18 | 	fakeruleGroup = ruleGroup{
 19 | 		Name:      "TestGroup",
 20 | 		FolderUID: "test-folder",
 21 | 		Rules: []alertingRule{
 22 | 			{
 23 | 				State:     "firing",
 24 | 				Name:      "Test Alert Rule",
 25 | 				UID:       "test-rule-uid",
 26 | 				FolderUID: "test-folder",
 27 | 				Labels:    labels.New(labels.Label{Name: "severity", Value: "critical"}),
 28 | 				Alerts: []alert{
 29 | 					{
 30 | 						Labels:      labels.New(labels.Label{Name: "instance", Value: "test-instance"}),
 31 | 						Annotations: labels.New(labels.Label{Name: "summary", Value: "Test alert firing"}),
 32 | 						State:       "firing",
 33 | 						Value:       "1",
 34 | 					},
 35 | 				},
 36 | 			},
 37 | 		},
 38 | 	}
 39 | )
 40 | 
 41 | func setupMockServer(handler http.HandlerFunc) (*httptest.Server, *alertingClient) {
 42 | 	server := httptest.NewServer(handler)
 43 | 	baseURL, _ := url.Parse(server.URL)
 44 | 	client := &alertingClient{
 45 | 		baseURL:    baseURL,
 46 | 		apiKey:     "test-api-key",
 47 | 		httpClient: &http.Client{},
 48 | 	}
 49 | 	return server, client
 50 | }
 51 | 
 52 | func mockrulesResponse() rulesResponse {
 53 | 	resp := rulesResponse{}
 54 | 	resp.Data.RuleGroups = []ruleGroup{fakeruleGroup}
 55 | 	return resp
 56 | }
 57 | 
 58 | func TestAlertingClient_GetRules(t *testing.T) {
 59 | 	server, client := setupMockServer(func(w http.ResponseWriter, r *http.Request) {
 60 | 		require.Equal(t, "/api/prometheus/grafana/api/v1/rules", r.URL.Path)
 61 | 		require.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))
 62 | 
 63 | 		resp := mockrulesResponse()
 64 | 		w.Header().Set("Content-Type", "application/json")
 65 | 		w.WriteHeader(http.StatusOK)
 66 | 		err := json.NewEncoder(w).Encode(resp)
 67 | 		require.NoError(t, err)
 68 | 	})
 69 | 	defer server.Close()
 70 | 
 71 | 	rules, err := client.GetRules(context.Background())
 72 | 	require.NoError(t, err)
 73 | 	require.NotNil(t, rules)
 74 | 	require.ElementsMatch(t, rules.Data.RuleGroups, []ruleGroup{fakeruleGroup})
 75 | }
 76 | 
 77 | func TestAlertingClient_GetRules_Error(t *testing.T) {
 78 | 	t.Run("internal server error", func(t *testing.T) {
 79 | 		server, client := setupMockServer(func(w http.ResponseWriter, r *http.Request) {
 80 | 			w.WriteHeader(http.StatusInternalServerError)
 81 | 			_, err := w.Write([]byte("internal server error"))
 82 | 			require.NoError(t, err)
 83 | 		})
 84 | 		defer server.Close()
 85 | 
 86 | 		rules, err := client.GetRules(context.Background())
 87 | 		require.Error(t, err)
 88 | 		require.Nil(t, rules)
 89 | 		require.ErrorContains(t, err, "grafana API returned status code 500: internal server error")
 90 | 	})
 91 | 
 92 | 	t.Run("network error", func(t *testing.T) {
 93 | 		server, client := setupMockServer(func(w http.ResponseWriter, r *http.Request) {})
 94 | 		server.Close()
 95 | 
 96 | 		rules, err := client.GetRules(context.Background())
 97 | 
 98 | 		require.Error(t, err)
 99 | 		require.Nil(t, rules)
100 | 		require.ErrorContains(t, err, "failed to execute request")
101 | 	})
102 | }
103 | 
104 | func TestNewAlertingClientFromContext(t *testing.T) {
105 | 	config := mcpgrafana.GrafanaConfig{
106 | 		URL:    "http://localhost:3000/",
107 | 		APIKey: "test-api-key",
108 | 	}
109 | 	ctx := mcpgrafana.WithGrafanaConfig(context.Background(), config)
110 | 
111 | 	client, err := newAlertingClientFromContext(ctx)
112 | 	require.NoError(t, err)
113 | 
114 | 	require.Equal(t, "http://localhost:3000", client.baseURL.String())
115 | 	require.Equal(t, "test-api-key", client.apiKey)
116 | 	require.NotNil(t, client.httpClient)
117 | }
118 | 
```

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

```go
  1 | package tools
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"net/url"
  7 | 	"strings"
  8 | 
  9 | 	"github.com/mark3labs/mcp-go/mcp"
 10 | 	"github.com/mark3labs/mcp-go/server"
 11 | 
 12 | 	mcpgrafana "github.com/grafana/mcp-grafana"
 13 | )
 14 | 
 15 | type GenerateDeeplinkParams struct {
 16 | 	ResourceType  string            `json:"resourceType" jsonschema:"required,description=Type of resource: dashboard\\, panel\\, or explore"`
 17 | 	DashboardUID  *string           `json:"dashboardUid,omitempty" jsonschema:"description=Dashboard UID (required for dashboard and panel types)"`
 18 | 	DatasourceUID *string           `json:"datasourceUid,omitempty" jsonschema:"description=Datasource UID (required for explore type)"`
 19 | 	PanelID       *int              `json:"panelId,omitempty" jsonschema:"description=Panel ID (required for panel type)"`
 20 | 	QueryParams   map[string]string `json:"queryParams,omitempty" jsonschema:"description=Additional query parameters"`
 21 | 	TimeRange     *TimeRange        `json:"timeRange,omitempty" jsonschema:"description=Time range for the link"`
 22 | }
 23 | 
 24 | type TimeRange struct {
 25 | 	From string `json:"from" jsonschema:"description=Start time (e.g.\\, 'now-1h')"`
 26 | 	To   string `json:"to" jsonschema:"description=End time (e.g.\\, 'now')"`
 27 | }
 28 | 
 29 | func generateDeeplink(ctx context.Context, args GenerateDeeplinkParams) (string, error) {
 30 | 	config := mcpgrafana.GrafanaConfigFromContext(ctx)
 31 | 	baseURL := strings.TrimRight(config.URL, "/")
 32 | 
 33 | 	if baseURL == "" {
 34 | 		return "", fmt.Errorf("grafana url not configured. Please set GRAFANA_URL environment variable or X-Grafana-URL header")
 35 | 	}
 36 | 
 37 | 	var deeplink string
 38 | 
 39 | 	switch strings.ToLower(args.ResourceType) {
 40 | 	case "dashboard":
 41 | 		if args.DashboardUID == nil {
 42 | 			return "", fmt.Errorf("dashboardUid is required for dashboard links")
 43 | 		}
 44 | 		deeplink = fmt.Sprintf("%s/d/%s", baseURL, *args.DashboardUID)
 45 | 	case "panel":
 46 | 		if args.DashboardUID == nil {
 47 | 			return "", fmt.Errorf("dashboardUid is required for panel links")
 48 | 		}
 49 | 		if args.PanelID == nil {
 50 | 			return "", fmt.Errorf("panelId is required for panel links")
 51 | 		}
 52 | 		deeplink = fmt.Sprintf("%s/d/%s?viewPanel=%d", baseURL, *args.DashboardUID, *args.PanelID)
 53 | 	case "explore":
 54 | 		if args.DatasourceUID == nil {
 55 | 			return "", fmt.Errorf("datasourceUid is required for explore links")
 56 | 		}
 57 | 		params := url.Values{}
 58 | 		exploreState := fmt.Sprintf(`{"datasource":"%s"}`, *args.DatasourceUID)
 59 | 		params.Set("left", exploreState)
 60 | 		deeplink = fmt.Sprintf("%s/explore?%s", baseURL, params.Encode())
 61 | 	default:
 62 | 		return "", fmt.Errorf("unsupported resource type: %s. Supported types are: dashboard, panel, explore", args.ResourceType)
 63 | 	}
 64 | 
 65 | 	if args.TimeRange != nil {
 66 | 		separator := "?"
 67 | 		if strings.Contains(deeplink, "?") {
 68 | 			separator = "&"
 69 | 		}
 70 | 		timeParams := url.Values{}
 71 | 		if args.TimeRange.From != "" {
 72 | 			timeParams.Set("from", args.TimeRange.From)
 73 | 		}
 74 | 		if args.TimeRange.To != "" {
 75 | 			timeParams.Set("to", args.TimeRange.To)
 76 | 		}
 77 | 		if len(timeParams) > 0 {
 78 | 			deeplink = fmt.Sprintf("%s%s%s", deeplink, separator, timeParams.Encode())
 79 | 		}
 80 | 	}
 81 | 
 82 | 	if len(args.QueryParams) > 0 {
 83 | 		separator := "?"
 84 | 		if strings.Contains(deeplink, "?") {
 85 | 			separator = "&"
 86 | 		}
 87 | 		additionalParams := url.Values{}
 88 | 		for key, value := range args.QueryParams {
 89 | 			additionalParams.Set(key, value)
 90 | 		}
 91 | 		deeplink = fmt.Sprintf("%s%s%s", deeplink, separator, additionalParams.Encode())
 92 | 	}
 93 | 
 94 | 	return deeplink, nil
 95 | }
 96 | 
 97 | var GenerateDeeplink = mcpgrafana.MustTool(
 98 | 	"generate_deeplink",
 99 | 	"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.",
100 | 	generateDeeplink,
101 | 	mcp.WithTitleAnnotation("Generate navigation deeplink"),
102 | 	mcp.WithIdempotentHintAnnotation(true),
103 | 	mcp.WithReadOnlyHintAnnotation(true),
104 | )
105 | 
106 | func AddNavigationTools(mcp *server.MCPServer) {
107 | 	GenerateDeeplink.Register(mcp)
108 | }
109 | 
```

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

```go
  1 | package tools
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"testing"
  6 | 
  7 | 	"github.com/stretchr/testify/assert"
  8 | 	"github.com/stretchr/testify/require"
  9 | 
 10 | 	mcpgrafana "github.com/grafana/mcp-grafana"
 11 | )
 12 | 
 13 | // Helper function to create string pointers
 14 | func stringPtr(s string) *string {
 15 | 	return &s
 16 | }
 17 | 
 18 | func TestGenerateDeeplink(t *testing.T) {
 19 | 	grafanaCfg := mcpgrafana.GrafanaConfig{
 20 | 		URL: "http://localhost:3000",
 21 | 	}
 22 | 	ctx := mcpgrafana.WithGrafanaConfig(context.Background(), grafanaCfg)
 23 | 
 24 | 	t.Run("Dashboard deeplink", func(t *testing.T) {
 25 | 		params := GenerateDeeplinkParams{
 26 | 			ResourceType: "dashboard",
 27 | 			DashboardUID: stringPtr("abc123"),
 28 | 		}
 29 | 
 30 | 		result, err := generateDeeplink(ctx, params)
 31 | 		require.NoError(t, err)
 32 | 		assert.Equal(t, "http://localhost:3000/d/abc123", result)
 33 | 	})
 34 | 
 35 | 	t.Run("Panel deeplink", func(t *testing.T) {
 36 | 		panelID := 5
 37 | 		params := GenerateDeeplinkParams{
 38 | 			ResourceType: "panel",
 39 | 			DashboardUID: stringPtr("dash-123"),
 40 | 			PanelID:      &panelID,
 41 | 		}
 42 | 
 43 | 		result, err := generateDeeplink(ctx, params)
 44 | 		require.NoError(t, err)
 45 | 		assert.Equal(t, "http://localhost:3000/d/dash-123?viewPanel=5", result)
 46 | 	})
 47 | 
 48 | 	t.Run("Explore deeplink", func(t *testing.T) {
 49 | 		params := GenerateDeeplinkParams{
 50 | 			ResourceType:  "explore",
 51 | 			DatasourceUID: stringPtr("prometheus-uid"),
 52 | 		}
 53 | 
 54 | 		result, err := generateDeeplink(ctx, params)
 55 | 		require.NoError(t, err)
 56 | 		assert.Contains(t, result, "http://localhost:3000/explore?left=")
 57 | 		assert.Contains(t, result, "prometheus-uid")
 58 | 	})
 59 | 
 60 | 	t.Run("With time range", func(t *testing.T) {
 61 | 		params := GenerateDeeplinkParams{
 62 | 			ResourceType: "dashboard",
 63 | 			DashboardUID: stringPtr("abc123"),
 64 | 			TimeRange: &TimeRange{
 65 | 				From: "now-1h",
 66 | 				To:   "now",
 67 | 			},
 68 | 		}
 69 | 
 70 | 		result, err := generateDeeplink(ctx, params)
 71 | 		require.NoError(t, err)
 72 | 		assert.Contains(t, result, "http://localhost:3000/d/abc123")
 73 | 		assert.Contains(t, result, "from=now-1h")
 74 | 		assert.Contains(t, result, "to=now")
 75 | 	})
 76 | 
 77 | 	t.Run("With additional query params", func(t *testing.T) {
 78 | 		params := GenerateDeeplinkParams{
 79 | 			ResourceType: "dashboard",
 80 | 			DashboardUID: stringPtr("abc123"),
 81 | 			QueryParams: map[string]string{
 82 | 				"var-datasource": "prometheus",
 83 | 				"refresh":        "30s",
 84 | 			},
 85 | 		}
 86 | 
 87 | 		result, err := generateDeeplink(ctx, params)
 88 | 		require.NoError(t, err)
 89 | 		assert.Contains(t, result, "http://localhost:3000/d/abc123")
 90 | 		assert.Contains(t, result, "var-datasource=prometheus")
 91 | 		assert.Contains(t, result, "refresh=30s")
 92 | 	})
 93 | 
 94 | 	t.Run("Error cases", func(t *testing.T) {
 95 | 		emptyGrafanaCfg := mcpgrafana.GrafanaConfig{
 96 | 			URL: "",
 97 | 		}
 98 | 		emptyCtx := mcpgrafana.WithGrafanaConfig(context.Background(), emptyGrafanaCfg)
 99 | 		params := GenerateDeeplinkParams{
100 | 			ResourceType: "dashboard",
101 | 			DashboardUID: stringPtr("abc123"),
102 | 		}
103 | 		_, err := generateDeeplink(emptyCtx, params)
104 | 		assert.Error(t, err)
105 | 		assert.Contains(t, err.Error(), "grafana url not configured")
106 | 
107 | 		params.ResourceType = "unsupported"
108 | 		_, err = generateDeeplink(ctx, params)
109 | 		assert.Error(t, err)
110 | 		assert.Contains(t, err.Error(), "unsupported resource type")
111 | 
112 | 		// Test missing dashboardUid for dashboard
113 | 		params = GenerateDeeplinkParams{
114 | 			ResourceType: "dashboard",
115 | 		}
116 | 		_, err = generateDeeplink(ctx, params)
117 | 		assert.Error(t, err)
118 | 		assert.Contains(t, err.Error(), "dashboardUid is required")
119 | 
120 | 		// Test missing dashboardUid for panel
121 | 		params = GenerateDeeplinkParams{
122 | 			ResourceType: "panel",
123 | 		}
124 | 		_, err = generateDeeplink(ctx, params)
125 | 		assert.Error(t, err)
126 | 		assert.Contains(t, err.Error(), "dashboardUid is required")
127 | 
128 | 		// Test missing panelId for panel
129 | 		params = GenerateDeeplinkParams{
130 | 			ResourceType: "panel",
131 | 			DashboardUID: stringPtr("dash-123"),
132 | 		}
133 | 		_, err = generateDeeplink(ctx, params)
134 | 		assert.Error(t, err)
135 | 		assert.Contains(t, err.Error(), "panelId is required")
136 | 
137 | 		// Test missing datasourceUid for explore
138 | 		params = GenerateDeeplinkParams{
139 | 			ResourceType: "explore",
140 | 		}
141 | 		_, err = generateDeeplink(ctx, params)
142 | 		assert.Error(t, err)
143 | 		assert.Contains(t, err.Error(), "datasourceUid is required")
144 | 	})
145 | }
146 | 
```

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

```go
  1 | //go:build unit
  2 | // +build unit
  3 | 
  4 | package tools
  5 | 
  6 | import (
  7 | 	"context"
  8 | 	"encoding/json"
  9 | 	"net/http"
 10 | 	"net/http/httptest"
 11 | 	"testing"
 12 | 	"time"
 13 | 
 14 | 	mcpgrafana "github.com/grafana/mcp-grafana"
 15 | 	"github.com/stretchr/testify/assert"
 16 | 	"github.com/stretchr/testify/require"
 17 | )
 18 | 
 19 | func setupMockAssertsServer(handler http.HandlerFunc) (*httptest.Server, context.Context) {
 20 | 	server := httptest.NewServer(handler)
 21 | 	config := mcpgrafana.GrafanaConfig{
 22 | 		URL:    server.URL,
 23 | 		APIKey: "test-api-key",
 24 | 	}
 25 | 	ctx := mcpgrafana.WithGrafanaConfig(context.Background(), config)
 26 | 	return server, ctx
 27 | }
 28 | 
 29 | func TestAssertTools(t *testing.T) {
 30 | 	t.Run("get assertions", func(t *testing.T) {
 31 | 		startTime := time.Date(2025, 4, 23, 10, 0, 0, 0, time.UTC)
 32 | 		endTime := time.Date(2025, 4, 23, 11, 0, 0, 0, time.UTC)
 33 | 		server, ctx := setupMockAssertsServer(func(w http.ResponseWriter, r *http.Request) {
 34 | 			require.Equal(t, "/api/plugins/grafana-asserts-app/resources/asserts/api-server/v1/assertions/llm-summary", r.URL.Path)
 35 | 			require.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))
 36 | 
 37 | 			var requestBody map[string]interface{}
 38 | 			err := json.NewDecoder(r.Body).Decode(&requestBody)
 39 | 			require.NoError(t, err)
 40 | 
 41 | 			expectedBody := map[string]interface{}{
 42 | 				"startTime": float64(startTime.UnixMilli()),
 43 | 				"endTime":   float64(endTime.UnixMilli()),
 44 | 				"entityKeys": []interface{}{
 45 | 					map[string]interface{}{
 46 | 						"type": "Service",
 47 | 						"name": "mongodb",
 48 | 						"scope": map[string]interface{}{
 49 | 							"env":       "asserts-demo",
 50 | 							"site":      "app",
 51 | 							"namespace": "robot-shop",
 52 | 						},
 53 | 					},
 54 | 				},
 55 | 				"suggestionSrcEntities": []interface{}{},
 56 | 				"alertCategories":       []interface{}{"saturation", "amend", "anomaly", "failure", "error"},
 57 | 			}
 58 | 			require.Equal(t, expectedBody, requestBody)
 59 | 
 60 | 			w.Header().Set("Content-Type", "application/json")
 61 | 			w.WriteHeader(http.StatusOK)
 62 | 			_, err = w.Write([]byte(`{"summary": "test summary"}`))
 63 | 			require.NoError(t, err)
 64 | 		})
 65 | 		defer server.Close()
 66 | 
 67 | 		result, err := getAssertions(ctx, GetAssertionsParams{
 68 | 			StartTime:  startTime,
 69 | 			EndTime:    endTime,
 70 | 			EntityType: "Service",
 71 | 			EntityName: "mongodb",
 72 | 			Env:        "asserts-demo",
 73 | 			Site:       "app",
 74 | 			Namespace:  "robot-shop",
 75 | 		})
 76 | 		require.NoError(t, err)
 77 | 		assert.NotNil(t, result)
 78 | 		assert.Equal(t, `{"summary": "test summary"}`, result)
 79 | 	})
 80 | 
 81 | 	t.Run("get assertions with no site and namespace", func(t *testing.T) {
 82 | 		startTime := time.Date(2025, 4, 23, 10, 0, 0, 0, time.UTC)
 83 | 		endTime := time.Date(2025, 4, 23, 11, 0, 0, 0, time.UTC)
 84 | 		server, ctx := setupMockAssertsServer(func(w http.ResponseWriter, r *http.Request) {
 85 | 			require.Equal(t, "/api/plugins/grafana-asserts-app/resources/asserts/api-server/v1/assertions/llm-summary", r.URL.Path)
 86 | 			require.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))
 87 | 
 88 | 			var requestBody map[string]interface{}
 89 | 			err := json.NewDecoder(r.Body).Decode(&requestBody)
 90 | 			require.NoError(t, err)
 91 | 
 92 | 			expectedBody := map[string]interface{}{
 93 | 				"startTime": float64(startTime.UnixMilli()),
 94 | 				"endTime":   float64(endTime.UnixMilli()),
 95 | 				"entityKeys": []interface{}{
 96 | 					map[string]interface{}{
 97 | 						"type": "Service",
 98 | 						"name": "mongodb",
 99 | 						"scope": map[string]interface{}{
100 | 							"env": "asserts-demo",
101 | 						},
102 | 					},
103 | 				},
104 | 				"suggestionSrcEntities": []interface{}{},
105 | 				"alertCategories":       []interface{}{"saturation", "amend", "anomaly", "failure", "error"},
106 | 			}
107 | 			require.Equal(t, expectedBody, requestBody)
108 | 
109 | 			w.Header().Set("Content-Type", "application/json")
110 | 			w.WriteHeader(http.StatusOK)
111 | 			_, err = w.Write([]byte(`{"summary": "test summary"}`))
112 | 			require.NoError(t, err)
113 | 		})
114 | 		defer server.Close()
115 | 
116 | 		result, err := getAssertions(ctx, GetAssertionsParams{
117 | 			StartTime:  startTime,
118 | 			EndTime:    endTime,
119 | 			EntityType: "Service",
120 | 			EntityName: "mongodb",
121 | 			Env:        "asserts-demo",
122 | 		})
123 | 		require.NoError(t, err)
124 | 		assert.NotNil(t, result)
125 | 		assert.Equal(t, `{"summary": "test summary"}`, result)
126 | 	})
127 | }
128 | 
```

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

```go
  1 | package mcpgrafana
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"encoding/base64"
  6 | 	"fmt"
  7 | 	"log/slog"
  8 | 	"sync"
  9 | 
 10 | 	mcp_client "github.com/mark3labs/mcp-go/client"
 11 | 	"github.com/mark3labs/mcp-go/client/transport"
 12 | 	"github.com/mark3labs/mcp-go/mcp"
 13 | )
 14 | 
 15 | // ProxiedClient represents a connection to a remote MCP server (e.g., Tempo datasource)
 16 | type ProxiedClient struct {
 17 | 	DatasourceUID  string
 18 | 	DatasourceName string
 19 | 	DatasourceType string
 20 | 	Client         *mcp_client.Client
 21 | 	Tools          []mcp.Tool
 22 | 	mutex          sync.RWMutex
 23 | }
 24 | 
 25 | // NewProxiedClient creates a new connection to a remote MCP server
 26 | func NewProxiedClient(ctx context.Context, datasourceUID, datasourceName, datasourceType, mcpEndpoint string) (*ProxiedClient, error) {
 27 | 	// Get Grafana config for authentication
 28 | 	config := GrafanaConfigFromContext(ctx)
 29 | 
 30 | 	// Build headers for authentication
 31 | 	headers := make(map[string]string)
 32 | 	if config.APIKey != "" {
 33 | 		headers["Authorization"] = "Bearer " + config.APIKey
 34 | 	} else if config.BasicAuth != nil {
 35 | 		auth := config.BasicAuth.String()
 36 | 		headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
 37 | 	}
 38 | 
 39 | 	// Create HTTP transport with authentication headers
 40 | 	slog.DebugContext(ctx, "connecting to MCP server", "datasource", datasourceUID, "url", mcpEndpoint)
 41 | 	httpTransport, err := transport.NewStreamableHTTP(
 42 | 		mcpEndpoint,
 43 | 		transport.WithHTTPHeaders(headers),
 44 | 	)
 45 | 	if err != nil {
 46 | 		return nil, fmt.Errorf("failed to create HTTP transport: %w", err)
 47 | 	}
 48 | 
 49 | 	// Create MCP client
 50 | 	mcpClient := mcp_client.NewClient(httpTransport)
 51 | 
 52 | 	// Initialize the connection
 53 | 	initReq := mcp.InitializeRequest{}
 54 | 	initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
 55 | 	initReq.Params.ClientInfo = mcp.Implementation{
 56 | 		Name:    "mcp-grafana-proxy",
 57 | 		Version: Version(),
 58 | 	}
 59 | 
 60 | 	_, err = mcpClient.Initialize(ctx, initReq)
 61 | 	if err != nil {
 62 | 		_ = mcpClient.Close()
 63 | 		return nil, fmt.Errorf("failed to initialize MCP client: %w", err)
 64 | 	}
 65 | 
 66 | 	// List available tools from the remote server
 67 | 	listReq := mcp.ListToolsRequest{}
 68 | 	toolsResult, err := mcpClient.ListTools(ctx, listReq)
 69 | 	if err != nil {
 70 | 		_ = mcpClient.Close()
 71 | 		return nil, fmt.Errorf("failed to list tools from remote MCP server: %w", err)
 72 | 	}
 73 | 
 74 | 	slog.DebugContext(ctx, "connected to proxied MCP server",
 75 | 		"datasource", datasourceUID,
 76 | 		"type", datasourceType,
 77 | 		"tools", len(toolsResult.Tools))
 78 | 
 79 | 	return &ProxiedClient{
 80 | 		DatasourceUID:  datasourceUID,
 81 | 		DatasourceName: datasourceName,
 82 | 		DatasourceType: datasourceType,
 83 | 		Client:         mcpClient,
 84 | 		Tools:          toolsResult.Tools,
 85 | 	}, nil
 86 | }
 87 | 
 88 | // CallTool forwards a tool call to the remote MCP server
 89 | func (pc *ProxiedClient) CallTool(ctx context.Context, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
 90 | 	pc.mutex.RLock()
 91 | 	defer pc.mutex.RUnlock()
 92 | 
 93 | 	// Validate the tool exists
 94 | 	var toolExists bool
 95 | 	for _, tool := range pc.Tools {
 96 | 		if tool.Name == toolName {
 97 | 			toolExists = true
 98 | 			break
 99 | 		}
100 | 	}
101 | 	if !toolExists {
102 | 		return nil, fmt.Errorf("tool %s not found in remote MCP server", toolName)
103 | 	}
104 | 
105 | 	// Create the call tool request
106 | 	req := mcp.CallToolRequest{}
107 | 	req.Params.Name = toolName
108 | 	req.Params.Arguments = arguments
109 | 
110 | 	// Forward the call to the remote server
111 | 	result, err := pc.Client.CallTool(ctx, req)
112 | 	if err != nil {
113 | 		return nil, fmt.Errorf("failed to call tool on remote MCP server: %w", err)
114 | 	}
115 | 
116 | 	return result, nil
117 | }
118 | 
119 | // ListTools returns the tools available from this remote server
120 | // Note: This method doesn't take a context parameter as the tools are cached locally
121 | func (pc *ProxiedClient) ListTools() []mcp.Tool {
122 | 	pc.mutex.RLock()
123 | 	defer pc.mutex.RUnlock()
124 | 
125 | 	// Return a copy to prevent external modification
126 | 	result := make([]mcp.Tool, len(pc.Tools))
127 | 	copy(result, pc.Tools)
128 | 	return result
129 | }
130 | 
131 | // Close closes the connection to the remote MCP server
132 | func (pc *ProxiedClient) Close() error {
133 | 	pc.mutex.Lock()
134 | 	defer pc.mutex.Unlock()
135 | 
136 | 	if pc.Client != nil {
137 | 		if err := pc.Client.Close(); err != nil {
138 | 			return fmt.Errorf("failed to close MCP client: %w", err)
139 | 		}
140 | 	}
141 | 
142 | 	return nil
143 | }
144 | 
```

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

```python
  1 | import json
  2 | import pytest
  3 | from langevals import expect
  4 | from langevals_langevals.llm_boolean import (
  5 |     CustomLLMBooleanEvaluator,
  6 |     CustomLLMBooleanSettings,
  7 | )
  8 | from litellm import Message, acompletion
  9 | from mcp import ClientSession
 10 | 
 11 | from conftest import models
 12 | from utils import (
 13 |     get_converted_tools,
 14 |     llm_tool_call_sequence,
 15 | )
 16 | 
 17 | pytestmark = pytest.mark.anyio
 18 | 
 19 | @pytest.mark.parametrize("model", models)
 20 | @pytest.mark.flaky(max_runs=3)
 21 | async def test_dashboard_panel_queries_tool(model: str, mcp_client: ClientSession):
 22 |     tools = await get_converted_tools(mcp_client)
 23 |     prompt = "Can you list the panel queries for the dashboard with UID fe9gm6guyzi0wd?"
 24 | 
 25 |     messages = [
 26 |         Message(role="system", content="You are a helpful assistant."),
 27 |         Message(role="user", content=prompt),
 28 |     ]
 29 | 
 30 |     # 1. Call the dashboard panel queries tool
 31 |     messages = await llm_tool_call_sequence(
 32 |         model, messages, tools, mcp_client, "get_dashboard_panel_queries",
 33 |         {"uid": "fe9gm6guyzi0wd"}
 34 |     )
 35 | 
 36 |     # 2. Final LLM response
 37 |     response = await acompletion(model=model, messages=messages, tools=tools)
 38 |     content = response.choices[0].message.content
 39 |     panel_queries_checker = CustomLLMBooleanEvaluator(
 40 |         settings=CustomLLMBooleanSettings(
 41 |             prompt="Does the response contain specific information about the panel queries and titles for a grafana dashboard?",
 42 |         )
 43 |     )
 44 |     print("content", content)
 45 |     expect(input=prompt, output=content).to_pass(panel_queries_checker)
 46 | 
 47 | 
 48 | @pytest.mark.parametrize("model", models)
 49 | @pytest.mark.flaky(max_runs=3)
 50 | async def test_dashboard_update_with_patch_operations(model: str, mcp_client: ClientSession):
 51 |     """Test that LLMs naturally use patch operations for dashboard updates"""
 52 |     tools = await get_converted_tools(mcp_client)
 53 | 
 54 |     # First, create a non-provisioned test dashboard by copying the demo dashboard
 55 |     # 1. Get the demo dashboard JSON
 56 |     demo_result = await mcp_client.call_tool("get_dashboard_by_uid", {"uid": "fe9gm6guyzi0wd"})
 57 |     demo_data = json.loads(demo_result.content[0].text)
 58 |     dashboard_json = demo_data["dashboard"]
 59 | 
 60 |     # 2. Remove uid and id to create a new dashboard
 61 |     if "uid" in dashboard_json:
 62 |         del dashboard_json["uid"]
 63 |     if "id" in dashboard_json:
 64 |         del dashboard_json["id"]
 65 | 
 66 |     # 3. Set a new title
 67 |     title = f"Test Dashboard"
 68 |     dashboard_json["title"] = title
 69 |     dashboard_json["tags"] = ["python-integration-test"]
 70 | 
 71 |     # 4. Create the dashboard in Grafana
 72 |     create_result = await mcp_client.call_tool("update_dashboard", {
 73 |         "dashboard": dashboard_json,
 74 |         "folderUid": "",
 75 |         "overwrite": False
 76 |     })
 77 |     create_data = json.loads(create_result.content[0].text)
 78 |     created_dashboard_uid = create_data["uid"]
 79 | 
 80 |     # 5. Update the dashboard title
 81 |     updated_title = f"Updated {title}"
 82 |     title_prompt = f"Update the title of the Test Dashboard to {updated_title}. Search for the dashboard by title first."
 83 | 
 84 |     messages = [
 85 |         Message(role="system", content="You are a helpful assistant"),
 86 |         Message(role="user", content=title_prompt),
 87 |     ]
 88 | 
 89 |     # 6. Search for the test dashboard
 90 |     messages = await llm_tool_call_sequence(
 91 |         model, messages, tools, mcp_client, "search_dashboards",
 92 |         {"query": title}
 93 |     )
 94 | 
 95 |     # 7. Update the dashboard using patch operations
 96 |     messages = await llm_tool_call_sequence(
 97 |         model, messages, tools, mcp_client, "update_dashboard",
 98 |         {
 99 |             "uid": created_dashboard_uid,
100 |             "operations": [
101 |                 {
102 |                     "op": "replace",
103 |                     "path": "$.title",
104 |                     "value": updated_title
105 |                 }
106 |             ]
107 |         }
108 |     )
109 | 
110 |     # 8. Final LLM response - just verify it completes successfully
111 |     response = await acompletion(model=model, messages=messages, tools=tools)
112 |     content = response.choices[0].message.content
113 | 
114 |     # Test passes if we get here - the tool call sequence worked correctly
115 |     assert len(content) > 0, "LLM should provide a response after updating the dashboard"
116 | 
117 | 
```

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

```python
  1 | import json
  2 | 
  3 | import pytest
  4 | from langevals import expect
  5 | from langevals_langevals.llm_boolean import (
  6 |     CustomLLMBooleanEvaluator,
  7 |     CustomLLMBooleanSettings,
  8 | )
  9 | from litellm import Message, acompletion
 10 | from mcp import ClientSession
 11 | 
 12 | from conftest import models
 13 | from utils import (
 14 |     get_converted_tools,
 15 |     flexible_tool_call,
 16 | )
 17 | 
 18 | pytestmark = pytest.mark.anyio
 19 | 
 20 | 
 21 | @pytest.mark.parametrize("model", models)
 22 | @pytest.mark.flaky(max_runs=3)
 23 | async def test_loki_logs_tool(model: str, mcp_client: ClientSession):
 24 |     tools = await get_converted_tools(mcp_client)
 25 |     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."
 26 | 
 27 |     messages = [
 28 |         Message(role="system", content="You are a helpful assistant."),
 29 |         Message(role="user", content=prompt),
 30 |     ]
 31 | 
 32 |     # 1. List datasources
 33 |     messages = await flexible_tool_call(
 34 |         model, messages, tools, mcp_client, "list_datasources"
 35 |     )
 36 |     datasources_response = messages[-1].content
 37 |     datasources_data = json.loads(datasources_response)
 38 |     loki_ds = get_first_loki_datasource(datasources_data)
 39 |     print(f"\nFound Loki datasource: {loki_ds['name']} (uid: {loki_ds['uid']})")
 40 | 
 41 |     # 2. Query logs
 42 |     messages = await flexible_tool_call(
 43 |         model, messages, tools, mcp_client, "query_loki_logs",
 44 |         required_params={"datasourceUid": loki_ds["uid"]}
 45 |     )
 46 | 
 47 |     # 3. Final LLM response
 48 |     response = await acompletion(model=model, messages=messages, tools=tools)
 49 |     content = response.choices[0].message.content
 50 |     log_lines_checker = CustomLLMBooleanEvaluator(
 51 |         settings=CustomLLMBooleanSettings(
 52 |             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.",
 53 |         )
 54 |     )
 55 |     expect(input=prompt, output=content).to_pass(log_lines_checker)
 56 | 
 57 | 
 58 | @pytest.mark.parametrize("model", models)
 59 | @pytest.mark.flaky(max_runs=3)
 60 | async def test_loki_container_labels(model: str, mcp_client: ClientSession):
 61 |     tools = await get_converted_tools(mcp_client)
 62 |     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."
 63 | 
 64 |     messages = [
 65 |         Message(role="system", content="You are a helpful assistant."),
 66 |         Message(role="user", content=prompt),
 67 |     ]
 68 | 
 69 |     # 1. List datasources
 70 |     messages = await flexible_tool_call(
 71 |         model, messages, tools, mcp_client, "list_datasources"
 72 |     )
 73 |     datasources_response = messages[-1].content
 74 |     datasources_data = json.loads(datasources_response)
 75 |     loki_ds = get_first_loki_datasource(datasources_data)
 76 |     print(f"\nFound Loki datasource: {loki_ds['name']} (uid: {loki_ds['uid']})")
 77 | 
 78 |     # 2. List label values for 'container'
 79 |     messages = await flexible_tool_call(
 80 |         model, messages, tools, mcp_client, "list_loki_label_values",
 81 |         required_params={"datasourceUid": loki_ds["uid"], "labelName": "container"}
 82 |     )
 83 | 
 84 |     # 3. Final LLM response
 85 |     response = await acompletion(model=model, messages=messages, tools=tools)
 86 |     content = response.choices[0].message.content
 87 |     label_checker = CustomLLMBooleanEvaluator(
 88 |         settings=CustomLLMBooleanSettings(
 89 |             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.",
 90 |         )
 91 |     )
 92 |     expect(input=prompt, output=content).to_pass(label_checker)
 93 | 
 94 | def get_first_loki_datasource(datasources_data):
 95 |     """
 96 |     Returns the first datasource with type 'loki' from a list of datasources.
 97 |     Raises an AssertionError if none are found.
 98 |     """
 99 |     loki_datasources = [ds for ds in datasources_data if ds.get("type") == "loki"]
100 |     assert len(loki_datasources) > 0, "No Loki datasource found"
101 |     return loki_datasources[0]
102 | 
```
Page 1/5FirstPrevNextLast