This is page 1 of 4. Use http://codebase.md/grafana/mcp-grafana?page={x} to view the full context.
# Directory Structure
```
├── .dockerignore
├── .github
│ ├── dependabot.yml
│ └── workflows
│ ├── docker.yml
│ ├── e2e.yml
│ ├── integration.yml
│ ├── release.yml
│ └── unit.yml
├── .gitignore
├── .golangci.yaml
├── .goreleaser.yaml
├── cmd
│ ├── linters
│ │ └── jsonschema
│ │ └── main.go
│ └── mcp-grafana
│ └── main.go
├── CODEOWNERS
├── docker-compose.yaml
├── Dockerfile
├── examples
│ └── tls_example.go
├── gemini-extension.json
├── go.mod
├── go.sum
├── image-tag
├── internal
│ └── linter
│ └── jsonschema
│ ├── jsonschema_lint_test.go
│ ├── jsonschema_lint.go
│ └── README.md
├── LICENSE
├── Makefile
├── mcpgrafana_test.go
├── mcpgrafana.go
├── proxied_client.go
├── proxied_handler.go
├── proxied_tools_test.go
├── proxied_tools.go
├── README.md
├── renovate.json
├── server.json
├── session_test.go
├── session.go
├── testdata
│ ├── dashboards
│ │ └── demo.json
│ ├── loki-config.yml
│ ├── prometheus-entrypoint.sh
│ ├── prometheus-seed.yml
│ ├── prometheus.yml
│ ├── promtail-config.yml
│ ├── provisioning
│ │ ├── alerting
│ │ │ ├── alert_rules.yaml
│ │ │ └── contact_points.yaml
│ │ ├── dashboards
│ │ │ └── dashboards.yaml
│ │ └── datasources
│ │ └── datasources.yaml
│ ├── tempo-config-2.yaml
│ └── tempo-config.yaml
├── tests
│ ├── .gitignore
│ ├── .python-version
│ ├── admin_test.py
│ ├── conftest.py
│ ├── dashboards_test.py
│ ├── disable_write_test.py
│ ├── health_test.py
│ ├── loki_test.py
│ ├── navigation_test.py
│ ├── pyproject.toml
│ ├── README.md
│ ├── tempo_test.py
│ ├── utils.py
│ └── uv.lock
├── tls_test.go
├── tools
│ ├── admin_test.go
│ ├── admin.go
│ ├── alerting_client_test.go
│ ├── alerting_client.go
│ ├── alerting_test.go
│ ├── alerting_unit_test.go
│ ├── alerting.go
│ ├── annotations_integration_test.go
│ ├── annotations_unit_test.go
│ ├── annotations.go
│ ├── asserts_cloud_test.go
│ ├── asserts_test.go
│ ├── asserts.go
│ ├── cloud_testing_utils.go
│ ├── dashboard_test.go
│ ├── dashboard.go
│ ├── datasources_test.go
│ ├── datasources.go
│ ├── folder.go
│ ├── incident_integration_test.go
│ ├── incident_test.go
│ ├── incident.go
│ ├── loki_test.go
│ ├── loki.go
│ ├── navigation_test.go
│ ├── navigation.go
│ ├── oncall_cloud_test.go
│ ├── oncall.go
│ ├── prometheus_test.go
│ ├── prometheus_unit_test.go
│ ├── prometheus.go
│ ├── pyroscope_test.go
│ ├── pyroscope.go
│ ├── search_test.go
│ ├── search.go
│ ├── sift_cloud_test.go
│ ├── sift.go
│ └── testcontext_test.go
├── tools_test.go
└── tools.go
```
# Files
--------------------------------------------------------------------------------
/tests/.gitignore:
--------------------------------------------------------------------------------
```
.env
```
--------------------------------------------------------------------------------
/tests/.python-version:
--------------------------------------------------------------------------------
```
3.13
```
--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------
```yaml
version: "2"
run:
concurrency: 16
timeout: 10m
go: "1.24"
relative-path-mode: gomod
allow-parallel-runners: true
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
.DS_Store
.vscode/
.env
.cursor/
# Virtual environments
.venv
.envrc
```
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
```
# Git
.git
.gitignore
.github/
# Docker
Dockerfile
.dockerignore
# Build artifacts
bin/
dist/
build/
*.exe
*.exe~
*.dll
*.so
*.dylib
# Go specific
vendor/
go.work
# Testing
*_test.go
**/test/
**/tests/
coverage.out
coverage.html
# IDE and editor files
.idea/
.vscode/
*.swp
*.swo
*~
# OS specific
.DS_Store
Thumbs.db
# Temporary files
tmp/
temp/
*.tmp
*.log
# Documentation
docs/
*.md
LICENSE
# Development tools
.air.toml
.golangci.yml
.goreleaser.yml
# Debug files
debug
__debug_bin
```
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
```yaml
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
before:
hooks:
- go mod tidy
git:
prerelease_suffix: "-rc"
builds:
- id: default
env:
- CGO_ENABLED=0
main: ./cmd/mcp-grafana
goos:
- linux
- windows
- darwin
ldflags: "-s -w -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser"
- id: gemini-cli-extension
env:
- CGO_ENABLED=0
main: ./cmd/mcp-grafana
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
ldflags: "-s -w -X main.commit={{.Commit}} -X main.date={{.Date}} -X main.builtBy=goreleaser"
archives:
- id: default
ids:
- default
formats: tar.gz
# this name template makes the OS and Arch compatible with the results of `uname`.
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
# use zip for windows archives
format_overrides:
- goos: windows
formats: zip
# The Gemini CLI Extension format.
# See https://github.com/google-gemini/gemini-cli/blob/main/docs/extension-releasing.md#platform-specific-archives.
# We'll use platform and architecture specific archive names, which must look like:
#
# {platform}.{arch}.{name}.{extension}
#
# Where the fields are:
#
# - {name}: The name of your extension.
# - {platform}: The operating system. Supported values are:
# - darwin (macOS)
# - linux
# - win32 (Windows)
# - {arch}: The architecture. Supported values are:
# - x64
# - arm64
# - {extension}: The file extension of the archive (e.g., .tar.gz or .zip).
#
# Examples:
# - darwin.arm64.{project}.tar.gz (specific to Apple Silicon Macs)
# - linux.x64.{project}.tar.gz
# - win32.{project}.zip
- id: gemini-cli-extension
ids:
- gemini-cli-extension
formats: tar.gz
files:
- gemini-extension.json
name_template: >-
{{ if eq .Os "windows" }}win32
{{- else }}{{ .Os }}{{ end }}.
{{- if eq .Arch "amd64" }}x64
{{- else }}{{ .Arch }}{{ end }}.grafana
format_overrides:
- goos: windows
formats: zip
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
release:
footer: >-
---
Released by [GoReleaser](https://github.com/goreleaser/goreleaser).
```
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
```markdown
# Tests
This directory contains an e2e test suite for the Grafana MCP server.
The test suite evaluates the LLM's ability to use Grafana MCP tools effectively:
- **Loki tests**: Evaluates how well the LLM can use Grafana tools to:
- Navigate and use available tools
- Make appropriate tool calls
- Process and present the results in a meaningful way
- Evaluating the LLM responses using `langevals` package, using custom LLM-as-a-Judge approach.
The tests are run against two LLM models:
- GPT-4
- Claude 3.5 Sonnet
Tests are using [`uv`] to manage dependencies. Install uv following the instructions for your platform.
## Prerequisites
- Docker installed and running on your system
- Docker containers for the test environment must be started before running tests
## Setup
1. Create a virtual environment and install the dependencies:
```bash
uv sync --all-groups
```
2. Create a `.env` file with your API keys:
```env
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
```
3. Start the required Docker containers
4. Start the MCP server in SSE mode; from the root of the project:
```bash
go run ./cmd/mcp-grafana -t sse
```
5. Run the tests:
```bash
uv run pytest
```
[`uv`]: https://docs.astral.sh/uv/
```
--------------------------------------------------------------------------------
/internal/linter/jsonschema/README.md:
--------------------------------------------------------------------------------
```markdown
# JSONSchema Linter
This linter helps detect and prevent a common issue with Go struct tags in this project.
## The Problem
In Go struct tags using `jsonschema`, commas in the `description` field need to be escaped using `\\,` syntax. If commas aren't properly escaped, the description is silently truncated at the comma.
For example:
```go
// Problematic (description will be truncated at the first comma):
type Example struct {
Field string `jsonschema:"description=This is a description, but it will be truncated here"`
}
// Correct (commas properly escaped):
type Example struct {
Field string `jsonschema:"description=This is a description\\, and it will be fully included"`
}
```
## Usage
You can use this linter by running:
```shell
make lint-jsonschema
```
or directly:
```shell
go run ./cmd/linters/jsonschema --path .
```
### Auto-fixing issues
The linter can automatically fix unescaped commas in jsonschema descriptions by running:
```shell
make lint-jsonschema-fix
```
or directly:
```shell
go run ./cmd/linters/jsonschema --path . --fix
```
This will scan the codebase for unescaped commas and automatically escape them, then report what was fixed.
## Flags
- `--path`: Base directory to scan for Go files (default: ".")
- `--fix`: Automatically fix unescaped commas
- `--help`: Display help information
## Integration
This linter is integrated into the default `make lint` command, ensuring all PRs are checked for this issue.
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# Grafana MCP server
[](https://github.com/grafana/mcp-grafana/actions/workflows/unit.yml)
[](https://github.com/grafana/mcp-grafana/actions/workflows/integration.yml)
[](https://github.com/grafana/mcp-grafana/actions/workflows/e2e.yml)
[](https://pkg.go.dev/github.com/grafana/mcp-grafana)
[](https://archestra.ai/mcp-catalog/grafana__mcp-grafana)
A [Model Context Protocol][mcp] (MCP) server for Grafana.
This provides access to your Grafana instance and the surrounding ecosystem.
## Requirements
- **Grafana version 9.0 or later** is required for full functionality. Some features, particularly datasource-related operations, may not work correctly with earlier versions due to missing API endpoints.
## Features
_The following features are currently available in MCP server. This list is for informational purposes only and does not represent a roadmap or commitment to future features._
### Dashboards
- **Search for dashboards:** Find dashboards by title or other metadata
- **Get dashboard by UID:** Retrieve full dashboard details using its unique identifier. _Warning: Large dashboards can consume significant context window space._
- **Get dashboard summary:** Get a compact overview of a dashboard including title, panel count, panel types, variables, and metadata without the full JSON to minimize context window usage
- **Get dashboard property:** Extract specific parts of a dashboard using JSONPath expressions (e.g., `$.title`, `$.panels[*].title`) to fetch only needed data and reduce context window consumption
- **Update or create a dashboard:** Modify existing dashboards or create new ones. _Warning: Requires full dashboard JSON which can consume large amounts of context window space._
- **Patch dashboard:** Apply specific changes to a dashboard without requiring the full JSON, significantly reducing context window usage for targeted modifications
- **Get panel queries and datasource info:** Get the title, query string, and datasource information (including UID and type, if available) from every panel in a dashboard
#### Context Window Management
The dashboard tools now include several strategies to manage context window usage effectively ([issue #101](https://github.com/grafana/mcp-grafana/issues/101)):
- **Use `get_dashboard_summary`** for dashboard overview and planning modifications
- **Use `get_dashboard_property`** with JSONPath when you only need specific dashboard parts
- **Avoid `get_dashboard_by_uid`** unless you specifically need the complete dashboard JSON
### Datasources
- **List and fetch datasource information:** View all configured datasources and retrieve detailed information about each.
- _Supported datasource types: Prometheus, Loki._
### Prometheus Querying
- **Query Prometheus:** Execute PromQL queries (supports both instant and range metric queries) against Prometheus datasources.
- **Query Prometheus metadata:** Retrieve metric metadata, metric names, label names, and label values from Prometheus datasources.
### Loki Querying
- **Query Loki logs and metrics:** Run both log queries and metric queries using LogQL against Loki datasources.
- **Query Loki metadata:** Retrieve label names, label values, and stream statistics from Loki datasources.
### Incidents
- **Search, create, and update incidents:** Manage incidents in Grafana Incident, including searching, creating, and adding activities to incidents.
### Sift Investigations
- **List Sift investigations:** Retrieve a list of Sift investigations, with support for a limit parameter.
- **Get Sift investigation:** Retrieve details of a specific Sift investigation by its UUID.
- **Get Sift analyses:** Retrieve a specific analysis from a Sift investigation.
- **Find error patterns in logs:** Detect elevated error patterns in Loki logs using Sift.
- **Find slow requests:** Detect slow requests using Sift (Tempo).
### Alerting
- **List and fetch alert rule information:** View alert rules and their statuses (firing/normal/error/etc.) in Grafana.
- **List contact points:** View configured notification contact points in Grafana.
### Grafana OnCall
- **List and manage schedules:** View and manage on-call schedules in Grafana OnCall.
- **Get shift details:** Retrieve detailed information about specific on-call shifts.
- **Get current on-call users:** See which users are currently on call for a schedule.
- **List teams and users:** View all OnCall teams and users.
- **List alert groups:** View and filter alert groups from Grafana OnCall by various criteria including state, integration, labels, and time range.
- **Get alert group details:** Retrieve detailed information about a specific alert group by its ID.
### Admin
- **List teams:** View all configured teams in Grafana.
- **List Users:** View all users in an organization in Grafana.
### Navigation
- **Generate deeplinks:** Create accurate deeplink URLs for Grafana resources instead of relying on LLM URL guessing.
- **Dashboard links:** Generate direct links to dashboards using their UID (e.g., `http://localhost:3000/d/dashboard-uid`)
- **Panel links:** Create links to specific panels within dashboards with viewPanel parameter (e.g., `http://localhost:3000/d/dashboard-uid?viewPanel=5`)
- **Explore links:** Generate links to Grafana Explore with pre-configured datasources (e.g., `http://localhost:3000/explore?left={"datasource":"prometheus-uid"}`)
- **Time range support:** Add time range parameters to links (`from=now-1h&to=now`)
- **Custom parameters:** Include additional query parameters like dashboard variables or refresh intervals
### Annotations
- **Get Annotations:** Query annotations with filters. Supports time range, dashboard UID, tags, and match mode.
- **Create Annotation:** Create a new annotation on a dashboard or panel.
- **Create Graphite Annotation:** Create annotations using Graphite format (`what`, `when`, `tags`, `data`).
- **Update Annotation:** Replace all fields of an existing annotation (full update).
- **Patch Annotation:** Update only specific fields of an annotation (partial update).
- **Get Annotation Tags:** List available annotation tags with optional filtering.
The list of tools is configurable, so you can choose which tools you want to make available to the MCP client.
This is useful if you don't use certain functionality or if you don't want to take up too much of the context window.
To disable a category of tools, use the `--disable-<category>` flag when starting the server. For example, to disable
the OnCall tools, use `--disable-oncall`, or to disable navigation deeplink generation, use `--disable-navigation`.
#### RBAC Permissions
Each tool requires specific RBAC permissions to function properly. When creating a service account for the MCP server, ensure it has the necessary permissions based on which tools you plan to use. The permissions listed are the minimum required actions - you may also need appropriate scopes (e.g., `datasources:*`, `dashboards:*`, `folders:*`) depending on your use case.
Tip: If you're not familiar with Grafana RBAC or you want a quicker, simpler setup instead of configuring many granular scopes, you can assign a built-in role such as `Editor` to the service account. The `Editor` role grants broad read/write access that will allow most MCP server operations; it is less granular (and therefore less restrictive) than manually-applied scopes, so use it only when convenience is more important than strict least-privilege access.
**Note:** Grafana Incident and Sift tools use basic Grafana roles instead of fine-grained RBAC permissions:
- **Viewer role:** Required for read-only operations (list incidents, get investigations)
- **Editor role:** Required for write operations (create incidents, modify investigations)
For more information about Grafana RBAC, see the [official documentation](https://grafana.com/docs/grafana/latest/administration/roles-and-permissions/access-control/).
#### RBAC Scopes
Scopes define the specific resources that permissions apply to. Each action requires both the appropriate permission and scope combination.
**Common Scope Patterns:**
- **Broad access:** Use `*` wildcards for organization-wide access
- `datasources:*` - Access to all datasources
- `dashboards:*` - Access to all dashboards
- `folders:*` - Access to all folders
- `teams:*` - Access to all teams
- **Limited access:** Use specific UIDs or IDs to restrict access to individual resources
- `datasources:uid:prometheus-uid` - Access only to a specific Prometheus datasource
- `dashboards:uid:abc123` - Access only to dashboard with UID `abc123`
- `folders:uid:xyz789` - Access only to folder with UID `xyz789`
- `teams:id:5` - Access only to team with ID `5`
- `global.users:id:123` - Access only to user with ID `123`
**Examples:**
- **Full MCP server access:** Grant broad permissions for all tools
```
datasources:* (datasources:read, datasources:query)
dashboards:* (dashboards:read, dashboards:create, dashboards:write)
folders:* (for dashboard creation and alert rules)
teams:* (teams:read)
global.users:* (users:read)
```
- **Limited datasource access:** Only query specific Prometheus and Loki instances
```
datasources:uid:prometheus-prod (datasources:query)
datasources:uid:loki-prod (datasources:query)
```
- **Dashboard-specific access:** Read only specific dashboards
```
dashboards:uid:monitoring-dashboard (dashboards:read)
dashboards:uid:alerts-dashboard (dashboards:read)
```
### Tools
| Tool | Category | Description | Required RBAC Permissions | Required Scopes |
| --------------------------------- | ----------- | ------------------------------------------------------------------ | --------------------------------------- | --------------------------------------------------- |
| `list_teams` | Admin | List all teams | `teams:read` | `teams:*` or `teams:id:1` |
| `list_users_by_org` | Admin | List all users in an organization | `users:read` | `global.users:*` or `global.users:id:123` |
| `search_dashboards` | Search | Search for dashboards | `dashboards:read` | `dashboards:*` or `dashboards:uid:abc123` |
| `get_dashboard_by_uid` | Dashboard | Get a dashboard by uid | `dashboards:read` | `dashboards:uid:abc123` |
| `update_dashboard` | Dashboard | Update or create a new dashboard | `dashboards:create`, `dashboards:write` | `dashboards:*`, `folders:*` or `folders:uid:xyz789` |
| `get_dashboard_panel_queries` | Dashboard | Get panel title, queries, datasource UID and type from a dashboard | `dashboards:read` | `dashboards:uid:abc123` |
| `get_dashboard_property` | Dashboard | Extract specific parts of a dashboard using JSONPath expressions | `dashboards:read` | `dashboards:uid:abc123` |
| `get_dashboard_summary` | Dashboard | Get a compact summary of a dashboard without full JSON | `dashboards:read` | `dashboards:uid:abc123` |
| `list_datasources` | Datasources | List datasources | `datasources:read` | `datasources:*` |
| `get_datasource_by_uid` | Datasources | Get a datasource by uid | `datasources:read` | `datasources:uid:prometheus-uid` |
| `get_datasource_by_name` | Datasources | Get a datasource by name | `datasources:read` | `datasources:*` or `datasources:uid:loki-uid` |
| `query_prometheus` | Prometheus | Execute a query against a Prometheus datasource | `datasources:query` | `datasources:uid:prometheus-uid` |
| `list_prometheus_metric_metadata` | Prometheus | List metric metadata | `datasources:query` | `datasources:uid:prometheus-uid` |
| `list_prometheus_metric_names` | Prometheus | List available metric names | `datasources:query` | `datasources:uid:prometheus-uid` |
| `list_prometheus_label_names` | Prometheus | List label names matching a selector | `datasources:query` | `datasources:uid:prometheus-uid` |
| `list_prometheus_label_values` | Prometheus | List values for a specific label | `datasources:query` | `datasources:uid:prometheus-uid` |
| `list_incidents` | Incident | List incidents in Grafana Incident | Viewer role | N/A |
| `create_incident` | Incident | Create an incident in Grafana Incident | Editor role | N/A |
| `add_activity_to_incident` | Incident | Add an activity item to an incident in Grafana Incident | Editor role | N/A |
| `get_incident` | Incident | Get a single incident by ID | Viewer role | N/A |
| `query_loki_logs` | Loki | Query and retrieve logs using LogQL (either log or metric queries) | `datasources:query` | `datasources:uid:loki-uid` |
| `list_loki_label_names` | Loki | List all available label names in logs | `datasources:query` | `datasources:uid:loki-uid` |
| `list_loki_label_values` | Loki | List values for a specific log label | `datasources:query` | `datasources:uid:loki-uid` |
| `query_loki_stats` | Loki | Get statistics about log streams | `datasources:query` | `datasources:uid:loki-uid` |
| `list_alert_rules` | Alerting | List alert rules | `alert.rules:read` | `folders:*` or `folders:uid:alerts-folder` |
| `get_alert_rule_by_uid` | Alerting | Get alert rule by UID | `alert.rules:read` | `folders:uid:alerts-folder` |
| `list_contact_points` | Alerting | List notification contact points | `alert.notifications:read` | Global scope |
| `list_oncall_schedules` | OnCall | List schedules from Grafana OnCall | `grafana-oncall-app.schedules:read` | Plugin-specific scopes |
| `get_oncall_shift` | OnCall | Get details for a specific OnCall shift | `grafana-oncall-app.schedules:read` | Plugin-specific scopes |
| `get_current_oncall_users` | OnCall | Get users currently on-call for a specific schedule | `grafana-oncall-app.schedules:read` | Plugin-specific scopes |
| `list_oncall_teams` | OnCall | List teams from Grafana OnCall | `grafana-oncall-app.user-settings:read` | Plugin-specific scopes |
| `list_oncall_users` | OnCall | List users from Grafana OnCall | `grafana-oncall-app.user-settings:read` | Plugin-specific scopes |
| `list_alert_groups` | OnCall | List alert groups from Grafana OnCall with filtering options | `grafana-oncall-app.alert-groups:read` | Plugin-specific scopes |
| `get_alert_group` | OnCall | Get a specific alert group from Grafana OnCall by its ID | `grafana-oncall-app.alert-groups:read` | Plugin-specific scopes |
| `get_sift_investigation` | Sift | Retrieve an existing Sift investigation by its UUID | Viewer role | N/A |
| `get_sift_analysis` | Sift | Retrieve a specific analysis from a Sift investigation | Viewer role | N/A |
| `list_sift_investigations` | Sift | Retrieve a list of Sift investigations with an optional limit | Viewer role | N/A |
| `find_error_pattern_logs` | Sift | Finds elevated error patterns in Loki logs. | Editor role | N/A |
| `find_slow_requests` | Sift | Finds slow requests from the relevant tempo datasources. | Editor role | N/A |
| `list_pyroscope_label_names` | Pyroscope | List label names matching a selector | `datasources:query` | `datasources:uid:pyroscope-uid` |
| `list_pyroscope_label_values` | Pyroscope | List label values matching a selector for a label name | `datasources:query` | `datasources:uid:pyroscope-uid` |
| `list_pyroscope_profile_types` | Pyroscope | List available profile types | `datasources:query` | `datasources:uid:pyroscope-uid` |
| `fetch_pyroscope_profile` | Pyroscope | Fetches a profile in DOT format for analysis | `datasources:query` | `datasources:uid:pyroscope-uid` |
| `get_assertions` | Asserts | Get assertion summary for a given entity | Plugin-specific permissions | Plugin-specific scopes |
| `generate_deeplink` | Navigation | Generate accurate deeplink URLs for Grafana resources | None (read-only URL generation) | N/A
| `get_annotations` | Annotations | Fetch annotations with filters | `annotations:read` | `annotations:*` or `annotations:id:123` |
| `create_annotation` | Annotations | Create a new annotation on a dashboard or panel | `annotations:write` | `annotations:*` |
| `create_graphite_annotation` | Annotations | Create an annotation using Graphite format | `annotations:write` | `annotations:*` |
| `update_annotation` | Annotations | Replace all fields of an annotation (full update) | `annotations:write` | `annotations:*` |
| `patch_annotation` | Annotations | Update only specific fields of an annotation (partial update) | `annotations:write` | `annotations:*` |
| `get_annotation_tags` | Annotations | List annotation tags with optional filtering | `annotations:read` | `annotations:*` |
|
## CLI Flags Reference
The `mcp-grafana` binary supports various command-line flags for configuration:
**Transport Options:**
- `-t, --transport`: Transport type (`stdio`, `sse`, or `streamable-http`) - default: `stdio`
- `--address`: The host and port for SSE/streamable-http server - default: `localhost:8000`
- `--base-path`: Base path for the SSE/streamable-http server
- `--endpoint-path`: Endpoint path for the streamable-http server - default: `/`
**Debug and Logging:**
- `--debug`: Enable debug mode for detailed HTTP request/response logging
**Tool Configuration:**
- `--enabled-tools`: Comma-separated list of enabled categories - default: all categories enabled - example: "loki,datasources"
- `--disable-search`: Disable search tools
- `--disable-datasource`: Disable datasource tools
- `--disable-incident`: Disable incident tools
- `--disable-prometheus`: Disable prometheus tools
- `--disable-write`: Disable write tools (create/update operations)
- `--disable-loki`: Disable loki tools
- `--disable-alerting`: Disable alerting tools
- `--disable-dashboard`: Disable dashboard tools
- `--disable-oncall`: Disable oncall tools
- `--disable-asserts`: Disable asserts tools
- `--disable-sift`: Disable sift tools
- `--disable-admin`: Disable admin tools
- `--disable-pyroscope`: Disable pyroscope tools
- `--disable-navigation`: Disable navigation tools
### Read-Only Mode
The `--disable-write` flag provides a way to run the MCP server in read-only mode, preventing any write operations to your Grafana instance. This is useful for scenarios where you want to provide safe, read-only access such as:
- Using service accounts with limited read-only permissions
- Providing AI assistants with observability data without modification capabilities
- Running in production environments where write access should be restricted
- Testing and development scenarios where you want to prevent accidental modifications
When `--disable-write` is enabled, the following write operations are disabled:
**Dashboard Tools:**
- `update_dashboard`
**Folder Tools:**
- `create_folder`
**Incident Tools:**
- `create_incident`
- `add_activity_to_incident`
**Alerting Tools:**
- `create_alert_rule`
- `update_alert_rule`
- `delete_alert_rule`
**Annotation Tools:**
- `create_annotation`
- `create_graphite_annotation`
- `update_annotation`
- `patch_annotation`
**Sift Tools:**
- `find_error_pattern_logs` (creates investigations)
- `find_slow_requests` (creates investigations)
All read operations remain available, allowing you to query dashboards, run PromQL/LogQL queries, list resources, and retrieve data.
**Client TLS Configuration (for Grafana connections):**
- `--tls-cert-file`: Path to TLS certificate file for client authentication
- `--tls-key-file`: Path to TLS private key file for client authentication
- `--tls-ca-file`: Path to TLS CA certificate file for server verification
- `--tls-skip-verify`: Skip TLS certificate verification (insecure)
**Server TLS Configuration (streamable-http transport only):**
- `--server.tls-cert-file`: Path to TLS certificate file for server HTTPS
- `--server.tls-key-file`: Path to TLS private key file for server HTTPS
## Usage
This MCP server works with both local Grafana instances and Grafana Cloud. For Grafana Cloud, use your instance URL (e.g., `https://myinstance.grafana.net`) instead of `http://localhost:3000` in the configuration examples below.
1. If using service account token authentication, create a service account in Grafana with enough permissions to use the tools you want to use,
generate a service account token, and copy it to the clipboard for use in the configuration file.
Follow the [Grafana service account documentation][service-account] for details on creating service account tokens.
Tip: If you're not comfortable configuring fine-grained RBAC scopes, a simpler (but less restrictive) option is to assign the built-in `Editor` role to the service account. This grants broad read/write access that covers most MCP server operations — use it when convenience outweighs strict least-privilege requirements.
> **Note:** The environment variable `GRAFANA_API_KEY` is deprecated and will be removed in a future version. Please migrate to using `GRAFANA_SERVICE_ACCOUNT_TOKEN` instead. The old variable name will continue to work for backward compatibility but will show deprecation warnings.
### Multi-Organization Support
You can specify which organization to interact with using either:
- **Environment variable:** Set `GRAFANA_ORG_ID` to the numeric organization ID
- **HTTP header:** Set `X-Grafana-Org-Id` when using SSE or streamable HTTP transports (header takes precedence over environment variable - meaning you can set a default org as well).
When an organization ID is provided, the MCP server will set the `X-Grafana-Org-Id` header on all requests to Grafana, ensuring that operations are performed within the specified organization context.
**Example with organization ID:**
```json
{
"mcpServers": {
"grafana": {
"command": "mcp-grafana",
"args": [],
"env": {
"GRAFANA_URL": "http://localhost:3000",
"GRAFANA_USERNAME": "<your username>",
"GRAFANA_PASSWORD": "<your password>",
"GRAFANA_ORG_ID": "2"
}
}
}
}
```
2. You have several options to install `mcp-grafana`:
- **Docker image**: Use the pre-built Docker image from Docker Hub.
**Important**: The Docker image's entrypoint is configured to run the MCP server in SSE mode by default, but most users will want to use STDIO mode for direct integration with AI assistants like Claude Desktop:
1. **STDIO Mode**: For stdio mode you must explicitly override the default with `-t stdio` and include the `-i` flag to keep stdin open:
```bash
docker pull mcp/grafana
# For local Grafana:
docker run --rm -i -e GRAFANA_URL=http://localhost:3000 -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> mcp/grafana -t stdio
# For Grafana Cloud:
docker run --rm -i -e GRAFANA_URL=https://myinstance.grafana.net -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> mcp/grafana -t stdio
```
2. **SSE Mode**: In this mode, the server runs as an HTTP server that clients connect to. You must expose port 8000 using the `-p` flag:
```bash
docker pull mcp/grafana
docker run --rm -p 8000:8000 -e GRAFANA_URL=http://localhost:3000 -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> mcp/grafana
```
3. **Streamable HTTP Mode**: In this mode, the server operates as an independent process that can handle multiple client connections. You must expose port 8000 using the `-p` flag: For this mode you must explicitly override the default with `-t streamable-http`
```bash
docker pull mcp/grafana
docker run --rm -p 8000:8000 -e GRAFANA_URL=http://localhost:3000 -e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> mcp/grafana -t streamable-http
```
For HTTPS streamable HTTP mode with server TLS certificates:
```bash
docker pull mcp/grafana
docker run --rm -p 8443:8443 \
-v /path/to/certs:/certs:ro \
-e GRAFANA_URL=http://localhost:3000 \
-e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> \
mcp/grafana \
-t streamable-http \
-addr :8443 \
--server.tls-cert-file /certs/server.crt \
--server.tls-key-file /certs/server.key
```
- **Download binary**: Download the latest release of `mcp-grafana` from the [releases page](https://github.com/grafana/mcp-grafana/releases) and place it in your `$PATH`.
- **Build from source**: If you have a Go toolchain installed you can also build and install it from source, using the `GOBIN` environment variable
to specify the directory where the binary should be installed. This should also be in your `PATH`.
```bash
GOBIN="$HOME/go/bin" go install github.com/grafana/mcp-grafana/cmd/mcp-grafana@latest
```
- **Deploy to Kubernetes using Helm**: use the [Helm chart from the Grafana helm-charts repository](https://github.com/grafana/helm-charts/tree/main/charts/grafana-mcp)
```bash
helm repo add grafana https://grafana.github.io/helm-charts
helm install --set grafana.apiKey=<Grafana_ApiKey> --set grafana.url=<GrafanaUrl> my-release grafana/grafana-mcp
```
3. Add the server configuration to your client configuration file. For example, for Claude Desktop:
**If using the binary:**
```json
{
"mcpServers": {
"grafana": {
"command": "mcp-grafana",
"args": [],
"env": {
"GRAFANA_URL": "http://localhost:3000", // Or "https://myinstance.grafana.net" for Grafana Cloud
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>",
// If using username/password authentication
"GRAFANA_USERNAME": "<your username>",
"GRAFANA_PASSWORD": "<your password>",
// Optional: specify organization ID for multi-org support
"GRAFANA_ORG_ID": "1"
}
}
}
}
```
> Note: if you see `Error: spawn mcp-grafana ENOENT` in Claude Desktop, you need to specify the full path to `mcp-grafana`.
**If using Docker:**
```json
{
"mcpServers": {
"grafana": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"-e",
"GRAFANA_URL",
"-e",
"GRAFANA_SERVICE_ACCOUNT_TOKEN",
"mcp/grafana",
"-t",
"stdio"
],
"env": {
"GRAFANA_URL": "http://localhost:3000", // Or "https://myinstance.grafana.net" for Grafana Cloud
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>",
// If using username/password authentication
"GRAFANA_USERNAME": "<your username>",
"GRAFANA_PASSWORD": "<your password>",
// Optional: specify organization ID for multi-org support
"GRAFANA_ORG_ID": "1"
}
}
}
}
```
> Note: The `-t stdio` argument is essential here because it overrides the default SSE mode in the Docker image.
**Using VSCode with remote MCP server**
If you're using VSCode and running the MCP server in SSE mode (which is the default when using the Docker image without overriding the transport), make sure your `.vscode/settings.json` includes the following:
```json
"mcp": {
"servers": {
"grafana": {
"type": "sse",
"url": "http://localhost:8000/sse"
}
}
}
```
For HTTPS streamable HTTP mode with server TLS certificates:
```json
"mcp": {
"servers": {
"grafana": {
"type": "sse",
"url": "https://localhost:8443/sse"
}
}
}
```
### Debug Mode
You can enable debug mode for the Grafana transport by adding the `-debug` flag to the command. This will provide detailed logging of HTTP requests and responses between the MCP server and the Grafana API, which can be helpful for troubleshooting.
To use debug mode with the Claude Desktop configuration, update your config as follows:
**If using the binary:**
```json
{
"mcpServers": {
"grafana": {
"command": "mcp-grafana",
"args": ["-debug"],
"env": {
"GRAFANA_URL": "http://localhost:3000", // Or "https://myinstance.grafana.net" for Grafana Cloud
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
}
}
}
}
```
**If using Docker:**
```json
{
"mcpServers": {
"grafana": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"-e",
"GRAFANA_URL",
"-e",
"GRAFANA_SERVICE_ACCOUNT_TOKEN",
"mcp/grafana",
"-t",
"stdio",
"-debug"
],
"env": {
"GRAFANA_URL": "http://localhost:3000", // Or "https://myinstance.grafana.net" for Grafana Cloud
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
}
}
}
}
```
> Note: As with the standard configuration, the `-t stdio` argument is required to override the default SSE mode in the Docker image.
### TLS Configuration
If your Grafana instance is behind mTLS or requires custom TLS certificates, you can configure the MCP server to use custom certificates. The server supports the following TLS configuration options:
- `--tls-cert-file`: Path to TLS certificate file for client authentication
- `--tls-key-file`: Path to TLS private key file for client authentication
- `--tls-ca-file`: Path to TLS CA certificate file for server verification
- `--tls-skip-verify`: Skip TLS certificate verification (insecure, use only for testing)
**Example with client certificate authentication:**
```json
{
"mcpServers": {
"grafana": {
"command": "mcp-grafana",
"args": [
"--tls-cert-file",
"/path/to/client.crt",
"--tls-key-file",
"/path/to/client.key",
"--tls-ca-file",
"/path/to/ca.crt"
],
"env": {
"GRAFANA_URL": "https://secure-grafana.example.com",
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
}
}
}
}
```
**Example with Docker:**
```json
{
"mcpServers": {
"grafana": {
"command": "docker",
"args": [
"run",
"--rm",
"-i",
"-v",
"/path/to/certs:/certs:ro",
"-e",
"GRAFANA_URL",
"-e",
"GRAFANA_SERVICE_ACCOUNT_TOKEN",
"mcp/grafana",
"-t",
"stdio",
"--tls-cert-file",
"/certs/client.crt",
"--tls-key-file",
"/certs/client.key",
"--tls-ca-file",
"/certs/ca.crt"
],
"env": {
"GRAFANA_URL": "https://secure-grafana.example.com",
"GRAFANA_SERVICE_ACCOUNT_TOKEN": "<your service account token>"
}
}
}
}
```
The TLS configuration is applied to all HTTP clients used by the MCP server, including:
- The main Grafana OpenAPI client
- Prometheus datasource clients
- Loki datasource clients
- Incident management clients
- Sift investigation clients
- Alerting clients
- Asserts clients
**Direct CLI Usage Examples:**
For testing with self-signed certificates:
```bash
./mcp-grafana --tls-skip-verify -debug
```
With client certificate authentication:
```bash
./mcp-grafana \
--tls-cert-file /path/to/client.crt \
--tls-key-file /path/to/client.key \
--tls-ca-file /path/to/ca.crt \
-debug
```
With custom CA certificate only:
```bash
./mcp-grafana --tls-ca-file /path/to/ca.crt
```
**Programmatic Usage:**
If you're using this library programmatically, you can also create TLS-enabled context functions:
```go
// Using struct literals
tlsConfig := &mcpgrafana.TLSConfig{
CertFile: "/path/to/client.crt",
KeyFile: "/path/to/client.key",
CAFile: "/path/to/ca.crt",
}
grafanaConfig := mcpgrafana.GrafanaConfig{
Debug: true,
TLSConfig: tlsConfig,
}
contextFunc := mcpgrafana.ComposedStdioContextFunc(grafanaConfig)
// Or inline
grafanaConfig := mcpgrafana.GrafanaConfig{
Debug: true,
TLSConfig: &mcpgrafana.TLSConfig{
CertFile: "/path/to/client.crt",
KeyFile: "/path/to/client.key",
CAFile: "/path/to/ca.crt",
},
}
contextFunc := mcpgrafana.ComposedStdioContextFunc(grafanaConfig)
```
### Server TLS Configuration (Streamable HTTP Transport Only)
When using the streamable HTTP transport (`-t streamable-http`), you can configure the MCP server to serve HTTPS instead of HTTP. This is useful when you need to secure the connection between your MCP client and the server itself.
The server supports the following TLS configuration options for the streamable HTTP transport:
- `--server.tls-cert-file`: Path to TLS certificate file for server HTTPS (required for TLS)
- `--server.tls-key-file`: Path to TLS private key file for server HTTPS (required for TLS)
**Note**: These flags are completely separate from the client TLS flags documented above. The client TLS flags configure how the MCP server connects to Grafana, while these server TLS flags configure how clients connect to the MCP server when using streamable HTTP transport.
**Example with HTTPS streamable HTTP server:**
```bash
./mcp-grafana \
-t streamable-http \
--server.tls-cert-file /path/to/server.crt \
--server.tls-key-file /path/to/server.key \
-addr :8443
```
This would start the MCP server on HTTPS port 8443. Clients would then connect to `https://localhost:8443/` instead of `http://localhost:8000/`.
**Docker example with server TLS:**
```bash
docker run --rm -p 8443:8443 \
-v /path/to/certs:/certs:ro \
-e GRAFANA_URL=http://localhost:3000 \
-e GRAFANA_SERVICE_ACCOUNT_TOKEN=<your service account token> \
mcp/grafana \
-t streamable-http \
-addr :8443 \
--server.tls-cert-file /certs/server.crt \
--server.tls-key-file /certs/server.key
```
### Health Check Endpoint
When using the SSE (`-t sse`) or streamable HTTP (`-t streamable-http`) transports, the MCP server exposes a health check endpoint at `/healthz`. This endpoint can be used by load balancers, monitoring systems, or orchestration platforms to verify that the server is running and accepting connections.
**Endpoint:** `GET /healthz`
**Response:**
- Status Code: `200 OK`
- Body: `ok`
**Example usage:**
```bash
# For streamable HTTP or SSE transport on default port
curl http://localhost:8000/healthz
# With custom address
curl http://localhost:9090/healthz
```
**Note:** The health check endpoint is only available when using SSE or streamable HTTP transports. It is not available when using the stdio transport (`-t stdio`), as stdio does not expose an HTTP server.
## Troubleshooting
### Grafana Version Compatibility
If you encounter the following error when using datasource-related tools:
```
get datasource by uid : [GET /datasources/uid/{uid}][400] getDataSourceByUidBadRequest {"message":"id is invalid"}
```
This typically indicates that you are using a Grafana version earlier than 9.0. The `/datasources/uid/{uid}` API endpoint was introduced in Grafana 9.0, and datasource operations will fail on earlier versions.
**Solution:** Upgrade your Grafana instance to version 9.0 or later to resolve this issue.
## Development
Contributions are welcome! Please open an issue or submit a pull request if you have any suggestions or improvements.
This project is written in Go. Install Go following the instructions for your platform.
To run the server locally in STDIO mode (which is the default for local development), use:
```bash
make run
```
To run the server locally in SSE mode, use:
```bash
go run ./cmd/mcp-grafana --transport sse
```
You can also run the server using the SSE transport inside a custom built Docker image. Just like the published Docker image, this custom image's entrypoint defaults to SSE mode. To build the image, use:
```
make build-image
```
And to run the image in SSE mode (the default), use:
```
docker run -it --rm -p 8000:8000 mcp-grafana:latest
```
If you need to run it in STDIO mode instead, override the transport setting:
```
docker run -it --rm mcp-grafana:latest -t stdio
```
### Testing
There are three types of tests available:
1. Unit Tests (no external dependencies required):
```bash
make test-unit
```
You can also run unit tests with:
```bash
make test
```
2. Integration Tests (requires docker containers to be up and running):
```bash
make test-integration
```
3. Cloud Tests (requires cloud Grafana instance and credentials):
```bash
make test-cloud
```
> Note: Cloud tests are automatically configured in CI. For local development, you'll need to set up your own Grafana Cloud instance and credentials.
More comprehensive integration tests will require a Grafana instance to be running locally on port 3000; you can start one with Docker Compose:
```bash
docker-compose up -d
```
The integration tests can be run with:
```bash
make test-all
```
If you're adding more tools, please add integration tests for them. The existing tests should be a good starting point.
### Linting
To lint the code, run:
```bash
make lint
```
This includes a custom linter that checks for unescaped commas in `jsonschema` struct tags. The commas in `description` fields must be escaped with `\\,` to prevent silent truncation. You can run just this linter with:
```bash
make lint-jsonschema
```
See the [JSONSchema Linter documentation](internal/linter/jsonschema/README.md) for more details.
## License
This project is licensed under the [Apache License, Version 2.0](LICENSE).
[mcp]: https://modelcontextprotocol.io/
[service-account]: https://grafana.com/docs/grafana/latest/administration/service-accounts/#add-a-token-to-a-service-account-in-grafana
```
--------------------------------------------------------------------------------
/testdata/prometheus-seed.yml:
--------------------------------------------------------------------------------
```yaml
groups:
- name: seed
rules:
- record: test
expr: vector(1)
```
--------------------------------------------------------------------------------
/gemini-extension.json:
--------------------------------------------------------------------------------
```json
{
"name": "grafana",
"version": "0.7.0",
"mcpServers": {
"grafana": {
"command": "${extensionPath}${/}mcp-grafana"
}
}
}
```
--------------------------------------------------------------------------------
/testdata/prometheus.yml:
--------------------------------------------------------------------------------
```yaml
global:
scrape_interval: 1s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
rule_files:
- prometheus-seed.yml
```
--------------------------------------------------------------------------------
/testdata/provisioning/dashboards/dashboards.yaml:
--------------------------------------------------------------------------------
```yaml
apiVersion: 1
providers:
- name: "docker-compose"
orgId: 1
folder: "Tests"
folderUid: "tests"
type: file
disableDeletion: true
updateIntervalSeconds: 60
allowUiUpdates: false
options:
# <string, required> path to dashboard files on disk. Required when using the 'file' type
path: /var/lib/grafana/dashboards
```
--------------------------------------------------------------------------------
/tests/health_test.py:
--------------------------------------------------------------------------------
```python
import httpx
import pytest
pytestmark = pytest.mark.anyio
async def test_healthz(mcp_transport: str, mcp_url: str):
if mcp_transport == "stdio":
return
health_url = f"{mcp_url}/healthz"
async with httpx.AsyncClient() as client:
response = await client.get(health_url)
assert response.status_code == 200
assert response.text == "ok"
```
--------------------------------------------------------------------------------
/testdata/tempo-config.yaml:
--------------------------------------------------------------------------------
```yaml
server:
http_listen_port: 3200
log_level: debug
query_frontend:
mcp_server:
enabled: true
distributor:
receivers:
otlp:
protocols:
http:
grpc:
ingester:
max_block_duration: 5m
compactor:
compaction:
block_retention: 1h
storage:
trace:
backend: local
local:
path: /tmp/tempo/blocks
wal:
path: /tmp/tempo/wal
```
--------------------------------------------------------------------------------
/testdata/tempo-config-2.yaml:
--------------------------------------------------------------------------------
```yaml
server:
http_listen_port: 3201
log_level: debug
query_frontend:
mcp_server:
enabled: true
distributor:
receivers:
otlp:
protocols:
http:
grpc:
ingester:
max_block_duration: 5m
compactor:
compaction:
block_retention: 1h
storage:
trace:
backend: local
local:
path: /tmp/tempo2/blocks
wal:
path: /tmp/tempo2/wal
```
--------------------------------------------------------------------------------
/testdata/promtail-config.yml:
--------------------------------------------------------------------------------
```yaml
server:
http_listen_port: 9080
positions:
filename: /tmp/positions.yaml
clients:
- url: http://loki:3100/loki/api/v1/push
scrape_configs:
- job_name: docker
docker_sd_configs:
- host: unix:///var/run/docker.sock
refresh_interval: 5s
relabel_configs:
- source_labels: ['__meta_docker_container_name']
regex: '/(.*)'
target_label: 'container'
```
--------------------------------------------------------------------------------
/testdata/provisioning/alerting/contact_points.yaml:
--------------------------------------------------------------------------------
```yaml
apiVersion: 1
contactPoints:
- name: Email1
receivers:
- uid: email1
type: email
settings:
addresses: [email protected]
singleEmail: false
message: my optional message1 to include
- name: Email2
receivers:
- uid: email2
type: email
settings:
addresses: [email protected]
singleEmail: false
message: my optional message2 to include
```
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
```json
{
"ignorePresets": [
"github>grafana/grafana-renovate-config//presets/base",
"github>grafana/grafana-renovate-config//presets/automerge",
"github>grafana/grafana-renovate-config//presets/labels",
"github>grafana/grafana-renovate-config//presets/npm"
],
"extends": [
"config:best-practices",
":disableDependencyDashboard",
":preserveSemverRanges",
"github>grafana/grafana-renovate-config//presets/plugin-ci-workflows"
],
"prConcurrentLimit": 5,
"minimumReleaseAge": "14 days",
"rebaseWhen": "behind-base-branch"
}
```
--------------------------------------------------------------------------------
/testdata/loki-config.yml:
--------------------------------------------------------------------------------
```yaml
auth_enabled: false
server:
http_listen_port: 3100
ingester:
lifecycler:
address: 127.0.0.1
ring:
kvstore:
store: inmemory
replication_factor: 1
final_sleep: 0s
chunk_idle_period: 5m
chunk_retain_period: 30s
wal:
enabled: true
dir: /loki/wal
compactor:
working_directory: /loki/compactor
schema_config:
configs:
- from: 2020-10-24
store: boltdb-shipper
object_store: filesystem
schema: v11
index:
prefix: index_
period: 24h
storage_config:
boltdb_shipper:
active_index_directory: /loki/boltdb-shipper-active
cache_location: /loki/boltdb-shipper-cache
cache_ttl: 24h
shared_store: filesystem
filesystem:
directory: /loki/chunks
```
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
```yaml
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "gomod" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "weekly"
groups:
go-dependencies:
patterns:
- "*"
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
github-actions:
patterns:
- "*"
```
--------------------------------------------------------------------------------
/tests/pyproject.toml:
--------------------------------------------------------------------------------
```toml
[project]
name = "tests"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
authors = [
{ name = "Ben Sully", email = "[email protected]" },
{ name = "Ioanna Armouti", email = "[email protected]" },
{ name = "Chris Marchbanks", email = "[email protected]" },
]
requires-python = ">=3.13"
dependencies = []
[dependency-groups]
dev = [
"anyio>=4.9.0",
"flaky>=3.8.1",
"langevals[langevals]>=0.1.8",
"litellm>=1.63.12",
"mcp>=1.9.3",
"pytest>=8.3.5",
"python-dotenv>=1.0.0",
]
[tool.pytest.ini_options]
[tool.uv.sources]
# Until https://github.com/langwatch/langevals/issues/20.
langevals = { git = "https://github.com/langwatch/langevals", rev = "3a934d1dc4ea95f039cf7bc4969e6bad1543c719" }
```
--------------------------------------------------------------------------------
/testdata/provisioning/datasources/datasources.yaml:
--------------------------------------------------------------------------------
```yaml
apiVersion: 1
datasources:
- name: Prometheus
id: 1
uid: prometheus
type: prometheus
access: proxy
url: http://prometheus:9090
isDefault: true
- name: Prometheus Demo
id: 2
uid: prometheus-demo
type: prometheus
access: proxy
url: https://prometheus.demo.prometheus.io
- name: Loki
id: 3
uid: loki
type: loki
access: proxy
url: http://loki:3100
isDefault: false
- name: pyroscope
uid: pyroscope
type: grafana-pyroscope-datasource
access: proxy
url: http://pyroscope:4040
isDefault: false
- name: Tempo
id: 4
uid: tempo
type: tempo
access: proxy
url: http://tempo:3200
isDefault: false
- name: Tempo Secondary
id: 5
uid: tempo-secondary
type: tempo
access: proxy
url: http://tempo2:3201
isDefault: false
```
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
```yaml
name: goreleaser
on:
push:
# run only against tags
tags:
- "v*"
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with:
go-version: stable
# Do not use any caches when creating a release.
cache: false
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@a08664b80c0ab417b1babcbf750274aed2018fef
with:
distribution: goreleaser
# 'latest', 'nightly', or a semver
version: "~> v2"
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
```
--------------------------------------------------------------------------------
/tools/search_test.go:
--------------------------------------------------------------------------------
```go
// Requires a Grafana instance running on localhost:3000,
// with a dashboard named "Demo" provisioned.
// Run with `go test -tags integration`.
//go:build integration
package tools
import (
"testing"
"github.com/grafana/grafana-openapi-client-go/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSearchTools(t *testing.T) {
t.Run("search dashboards", func(t *testing.T) {
ctx := newTestContext()
result, err := searchDashboards(ctx, SearchDashboardsParams{
Query: "Demo",
})
require.NoError(t, err)
assert.Len(t, result, 1)
assert.Equal(t, models.HitType("dash-db"), result[0].Type)
})
t.Run("search folders", func(t *testing.T) {
ctx := newTestContext()
result, err := searchFolders(ctx, SearchFoldersParams{
Query: "Tests",
})
require.NoError(t, err)
assert.NotEmpty(t, result)
assert.Equal(t, models.HitType("dash-folder"), result[0].Type)
})
}
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
# Build stage
FROM golang:1.24-bullseye@sha256:2cdc80dc25edcb96ada1654f73092f2928045d037581fa4aa7c40d18af7dd85a AS builder
# Set the working directory
WORKDIR /app
# Copy go.mod and go.sum files
COPY go.mod go.sum ./
# Download dependencies
RUN go mod download
# Copy the source code
COPY . .
# Build the application
RUN go build -o mcp-grafana ./cmd/mcp-grafana
# Final stage
FROM debian:bullseye-slim@sha256:52927eff8153b563244f98cdc802ba97918afcdf67f9e4867cbf1f7afb3d147b
LABEL io.modelcontextprotocol.server.name="io.github.grafana/mcp-grafana"
# Install ca-certificates for HTTPS requests
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
# Create a non-root user
RUN useradd -r -u 1000 -m mcp-grafana
# Set the working directory
WORKDIR /app
# Copy the binary from the builder stage
COPY --from=builder --chown=1000:1000 /app/mcp-grafana /app/
# Use the non-root user
USER mcp-grafana
# Expose the port the app runs on
EXPOSE 8000
# Run the application
ENTRYPOINT ["/app/mcp-grafana", "--transport", "sse", "--address", "0.0.0.0:8000"]
```
--------------------------------------------------------------------------------
/cmd/linters/jsonschema/main.go:
--------------------------------------------------------------------------------
```go
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
linter "github.com/grafana/mcp-grafana/internal/linter/jsonschema"
)
func main() {
var (
basePath string
help bool
fix bool
)
flag.StringVar(&basePath, "path", ".", "Base directory to scan for Go files")
flag.BoolVar(&help, "help", false, "Show help message")
flag.BoolVar(&fix, "fix", false, "Automatically fix unescaped commas")
flag.Parse()
if help {
fmt.Println("jsonschema-linter - A tool to find unescaped commas in jsonschema struct tags")
fmt.Println("\nUsage:")
flag.PrintDefaults()
os.Exit(0)
}
// Resolve to absolute path
absPath, err := filepath.Abs(basePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error resolving path: %v\n", err)
os.Exit(1)
}
// Initialize linter
jsonLinter := &linter.JSONSchemaLinter{
FixMode: fix,
}
// Find unescaped commas
err = jsonLinter.FindUnescapedCommas(absPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error scanning files: %v\n", err)
os.Exit(1)
}
// Print errors
jsonLinter.PrintErrors()
// Exit with error code if issues were found
if len(jsonLinter.Errors) > 0 {
os.Exit(1)
}
}
```
--------------------------------------------------------------------------------
/testdata/prometheus-entrypoint.sh:
--------------------------------------------------------------------------------
```bash
#!/bin/sh
# Prometheus entrypoint script to backfill recording rules and start Prometheus
set -e
echo "Starting Prometheus entrypoint script..."
backfill() {
# Calculate time range for backfilling (5 hours ago from now)
# Get current time in seconds since epoch
CURRENT_TIME=$(date -u +%s)
# Subtract 5 hours (5 * 60 * 60 = 18000 seconds)
START_TIME=$((CURRENT_TIME - 18000))
# wait until Prometheus is up and running
until wget http://localhost:9090/-/healthy -q -O /dev/null; do
sleep 1
done
promtool tsdb create-blocks-from \
rules \
--url=http://localhost:9090 \
--start="${START_TIME}" \
--end="${CURRENT_TIME}" \
--eval-interval=30s \
/etc/prometheus/prometheus-seed.yml
}
# Start Prometheus with the regular configuration, this is needed for backfilling
/bin/prometheus \
--config.file=/etc/prometheus/prometheus.yml &
backfill
# Restarting Prometheus after backfilling will allow to load the new blocks directly
# without having to wait for the next compaction cycle
kill %1
echo "Starting Prometheus server..."
# Start Prometheus with the regular configuration
/bin/prometheus \
--config.file=/etc/prometheus/prometheus.yml
```
--------------------------------------------------------------------------------
/tools/asserts_cloud_test.go:
--------------------------------------------------------------------------------
```go
//go:build cloud
// +build cloud
// This file contains cloud integration tests that run against a dedicated test instance
// connected to a Grafana instance at (ASSERTS_GRAFANA_URL, ASSERTS_GRAFANA_SERVICE_ACCOUNT_TOKEN or ASSERTS_GRAFANA_API_KEY).
// These tests expect this configuration to exist and will skip if the required
// environment variables are not set. The ASSERTS_GRAFANA_API_KEY variable is deprecated.
package tools
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAssertsCloudIntegration(t *testing.T) {
ctx := createCloudTestContext(t, "Asserts", "ASSERTS_GRAFANA_URL", "ASSERTS_GRAFANA_API_KEY")
t.Run("get assertions", func(t *testing.T) {
// Set up time range for the last hour
endTime := time.Now()
startTime := endTime.Add(-24 * time.Hour)
// Test parameters for a known service in the environment
params := GetAssertionsParams{
StartTime: startTime,
EndTime: endTime,
EntityType: "Service", // Adjust these values based on your actual environment
EntityName: "model-builder",
Env: "dev-us-central-0",
Namespace: "asserts",
}
// Get assertions from the real Grafana instance
result, err := getAssertions(ctx, params)
require.NoError(t, err, "Failed to get assertions from Grafana")
assert.NotEmpty(t, result, "Expected non-empty assertions result")
// Basic validation of the response structure
assert.Contains(t, result, "summaries", "Response should contain a summaries field")
})
}
```
--------------------------------------------------------------------------------
/tools/folder.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/grafana/grafana-openapi-client-go/models"
mcpgrafana "github.com/grafana/mcp-grafana"
)
type CreateFolderParams struct {
Title string `json:"title" jsonschema:"required,description=The title of the folder."`
UID string `json:"uid,omitempty" jsonschema:"description=Optional folder UID. If omitted\\, Grafana will generate one."`
ParentUID string `json:"parentUid,omitempty" jsonschema:"description=Optional parent folder UID. If set\\, the folder will be created under this parent."`
}
func createFolder(ctx context.Context, args CreateFolderParams) (*models.Folder, error) {
if args.Title == "" {
return nil, fmt.Errorf("title is required")
}
c := mcpgrafana.GrafanaClientFromContext(ctx)
cmd := &models.CreateFolderCommand{Title: args.Title}
if args.UID != "" {
cmd.UID = args.UID
}
if args.ParentUID != "" {
cmd.ParentUID = args.ParentUID
}
resp, err := c.Folders.CreateFolder(cmd)
if err != nil {
return nil, fmt.Errorf("create folder '%s': %w", args.Title, err)
}
return resp.Payload, nil
}
var CreateFolder = mcpgrafana.MustTool(
"create_folder",
"Create a Grafana folder. Provide a title and optional UID. Returns the created folder.",
createFolder,
mcp.WithTitleAnnotation("Create folder"),
mcp.WithIdempotentHintAnnotation(false),
mcp.WithReadOnlyHintAnnotation(false),
)
func AddFolderTools(mcp *server.MCPServer, enableWriteTools bool) {
if enableWriteTools {
CreateFolder.Register(mcp)
}
}
```
--------------------------------------------------------------------------------
/.github/workflows/unit.yml:
--------------------------------------------------------------------------------
```yaml
name: Unit Tests & Linting
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 0 * * *"
permissions:
contents: read
jobs:
lint-jsonschema:
name: Lint JSON Schemas
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with:
go-version: "1.24"
cache: true
- name: Run linter
run: make lint-jsonschema
lint-go:
name: Lint Go
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with:
go-version: "1.24"
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
with:
version: v2.3.0
test-unit:
name: Test Unit
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with:
go-version: "1.24"
cache: true
- name: Run unit tests
run: make test-unit
```
--------------------------------------------------------------------------------
/tools/incident_test.go:
--------------------------------------------------------------------------------
```go
//go:build unit
// +build unit
package tools
import (
"context"
"testing"
"github.com/grafana/incident-go"
mcpgrafana "github.com/grafana/mcp-grafana"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newIncidentTestContext() context.Context {
client := incident.NewTestClient()
return mcpgrafana.WithIncidentClient(context.Background(), client)
}
func TestIncidentTools(t *testing.T) {
t.Run("list incidents", func(t *testing.T) {
ctx := newIncidentTestContext()
result, err := listIncidents(ctx, ListIncidentsParams{
Limit: 2,
})
require.NoError(t, err)
assert.Len(t, result.IncidentPreviews, 2)
})
t.Run("create incident", func(t *testing.T) {
ctx := newIncidentTestContext()
result, err := createIncident(ctx, CreateIncidentParams{
Title: "high latency in web requests",
Severity: "minor",
RoomPrefix: "test",
IsDrill: true,
Status: "active",
AttachCaption: "Test attachment",
AttachURL: "https://grafana.com",
})
require.NoError(t, err)
assert.Equal(t, "high latency in web requests", result.Title)
assert.Equal(t, "minor", result.Severity)
assert.True(t, result.IsDrill)
assert.Equal(t, "active", result.Status)
})
t.Run("add activity to incident", func(t *testing.T) {
ctx := newIncidentTestContext()
result, err := addActivityToIncident(ctx, AddActivityToIncidentParams{
IncidentID: "123",
Body: "The incident was created by user-123",
EventTime: "2021-08-07T11:58:23Z",
})
require.NoError(t, err)
assert.Equal(t, "The incident was created by user-123", result.Body)
assert.Equal(t, "2021-08-07T11:58:23Z", result.EventTime)
})
}
```
--------------------------------------------------------------------------------
/tools/testcontext_test.go:
--------------------------------------------------------------------------------
```go
//go:build integration
package tools
import (
"context"
"fmt"
"net/url"
"os"
"github.com/go-openapi/strfmt"
"github.com/grafana/grafana-openapi-client-go/client"
mcpgrafana "github.com/grafana/mcp-grafana"
)
// newTestContext creates a new context with the Grafana URL and service account token
// from the environment variables GRAFANA_URL and GRAFANA_SERVICE_ACCOUNT_TOKEN (or deprecated GRAFANA_API_KEY).
func newTestContext() context.Context {
cfg := client.DefaultTransportConfig()
cfg.Host = "localhost:3000"
cfg.Schemes = []string{"http"}
// Extract transport config from env vars, and set it on the context.
if u, ok := os.LookupEnv("GRAFANA_URL"); ok {
url, err := url.Parse(u)
if err != nil {
panic(fmt.Errorf("invalid %s: %w", "GRAFANA_URL", err))
}
cfg.Host = url.Host
// The Grafana client will always prefer HTTPS even if the URL is HTTP,
// so we need to limit the schemes to HTTP if the URL is HTTP.
if url.Scheme == "http" {
cfg.Schemes = []string{"http"}
}
}
// Check for the new service account token environment variable first
if apiKey := os.Getenv("GRAFANA_SERVICE_ACCOUNT_TOKEN"); apiKey != "" {
cfg.APIKey = apiKey
} else if apiKey := os.Getenv("GRAFANA_API_KEY"); apiKey != "" {
// Fall back to the deprecated API key environment variable
cfg.APIKey = apiKey
} else {
cfg.BasicAuth = url.UserPassword("admin", "admin")
}
client := client.NewHTTPClientWithConfig(strfmt.Default, cfg)
grafanaCfg := mcpgrafana.GrafanaConfig{
Debug: true,
URL: "http://localhost:3000",
APIKey: cfg.APIKey,
BasicAuth: cfg.BasicAuth,
}
ctx := mcpgrafana.WithGrafanaConfig(context.Background(), grafanaCfg)
return mcpgrafana.WithGrafanaClient(ctx, client)
}
```
--------------------------------------------------------------------------------
/tools/datasources_test.go:
--------------------------------------------------------------------------------
```go
// Requires a Grafana instance running on localhost:3000,
// with a Prometheus datasource provisioned.
// Run with `go test -tags integration`.
//go:build integration
package tools
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDatasourcesTools(t *testing.T) {
t.Run("list datasources", func(t *testing.T) {
ctx := newTestContext()
result, err := listDatasources(ctx, ListDatasourcesParams{})
require.NoError(t, err)
// Six datasources are provisioned in the test environment (Prometheus, Prometheus Demo, Loki, Pyroscope, Tempo, and Tempo Secondary).
assert.Len(t, result, 6)
})
t.Run("list datasources for type", func(t *testing.T) {
ctx := newTestContext()
result, err := listDatasources(ctx, ListDatasourcesParams{Type: "Prometheus"})
require.NoError(t, err)
// Only two Prometheus datasources are provisioned in the test environment.
assert.Len(t, result, 2)
})
t.Run("get datasource by uid", func(t *testing.T) {
ctx := newTestContext()
result, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{
UID: "prometheus",
})
require.NoError(t, err)
assert.Equal(t, "Prometheus", result.Name)
})
t.Run("get datasource by uid - not found", func(t *testing.T) {
ctx := newTestContext()
result, err := getDatasourceByUID(ctx, GetDatasourceByUIDParams{
UID: "non-existent-datasource",
})
require.Error(t, err)
require.Nil(t, result)
assert.Contains(t, err.Error(), "not found")
})
t.Run("get datasource by name", func(t *testing.T) {
ctx := newTestContext()
result, err := getDatasourceByName(ctx, GetDatasourceByNameParams{
Name: "Prometheus",
})
require.NoError(t, err)
assert.Equal(t, "Prometheus", result.Name)
})
}
```
--------------------------------------------------------------------------------
/server.json:
--------------------------------------------------------------------------------
```json
{
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-10-17/server.schema.json",
"name": "io.github.grafana/mcp-grafana",
"description": "An MCP server giving access to Grafana dashboards, data and more.",
"repository": {
"url": "https://github.com/grafana/mcp-grafana",
"source": "github"
},
"version": "$VERSION",
"packages": [
{
"registryType": "oci",
"identifier": "docker.io/grafana/mcp-grafana:$VERSION",
"transport": {
"type": "stdio"
},
"environmentVariables": [
{
"description": "URL to your Grafana instance",
"isRequired": true,
"format": "string",
"isSecret": false,
"name": "GRAFANA_URL"
},
{
"description": "Service account token used to authenticate with your Grafana instance",
"isRequired": false,
"format": "string",
"isSecret": true,
"name": "GRAFANA_SERVICE_ACCOUNT_TOKEN"
},
{
"description": "Username to authenticate with your Grafana instance",
"isRequired": false,
"format": "string",
"isSecret": false,
"name": "GRAFANA_USERNAME"
},
{
"description": "Password to authenticate with your Grafana instance",
"isRequired": false,
"format": "string",
"isSecret": true,
"name": "GRAFANA_PASSWORD"
},
{
"description": "Organization ID for multi-org support. Can also be set via X-Grafana-Org-Id header in SSE/streamable HTTP transports.",
"isRequired": false,
"format": "string",
"isSecret": false,
"name": "GRAFANA_ORG_ID"
}
]
}
]
}
```
--------------------------------------------------------------------------------
/tools/cloud_testing_utils.go:
--------------------------------------------------------------------------------
```go
//go:build cloud
// +build cloud
package tools
import (
"context"
"os"
"strings"
"testing"
mcpgrafana "github.com/grafana/mcp-grafana"
)
// createCloudTestContext creates a context with a Grafana URL, Grafana service account token and
// Grafana client for cloud integration tests.
// The test will be skipped if required environment variables are not set.
// testName is used to customize the skip message (e.g. "OnCall", "Sift", "Incident")
// urlEnv and apiKeyEnv specify the environment variable names for the Grafana URL and API key (deprecated).
// The function will automatically try the new SERVICE_ACCOUNT_TOKEN pattern first, then fall back to API_KEY.
func createCloudTestContext(t *testing.T, testName, urlEnv, apiKeyEnv string) context.Context {
ctx := context.Background()
grafanaURL := os.Getenv(urlEnv)
if grafanaURL == "" {
t.Skipf("%s environment variable not set, skipping cloud %s integration tests", urlEnv, testName)
}
// Try the new service account token environment variable first
serviceAccountTokenEnv := strings.Replace(apiKeyEnv, "API_KEY", "SERVICE_ACCOUNT_TOKEN", 1)
grafanaApiKey := os.Getenv(serviceAccountTokenEnv)
if grafanaApiKey == "" {
// Fall back to the deprecated API key environment variable
grafanaApiKey = os.Getenv(apiKeyEnv)
if grafanaApiKey != "" {
t.Logf("Warning: %s is deprecated, please use %s instead", apiKeyEnv, serviceAccountTokenEnv)
}
}
if grafanaApiKey == "" {
t.Skipf("Neither %s nor %s environment variables are set, skipping cloud %s integration tests", serviceAccountTokenEnv, apiKeyEnv, testName)
}
client := mcpgrafana.NewGrafanaClient(ctx, grafanaURL, grafanaApiKey, nil, 0)
config := mcpgrafana.GrafanaConfig{
URL: grafanaURL,
APIKey: grafanaApiKey,
}
ctx = mcpgrafana.WithGrafanaConfig(ctx, config)
ctx = mcpgrafana.WithGrafanaClient(ctx, client)
return ctx
}
```
--------------------------------------------------------------------------------
/tools/incident_integration_test.go:
--------------------------------------------------------------------------------
```go
// Requires a Cloud or other Grafana instance with Grafana Incident available,
// with a Prometheus datasource provisioned.
//go:build cloud
// +build cloud
// This file contains cloud integration tests that run against a dedicated test instance
// at mcptests.grafana-dev.net. This instance is configured with a minimal setup on the Incident side
// with two incidents created, one minor and one major, and both of them resolved.
// These tests expect this configuration to exist and will skip if the required
// environment variables (GRAFANA_URL, GRAFANA_SERVICE_ACCOUNT_TOKEN or GRAFANA_API_KEY) are not set.
// The GRAFANA_API_KEY variable is deprecated.
package tools
import (
"testing"
mcpgrafana "github.com/grafana/mcp-grafana"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCloudIncidentTools(t *testing.T) {
t.Run("list incidents", func(t *testing.T) {
ctx := createCloudTestContext(t, "Incident", "GRAFANA_URL", "GRAFANA_API_KEY")
ctx = mcpgrafana.ExtractIncidentClientFromEnv(ctx)
result, err := listIncidents(ctx, ListIncidentsParams{
Limit: 1,
})
require.NoError(t, err)
assert.NotNil(t, result, "Result should not be nil")
assert.NotNil(t, result.IncidentPreviews, "IncidentPreviews should not be nil")
assert.LessOrEqual(t, len(result.IncidentPreviews), 1, "Should not return more incidents than the limit")
})
t.Run("get incident by ID", func(t *testing.T) {
ctx := createCloudTestContext(t, "Incident", "GRAFANA_URL", "GRAFANA_API_KEY")
ctx = mcpgrafana.ExtractIncidentClientFromEnv(ctx)
result, err := getIncident(ctx, GetIncidentParams{
ID: "1",
})
require.NoError(t, err)
assert.NotNil(t, result, "Result should not be nil")
assert.Equal(t, "1", result.IncidentID, "Should return the requested incident ID")
assert.NotEmpty(t, result.Title, "Incident should have a title")
assert.NotEmpty(t, result.Status, "Incident should have a status")
})
}
```
--------------------------------------------------------------------------------
/tools/admin.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/grafana/grafana-openapi-client-go/client/org"
"github.com/grafana/grafana-openapi-client-go/client/teams"
"github.com/grafana/grafana-openapi-client-go/models"
mcpgrafana "github.com/grafana/mcp-grafana"
)
type ListTeamsParams struct {
Query string `json:"query" jsonschema:"description=The query to search for teams. Can be left empty to fetch all teams"`
}
func listTeams(ctx context.Context, args ListTeamsParams) (*models.SearchTeamQueryResult, error) {
c := mcpgrafana.GrafanaClientFromContext(ctx)
params := teams.NewSearchTeamsParamsWithContext(ctx)
if args.Query != "" {
params.SetQuery(&args.Query)
}
search, err := c.Teams.SearchTeams(params)
if err != nil {
return nil, fmt.Errorf("search teams for %+v: %w", c, err)
}
return search.Payload, nil
}
var ListTeams = mcpgrafana.MustTool(
"list_teams",
"Search for Grafana teams by a query string. Returns a list of matching teams with details like name, ID, and URL.",
listTeams,
mcp.WithTitleAnnotation("List teams"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type ListUsersByOrgParams struct{}
func listUsersByOrg(ctx context.Context, args ListUsersByOrgParams) ([]*models.OrgUserDTO, error) {
c := mcpgrafana.GrafanaClientFromContext(ctx)
params := org.NewGetOrgUsersForCurrentOrgParamsWithContext(ctx)
search, err := c.Org.GetOrgUsersForCurrentOrg(params)
if err != nil {
return nil, fmt.Errorf("search users: %w", err)
}
return search.Payload, nil
}
var ListUsersByOrg = mcpgrafana.MustTool(
"list_users_by_org",
"List users by organization. Returns a list of users with details like userid, email, role etc.",
listUsersByOrg,
mcp.WithTitleAnnotation("List users by org"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
func AddAdminTools(mcp *server.MCPServer) {
ListTeams.Register(mcp)
ListUsersByOrg.Register(mcp)
}
```
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
```yaml
services:
grafana:
image: grafana/grafana@sha256:35c41e0fd0295f5d0ee5db7e780cf33506abfaf47686196f825364889dee878b
environment:
GF_AUTH_ANONYMOUS_ENABLED: "false"
GF_LOG_LEVEL: debug
GF_SERVER_ROUTER_LOGGING: "true"
ports:
- 3000:3000/tcp
volumes:
- ./testdata/provisioning:/etc/grafana/provisioning
- ./testdata/dashboards:/var/lib/grafana/dashboards
prometheus:
image: prom/prometheus@sha256:ff7e389acbe064a4823212a500393d40a28a8f362e4b05cbf6742a9a3ef736b2
ports:
- "9090:9090"
entrypoint: /etc/prometheus/entrypoint.sh
volumes:
- ./testdata/prometheus.yml:/etc/prometheus/prometheus.yml
- ./testdata/prometheus-seed.yml:/etc/prometheus/prometheus-seed.yml
- ./testdata/prometheus-entrypoint.sh:/etc/prometheus/entrypoint.sh
loki:
image: grafana/loki:2.9.15@sha256:2fde6baaa4743a6870acb9ab5f15633de35adced3c0e3d61effd2a5f1008f1c3
ports:
- "3100:3100"
command: -config.file=/etc/loki/loki-config.yml
volumes:
- ./testdata/loki-config.yml:/etc/loki/loki-config.yml
promtail:
image: grafana/promtail:2.9.15@sha256:466ba2fac4448ed2dc509b267995a3c13511d69f6bba01800ca7b38d9953f899
volumes:
- ./testdata/promtail-config.yml:/etc/promtail/config.yml
- /var/log:/var/log
- /var/run/docker.sock:/var/run/docker.sock
command: -config.file=/etc/promtail/config.yml
depends_on:
- loki
pyroscope:
image: grafana/pyroscope:1.13.4@sha256:7e8f1911cbe9353f5c2433b81ff494d5c728c773e76ae9e886d2c009b0a28ada
ports:
- 4040:4040
tempo:
image: grafana/tempo:2.9.0-rc.0@sha256:5517ee34d335dedb9ad43028bd8f72edd0bb98b744ea5847a7572755d93d9866
command: ["-config.file=/etc/tempo/tempo-config.yaml"]
volumes:
- ./testdata/tempo-config.yaml:/etc/tempo/tempo-config.yaml
ports:
- "3200:3200" # tempo
tempo2:
image: grafana/tempo:2.9.0-rc.0@sha256:5517ee34d335dedb9ad43028bd8f72edd0bb98b744ea5847a7572755d93d9866
command: ["-config.file=/etc/tempo/tempo-config.yaml"]
volumes:
- ./testdata/tempo-config-2.yaml:/etc/tempo/tempo-config.yaml
ports:
- "3201:3201" # tempo instance 2
```
--------------------------------------------------------------------------------
/tools/search.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/grafana/grafana-openapi-client-go/client/search"
"github.com/grafana/grafana-openapi-client-go/models"
mcpgrafana "github.com/grafana/mcp-grafana"
)
var dashboardTypeStr = "dash-db"
var folderTypeStr = "dash-folder"
type SearchDashboardsParams struct {
Query string `json:"query" jsonschema:"description=The query to search for"`
}
func searchDashboards(ctx context.Context, args SearchDashboardsParams) (models.HitList, error) {
c := mcpgrafana.GrafanaClientFromContext(ctx)
params := search.NewSearchParamsWithContext(ctx)
if args.Query != "" {
params.SetQuery(&args.Query)
params.SetType(&dashboardTypeStr)
}
search, err := c.Search.Search(params)
if err != nil {
return nil, fmt.Errorf("search dashboards for %+v: %w", c, err)
}
return search.Payload, nil
}
var SearchDashboards = mcpgrafana.MustTool(
"search_dashboards",
"Search for Grafana dashboards by a query string. Returns a list of matching dashboards with details like title, UID, folder, tags, and URL.",
searchDashboards,
mcp.WithTitleAnnotation("Search dashboards"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type SearchFoldersParams struct {
Query string `json:"query" jsonschema:"description=The query to search for"`
}
func searchFolders(ctx context.Context, args SearchFoldersParams) (models.HitList, error) {
c := mcpgrafana.GrafanaClientFromContext(ctx)
params := search.NewSearchParamsWithContext(ctx)
if args.Query != "" {
params.SetQuery(&args.Query)
}
params.SetType(&folderTypeStr)
search, err := c.Search.Search(params)
if err != nil {
return nil, fmt.Errorf("search folders for %+v: %w", c, err)
}
return search.Payload, nil
}
var SearchFolders = mcpgrafana.MustTool(
"search_folders",
"Search for Grafana folders by a query string. Returns matching folders with details like title, UID, and URL.",
searchFolders,
mcp.WithTitleAnnotation("Search folders"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
func AddSearchTools(mcp *server.MCPServer) {
SearchDashboards.Register(mcp)
SearchFolders.Register(mcp)
}
```
--------------------------------------------------------------------------------
/.github/workflows/integration.yml:
--------------------------------------------------------------------------------
```yaml
name: Integration Tests
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 0 * * *"
permissions:
contents: read
jobs:
test-integration:
name: Test Integration
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
# Start the Grafana server.
# Do this early so that it can start up in time for the tests to run.
# We may need to add a wait here.
- name: Start docker-compose services
uses: hoverkraft-tech/compose-action@ccd64b05f85e42d4fa426d34ecb5884c99537eb4
with:
compose-file: "docker-compose.yaml"
- name: Set up Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with:
go-version: "1.24"
cache: true
- name: Wait for Grafana server and Prometheus server to start and scrape
run: sleep 30
- name: Run integration tests
run: make test-integration
test-cloud:
name: Test Cloud
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
persist-credentials: false
- name: Set up Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with:
go-version: "1.24"
cache: true
- id: get-secrets
uses: grafana/shared-workflows/actions/get-vault-secrets@5d7e361bc7e0a183cde8afe9899fb7b596d2659b # get-vault-secrets-v1.2.0
with:
# Secrets placed in the ci/repo/grafana/mcp-grafana/<path> path in Vault
repo_secrets: |
GRAFANA_SERVICE_ACCOUNT_TOKEN=mcptests-grafana:api-key
ASSERTS_GRAFANA_SERVICE_ACCOUNT_TOKEN=dev-grafana:api-key
- name: Run cloud tests
env:
GRAFANA_URL: ${{ vars.CLOUD_GRAFANA_URL }}
ASSERTS_GRAFANA_URL: ${{ vars.ASSERTS_GRAFANA_URL }}
run: make test-cloud
```
--------------------------------------------------------------------------------
/.github/workflows/e2e.yml:
--------------------------------------------------------------------------------
```yaml
name: E2E Tests
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 0 * * *"
permissions:
contents: read
jobs:
test-python-e2e:
name: Python E2E Tests (${{ matrix.transport }})
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
strategy:
matrix:
transport: [stdio, sse, streamable-http]
permissions:
id-token: write
contents: read
env:
# Set auth here so stdio transport and background process pick them up
GRAFANA_USERNAME: admin
GRAFANA_PASSWORD: admin
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install uv
uses: astral-sh/setup-uv@85856786d1ce8acfbcc2f13a5f3fbd6b938f9f41 # v7.1.2
- name: Set up Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
with:
go-version: "1.24"
cache: true
- name: Install Python dependencies
run: |
cd tests
uv sync --all-groups
- id: get-secrets
uses: grafana/shared-workflows/actions/get-vault-secrets@5d7e361bc7e0a183cde8afe9899fb7b596d2659b # get-vault-secrets-v1.2.0
with:
# Secrets placed in the ci/repo/grafana/mcp-grafana/<path> path in Vault
repo_secrets: |
ANTHROPIC_API_KEY=anthropic:api-key
OPENAI_API_KEY=openai:api-key
- name: Start docker-compose services
uses: hoverkraft-tech/compose-action@ccd64b05f85e42d4fa426d34ecb5884c99537eb4
with:
compose-file: "docker-compose.yaml"
- name: Wait for Grafana server and Prometheus server to start and scrape
run: sleep 30
- name: Build mcp-grafana
run: go build -o dist/mcp-grafana ./cmd/mcp-grafana
- name: Start MCP server in background
if: matrix.transport != 'stdio'
run: nohup ./dist/mcp-grafana -t ${{ matrix.transport }} > mcp.log 2>&1 &
- name: Run Python e2e tests
env:
MCP_GRAFANA_PATH: ../dist/mcp-grafana
MCP_TRANSPORT: ${{ matrix.transport }}
run: |
cd tests
uv run pytest
- if: failure() && matrix.transport != 'stdio'
name: Print MCP logs
run: cat mcp.log
```
--------------------------------------------------------------------------------
/tools/admin_test.go:
--------------------------------------------------------------------------------
```go
//go:build unit
// +build unit
package tools
import (
"context"
"testing"
mcpgrafana "github.com/grafana/mcp-grafana"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAdminToolsUnit(t *testing.T) {
t.Run("tool definitions", func(t *testing.T) {
// Test that the tools are properly defined with correct metadata
require.NotNil(t, ListUsersByOrg, "ListUsersByOrg tool should be defined")
require.NotNil(t, ListTeams, "ListTeams tool should be defined")
// Verify tool metadata
assert.Equal(t, "list_users_by_org", ListUsersByOrg.Tool.Name)
assert.Equal(t, "list_teams", ListTeams.Tool.Name)
assert.Contains(t, ListUsersByOrg.Tool.Description, "List users by organization")
assert.Contains(t, ListTeams.Tool.Description, "Search for Grafana teams")
})
t.Run("parameter structures", func(t *testing.T) {
// Test parameter types are correctly defined
userParams := ListUsersByOrgParams{}
teamParams := ListTeamsParams{Query: "test-query"}
// ListUsersByOrgParams should be an empty struct (no parameters required)
assert.IsType(t, ListUsersByOrgParams{}, userParams)
// ListTeamsParams should have a Query field
assert.Equal(t, "test-query", teamParams.Query)
})
t.Run("nil client handling", func(t *testing.T) {
// Test that functions handle missing client gracefully
ctx := context.Background() // No client in context
// Both functions should return nil when client is not available
// (they will panic on nil pointer dereference, which is the current behavior)
assert.Panics(t, func() {
listUsersByOrg(ctx, ListUsersByOrgParams{})
}, "Should panic when no Grafana client in context")
assert.Panics(t, func() {
listTeams(ctx, ListTeamsParams{})
}, "Should panic when no Grafana client in context")
})
t.Run("function signatures", func(t *testing.T) {
// Verify that function signatures follow the expected pattern
// This test ensures the API migration was done correctly
// Create context with configuration but no client
ctx := mcpgrafana.WithGrafanaConfig(context.Background(), mcpgrafana.GrafanaConfig{
URL: "http://test.grafana.com",
APIKey: "test-key",
})
// Test that both functions can be called with correct parameter types
// They will fail due to no client, but this validates the signature
assert.Panics(t, func() {
listUsersByOrg(ctx, ListUsersByOrgParams{})
})
assert.Panics(t, func() {
listTeams(ctx, ListTeamsParams{Query: "test"})
})
})
}
```
--------------------------------------------------------------------------------
/tools/annotations_integration_test.go:
--------------------------------------------------------------------------------
```go
// Requires a Grafana instance running on localhost:3000,
// Run with `go test -tags integration`.
//go:build integration
package tools
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAnnotationTools(t *testing.T) {
ctx := newTestContext()
// get existing provisioned dashboard.
orig := getExistingTestDashboard(t, ctx, "")
origMap := getTestDashboardJSON(t, ctx, orig)
// remove identifiers so grafana treats it as a new dashboard
delete(origMap, "uid")
delete(origMap, "id")
origMap["title"] = "Integration Test for Annotations"
// create new dashboard.
result, err := updateDashboard(ctx, UpdateDashboardParams{
Dashboard: origMap,
Message: "creating new dashboard for Annotations Tool Test",
Overwrite: false,
UserID: 1,
})
require.NoError(t, err)
// new UID for the test dashboard.
newUID := result.UID
// create, update and patch.
t.Run("create, update and patch annotation", func(t *testing.T) {
// 1. create annotation.
created, err := createAnnotation(ctx, CreateAnnotationInput{
DashboardUID: *newUID,
Time: time.Now().UnixMilli(),
Text: "integration-test-update-initial",
Tags: []string{"init"},
})
require.NoError(t, err)
require.NotNil(t, created)
id := created.Payload.ID // *int64
// 2. update annotation (PUT).
_, err = updateAnnotation(ctx, UpdateAnnotationInput{
ID: *id,
Time: time.Now().UnixMilli(),
Text: "integration-test-updated",
Tags: []string{"updated"},
})
require.NoError(t, err)
// 3. patch annotation (PATCH).
newText := "patched"
_, err = patchAnnotation(ctx, PatchAnnotationInput{
ID: *id,
Text: &newText,
})
require.NoError(t, err)
})
// create graphite annotation.
t.Run("create graphite annotation", func(t *testing.T) {
resp, err := createAnnotationGraphiteFormat(ctx, CreateGraphiteAnnotationInput{
What: "integration-test-graphite",
When: time.Now().UnixMilli(),
Tags: []string{"mcp", "graphite"},
})
require.NoError(t, err)
require.NotNil(t, resp)
})
// list all annotations.
t.Run("list annotations", func(t *testing.T) {
limit := int64(1)
out, err := getAnnotations(ctx, GetAnnotationsInput{
DashboardUID: newUID,
Limit: &limit,
})
require.NoError(t, err)
assert.NotNil(t, out)
})
// list all tags.
t.Run("list annotation tags", func(t *testing.T) {
out, err := getAnnotationTags(ctx, GetAnnotationTagsInput{})
require.NoError(t, err)
assert.NotNil(t, out)
})
}
```
--------------------------------------------------------------------------------
/tools/loki_test.go:
--------------------------------------------------------------------------------
```go
//go:build integration
package tools
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestLokiTools(t *testing.T) {
t.Run("list loki label names", func(t *testing.T) {
ctx := newTestContext()
result, err := listLokiLabelNames(ctx, ListLokiLabelNamesParams{
DatasourceUID: "loki",
})
require.NoError(t, err)
assert.Len(t, result, 1)
})
t.Run("get loki label values", func(t *testing.T) {
ctx := newTestContext()
result, err := listLokiLabelValues(ctx, ListLokiLabelValuesParams{
DatasourceUID: "loki",
LabelName: "container",
})
require.NoError(t, err)
assert.NotEmpty(t, result, "Should have at least one container label value")
})
t.Run("query loki stats", func(t *testing.T) {
ctx := newTestContext()
result, err := queryLokiStats(ctx, QueryLokiStatsParams{
DatasourceUID: "loki",
LogQL: `{container="grafana"}`,
})
require.NoError(t, err)
assert.NotNil(t, result, "Should return a result")
// We can't assert on specific values as they will vary,
// but we can check that the structure is correct
assert.GreaterOrEqual(t, result.Streams, 0, "Should have a valid streams count")
assert.GreaterOrEqual(t, result.Chunks, 0, "Should have a valid chunks count")
assert.GreaterOrEqual(t, result.Entries, 0, "Should have a valid entries count")
assert.GreaterOrEqual(t, result.Bytes, 0, "Should have a valid bytes count")
})
t.Run("query loki logs", func(t *testing.T) {
ctx := newTestContext()
result, err := queryLokiLogs(ctx, QueryLokiLogsParams{
DatasourceUID: "loki",
LogQL: `{container=~".+"}`,
Limit: 10,
})
require.NoError(t, err)
// We can't assert on specific log content as it will vary,
// but we can check that the structure is correct
// If we got logs, check that they have the expected structure
for _, entry := range result {
assert.NotEmpty(t, entry.Timestamp, "Log entry should have a timestamp")
assert.NotNil(t, entry.Labels, "Log entry should have labels")
}
})
t.Run("query loki logs with no results", func(t *testing.T) {
ctx := newTestContext()
// Use a query that's unlikely to match any logs
result, err := queryLokiLogs(ctx, QueryLokiLogsParams{
DatasourceUID: "loki",
LogQL: `{container="non-existent-container-name-123456789"}`,
Limit: 10,
})
require.NoError(t, err)
// Should return an empty slice, not nil
assert.NotNil(t, result, "Empty results should be an empty slice, not nil")
assert.Equal(t, 0, len(result), "Empty results should have length 0")
})
}
```
--------------------------------------------------------------------------------
/tools/pyroscope_test.go:
--------------------------------------------------------------------------------
```go
//go:build integration
package tools
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPyroscopeTools(t *testing.T) {
t.Run("list Pyroscope label names", func(t *testing.T) {
ctx := newTestContext()
names, err := listPyroscopeLabelNames(ctx, ListPyroscopeLabelNamesParams{
DataSourceUID: "pyroscope",
Matchers: `{service_name="pyroscope"}`,
})
require.NoError(t, err)
require.ElementsMatch(t, names, []string{
"__name__",
"__period_type__",
"__period_unit__",
"__profile_type__",
"__service_name__",
"__type__",
"__unit__",
"hostname",
"pyroscope_spy",
"service_git_ref",
"service_name",
"service_repository",
"target",
})
})
t.Run("get Pyroscope label values", func(t *testing.T) {
ctx := newTestContext()
values, err := listPyroscopeLabelValues(ctx, ListPyroscopeLabelValuesParams{
DataSourceUID: "pyroscope",
Name: "target",
Matchers: `{service_name="pyroscope"}`,
})
require.NoError(t, err)
require.ElementsMatch(t, values, []string{"all"})
})
t.Run("get Pyroscope profile types", func(t *testing.T) {
ctx := newTestContext()
types, err := listPyroscopeProfileTypes(ctx, ListPyroscopeProfileTypesParams{
DataSourceUID: "pyroscope",
})
require.NoError(t, err)
require.ElementsMatch(t, types, []string{
"block:contentions:count:contentions:count",
"block:delay:nanoseconds:contentions:count",
"goroutines:goroutine:count:goroutine:count",
"memory:alloc_objects:count:space:bytes",
"memory:alloc_space:bytes:space:bytes",
"memory:inuse_objects:count:space:bytes",
"memory:inuse_space:bytes:space:bytes",
"mutex:contentions:count:contentions:count",
"mutex:delay:nanoseconds:contentions:count",
"process_cpu:cpu:nanoseconds:cpu:nanoseconds",
"process_cpu:samples:count:cpu:nanoseconds",
})
})
t.Run("fetch Pyroscope profile", func(t *testing.T) {
ctx := newTestContext()
profile, err := fetchPyroscopeProfile(ctx, FetchPyroscopeProfileParams{
DataSourceUID: "pyroscope",
ProfileType: "process_cpu:cpu:nanoseconds:cpu:nanoseconds",
Matchers: `{service_name="pyroscope"}`,
})
require.NoError(t, err)
require.NotEmpty(t, profile)
})
t.Run("fetch empty Pyroscope profile", func(t *testing.T) {
ctx := newTestContext()
_, err := fetchPyroscopeProfile(ctx, FetchPyroscopeProfileParams{
DataSourceUID: "pyroscope",
ProfileType: "process_cpu:cpu:nanoseconds:cpu:nanoseconds",
Matchers: `{service_name="pyroscope", label_does_not_exit="missing"}`,
})
require.EqualError(t, err, "failed to call Pyroscope API: pyroscope API returned a empty profile")
})
}
```
--------------------------------------------------------------------------------
/proxied_handler.go:
--------------------------------------------------------------------------------
```go
package mcpgrafana
import (
"context"
"fmt"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// ProxiedToolHandler implements the CallToolHandler interface for proxied tools
type ProxiedToolHandler struct {
sessionManager *SessionManager
toolManager *ToolManager
toolName string
}
// NewProxiedToolHandler creates a new handler for a proxied tool
func NewProxiedToolHandler(sm *SessionManager, tm *ToolManager, toolName string) *ProxiedToolHandler {
return &ProxiedToolHandler{
sessionManager: sm,
toolManager: tm,
toolName: toolName,
}
}
// Handle forwards the tool call to the appropriate remote MCP server
func (h *ProxiedToolHandler) Handle(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
// Check if session is in context
session := server.ClientSessionFromContext(ctx)
if session == nil {
return nil, fmt.Errorf("session not found in context")
}
// Extract arguments
args, ok := request.Params.Arguments.(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid arguments type")
}
// Extract required datasourceUid parameter
datasourceUidRaw, ok := args["datasourceUid"]
if !ok {
return nil, fmt.Errorf("datasourceUid parameter is required")
}
datasourceUID, ok := datasourceUidRaw.(string)
if !ok {
return nil, fmt.Errorf("datasourceUid must be a string")
}
// Parse the tool name to get datasource type and original tool name
// Format: datasourceType_originalToolName (e.g., "tempo_traceql-search")
datasourceType, originalToolName, err := parseProxiedToolName(h.toolName)
if err != nil {
return nil, fmt.Errorf("failed to parse tool name: %w", err)
}
// Get the proxied client for this datasource
var client *ProxiedClient
if h.toolManager.serverMode {
// Server mode (stdio): clients stored at manager level
client, err = h.toolManager.GetServerClient(datasourceType, datasourceUID)
} else {
// Session mode (HTTP/SSE): clients stored per-session
client, err = h.sessionManager.GetProxiedClient(ctx, datasourceType, datasourceUID)
if err != nil {
// Fallback to server-level in case of mixed mode
client, err = h.toolManager.GetServerClient(datasourceType, datasourceUID)
}
}
if err != nil {
return nil, fmt.Errorf("datasource '%s' not found or not accessible. Ensure the datasource exists and you have permission to access it", datasourceUID)
}
// Remove datasourceUid from args before forwarding to remote server
forwardArgs := make(map[string]any)
for k, v := range args {
if k != "datasourceUid" {
forwardArgs[k] = v
}
}
// Forward the call to the remote MCP server
return client.CallTool(ctx, originalToolName, forwardArgs)
}
```
--------------------------------------------------------------------------------
/tools/prometheus_unit_test.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseRelativeTime(t *testing.T) {
const day = 24 * time.Hour
const week = 7 * day
testCases := []struct {
name string
input string
expectedError bool
expectedDelta time.Duration // Expected time difference from now
isMonthCase bool // Special handling for month arithmetic
isYearCase bool // Special handling for year arithmetic
}{
{
name: "now",
input: "now",
expectedError: false,
expectedDelta: 0,
},
{
name: "now-1h",
input: "now-1h",
expectedError: false,
expectedDelta: -1 * time.Hour,
},
{
name: "now-30m",
input: "now-30m",
expectedError: false,
expectedDelta: -30 * time.Minute,
},
{
name: "now-1d",
input: "now-1d",
expectedError: false,
expectedDelta: -24 * time.Hour,
},
{
name: "now-1w",
input: "now-1w",
expectedError: false,
expectedDelta: -week,
},
{
name: "now-1M",
input: "now-1M",
expectedError: false,
isMonthCase: true,
},
{
name: "now-1y",
input: "now-1y",
expectedError: false,
isYearCase: true,
},
{
name: "now-1.5h",
input: "now-1.5h",
expectedError: true,
},
{
name: "invalid format",
input: "yesterday",
expectedError: true,
},
{
name: "empty string",
input: "",
expectedError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
now := time.Now()
result, err := parseTime(tc.input)
if tc.expectedError {
assert.Error(t, err)
return
}
require.NoError(t, err)
if tc.input == "now" {
// For "now", the result should be very close to the current time
// Allow a small tolerance for execution time
diff := result.Sub(now)
assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
} else if tc.isMonthCase {
// For month calculations, use proper calendar arithmetic
expected := now.AddDate(0, -1, 0)
diff := result.Sub(expected)
assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
} else if tc.isYearCase {
// For year calculations, use proper calendar arithmetic
expected := now.AddDate(-1, 0, 0)
diff := result.Sub(expected)
assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
} else {
// For other relative times, compare with the expected delta from now
expected := now.Add(tc.expectedDelta)
diff := result.Sub(expected)
assert.Less(t, diff.Abs(), 2*time.Second, "Time difference should be less than 2 seconds")
}
})
}
}
```
--------------------------------------------------------------------------------
/testdata/dashboards/demo.json:
--------------------------------------------------------------------------------
```json
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": 1,
"isStarred": true,
"links": [],
"panels": [
{
"datasource": {
"default": true,
"type": "prometheus",
"uid": "robustperception"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"barWidthFactor": 0.6,
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "auto",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "robustperception"
},
"editorMode": "code",
"expr": "node_load1",
"instant": false,
"legendFormat": "__auto",
"range": true,
"refId": "A"
}
],
"title": "Node Load",
"type": "timeseries"
}
],
"schemaVersion": 39,
"tags": [
"demo"
],
"templating": {
"list": []
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "browser",
"title": "Demo",
"uid": "fe9gm6guyzi0wd",
"version": 2,
"weekStart": ""
}
```
--------------------------------------------------------------------------------
/.github/workflows/docker.yml:
--------------------------------------------------------------------------------
```yaml
name: Build and Push Docker Image
on:
push:
tags:
- "v*.*.*"
release:
types: [published]
permissions:
contents: read
jobs:
docker:
runs-on: ubuntu-latest
permissions:
id-token: write
steps:
- uses: actions/checkout@ff7abcd0c3c05ccf6adc123a8cd1fd4fb30fb493
- name: Process tag name
id: tag
run: |
VERSION=${GITHUB_REF_NAME#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Check if this is a stable release (no rc, alpha, beta, etc.)
if [[ ! "$VERSION" =~ (rc|alpha|beta|pre|dev) ]]; then
echo "is_stable=true" >> $GITHUB_OUTPUT
else
echo "is_stable=false" >> $GITHUB_OUTPUT
fi
- name: Build and Push to Docker Hub
uses: grafana/shared-workflows/actions/build-push-to-dockerhub@60fadd1458bb20b97f00618568c22ed1c7d485bd
with:
context: .
file: ./Dockerfile
repository: grafana/mcp-grafana
platforms: linux/amd64,linux/arm64
tags: |
${{ steps.tag.outputs.is_stable == 'true' && 'latest' || '' }}
${{ steps.tag.outputs.version }}
push: true
mcp-registry:
runs-on: ubuntu-latest
needs: docker
permissions:
contents: read
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
fetch-depth: 0
- name: Extract version from tag
id: version
run: |
# Get the tag from the triggering workflow
TAG=$(git describe --tags --exact-match HEAD 2>/dev/null || echo "")
if [ -z "$TAG" ]; then
echo "No tag found at HEAD"
exit 1
fi
echo "VERSION=$TAG" >> $GITHUB_OUTPUT
- name: Extract image tag from version
id: image-tag
run: |
# Extract the image tag from the version
VERSION="${{ steps.version.outputs.VERSION }}"
echo "IMAGE_TAG=${VERSION#v}" >> $GITHUB_OUTPUT
- name: Install dependencies
run: |
sudo apt-get update && sudo apt-get install -y jq
curl -L "https://github.com/modelcontextprotocol/registry/releases/download/v1.3.0/mcp-publisher_linux_amd64.tar.gz" | tar xz mcp-publisher
chmod +x mcp-publisher
sudo mv mcp-publisher /usr/local/bin/
- name: Update server.json with Docker image
run: |
# Update the server.json with the correct Docker image reference
# (note the image tag does not include the "v" prefix)
jq --arg version "${{ steps.version.outputs.VERSION }}" \
--arg image "docker.io/grafana/mcp-grafana:${{ steps.image-tag.outputs.IMAGE_TAG }}" \
'.version = $version | .packages[0].identifier = $image' server.json > server.json.tmp
mv server.json.tmp server.json
- name: Login to MCP Registry
run: mcp-publisher login github-oidc
- name: Publish to MCP Registry
run: mcp-publisher publish
```
--------------------------------------------------------------------------------
/session.go:
--------------------------------------------------------------------------------
```go
package mcpgrafana
import (
"context"
"fmt"
"log/slog"
"sync"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
// SessionState holds the state for a single client session
type SessionState struct {
// Proxied tools state
initOnce sync.Once
proxiedToolsInitialized bool
proxiedTools []mcp.Tool
proxiedClients map[string]*ProxiedClient // key: datasourceType_datasourceUID
toolToDatasources map[string][]string // key: toolName, value: list of datasource keys that support it
mutex sync.RWMutex
}
func newSessionState() *SessionState {
return &SessionState{
proxiedClients: make(map[string]*ProxiedClient),
toolToDatasources: make(map[string][]string),
}
}
// SessionManager manages client sessions and their state
type SessionManager struct {
sessions map[string]*SessionState
mutex sync.RWMutex
}
func NewSessionManager() *SessionManager {
return &SessionManager{
sessions: make(map[string]*SessionState),
}
}
func (sm *SessionManager) CreateSession(ctx context.Context, session server.ClientSession) {
sm.mutex.Lock()
defer sm.mutex.Unlock()
sessionID := session.SessionID()
if _, exists := sm.sessions[sessionID]; !exists {
sm.sessions[sessionID] = newSessionState()
}
}
func (sm *SessionManager) GetSession(sessionID string) (*SessionState, bool) {
sm.mutex.RLock()
defer sm.mutex.RUnlock()
session, exists := sm.sessions[sessionID]
return session, exists
}
func (sm *SessionManager) RemoveSession(ctx context.Context, session server.ClientSession) {
sm.mutex.Lock()
sessionID := session.SessionID()
state, exists := sm.sessions[sessionID]
delete(sm.sessions, sessionID)
sm.mutex.Unlock()
if !exists {
return
}
// Clean up proxied clients outside of the main lock
state.mutex.Lock()
defer state.mutex.Unlock()
for key, client := range state.proxiedClients {
if err := client.Close(); err != nil {
slog.Error("failed to close proxied client", "key", key, "error", err)
}
}
}
// GetProxiedClient retrieves a proxied client for the given datasource
func (sm *SessionManager) GetProxiedClient(ctx context.Context, datasourceType, datasourceUID string) (*ProxiedClient, error) {
session := server.ClientSessionFromContext(ctx)
if session == nil {
return nil, fmt.Errorf("session not found in context")
}
state, exists := sm.GetSession(session.SessionID())
if !exists {
return nil, fmt.Errorf("session not found")
}
state.mutex.RLock()
defer state.mutex.RUnlock()
key := datasourceType + "_" + datasourceUID
client, exists := state.proxiedClients[key]
if !exists {
// List available datasources to help with debugging
var availableUIDs []string
for _, c := range state.proxiedClients {
if c.DatasourceType == datasourceType {
availableUIDs = append(availableUIDs, c.DatasourceUID)
}
}
if len(availableUIDs) > 0 {
return nil, fmt.Errorf("datasource '%s' not found. Available %s datasources: %v", datasourceUID, datasourceType, availableUIDs)
}
return nil, fmt.Errorf("datasource '%s' not found. No %s datasources with MCP support are configured", datasourceUID, datasourceType)
}
return client, nil
}
```
--------------------------------------------------------------------------------
/tools/alerting_client_test.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/prometheus/prometheus/model/labels"
"github.com/stretchr/testify/require"
mcpgrafana "github.com/grafana/mcp-grafana"
)
var (
fakeruleGroup = ruleGroup{
Name: "TestGroup",
FolderUID: "test-folder",
Rules: []alertingRule{
{
State: "firing",
Name: "Test Alert Rule",
UID: "test-rule-uid",
FolderUID: "test-folder",
Labels: labels.New(labels.Label{Name: "severity", Value: "critical"}),
Alerts: []alert{
{
Labels: labels.New(labels.Label{Name: "instance", Value: "test-instance"}),
Annotations: labels.New(labels.Label{Name: "summary", Value: "Test alert firing"}),
State: "firing",
Value: "1",
},
},
},
},
}
)
func setupMockServer(handler http.HandlerFunc) (*httptest.Server, *alertingClient) {
server := httptest.NewServer(handler)
baseURL, _ := url.Parse(server.URL)
client := &alertingClient{
baseURL: baseURL,
apiKey: "test-api-key",
httpClient: &http.Client{},
}
return server, client
}
func mockrulesResponse() rulesResponse {
resp := rulesResponse{}
resp.Data.RuleGroups = []ruleGroup{fakeruleGroup}
return resp
}
func TestAlertingClient_GetRules(t *testing.T) {
server, client := setupMockServer(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/prometheus/grafana/api/v1/rules", r.URL.Path)
require.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))
resp := mockrulesResponse()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
err := json.NewEncoder(w).Encode(resp)
require.NoError(t, err)
})
defer server.Close()
rules, err := client.GetRules(context.Background())
require.NoError(t, err)
require.NotNil(t, rules)
require.ElementsMatch(t, rules.Data.RuleGroups, []ruleGroup{fakeruleGroup})
}
func TestAlertingClient_GetRules_Error(t *testing.T) {
t.Run("internal server error", func(t *testing.T) {
server, client := setupMockServer(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, err := w.Write([]byte("internal server error"))
require.NoError(t, err)
})
defer server.Close()
rules, err := client.GetRules(context.Background())
require.Error(t, err)
require.Nil(t, rules)
require.ErrorContains(t, err, "grafana API returned status code 500: internal server error")
})
t.Run("network error", func(t *testing.T) {
server, client := setupMockServer(func(w http.ResponseWriter, r *http.Request) {})
server.Close()
rules, err := client.GetRules(context.Background())
require.Error(t, err)
require.Nil(t, rules)
require.ErrorContains(t, err, "failed to execute request")
})
}
func TestNewAlertingClientFromContext(t *testing.T) {
config := mcpgrafana.GrafanaConfig{
URL: "http://localhost:3000/",
APIKey: "test-api-key",
}
ctx := mcpgrafana.WithGrafanaConfig(context.Background(), config)
client, err := newAlertingClientFromContext(ctx)
require.NoError(t, err)
require.Equal(t, "http://localhost:3000", client.baseURL.String())
require.Equal(t, "test-api-key", client.apiKey)
require.NotNil(t, client.httpClient)
}
```
--------------------------------------------------------------------------------
/tools/navigation.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
mcpgrafana "github.com/grafana/mcp-grafana"
)
type GenerateDeeplinkParams struct {
ResourceType string `json:"resourceType" jsonschema:"required,description=Type of resource: dashboard\\, panel\\, or explore"`
DashboardUID *string `json:"dashboardUid,omitempty" jsonschema:"description=Dashboard UID (required for dashboard and panel types)"`
DatasourceUID *string `json:"datasourceUid,omitempty" jsonschema:"description=Datasource UID (required for explore type)"`
PanelID *int `json:"panelId,omitempty" jsonschema:"description=Panel ID (required for panel type)"`
QueryParams map[string]string `json:"queryParams,omitempty" jsonschema:"description=Additional query parameters"`
TimeRange *TimeRange `json:"timeRange,omitempty" jsonschema:"description=Time range for the link"`
}
type TimeRange struct {
From string `json:"from" jsonschema:"description=Start time (e.g.\\, 'now-1h')"`
To string `json:"to" jsonschema:"description=End time (e.g.\\, 'now')"`
}
func generateDeeplink(ctx context.Context, args GenerateDeeplinkParams) (string, error) {
config := mcpgrafana.GrafanaConfigFromContext(ctx)
baseURL := strings.TrimRight(config.URL, "/")
if baseURL == "" {
return "", fmt.Errorf("grafana url not configured. Please set GRAFANA_URL environment variable or X-Grafana-URL header")
}
var deeplink string
switch strings.ToLower(args.ResourceType) {
case "dashboard":
if args.DashboardUID == nil {
return "", fmt.Errorf("dashboardUid is required for dashboard links")
}
deeplink = fmt.Sprintf("%s/d/%s", baseURL, *args.DashboardUID)
case "panel":
if args.DashboardUID == nil {
return "", fmt.Errorf("dashboardUid is required for panel links")
}
if args.PanelID == nil {
return "", fmt.Errorf("panelId is required for panel links")
}
deeplink = fmt.Sprintf("%s/d/%s?viewPanel=%d", baseURL, *args.DashboardUID, *args.PanelID)
case "explore":
if args.DatasourceUID == nil {
return "", fmt.Errorf("datasourceUid is required for explore links")
}
params := url.Values{}
exploreState := fmt.Sprintf(`{"datasource":"%s"}`, *args.DatasourceUID)
params.Set("left", exploreState)
deeplink = fmt.Sprintf("%s/explore?%s", baseURL, params.Encode())
default:
return "", fmt.Errorf("unsupported resource type: %s. Supported types are: dashboard, panel, explore", args.ResourceType)
}
if args.TimeRange != nil {
separator := "?"
if strings.Contains(deeplink, "?") {
separator = "&"
}
timeParams := url.Values{}
if args.TimeRange.From != "" {
timeParams.Set("from", args.TimeRange.From)
}
if args.TimeRange.To != "" {
timeParams.Set("to", args.TimeRange.To)
}
if len(timeParams) > 0 {
deeplink = fmt.Sprintf("%s%s%s", deeplink, separator, timeParams.Encode())
}
}
if len(args.QueryParams) > 0 {
separator := "?"
if strings.Contains(deeplink, "?") {
separator = "&"
}
additionalParams := url.Values{}
for key, value := range args.QueryParams {
additionalParams.Set(key, value)
}
deeplink = fmt.Sprintf("%s%s%s", deeplink, separator, additionalParams.Encode())
}
return deeplink, nil
}
var GenerateDeeplink = mcpgrafana.MustTool(
"generate_deeplink",
"Generate deeplink URLs for Grafana resources. Supports dashboards (requires dashboardUid), panels (requires dashboardUid and panelId), and Explore queries (requires datasourceUid). Optionally accepts time range and additional query parameters.",
generateDeeplink,
mcp.WithTitleAnnotation("Generate navigation deeplink"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
func AddNavigationTools(mcp *server.MCPServer) {
GenerateDeeplink.Register(mcp)
}
```
--------------------------------------------------------------------------------
/tools/navigation_test.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
mcpgrafana "github.com/grafana/mcp-grafana"
)
// Helper function to create string pointers
func stringPtr(s string) *string {
return &s
}
func TestGenerateDeeplink(t *testing.T) {
grafanaCfg := mcpgrafana.GrafanaConfig{
URL: "http://localhost:3000",
}
ctx := mcpgrafana.WithGrafanaConfig(context.Background(), grafanaCfg)
t.Run("Dashboard deeplink", func(t *testing.T) {
params := GenerateDeeplinkParams{
ResourceType: "dashboard",
DashboardUID: stringPtr("abc123"),
}
result, err := generateDeeplink(ctx, params)
require.NoError(t, err)
assert.Equal(t, "http://localhost:3000/d/abc123", result)
})
t.Run("Panel deeplink", func(t *testing.T) {
panelID := 5
params := GenerateDeeplinkParams{
ResourceType: "panel",
DashboardUID: stringPtr("dash-123"),
PanelID: &panelID,
}
result, err := generateDeeplink(ctx, params)
require.NoError(t, err)
assert.Equal(t, "http://localhost:3000/d/dash-123?viewPanel=5", result)
})
t.Run("Explore deeplink", func(t *testing.T) {
params := GenerateDeeplinkParams{
ResourceType: "explore",
DatasourceUID: stringPtr("prometheus-uid"),
}
result, err := generateDeeplink(ctx, params)
require.NoError(t, err)
assert.Contains(t, result, "http://localhost:3000/explore?left=")
assert.Contains(t, result, "prometheus-uid")
})
t.Run("With time range", func(t *testing.T) {
params := GenerateDeeplinkParams{
ResourceType: "dashboard",
DashboardUID: stringPtr("abc123"),
TimeRange: &TimeRange{
From: "now-1h",
To: "now",
},
}
result, err := generateDeeplink(ctx, params)
require.NoError(t, err)
assert.Contains(t, result, "http://localhost:3000/d/abc123")
assert.Contains(t, result, "from=now-1h")
assert.Contains(t, result, "to=now")
})
t.Run("With additional query params", func(t *testing.T) {
params := GenerateDeeplinkParams{
ResourceType: "dashboard",
DashboardUID: stringPtr("abc123"),
QueryParams: map[string]string{
"var-datasource": "prometheus",
"refresh": "30s",
},
}
result, err := generateDeeplink(ctx, params)
require.NoError(t, err)
assert.Contains(t, result, "http://localhost:3000/d/abc123")
assert.Contains(t, result, "var-datasource=prometheus")
assert.Contains(t, result, "refresh=30s")
})
t.Run("Error cases", func(t *testing.T) {
emptyGrafanaCfg := mcpgrafana.GrafanaConfig{
URL: "",
}
emptyCtx := mcpgrafana.WithGrafanaConfig(context.Background(), emptyGrafanaCfg)
params := GenerateDeeplinkParams{
ResourceType: "dashboard",
DashboardUID: stringPtr("abc123"),
}
_, err := generateDeeplink(emptyCtx, params)
assert.Error(t, err)
assert.Contains(t, err.Error(), "grafana url not configured")
params.ResourceType = "unsupported"
_, err = generateDeeplink(ctx, params)
assert.Error(t, err)
assert.Contains(t, err.Error(), "unsupported resource type")
// Test missing dashboardUid for dashboard
params = GenerateDeeplinkParams{
ResourceType: "dashboard",
}
_, err = generateDeeplink(ctx, params)
assert.Error(t, err)
assert.Contains(t, err.Error(), "dashboardUid is required")
// Test missing dashboardUid for panel
params = GenerateDeeplinkParams{
ResourceType: "panel",
}
_, err = generateDeeplink(ctx, params)
assert.Error(t, err)
assert.Contains(t, err.Error(), "dashboardUid is required")
// Test missing panelId for panel
params = GenerateDeeplinkParams{
ResourceType: "panel",
DashboardUID: stringPtr("dash-123"),
}
_, err = generateDeeplink(ctx, params)
assert.Error(t, err)
assert.Contains(t, err.Error(), "panelId is required")
// Test missing datasourceUid for explore
params = GenerateDeeplinkParams{
ResourceType: "explore",
}
_, err = generateDeeplink(ctx, params)
assert.Error(t, err)
assert.Contains(t, err.Error(), "datasourceUid is required")
})
}
```
--------------------------------------------------------------------------------
/tools/asserts_test.go:
--------------------------------------------------------------------------------
```go
//go:build unit
// +build unit
package tools
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
mcpgrafana "github.com/grafana/mcp-grafana"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setupMockAssertsServer(handler http.HandlerFunc) (*httptest.Server, context.Context) {
server := httptest.NewServer(handler)
config := mcpgrafana.GrafanaConfig{
URL: server.URL,
APIKey: "test-api-key",
}
ctx := mcpgrafana.WithGrafanaConfig(context.Background(), config)
return server, ctx
}
func TestAssertTools(t *testing.T) {
t.Run("get assertions", func(t *testing.T) {
startTime := time.Date(2025, 4, 23, 10, 0, 0, 0, time.UTC)
endTime := time.Date(2025, 4, 23, 11, 0, 0, 0, time.UTC)
server, ctx := setupMockAssertsServer(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/plugins/grafana-asserts-app/resources/asserts/api-server/v1/assertions/llm-summary", r.URL.Path)
require.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))
var requestBody map[string]interface{}
err := json.NewDecoder(r.Body).Decode(&requestBody)
require.NoError(t, err)
expectedBody := map[string]interface{}{
"startTime": float64(startTime.UnixMilli()),
"endTime": float64(endTime.UnixMilli()),
"entityKeys": []interface{}{
map[string]interface{}{
"type": "Service",
"name": "mongodb",
"scope": map[string]interface{}{
"env": "asserts-demo",
"site": "app",
"namespace": "robot-shop",
},
},
},
"suggestionSrcEntities": []interface{}{},
"alertCategories": []interface{}{"saturation", "amend", "anomaly", "failure", "error"},
}
require.Equal(t, expectedBody, requestBody)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte(`{"summary": "test summary"}`))
require.NoError(t, err)
})
defer server.Close()
result, err := getAssertions(ctx, GetAssertionsParams{
StartTime: startTime,
EndTime: endTime,
EntityType: "Service",
EntityName: "mongodb",
Env: "asserts-demo",
Site: "app",
Namespace: "robot-shop",
})
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, `{"summary": "test summary"}`, result)
})
t.Run("get assertions with no site and namespace", func(t *testing.T) {
startTime := time.Date(2025, 4, 23, 10, 0, 0, 0, time.UTC)
endTime := time.Date(2025, 4, 23, 11, 0, 0, 0, time.UTC)
server, ctx := setupMockAssertsServer(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/plugins/grafana-asserts-app/resources/asserts/api-server/v1/assertions/llm-summary", r.URL.Path)
require.Equal(t, "Bearer test-api-key", r.Header.Get("Authorization"))
var requestBody map[string]interface{}
err := json.NewDecoder(r.Body).Decode(&requestBody)
require.NoError(t, err)
expectedBody := map[string]interface{}{
"startTime": float64(startTime.UnixMilli()),
"endTime": float64(endTime.UnixMilli()),
"entityKeys": []interface{}{
map[string]interface{}{
"type": "Service",
"name": "mongodb",
"scope": map[string]interface{}{
"env": "asserts-demo",
},
},
},
"suggestionSrcEntities": []interface{}{},
"alertCategories": []interface{}{"saturation", "amend", "anomaly", "failure", "error"},
}
require.Equal(t, expectedBody, requestBody)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, err = w.Write([]byte(`{"summary": "test summary"}`))
require.NoError(t, err)
})
defer server.Close()
result, err := getAssertions(ctx, GetAssertionsParams{
StartTime: startTime,
EndTime: endTime,
EntityType: "Service",
EntityName: "mongodb",
Env: "asserts-demo",
})
require.NoError(t, err)
assert.NotNil(t, result)
assert.Equal(t, `{"summary": "test summary"}`, result)
})
}
```
--------------------------------------------------------------------------------
/proxied_client.go:
--------------------------------------------------------------------------------
```go
package mcpgrafana
import (
"context"
"encoding/base64"
"fmt"
"log/slog"
"sync"
mcp_client "github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
)
// ProxiedClient represents a connection to a remote MCP server (e.g., Tempo datasource)
type ProxiedClient struct {
DatasourceUID string
DatasourceName string
DatasourceType string
Client *mcp_client.Client
Tools []mcp.Tool
mutex sync.RWMutex
}
// NewProxiedClient creates a new connection to a remote MCP server
func NewProxiedClient(ctx context.Context, datasourceUID, datasourceName, datasourceType, mcpEndpoint string) (*ProxiedClient, error) {
// Get Grafana config for authentication
config := GrafanaConfigFromContext(ctx)
// Build headers for authentication
headers := make(map[string]string)
if config.APIKey != "" {
headers["Authorization"] = "Bearer " + config.APIKey
} else if config.BasicAuth != nil {
auth := config.BasicAuth.String()
headers["Authorization"] = "Basic " + base64.StdEncoding.EncodeToString([]byte(auth))
}
// Create HTTP transport with authentication headers
slog.DebugContext(ctx, "connecting to MCP server", "datasource", datasourceUID, "url", mcpEndpoint)
httpTransport, err := transport.NewStreamableHTTP(
mcpEndpoint,
transport.WithHTTPHeaders(headers),
)
if err != nil {
return nil, fmt.Errorf("failed to create HTTP transport: %w", err)
}
// Create MCP client
mcpClient := mcp_client.NewClient(httpTransport)
// Initialize the connection
initReq := mcp.InitializeRequest{}
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
initReq.Params.ClientInfo = mcp.Implementation{
Name: "mcp-grafana-proxy",
Version: Version(),
}
_, err = mcpClient.Initialize(ctx, initReq)
if err != nil {
_ = mcpClient.Close()
return nil, fmt.Errorf("failed to initialize MCP client: %w", err)
}
// List available tools from the remote server
listReq := mcp.ListToolsRequest{}
toolsResult, err := mcpClient.ListTools(ctx, listReq)
if err != nil {
_ = mcpClient.Close()
return nil, fmt.Errorf("failed to list tools from remote MCP server: %w", err)
}
slog.DebugContext(ctx, "connected to proxied MCP server",
"datasource", datasourceUID,
"type", datasourceType,
"tools", len(toolsResult.Tools))
return &ProxiedClient{
DatasourceUID: datasourceUID,
DatasourceName: datasourceName,
DatasourceType: datasourceType,
Client: mcpClient,
Tools: toolsResult.Tools,
}, nil
}
// CallTool forwards a tool call to the remote MCP server
func (pc *ProxiedClient) CallTool(ctx context.Context, toolName string, arguments map[string]any) (*mcp.CallToolResult, error) {
pc.mutex.RLock()
defer pc.mutex.RUnlock()
// Validate the tool exists
var toolExists bool
for _, tool := range pc.Tools {
if tool.Name == toolName {
toolExists = true
break
}
}
if !toolExists {
return nil, fmt.Errorf("tool %s not found in remote MCP server", toolName)
}
// Create the call tool request
req := mcp.CallToolRequest{}
req.Params.Name = toolName
req.Params.Arguments = arguments
// Forward the call to the remote server
result, err := pc.Client.CallTool(ctx, req)
if err != nil {
return nil, fmt.Errorf("failed to call tool on remote MCP server: %w", err)
}
return result, nil
}
// ListTools returns the tools available from this remote server
// Note: This method doesn't take a context parameter as the tools are cached locally
func (pc *ProxiedClient) ListTools() []mcp.Tool {
pc.mutex.RLock()
defer pc.mutex.RUnlock()
// Return a copy to prevent external modification
result := make([]mcp.Tool, len(pc.Tools))
copy(result, pc.Tools)
return result
}
// Close closes the connection to the remote MCP server
func (pc *ProxiedClient) Close() error {
pc.mutex.Lock()
defer pc.mutex.Unlock()
if pc.Client != nil {
if err := pc.Client.Close(); err != nil {
return fmt.Errorf("failed to close MCP client: %w", err)
}
}
return nil
}
```
--------------------------------------------------------------------------------
/tests/dashboards_test.py:
--------------------------------------------------------------------------------
```python
import json
import pytest
from langevals import expect
from langevals_langevals.llm_boolean import (
CustomLLMBooleanEvaluator,
CustomLLMBooleanSettings,
)
from litellm import Message, acompletion
from mcp import ClientSession
from conftest import models
from utils import (
get_converted_tools,
llm_tool_call_sequence,
)
pytestmark = pytest.mark.anyio
@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_dashboard_panel_queries_tool(model: str, mcp_client: ClientSession):
tools = await get_converted_tools(mcp_client)
prompt = "Can you list the panel queries for the dashboard with UID fe9gm6guyzi0wd?"
messages = [
Message(role="system", content="You are a helpful assistant."),
Message(role="user", content=prompt),
]
# 1. Call the dashboard panel queries tool
messages = await llm_tool_call_sequence(
model, messages, tools, mcp_client, "get_dashboard_panel_queries",
{"uid": "fe9gm6guyzi0wd"}
)
# 2. Final LLM response
response = await acompletion(model=model, messages=messages, tools=tools)
content = response.choices[0].message.content
panel_queries_checker = CustomLLMBooleanEvaluator(
settings=CustomLLMBooleanSettings(
prompt="Does the response contain specific information about the panel queries and titles for a grafana dashboard?",
)
)
print("content", content)
expect(input=prompt, output=content).to_pass(panel_queries_checker)
@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_dashboard_update_with_patch_operations(model: str, mcp_client: ClientSession):
"""Test that LLMs naturally use patch operations for dashboard updates"""
tools = await get_converted_tools(mcp_client)
# First, create a non-provisioned test dashboard by copying the demo dashboard
# 1. Get the demo dashboard JSON
demo_result = await mcp_client.call_tool("get_dashboard_by_uid", {"uid": "fe9gm6guyzi0wd"})
demo_data = json.loads(demo_result.content[0].text)
dashboard_json = demo_data["dashboard"]
# 2. Remove uid and id to create a new dashboard
if "uid" in dashboard_json:
del dashboard_json["uid"]
if "id" in dashboard_json:
del dashboard_json["id"]
# 3. Set a new title
title = f"Test Dashboard"
dashboard_json["title"] = title
dashboard_json["tags"] = ["python-integration-test"]
# 4. Create the dashboard in Grafana
create_result = await mcp_client.call_tool("update_dashboard", {
"dashboard": dashboard_json,
"folderUid": "",
"overwrite": False
})
create_data = json.loads(create_result.content[0].text)
created_dashboard_uid = create_data["uid"]
# 5. Update the dashboard title
updated_title = f"Updated {title}"
title_prompt = f"Update the title of the Test Dashboard to {updated_title}. Search for the dashboard by title first."
messages = [
Message(role="system", content="You are a helpful assistant"),
Message(role="user", content=title_prompt),
]
# 6. Search for the test dashboard
messages = await llm_tool_call_sequence(
model, messages, tools, mcp_client, "search_dashboards",
{"query": title}
)
# 7. Update the dashboard using patch operations
messages = await llm_tool_call_sequence(
model, messages, tools, mcp_client, "update_dashboard",
{
"uid": created_dashboard_uid,
"operations": [
{
"op": "replace",
"path": "$.title",
"value": updated_title
}
]
}
)
# 8. Final LLM response - just verify it completes successfully
response = await acompletion(model=model, messages=messages, tools=tools)
content = response.choices[0].message.content
# Test passes if we get here - the tool call sequence worked correctly
assert len(content) > 0, "LLM should provide a response after updating the dashboard"
```
--------------------------------------------------------------------------------
/tests/loki_test.py:
--------------------------------------------------------------------------------
```python
import json
import pytest
from langevals import expect
from langevals_langevals.llm_boolean import (
CustomLLMBooleanEvaluator,
CustomLLMBooleanSettings,
)
from litellm import Message, acompletion
from mcp import ClientSession
from conftest import models
from utils import (
get_converted_tools,
flexible_tool_call,
)
pytestmark = pytest.mark.anyio
@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_loki_logs_tool(model: str, mcp_client: ClientSession):
tools = await get_converted_tools(mcp_client)
prompt = "Can you list the last 10 log lines from container 'mcp-grafana-grafana-1' using any available Loki datasource? Give me the raw log lines. Please use only the necessary tools to get this information."
messages = [
Message(role="system", content="You are a helpful assistant."),
Message(role="user", content=prompt),
]
# 1. List datasources
messages = await flexible_tool_call(
model, messages, tools, mcp_client, "list_datasources"
)
datasources_response = messages[-1].content
datasources_data = json.loads(datasources_response)
loki_ds = get_first_loki_datasource(datasources_data)
print(f"\nFound Loki datasource: {loki_ds['name']} (uid: {loki_ds['uid']})")
# 2. Query logs
messages = await flexible_tool_call(
model, messages, tools, mcp_client, "query_loki_logs",
required_params={"datasourceUid": loki_ds["uid"]}
)
# 3. Final LLM response
response = await acompletion(model=model, messages=messages, tools=tools)
content = response.choices[0].message.content
log_lines_checker = CustomLLMBooleanEvaluator(
settings=CustomLLMBooleanSettings(
prompt="Does the response contain specific information that could only come from a Loki datasource? This could be actual log lines with timestamps, container names, or a summary that references specific log data. The response should show evidence of real data rather than generic statements.",
)
)
expect(input=prompt, output=content).to_pass(log_lines_checker)
@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_loki_container_labels(model: str, mcp_client: ClientSession):
tools = await get_converted_tools(mcp_client)
prompt = "Can you list the values for the label container in any available loki datasource? Please use only the necessary tools to get this information."
messages = [
Message(role="system", content="You are a helpful assistant."),
Message(role="user", content=prompt),
]
# 1. List datasources
messages = await flexible_tool_call(
model, messages, tools, mcp_client, "list_datasources"
)
datasources_response = messages[-1].content
datasources_data = json.loads(datasources_response)
loki_ds = get_first_loki_datasource(datasources_data)
print(f"\nFound Loki datasource: {loki_ds['name']} (uid: {loki_ds['uid']})")
# 2. List label values for 'container'
messages = await flexible_tool_call(
model, messages, tools, mcp_client, "list_loki_label_values",
required_params={"datasourceUid": loki_ds["uid"], "labelName": "container"}
)
# 3. Final LLM response
response = await acompletion(model=model, messages=messages, tools=tools)
content = response.choices[0].message.content
label_checker = CustomLLMBooleanEvaluator(
settings=CustomLLMBooleanSettings(
prompt="Does the response provide a clear and organized list of container names found in the logs? It should present the container names in a readable format and may include additional context about their usage.",
)
)
expect(input=prompt, output=content).to_pass(label_checker)
def get_first_loki_datasource(datasources_data):
"""
Returns the first datasource with type 'loki' from a list of datasources.
Raises an AssertionError if none are found.
"""
loki_datasources = [ds for ds in datasources_data if ds.get("type") == "loki"]
assert len(loki_datasources) > 0, "No Loki datasource found"
return loki_datasources[0]
```
--------------------------------------------------------------------------------
/tools/datasources.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"fmt"
"strings"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/grafana/grafana-openapi-client-go/models"
mcpgrafana "github.com/grafana/mcp-grafana"
)
type ListDatasourcesParams struct {
Type string `json:"type,omitempty" jsonschema:"description=The type of datasources to search for. For example\\, 'prometheus'\\, 'loki'\\, 'tempo'\\, etc..."`
}
type dataSourceSummary struct {
ID int64 `json:"id"`
UID string `json:"uid"`
Name string `json:"name"`
Type string `json:"type"`
IsDefault bool `json:"isDefault"`
}
func listDatasources(ctx context.Context, args ListDatasourcesParams) ([]dataSourceSummary, error) {
c := mcpgrafana.GrafanaClientFromContext(ctx)
resp, err := c.Datasources.GetDataSources()
if err != nil {
return nil, fmt.Errorf("list datasources: %w", err)
}
datasources := filterDatasources(resp.Payload, args.Type)
return summarizeDatasources(datasources), nil
}
// filterDatasources returns only datasources of the specified type `t`. If `t`
// is an empty string no filtering is done.
func filterDatasources(datasources models.DataSourceList, t string) models.DataSourceList {
if t == "" {
return datasources
}
filtered := models.DataSourceList{}
t = strings.ToLower(t)
for _, ds := range datasources {
if strings.Contains(strings.ToLower(ds.Type), t) {
filtered = append(filtered, ds)
}
}
return filtered
}
func summarizeDatasources(dataSources models.DataSourceList) []dataSourceSummary {
result := make([]dataSourceSummary, 0, len(dataSources))
for _, ds := range dataSources {
result = append(result, dataSourceSummary{
ID: ds.ID,
UID: ds.UID,
Name: ds.Name,
Type: ds.Type,
IsDefault: ds.IsDefault,
})
}
return result
}
var ListDatasources = mcpgrafana.MustTool(
"list_datasources",
"List available Grafana datasources. Optionally filter by datasource type (e.g., 'prometheus', 'loki'). Returns a summary list including ID, UID, name, type, and default status.",
listDatasources,
mcp.WithTitleAnnotation("List datasources"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type GetDatasourceByUIDParams struct {
UID string `json:"uid" jsonschema:"required,description=The uid of the datasource"`
}
func getDatasourceByUID(ctx context.Context, args GetDatasourceByUIDParams) (*models.DataSource, error) {
c := mcpgrafana.GrafanaClientFromContext(ctx)
datasource, err := c.Datasources.GetDataSourceByUID(args.UID)
if err != nil {
// Check if it's a 404 Not Found Error
if strings.Contains(err.Error(), "404") {
return nil, fmt.Errorf("datasource with UID '%s' not found. Please check if the datasource exists and is accessible", args.UID)
}
return nil, fmt.Errorf("get datasource by uid %s: %w", args.UID, err)
}
return datasource.Payload, nil
}
var GetDatasourceByUID = mcpgrafana.MustTool(
"get_datasource_by_uid",
"Retrieves detailed information about a specific datasource using its UID. Returns the full datasource model, including name, type, URL, access settings, JSON data, and secure JSON field status.",
getDatasourceByUID,
mcp.WithTitleAnnotation("Get datasource by UID"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
type GetDatasourceByNameParams struct {
Name string `json:"name" jsonschema:"required,description=The name of the datasource"`
}
func getDatasourceByName(ctx context.Context, args GetDatasourceByNameParams) (*models.DataSource, error) {
c := mcpgrafana.GrafanaClientFromContext(ctx)
datasource, err := c.Datasources.GetDataSourceByName(args.Name)
if err != nil {
return nil, fmt.Errorf("get datasource by name %s: %w", args.Name, err)
}
return datasource.Payload, nil
}
var GetDatasourceByName = mcpgrafana.MustTool(
"get_datasource_by_name",
"Retrieves detailed information about a specific datasource using its name. Returns the full datasource model, including UID, type, URL, access settings, JSON data, and secure JSON field status.",
getDatasourceByName,
mcp.WithTitleAnnotation("Get datasource by name"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
func AddDatasourceTools(mcp *server.MCPServer) {
ListDatasources.Register(mcp)
GetDatasourceByUID.Register(mcp)
GetDatasourceByName.Register(mcp)
}
```
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
```python
import pytest
import os
import asyncio
import gc
import base64
from dotenv import load_dotenv
from mcp.client.sse import sse_client
from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client
from mcp import ClientSession, StdioServerParameters
load_dotenv()
DEFAULT_GRAFANA_URL = "http://localhost:3000"
DEFAULT_MCP_URL = "http://localhost:8000"
DEFAULT_MCP_TRANSPORT = "sse"
models = ["gpt-4o", "claude-3-5-sonnet-20240620"]
pytestmark = pytest.mark.anyio
@pytest.fixture
def anyio_backend():
return "asyncio"
@pytest.fixture(autouse=True)
async def cleanup_sessions():
"""Clean up any lingering HTTP sessions after each test."""
yield
# Force garbage collection to clean up any unclosed sessions
gc.collect()
# Give a brief moment for cleanup
await asyncio.sleep(0.01)
@pytest.fixture
def mcp_transport():
return os.environ.get("MCP_TRANSPORT", DEFAULT_MCP_TRANSPORT)
@pytest.fixture
def mcp_url():
return os.environ.get("MCP_GRAFANA_URL", DEFAULT_MCP_URL)
@pytest.fixture
def grafana_env():
env = {"GRAFANA_URL": os.environ.get("GRAFANA_URL", DEFAULT_GRAFANA_URL)}
# Check for the new service account token environment variable first
if key := os.environ.get("GRAFANA_SERVICE_ACCOUNT_TOKEN"):
env["GRAFANA_SERVICE_ACCOUNT_TOKEN"] = key
elif key := os.environ.get("GRAFANA_API_KEY"):
env["GRAFANA_API_KEY"] = key
import warnings
warnings.warn(
"GRAFANA_API_KEY is deprecated, please use GRAFANA_SERVICE_ACCOUNT_TOKEN instead. See https://grafana.com/docs/grafana/latest/administration/service-accounts/#add-a-token-to-a-service-account-in-grafana for details on creating service account tokens.",
DeprecationWarning,
)
elif (username := os.environ.get("GRAFANA_USERNAME")) and (
password := os.environ.get("GRAFANA_PASSWORD")
):
env["GRAFANA_USERNAME"] = username
env["GRAFANA_PASSWORD"] = password
return env
@pytest.fixture
def grafana_headers():
headers = {
"X-Grafana-URL": os.environ.get("GRAFANA_URL", DEFAULT_GRAFANA_URL),
}
# Check for the new service account token environment variable first
if key := os.environ.get("GRAFANA_SERVICE_ACCOUNT_TOKEN"):
headers["X-Grafana-API-Key"] = key
elif key := os.environ.get("GRAFANA_API_KEY"):
headers["X-Grafana-API-Key"] = key
import warnings
warnings.warn(
"GRAFANA_API_KEY is deprecated, please use GRAFANA_SERVICE_ACCOUNT_TOKEN instead. See https://grafana.com/docs/grafana/latest/administration/service-accounts/#add-a-token-to-a-service-account-in-grafana for details on creating service account tokens.",
DeprecationWarning,
)
elif (username := os.environ.get("GRAFANA_USERNAME")) and (
password := os.environ.get("GRAFANA_PASSWORD")
):
credentials = f"{username}:{password}"
headers["Authorization"] = (
"Basic " + base64.b64encode(credentials.encode("utf-8")).decode()
)
return headers
@pytest.fixture
async def mcp_client(mcp_transport, mcp_url, grafana_env, grafana_headers):
if mcp_transport == "stdio":
params = StdioServerParameters(
command=os.environ.get("MCP_GRAFANA_PATH", "../dist/mcp-grafana"),
args=["--debug", "--log-level", "debug"],
env=grafana_env,
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
yield session
elif mcp_transport == "sse":
url = f"{mcp_url}/sse"
async with sse_client(url, headers=grafana_headers) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
yield session
elif mcp_transport == "streamable-http":
# Use HTTP client for streamable-http transport
url = f"{mcp_url}/mcp"
async with streamablehttp_client(url, headers=grafana_headers) as (
read,
write,
_,
):
async with ClientSession(read, write) as session:
await session.initialize()
yield session
else:
raise ValueError(f"Unsupported transport: {mcp_transport}")
```
--------------------------------------------------------------------------------
/tests/disable_write_test.py:
--------------------------------------------------------------------------------
```python
import pytest
import os
from mcp.client.stdio import stdio_client
from mcp import ClientSession, StdioServerParameters
pytestmark = pytest.mark.anyio
@pytest.fixture
def grafana_env():
env = {"GRAFANA_URL": os.environ.get("GRAFANA_URL", "http://localhost:3000")}
# Check for the new service account token environment variable first
if key := os.environ.get("GRAFANA_SERVICE_ACCOUNT_TOKEN"):
env["GRAFANA_SERVICE_ACCOUNT_TOKEN"] = key
elif key := os.environ.get("GRAFANA_API_KEY"):
env["GRAFANA_API_KEY"] = key
return env
async def test_disable_write_flag_disables_write_tools(grafana_env):
"""Test that --disable-write flag disables write tools."""
params = StdioServerParameters(
command=os.environ.get("MCP_GRAFANA_PATH", "../dist/mcp-grafana"),
args=["--disable-write"],
env=grafana_env,
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# List all available tools
tools_result = await session.list_tools()
tool_names = [tool.name for tool in tools_result.tools]
# Verify write tools are NOT present
write_tools = [
"update_dashboard",
"create_folder",
"create_incident",
"add_activity_to_incident",
"create_alert_rule",
"update_alert_rule",
"delete_alert_rule",
"create_annotation",
"create_graphite_annotation",
"update_annotation",
"patch_annotation",
"find_error_pattern_logs",
"find_slow_requests",
]
for tool in write_tools:
assert tool not in tool_names, f"Write tool '{tool}' should not be available with --disable-write flag"
# Verify read tools ARE still present
read_tools = [
"get_dashboard_by_uid",
"list_alert_rules",
"get_alert_rule_by_uid",
"list_contact_points",
"list_incidents",
"get_incident",
"get_sift_investigation",
"get_annotations",
"get_annotation_tags",
]
for tool in read_tools:
assert tool in tool_names, f"Read tool '{tool}' should still be available with --disable-write flag"
async def test_without_disable_write_flag_enables_write_tools(grafana_env):
"""Test that without --disable-write flag, write tools are enabled."""
params = StdioServerParameters(
command=os.environ.get("MCP_GRAFANA_PATH", "../dist/mcp-grafana"),
args=[], # No --disable-write flag
env=grafana_env,
)
async with stdio_client(params) as (read, write):
async with ClientSession(read, write) as session:
await session.initialize()
# List all available tools
tools_result = await session.list_tools()
tool_names = [tool.name for tool in tools_result.tools]
# Verify write tools ARE present
write_tools = [
"update_dashboard",
"create_folder",
"create_incident",
"add_activity_to_incident",
"create_alert_rule",
"update_alert_rule",
"delete_alert_rule",
"create_annotation",
"create_graphite_annotation",
"update_annotation",
"patch_annotation",
"find_error_pattern_logs",
"find_slow_requests",
]
for tool in write_tools:
assert tool in tool_names, f"Write tool '{tool}' should be available without --disable-write flag"
# Verify read tools are also present
read_tools = [
"get_dashboard_by_uid",
"list_alert_rules",
"get_alert_rule_by_uid",
"list_contact_points",
"list_incidents",
"get_incident",
"get_sift_investigation",
"get_annotations",
"get_annotation_tags",
]
for tool in read_tools:
assert tool in tool_names, f"Read tool '{tool}' should be available without --disable-write flag"
```
--------------------------------------------------------------------------------
/testdata/provisioning/alerting/alert_rules.yaml:
--------------------------------------------------------------------------------
```yaml
apiVersion: 1
groups:
- orgId: 1
name: Test Alert Rules
folder: Tests
interval: 1m
rules:
- uid: test_alert_rule_1
title: Test Alert Rule 1
condition: B
data:
- refId: A
relativeTimeRange:
from: 600
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: vector(1)
hide: false
instant: true
legendFormat: __auto
range: false
refId: A
- refId: B
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 0
- 0
type: gt
operator:
type: and
query:
params: []
reducer:
params: []
type: avg
type: query
datasource:
name: Expression
type: __expr__
uid: __expr__
expression: A
hide: false
refId: B
type: threshold
noDataState: NoData
execErrState: Error
for: 1m
keepFiringFor: 0s
annotations:
description: This is a test alert rule that is always firing
labels:
severity: info
type: test
rule: first
isPaused: false
- uid: test_alert_rule_2
title: Test Alert Rule 2
condition: B
data:
- refId: A
relativeTimeRange:
from: 600
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: vector(0)
hide: false
instant: true
legendFormat: __auto
range: false
refId: A
- refId: B
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 0
- 0
type: gt
operator:
type: and
query:
params: []
reducer:
params: []
type: avg
type: query
datasource:
name: Expression
type: __expr__
uid: __expr__
expression: A
hide: false
refId: B
type: threshold
noDataState: NoData
execErrState: Error
for: 1m
keepFiringFor: 0s
annotations:
description: This is a test alert rule that is always normal
labels:
severity: info
type: test
rule: second
isPaused: false
- uid: test_alert_rule_paused
title: Test Alert Rule (Paused)
condition: B
data:
- refId: A
relativeTimeRange:
from: 600
to: 0
datasourceUid: prometheus
model:
datasource:
type: prometheus
uid: prometheus
editorMode: code
expr: vector(1)
hide: false
instant: true
legendFormat: __auto
range: false
refId: A
- refId: B
datasourceUid: __expr__
model:
conditions:
- evaluator:
params:
- 0
- 0
type: gt
operator:
type: and
query:
params: []
reducer:
params: []
type: avg
type: query
datasource:
name: Expression
type: __expr__
uid: __expr__
expression: A
hide: false
refId: B
type: threshold
noDataState: NoData
execErrState: Error
for: 1m
keepFiringFor: 0s
annotations:
description: This is a paused alert rule
labels:
severity: info
type: test
rule: third
isPaused: true
```
--------------------------------------------------------------------------------
/tools/asserts.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
mcpgrafana "github.com/grafana/mcp-grafana"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
func newAssertsClient(ctx context.Context) (*Client, error) {
cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
url := fmt.Sprintf("%s/api/plugins/grafana-asserts-app/resources/asserts/api-server", strings.TrimRight(cfg.URL, "/"))
// Create custom transport with TLS configuration if available
var transport = http.DefaultTransport
if tlsConfig := cfg.TLSConfig; tlsConfig != nil {
var err error
transport, err = tlsConfig.HTTPTransport(transport.(*http.Transport))
if err != nil {
return nil, fmt.Errorf("failed to create custom transport: %w", err)
}
}
transport = NewAuthRoundTripper(transport, cfg.AccessToken, cfg.IDToken, cfg.APIKey, cfg.BasicAuth)
transport = mcpgrafana.NewOrgIDRoundTripper(transport, cfg.OrgID)
client := &http.Client{
Transport: mcpgrafana.NewUserAgentTransport(
transport,
),
}
return &Client{
httpClient: client,
baseURL: url,
}, nil
}
type GetAssertionsParams struct {
StartTime time.Time `json:"startTime" jsonschema:"required,description=The start time in RFC3339 format"`
EndTime time.Time `json:"endTime" jsonschema:"required,description=The end time in RFC3339 format"`
EntityType string `json:"entityType" jsonschema:"description=The type of the entity to list (e.g. Service\\, Node\\, Pod\\, etc.)"`
EntityName string `json:"entityName" jsonschema:"description=The name of the entity to list"`
Env string `json:"env,omitempty" jsonschema:"description=The env of the entity to list"`
Site string `json:"site,omitempty" jsonschema:"description=The site of the entity to list"`
Namespace string `json:"namespace,omitempty" jsonschema:"description=The namespace of the entity to list"`
}
type scope struct {
Env string `json:"env,omitempty"`
Site string `json:"site,omitempty"`
Namespace string `json:"namespace,omitempty"`
}
type entity struct {
Name string `json:"name"`
Type string `json:"type"`
Scope scope `json:"scope"`
}
type requestBody struct {
StartTime int64 `json:"startTime"`
EndTime int64 `json:"endTime"`
EntityKeys []entity `json:"entityKeys"`
SuggestionSrcEntities []entity `json:"suggestionSrcEntities"`
AlertCategories []string `json:"alertCategories"`
}
func (c *Client) fetchAssertsData(ctx context.Context, urlPath string, method string, reqBody any) (string, error) {
jsonData, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+urlPath, bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to execute request: %w", err)
}
defer func() {
_ = resp.Body.Close() //nolint:errcheck
}()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body))
}
return string(body), nil
}
func getAssertions(ctx context.Context, args GetAssertionsParams) (string, error) {
client, err := newAssertsClient(ctx)
if err != nil {
return "", fmt.Errorf("failed to create Asserts client: %w", err)
}
// Create request body
reqBody := requestBody{
StartTime: args.StartTime.UnixMilli(),
EndTime: args.EndTime.UnixMilli(),
EntityKeys: []entity{
{
Name: args.EntityName,
Type: args.EntityType,
Scope: scope{},
},
},
SuggestionSrcEntities: []entity{},
AlertCategories: []string{"saturation", "amend", "anomaly", "failure", "error"},
}
if args.Env != "" {
reqBody.EntityKeys[0].Scope.Env = args.Env
}
if args.Site != "" {
reqBody.EntityKeys[0].Scope.Site = args.Site
}
if args.Namespace != "" {
reqBody.EntityKeys[0].Scope.Namespace = args.Namespace
}
data, err := client.fetchAssertsData(ctx, "/v1/assertions/llm-summary", "POST", reqBody)
if err != nil {
return "", fmt.Errorf("failed to fetch data: %w", err)
}
return data, nil
}
var GetAssertions = mcpgrafana.MustTool(
"get_assertions",
"Get assertion summary for a given entity with its type, name, env, site, namespace, and a time range",
getAssertions,
mcp.WithTitleAnnotation("Get assertions summary"),
mcp.WithIdempotentHintAnnotation(true),
mcp.WithReadOnlyHintAnnotation(true),
)
func AddAssertsTools(mcp *server.MCPServer) {
GetAssertions.Register(mcp)
}
```
--------------------------------------------------------------------------------
/tests/admin_test.py:
--------------------------------------------------------------------------------
```python
from typing import Dict
import pytest
from langevals import expect
from langevals_langevals.llm_boolean import (
CustomLLMBooleanEvaluator,
CustomLLMBooleanSettings,
)
from litellm import Message, acompletion
from mcp import ClientSession
import aiohttp
import uuid
import os
from conftest import DEFAULT_GRAFANA_URL
from conftest import models
from utils import (
get_converted_tools,
llm_tool_call_sequence,
)
pytestmark = pytest.mark.anyio
@pytest.fixture
async def grafana_team():
"""Create a temporary test team and clean it up after the test is done."""
# Generate a unique team name to avoid conflicts
team_name = f"test-team-{uuid.uuid4().hex[:8]}"
# Get Grafana URL and service account token from environment
grafana_url = os.environ.get("GRAFANA_URL", DEFAULT_GRAFANA_URL)
auth_header = None
# Check for the new service account token environment variable first
if api_key := os.environ.get("GRAFANA_SERVICE_ACCOUNT_TOKEN"):
auth_header = {"Authorization": f"Bearer {api_key}"}
elif api_key := os.environ.get("GRAFANA_API_KEY"):
auth_header = {"Authorization": f"Bearer {api_key}"}
import warnings
warnings.warn(
"GRAFANA_API_KEY is deprecated, please use GRAFANA_SERVICE_ACCOUNT_TOKEN instead. See https://grafana.com/docs/grafana/latest/administration/service-accounts/#add-a-token-to-a-service-account-in-grafana for details on creating service account tokens.",
DeprecationWarning,
)
if not auth_header:
pytest.skip("No authentication credentials available to create team")
# Create the team using Grafana API
team_id = None
async with aiohttp.ClientSession() as session:
create_url = f"{grafana_url}/api/teams"
async with session.post(
create_url,
headers=auth_header,
json={"name": team_name, "email": f"{team_name}@example.com"},
) as response:
if response.status != 200:
resp_text = await response.text()
pytest.skip(f"Failed to create team: {resp_text}")
resp_data = await response.json()
team_id = resp_data.get("teamId")
# Yield the team info for the test to use
yield {"id": team_id, "name": team_name}
# Clean up after the test
if team_id:
async with aiohttp.ClientSession() as session:
delete_url = f"{grafana_url}/api/teams/{team_id}"
async with session.delete(delete_url, headers=auth_header) as response:
if response.status != 200:
resp_text = await response.text()
print(f"Warning: Failed to delete team: {resp_text}")
@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_list_teams_tool(
model: str, mcp_client: ClientSession, grafana_team: Dict[str, str]
):
tools = await get_converted_tools(mcp_client)
team_name = grafana_team["name"]
prompt = "Can you list the teams in Grafana?"
messages = [
Message(role="system", content="You are a helpful assistant."),
Message(role="user", content=prompt),
]
# 1. Call the list teams tool
messages = await llm_tool_call_sequence(
model,
messages,
tools,
mcp_client,
"list_teams",
)
# 2. Final LLM response
response = await acompletion(model=model, messages=messages, tools=tools)
content = response.choices[0].message.content
panel_queries_checker = CustomLLMBooleanEvaluator(
settings=CustomLLMBooleanSettings(
prompt=(
"Does the response contain specific information about "
"the teams in Grafana?"
f"There should be a team named {team_name}. "
),
)
)
expect(input=prompt, output=content).to_pass(panel_queries_checker)
@pytest.mark.parametrize("model", models)
@pytest.mark.flaky(max_runs=3)
async def test_list_users_by_org_tool(model: str, mcp_client: ClientSession):
tools = await get_converted_tools(mcp_client)
prompt = "Can you list the users in Grafana?"
messages = [
Message(role="system", content="You are a helpful assistant."),
Message(role="user", content=prompt),
]
# 1. Call the list_users_by_org tool
messages = await llm_tool_call_sequence(
model, messages, tools, mcp_client, "list_users_by_org"
)
# 2. Final LLM response
response = await acompletion(model=model, messages=messages, tools=tools)
content = response.choices[0].message.content
user_checker = CustomLLMBooleanEvaluator(
settings=CustomLLMBooleanSettings(
prompt="Does the response contain specific information about users in Grafana, such as usernames, emails, or roles?",
)
)
expect(input=prompt, output=content).to_pass(user_checker)
```
--------------------------------------------------------------------------------
/tools/alerting_client.go:
--------------------------------------------------------------------------------
```go
package tools
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/prometheus/prometheus/model/labels"
mcpgrafana "github.com/grafana/mcp-grafana"
)
const (
defaultTimeout = 30 * time.Second
rulesEndpointPath = "/api/prometheus/grafana/api/v1/rules"
)
type alertingClient struct {
baseURL *url.URL
accessToken string
idToken string
apiKey string
basicAuth *url.Userinfo
orgID int64
httpClient *http.Client
}
func newAlertingClientFromContext(ctx context.Context) (*alertingClient, error) {
cfg := mcpgrafana.GrafanaConfigFromContext(ctx)
baseURL := strings.TrimRight(cfg.URL, "/")
parsedBaseURL, err := url.Parse(baseURL)
if err != nil {
return nil, fmt.Errorf("invalid Grafana base URL %q: %w", baseURL, err)
}
client := &alertingClient{
baseURL: parsedBaseURL,
accessToken: cfg.AccessToken,
idToken: cfg.IDToken,
apiKey: cfg.APIKey,
basicAuth: cfg.BasicAuth,
orgID: cfg.OrgID,
httpClient: &http.Client{
Timeout: defaultTimeout,
},
}
// Create custom transport with TLS configuration if available
if tlsConfig := mcpgrafana.GrafanaConfigFromContext(ctx).TLSConfig; tlsConfig != nil {
client.httpClient.Transport, err = tlsConfig.HTTPTransport(http.DefaultTransport.(*http.Transport))
if err != nil {
return nil, fmt.Errorf("failed to create custom transport: %w", err)
}
// Wrap with user agent
client.httpClient.Transport = mcpgrafana.NewUserAgentTransport(
client.httpClient.Transport,
)
} else {
// No custom TLS, but still add user agent
client.httpClient.Transport = mcpgrafana.NewUserAgentTransport(
http.DefaultTransport,
)
}
return client, nil
}
func (c *alertingClient) makeRequest(ctx context.Context, path string) (*http.Response, error) {
p := c.baseURL.JoinPath(path).String()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, p, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request to %s: %w", p, err)
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
// If accessToken is set we use that first and fall back to normal Authorization.
if c.accessToken != "" && c.idToken != "" {
req.Header.Set("X-Access-Token", c.accessToken)
req.Header.Set("X-Grafana-Id", c.idToken)
} else if c.apiKey != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.apiKey))
} else if c.basicAuth != nil {
password, _ := c.basicAuth.Password()
req.SetBasicAuth(c.basicAuth.Username(), password)
}
// Add org ID header for multi-org support
if c.orgID > 0 {
req.Header.Set("X-Scope-OrgId", strconv.FormatInt(c.orgID, 10))
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to execute request to %s: %w", p, err)
}
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close() //nolint:errcheck
return nil, fmt.Errorf("grafana API returned status code %d: %s", resp.StatusCode, string(bodyBytes))
}
return resp, nil
}
func (c *alertingClient) GetRules(ctx context.Context) (*rulesResponse, error) {
resp, err := c.makeRequest(ctx, rulesEndpointPath)
if err != nil {
return nil, fmt.Errorf("failed to get alert rules from Grafana API: %w", err)
}
defer func() {
_ = resp.Body.Close() //nolint:errcheck
}()
var rulesResponse rulesResponse
decoder := json.NewDecoder(resp.Body)
if err := decoder.Decode(&rulesResponse); err != nil {
return nil, fmt.Errorf("failed to decode rules response from %s: %w", rulesEndpointPath, err)
}
return &rulesResponse, nil
}
type rulesResponse struct {
Data struct {
RuleGroups []ruleGroup `json:"groups"`
NextToken string `json:"groupNextToken,omitempty"`
Totals map[string]int64 `json:"totals,omitempty"`
} `json:"data"`
}
type ruleGroup struct {
Name string `json:"name"`
FolderUID string `json:"folderUid"`
Rules []alertingRule `json:"rules"`
Interval float64 `json:"interval"`
LastEvaluation time.Time `json:"lastEvaluation"`
EvaluationTime float64 `json:"evaluationTime"`
}
type alertingRule struct {
State string `json:"state,omitempty"`
Name string `json:"name,omitempty"`
Query string `json:"query,omitempty"`
Duration float64 `json:"duration,omitempty"`
KeepFiringFor float64 `json:"keepFiringFor,omitempty"`
Annotations labels.Labels `json:"annotations,omitempty"`
ActiveAt *time.Time `json:"activeAt,omitempty"`
Alerts []alert `json:"alerts,omitempty"`
Totals map[string]int64 `json:"totals,omitempty"`
TotalsFiltered map[string]int64 `json:"totalsFiltered,omitempty"`
UID string `json:"uid"`
FolderUID string `json:"folderUid"`
Labels labels.Labels `json:"labels,omitempty"`
Health string `json:"health"`
LastError string `json:"lastError,omitempty"`
Type string `json:"type"`
LastEvaluation time.Time `json:"lastEvaluation"`
EvaluationTime float64 `json:"evaluationTime"`
}
type alert struct {
Labels labels.Labels `json:"labels"`
Annotations labels.Labels `json:"annotations"`
State string `json:"state"`
ActiveAt *time.Time `json:"activeAt"`
Value string `json:"value"`
}
```
--------------------------------------------------------------------------------
/internal/linter/jsonschema/jsonschema_lint_test.go:
--------------------------------------------------------------------------------
```go
package linter
import (
"os"
"path/filepath"
"testing"
)
func TestFindUnescapedCommas(t *testing.T) {
// Create a temporary directory for test files
tmpDir, err := os.MkdirTemp("", "jsonschema-linter-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
t.Logf("Failed to remove temp dir: %v", err)
}
}()
// Create test files
testFiles := map[string]string{
"valid.go": `package test
// Valid has properly escaped commas
type Valid struct {
Name string ` + "`json:\"name\" jsonschema:\"description=A valid field\\, with escaped comma\"`" + `
Age int ` + "`json:\"age\" jsonschema:\"description=Another valid field\"`" + `
}
`,
"invalid.go": `package test
// Invalid has unescaped commas
type Invalid struct {
Name string ` + "`json:\"name\" jsonschema:\"description=An invalid field, with unescaped comma\"`" + `
Age int ` + "`json:\"age\" jsonschema:\"description=Another valid field\"`" + `
}
`,
"mixed.go": `package test
// Mixed has both valid and invalid fields
type Mixed struct {
Valid string ` + "`json:\"valid\" jsonschema:\"description=A valid field\\, with escaped comma\"`" + `
Invalid string ` + "`json:\"invalid\" jsonschema:\"description=An invalid field, with unescaped comma\"`" + `
}
`,
}
for filename, content := range testFiles {
filePath := filepath.Join(tmpDir, filename)
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file %s: %v", filename, err)
}
}
// Run the linter
linter := &JSONSchemaLinter{}
err = linter.FindUnescapedCommas(tmpDir)
if err != nil {
t.Fatalf("Linter failed: %v", err)
}
// Check if we found the expected errors
if len(linter.Errors) != 2 {
t.Errorf("Expected 2 errors, got %d", len(linter.Errors))
}
// Check if the errors are in the expected files
fileErrors := make(map[string]int)
for _, e := range linter.Errors {
fileName := filepath.Base(e.FilePath)
fileErrors[fileName]++
}
if fileErrors["invalid.go"] != 1 {
t.Errorf("Expected 1 error in invalid.go, got %d", fileErrors["invalid.go"])
}
if fileErrors["mixed.go"] != 1 {
t.Errorf("Expected 1 error in mixed.go, got %d", fileErrors["mixed.go"])
}
if fileErrors["valid.go"] != 0 {
t.Errorf("Expected 0 errors in valid.go, got %d", fileErrors["valid.go"])
}
}
// TestEscapedQuotesWithComma tests if the regex correctly identifies unescaped commas
// in jsonschema tags that contain escaped quotes
func TestEscapedQuotesWithComma(t *testing.T) {
testCases := []struct {
tag string
shouldMatch bool
description string
}{
{`jsonschema:"description=This has an unescaped, comma"`, true, "Simple unescaped comma"},
{`jsonschema:"description=This has escaped quote \"followed by, comma"`, true, "Escaped quote then unescaped comma"},
{`jsonschema:"description=This has escaped quote \", comma"`, true, "Escaped quote, comma with space"},
{`jsonschema:"description=This has escaped quote \\\"and escaped\\, comma"`, false, "Properly escaped quote and comma"},
{`jsonschema:"description=No comma here"`, false, "No comma at all"},
}
for _, tc := range testCases {
t.Run(tc.description, func(t *testing.T) {
matches := tagPattern.FindStringSubmatch(tc.tag)
hasMatch := len(matches) > 0
if hasMatch != tc.shouldMatch {
t.Fatalf("Test failed for %s: expected match=%v, got=%v\n", tc.description, tc.shouldMatch, hasMatch)
}
})
}
}
func TestFixUnescapedCommas(t *testing.T) {
// Create a temporary directory for test files
tmpDir, err := os.MkdirTemp("", "jsonschema-linter-test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer func() {
if err := os.RemoveAll(tmpDir); err != nil {
t.Logf("Failed to remove temp dir: %v", err)
}
}()
// Create a test file with unescaped commas
invalidContent := `package test
// Invalid has unescaped commas
type Invalid struct {
Name string ` + "`json:\"name\" jsonschema:\"description=An invalid field, with unescaped comma\"`" + `
Age int ` + "`json:\"age\" jsonschema:\"description=Another field, also with unescaped comma\"`" + `
}
`
// Expected content after fixing
// Note: We need double backslashes in the actual file, so we use double escaped backslashes here
expectedContent := `package test
// Invalid has unescaped commas
type Invalid struct {
Name string ` + "`json:\"name\" jsonschema:\"description=An invalid field\\\\, with unescaped comma\"`" + `
Age int ` + "`json:\"age\" jsonschema:\"description=Another field\\\\, also with unescaped comma\"`" + `
}
`
filePath := filepath.Join(tmpDir, "invalid.go")
if err := os.WriteFile(filePath, []byte(invalidContent), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Run the linter with fix mode enabled
linter := &JSONSchemaLinter{FixMode: true}
err = linter.FindUnescapedCommas(tmpDir)
if err != nil {
t.Fatalf("Linter failed: %v", err)
}
// Check if we found the expected errors
if len(linter.Errors) != 2 {
t.Errorf("Expected 2 errors, got %d", len(linter.Errors))
}
// Verify the file was fixed
fixedContent, err := os.ReadFile(filePath)
if err != nil {
t.Fatalf("Failed to read fixed file: %v", err)
}
if string(fixedContent) != expectedContent {
t.Errorf("File not fixed correctly.\nExpected:\n%s\n\nGot:\n%s", expectedContent, string(fixedContent))
}
// Verify the fixed field was correctly tracked
if !linter.Fixed[filePath] {
t.Errorf("Fixed file not tracked in linter.Fixed")
}
}
```
--------------------------------------------------------------------------------
/tools/sift_cloud_test.go:
--------------------------------------------------------------------------------
```go
//go:build cloud
// +build cloud
// This file contains cloud integration tests that run against a dedicated test instance
// at mcptests.grafana-dev.net. This instance is configured with a minimal setup on the Sift side:
// - 2 test investigations
// These tests expect this configuration to exist and will skip if the required
// environment variables (GRAFANA_URL, GRAFANA_SERVICE_ACCOUNT_TOKEN or GRAFANA_API_KEY) are not set.
// The GRAFANA_API_KEY variable is deprecated.
package tools
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCloudSiftInvestigations(t *testing.T) {
ctx := createCloudTestContext(t, "Sift", "GRAFANA_URL", "GRAFANA_API_KEY")
// Test listing all investigations
t.Run("list all investigations", func(t *testing.T) {
result, err := listSiftInvestigations(ctx, ListSiftInvestigationsParams{})
require.NoError(t, err, "Should not error when listing investigations")
assert.NotNil(t, result, "Result should not be nil")
assert.GreaterOrEqual(t, len(result), 1, "Should have at least one investigation")
})
// Test listing investigations with a limit
t.Run("list investigations with limit", func(t *testing.T) {
// Get the client
client, err := siftClientFromContext(ctx)
require.NoError(t, err, "Should not error when getting Sift client")
// List investigations with a limit of 1
investigations, err := client.listSiftInvestigations(ctx, 1)
require.NoError(t, err, "Should not error when listing investigations with limit")
assert.NotNil(t, investigations, "Investigations should not be nil")
assert.LessOrEqual(t, len(investigations), 1, "Should have at most one investigation")
// If there are investigations, verify their structure
if len(investigations) > 0 {
investigation := investigations[0]
assert.NotEmpty(t, investigation.ID, "Investigation should have an ID")
assert.NotEmpty(t, investigation.Name, "Investigation should have a name")
assert.NotEmpty(t, investigation.TenantID, "Investigation should have a tenant ID")
}
})
// Get an investigation ID from the list to test getting a specific investigation
investigations, err := listSiftInvestigations(ctx, ListSiftInvestigationsParams{Limit: 10})
require.NoError(t, err, "Should not error when listing investigations")
require.NotEmpty(t, investigations, "Should have at least one investigation to test with")
// Find an investigation with at least one analysis.
var investigationID string
for _, investigation := range investigations {
if len(investigation.Analyses.Items) > 0 {
investigationID = investigation.ID.String()
break
}
}
require.NotEmpty(t, investigationID, "Should have at least one investigation with at least one analysis")
// Test getting a specific investigation
t.Run("get specific investigation", func(t *testing.T) {
result, err := getSiftInvestigation(ctx, GetSiftInvestigationParams{
ID: investigationID,
})
require.NoError(t, err, "Should not error when getting specific investigation")
assert.NotNil(t, result, "Result should not be nil")
assert.Equal(t, investigationID, result.ID.String(), "Should return the correct investigation")
// Verify all required fields are present
assert.NotEmpty(t, result.Name, "Investigation should have a name")
assert.NotEmpty(t, result.TenantID, "Investigation should have a tenant ID")
assert.NotNil(t, result.GrafanaURL, "Investigation should have a Grafana URL")
assert.NotNil(t, result.Status, "Investigation should have a status")
assert.NotNil(t, result.FailureReason, "Investigation should have a failure reason")
})
// Test getting a non-existent investigation
t.Run("get non-existent investigation", func(t *testing.T) {
_, err := getSiftInvestigation(ctx, GetSiftInvestigationParams{
ID: "00000000-0000-0000-0000-000000000000",
})
assert.NoError(t, err, "Should not error when getting non-existent investigation")
})
// Test getting analyses for an investigation
t.Run("get analyses for investigation", func(t *testing.T) {
// Get the investigation
result, err := getSiftInvestigation(ctx, GetSiftInvestigationParams{
ID: investigationID,
})
require.NoError(t, err, "Should not error when getting specific investigation")
assert.NotNil(t, result, "Result should not be nil")
// Get an analysis ID
analysisID := result.Analyses.Items[0].ID
// Get the analysis
analysis, err := getSiftAnalysis(ctx, GetSiftAnalysisParams{
InvestigationID: investigationID,
AnalysisID: analysisID.String(),
})
require.NoError(t, err, "Should not error when getting specific analysis")
assert.NotNil(t, analysis, "Analysis should not be nil")
// Verify all required fields are present
assert.NotEmpty(t, analysis.Name, "Analysis should have a name")
assert.NotEmpty(t, analysis.InvestigationID, "Analysis should have an investigation ID")
assert.NotNil(t, analysis.Result, "Analysis should have a result")
})
t.Run("find error patterns", func(t *testing.T) {
// Find error patterns
analysis, err := findErrorPatternLogs(ctx, FindErrorPatternLogsParams{
Name: "Test Sift",
Labels: map[string]string{
"namespace": "hosted-grafana",
"cluster": "dev-eu-west-2",
"slug": "mcptests",
},
Start: time.Now().Add(-5 * time.Minute),
End: time.Now(),
})
require.NoError(t, err, "Should not error when finding error patterns")
assert.NotNil(t, analysis, "Result should not be nil")
// Verify all required fields are present
assert.NotEmpty(t, analysis.Name, "Analysis should have a name")
assert.NotEmpty(t, analysis.InvestigationID, "Analysis should have an investigation ID")
assert.NotEmpty(t, analysis.Result.Message, "Analysis should have a message")
})
}
```