Skip to content

Commit 6587178

Browse files
authored
Merge pull request #2 from grafana/prometheus-tools
2 parents 3a3b1d9 + 4ffa8c5 commit 6587178

File tree

6 files changed

+278
-7
lines changed

6 files changed

+278
-7
lines changed

README.md

+11-5
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ This provides access to your Grafana instance and the surrounding ecosystem.
99
- [x] Search for dashboards
1010
- [x] List and fetch datasource information
1111
- [ ] Query datasources
12-
- [ ] Prometheus
12+
- [x] Prometheus
1313
- [ ] Loki (log queries, metric queries)
1414
- [ ] Tempo
1515
- [ ] Pyroscope
16-
- [ ] Query Prometheus metadata
17-
- [ ] Metric names
18-
- [ ] Label names
19-
- [ ] Label values
16+
- [x] Query Prometheus metadata
17+
- [x] Metric metadata
18+
- [x] Metric names
19+
- [x] Label names
20+
- [x] Label values
2021
- [x] Search, create, update and close incidents
2122
- [ ] Start Sift investigations and view the results
2223

@@ -31,6 +32,11 @@ This is useful if you don't use certain functionality or if you don't want to ta
3132
| `list_datasources` | Datasources | List datasources |
3233
| `get_datasource_by_uid` | Datasources | Get a datasource by uid |
3334
| `get_datasource_by_name` | Datasources | Get a datasource by name |
35+
| `query_prometheus` | Prometheus | Execute a query against a Prometheus datasource |
36+
| `get_prometheus_metric_metadata` | Prometheus | Get metadata for a metric |
37+
| `get_prometheus_metric_names` | Prometheus | Get list of available metric names |
38+
| `get_prometheus_label_names` | Prometheus | Get list of label names for a metric |
39+
| `get_prometheus_label_values` | Prometheus | Get values for a specific label |
3440
| `list_incidents` | Incident | List incidents in Grafana Incident |
3541
| `create_incident` | Incident | Create an incident in Grafana Incident |
3642
| `add_activity_to_incident` | Incident | Add an activity item to an incident in Grafana Incident |

src/mcp_grafana/client.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ async def query(self, _from: datetime, to: datetime, queries: list[Query]) -> by
147147
body = {
148148
"from": str(math.floor(_from.timestamp() * 1000)),
149149
"to": str(math.floor(to.timestamp() * 1000)),
150-
"queries": query_list.dump_python(queries),
150+
"queries": query_list.dump_python(queries, by_alias=True),
151151
}
152152
return await self.post("/api/ds/query", json=body)
153153

src/mcp_grafana/settings.py

+11
Original file line numberDiff line numberDiff line change
@@ -36,10 +36,21 @@ class IncidentSettings(BaseSettings):
3636
)
3737

3838

39+
class PrometheusSettings(BaseSettings):
40+
"""
41+
Settings for the Prometheus tools.
42+
"""
43+
44+
enabled: bool = Field(
45+
default=True, description="Whether to enable the Prometheus tools."
46+
)
47+
48+
3949
class ToolSettings(BaseSettings):
4050
search: SearchSettings = Field(default_factory=SearchSettings)
4151
datasources: DatasourcesSettings = Field(default_factory=DatasourcesSettings)
4252
incident: IncidentSettings = Field(default_factory=IncidentSettings)
53+
prometheus: PrometheusSettings = Field(default_factory=PrometheusSettings)
4354

4455

4556
class GrafanaSettings(BaseSettings):

src/mcp_grafana/tools/__init__.py

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from mcp.server import FastMCP
22

3-
from . import datasources, incident, search
3+
from . import datasources, incident, prometheus, search
44
from ..settings import grafana_settings
55

66

@@ -14,3 +14,5 @@ def add_tools(mcp: FastMCP):
1414
datasources.add_tools(mcp)
1515
if grafana_settings.tools.incident.enabled:
1616
incident.add_tools(mcp)
17+
if grafana_settings.tools.prometheus.enabled:
18+
prometheus.add_tools(mcp)

src/mcp_grafana/tools/prometheus.py

+179
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import re
2+
from datetime import datetime
3+
from typing import Literal
4+
5+
from mcp.server import FastMCP
6+
7+
from ..client import grafana_client
8+
from ..grafana_types import (
9+
DatasourceRef,
10+
DSQueryResponse,
11+
PrometheusMetricMetadata,
12+
Query,
13+
ResponseWrapper,
14+
Selector,
15+
)
16+
17+
18+
PrometheusQueryType = Literal["range", "instant"]
19+
20+
21+
async def query_prometheus(
22+
datasource_uid: str,
23+
expr: str,
24+
start_rfc3339: str,
25+
end_rfc3339: str,
26+
step_seconds: int,
27+
query_type: PrometheusQueryType = "range",
28+
) -> DSQueryResponse:
29+
"""
30+
Query Prometheus using a range request.
31+
32+
# Parameters.
33+
34+
datasource_uid: The uid of the datasource to query.
35+
expr: The PromQL expression to query.
36+
start_rfc3339: The start time in RFC3339 format.
37+
end_rfc3339: The end time in RFC3339 format. Ignored if `query_type` is 'instant'.
38+
step_seconds: The time series step size in seconds. Ignored if `query_type` is 'instant'.
39+
query_type: The type of query to use. Either 'range' or 'instant'.
40+
"""
41+
start = datetime.fromisoformat(start_rfc3339)
42+
end = datetime.fromisoformat(end_rfc3339)
43+
query = Query(
44+
refId="A",
45+
datasource=DatasourceRef(
46+
uid=datasource_uid,
47+
type="prometheus",
48+
),
49+
queryType=query_type,
50+
expr=expr, # type: ignore
51+
intervalMs=step_seconds * 1000,
52+
)
53+
response = await grafana_client.query(start, end, [query])
54+
return DSQueryResponse.model_validate_json(response)
55+
56+
57+
async def get_prometheus_metric_metadata(
58+
datasource_uid: str,
59+
limit: int | None = None,
60+
limit_per_metric: int | None = None,
61+
metric: str | None = None,
62+
) -> dict[str, list[PrometheusMetricMetadata]]:
63+
"""
64+
Get metadata for all metrics in Prometheus.
65+
66+
# Parameters.
67+
68+
datasource_uid: The uid of the Grafana datasource to query.
69+
limit: Optionally, the maximum number of results to return.
70+
limit_per_metric: Optionally, the maximum number of results to return per metric.
71+
metric: Optionally, a metric name to filter the results by.
72+
73+
# Returns.
74+
75+
A mapping from metric name to all available metadata for that metric.
76+
"""
77+
response = await grafana_client.get_prometheus_metric_metadata(
78+
datasource_uid,
79+
limit=limit,
80+
limit_per_metric=limit_per_metric,
81+
metric=metric,
82+
)
83+
return (
84+
ResponseWrapper[dict[str, list[PrometheusMetricMetadata]]]
85+
.model_validate_json(response)
86+
.data
87+
)
88+
89+
90+
async def get_prometheus_metric_names(
91+
datasource_uid: str,
92+
regex: str,
93+
) -> list[str]:
94+
"""
95+
Get metric names in a Prometheus datasource that match the given regex.
96+
97+
# Parameters.
98+
99+
datasource_uid: The uid of the Grafana datasource to query.
100+
regex: The regex to match against the metric names. Uses Python's re.match.
101+
102+
# Returns.
103+
104+
A list of metric names that match the given regex.
105+
"""
106+
name_label_values = await get_prometheus_label_values(datasource_uid, "__name__")
107+
compiled = re.compile(regex)
108+
return [name for name in name_label_values if compiled.match(name)]
109+
110+
111+
async def get_prometheus_label_names(
112+
datasource_uid: str,
113+
matches: list[Selector] | None = None,
114+
start: datetime | None = None,
115+
end: datetime | None = None,
116+
limit: int | None = None,
117+
) -> list[str]:
118+
"""
119+
Get the label names in a Prometheus datasource, optionally filtered to those
120+
matching the given selectors and within the given time range.
121+
122+
If you want to get the label names for a specific metric, pass a matcher
123+
like `{__name__="metric_name"}` to the `matches` parameter.
124+
125+
# Parameters.
126+
127+
datasource_uid: The uid of the Grafana datasource to query.
128+
matches: Optionally, a list of label matchers to filter the results by.
129+
start: Optionally, the start time of the time range to filter the results by.
130+
end: Optionally, the end time of the time range to filter the results by.
131+
limit: Optionally, the maximum number of results to return.
132+
"""
133+
response = await grafana_client.get_prometheus_label_names(
134+
datasource_uid,
135+
matches=matches,
136+
start=start,
137+
end=end,
138+
limit=limit,
139+
)
140+
return ResponseWrapper[list[str]].model_validate_json(response).data
141+
142+
143+
async def get_prometheus_label_values(
144+
datasource_uid: str,
145+
label_name: str,
146+
matches: list[Selector] | None = None,
147+
start: datetime | None = None,
148+
end: datetime | None = None,
149+
limit: int | None = None,
150+
):
151+
"""
152+
Get the values of a label in Prometheus.
153+
154+
# Parameters.
155+
156+
datasource_uid: The uid of the Grafana datasource to query.
157+
label_name: The name of the label to query.
158+
matches: Optionally, a list of selectors to filter the results by.
159+
start: Optionally, the start time of the query.
160+
end: Optionally, the end time of the query.
161+
limit: Optionally, the maximum number of results to return.
162+
"""
163+
response = await grafana_client.get_prometheus_label_values(
164+
datasource_uid,
165+
label_name,
166+
matches=matches,
167+
start=start,
168+
end=end,
169+
limit=limit,
170+
)
171+
return ResponseWrapper[list[str]].model_validate_json(response).data
172+
173+
174+
def add_tools(mcp: FastMCP):
175+
mcp.add_tool(query_prometheus)
176+
mcp.add_tool(get_prometheus_metric_metadata)
177+
mcp.add_tool(get_prometheus_metric_names)
178+
mcp.add_tool(get_prometheus_label_names)
179+
mcp.add_tool(get_prometheus_label_values)

tests/tools/prometheus_test.py

+73
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from datetime import datetime, timedelta
2+
3+
import pytest
4+
5+
from mcp_grafana.grafana_types import LabelMatcher, Selector
6+
from mcp_grafana.tools.prometheus import (
7+
query_prometheus,
8+
get_prometheus_metric_metadata,
9+
get_prometheus_metric_names,
10+
get_prometheus_label_names,
11+
get_prometheus_label_values,
12+
)
13+
14+
from . import mark_integration
15+
16+
pytestmark = mark_integration
17+
18+
19+
@pytest.mark.parametrize("step_seconds", [15, 60, 300])
20+
async def test_query_prometheus_range(step_seconds):
21+
end = datetime.now()
22+
start = end - timedelta(minutes=10)
23+
24+
results = await query_prometheus(
25+
"robustperception",
26+
start_rfc3339=start.isoformat(),
27+
end_rfc3339=end.isoformat(),
28+
step_seconds=step_seconds,
29+
expr="node_load1",
30+
query_type="range",
31+
)
32+
query_result = results.results["A"]
33+
assert query_result.status == 200
34+
assert (
35+
query_result.frames[0].data.values[0][0] - start.timestamp() * 1000
36+
< step_seconds * 1000
37+
)
38+
assert (
39+
len(query_result.frames[0].data.values[0])
40+
== (end - start).total_seconds() // step_seconds + 1
41+
)
42+
43+
44+
async def test_get_prometheus_metric_metadata():
45+
# Test fetching metric metadata
46+
metadata = await get_prometheus_metric_metadata("robustperception", 10)
47+
assert 0 < len(metadata) <= 10
48+
49+
50+
async def test_get_prometheus_metric_names():
51+
# Test getting list of available metric names
52+
metric_names = await get_prometheus_metric_names("robustperception", ".*")
53+
assert isinstance(metric_names, list)
54+
assert len(metric_names) > 0
55+
assert "up" in metric_names # 'up' metric should always be present
56+
57+
58+
async def test_get_prometheus_label_names():
59+
# Test getting list of label names for a metric
60+
label_names = await get_prometheus_label_names(
61+
"robustperception",
62+
[Selector(filters=[LabelMatcher(name="job", value="node")])],
63+
)
64+
assert isinstance(label_names, list)
65+
assert len(label_names) > 0
66+
assert "instance" in label_names # 'instance' is a common label
67+
68+
69+
async def test_get_prometheus_label_values():
70+
# Test getting values for a specific label
71+
label_values = await get_prometheus_label_values("robustperception", "job")
72+
assert isinstance(label_values, list)
73+
assert len(label_values) > 0

0 commit comments

Comments
 (0)