├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── config.json ├── docker ├── docker-compose.yml └── prometheus.yml ├── docs └── imgs │ └── cursor_screenshot.png ├── pytest.ini ├── requirements.txt ├── smithery.yaml ├── src └── prometheus_mcp_server │ ├── __init__.py │ ├── __main__.py │ ├── db_connector.py │ ├── prometheus_api.py │ ├── pyproject.toml │ └── server.py └── test ├── prom.py ├── test_db_connector.py └── test_mcp_server.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[cod] 4 | *.so 5 | *.dylib 6 | build/ 7 | dist/ 8 | wheels/ 9 | *.egg-info 10 | *.egg 11 | 12 | # Virtual environments 13 | .venv/ 14 | venv/ 15 | Lib/ 16 | Scripts/ 17 | env/ 18 | .env/ 19 | 20 | # Development environment 21 | .python-version 22 | .python-version-lock 23 | uv.lock 24 | pyvenv.cfg 25 | 26 | # IDE settings (optional) 27 | .vscode/ 28 | .idea/ 29 | .project 30 | .pydevproject 31 | 32 | # Distribution directories 33 | *.dist-info/ 34 | 35 | # Unit test / coverage reports 36 | htmlcov/ 37 | .tox/ 38 | .coverage 39 | .coverage.* 40 | nosetests.xml 41 | coverage.xml 42 | 43 | # Jupyter Notebook checkpoints 44 | .ipynb_checkpoints 45 | 46 | # MacOS 47 | .DS_Store 48 | 49 | # MyPy 50 | .mypy_cache/ 51 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Generated by https://smithery.ai. See: https://smithery.ai/docs/config#dockerfile 2 | FROM python:3.10-slim 3 | 4 | # Set work directory 5 | WORKDIR /app 6 | 7 | # Install system dependencies if any (optional, adjust if needed) 8 | 9 | # Copy requirements and install dependencies 10 | COPY requirements.txt ./ 11 | RUN pip install --no-cache-dir -r requirements.txt 12 | 13 | # Copy the entire repository 14 | COPY . . 15 | 16 | # Expose port if needed (depending on how your server is accessed) 17 | # EXPOSE 8000 18 | 19 | # Set the default command to start the MCP server 20 | CMD ["python3", "src/prometheus_mcp_server/server.py"] 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 CaesarYangs 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MCP Server for Prometheus 2 | [![smithery badge](https://smithery.ai/badge/@CaesarYangs/prometheus_mcp_server)](https://smithery.ai/server/@CaesarYangs/prometheus_mcp_server) 3 | 4 | A Model Context Protocol (MCP) server for retrieving data from Prometheus databases. This MCP server enables Large Language Models (LLMs) to invoke tool functions that retrieve and analyze vast amounts of metric data, search metric usage, execute complex queries, and perform other related tasks through pre-defined routes with enhanced control over usage. 5 | 6 | - Data Retrieval: Fetch specific metrics or ranges of data from Prometheus. 7 | - Metric Analysis: Perform statistical analysis on retrieved metrics. 8 | - Usage Search: Find and explore metric usage patterns. 9 | - Complex Querying: Execute advanced PromQL queries for in-depth data exploration. 10 | 11 | ## Capibilites 12 | 13 | ✅ Retrieve comprehensive metric information, including names and descriptions, from Prometheus 14 | 15 | ✅ Fetch and analyze specific metric data using metric names 16 | 17 | ✅ Analyze metric data within custom time ranges 18 | 19 | 🚧 Filter and match data using specific labels (in development) 20 | 21 | ⏳ Additional features planned... 22 | 23 | ## Getting Started 24 | 25 | MCP runing requires a python virtual environment(venv), all packages should be installed into this venv so the MCP server can be automically started. 26 | 27 | ### Installing via Smithery 28 | 29 | To install Prometheus MCP Server for Claude Desktop automatically via [Smithery](https://smithery.ai/server/@CaesarYangs/prometheus_mcp_server): 30 | 31 | ```bash 32 | npx -y @smithery/cli install @CaesarYangs/prometheus_mcp_server --client claude 33 | ``` 34 | 35 | ### Manual Installation 36 | **Prepare python env** 37 | 38 | ```sh 39 | cd ./src/prometheus_mcp_server 40 | python3 -m venv .venv 41 | ``` 42 | 43 | ```sh 44 | # linux/macos: 45 | source .venv/bin/activate 46 | 47 | # windows: 48 | .venv\Scripts\activate 49 | ``` 50 | Then it is ready to be used as a dedicated python environment. 51 | 52 | **Install required packages** 53 | 54 | Make sure pip is properly isntalled. If your venv is installed without pip, then manually install it using: 55 | ```sh 56 | wget https://bootstrap.pypa.io/get-pip.py 57 | python3 get-pip.py 58 | ``` 59 | 60 | Then install all required packages: 61 | ```sh 62 | pip install -r requirements.txt 63 | ``` 64 | 65 | ## Usage 66 | 67 | ### With Cursor Env 68 | 69 | Ready to update depend on more easy-to-use Cursor environment. 70 | 71 | Set this in the MCP section in Cursor Settings: 72 | 73 | ``` 74 | uv --directory /path/to/prometheus_mcp_server run server.py 75 | ``` 76 | 77 | ![](./docs/imgs/cursor_screenshot.png) 78 | 79 | ### With MCP Client(include Claude Desktop) 80 | 81 | Config your Claude Desktop app's configuration at `~/Library/Application Support/Claude/claude_desktop_config.json`(macos) 82 | 83 | ``` 84 | { 85 | "mcpServers": { 86 | "prometheus": { 87 | "command": "uv", 88 | "args": [ 89 | "--directory", 90 | "/path/to/prometheus_mcp_server", 91 | "run", 92 | "server.py" 93 | ], 94 | "env": { 95 | "PROMETHEUS_HOST": "http://localhost:9090" 96 | } 97 | } 98 | } 99 | } 100 | ``` 101 | 102 | ### Standalone MCP Server 103 | 104 | Started this MCP server alone: 105 | 106 | **uv method** 107 | 108 | ```sh 109 | uv --directory /path/to/prometheus_mcp_server run server.py 110 | ``` 111 | 112 | This is also a way to make sure this MCP server can be automatically started since the Claude Desktop is using this ux script way to start when the app launches. 113 | 114 | **regular python method** 115 | 116 | ```sh 117 | python3 server.py 118 | ``` 119 | 120 | ## Contributing 121 | 122 | Contributions are welcome! Here's a quick guide: 123 | 124 | 1. Fork the repo 125 | 2. Create your feature branch (`git checkout -b feature/AmazingFeature`) 126 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`) 127 | 4. Push to the branch (`git push origin feature/AmazingFeature`) 128 | 5. Open a Pull Request 129 | 130 | For major changes, please open an issue first to discuss what you would like to change. 131 | 132 | Thank you for your contributions! 133 | 134 | 135 | ## License 136 | 137 | MIT License 138 | 139 | ## References & Acknowledgments 140 | 141 | This project was inspired by or uses code from the following open-source projects: 142 | 143 | - [Prometheus API Client](https://prometheus-api-client-python.readthedocs.io/en/latest/source/prometheus_api_client.html) - The Prometheus API calling code is modified based on this library 144 | - [MySQL MCP Server](https://github.com/designcomputer/mysql_mcp_server/blob/main/src/mysql_mcp_server) - A similar database oriented MCP server implmentation 145 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "mcpServers": { 3 | "prometheus": { 4 | "command": "uv", 5 | "args": [ 6 | "--directory", 7 | "/path/to/prometheus_mcp_server", 8 | "run", 9 | "server.py" 10 | ], 11 | "env": { 12 | "PROMETHEUS_HOST": "http://localhost:9090" 13 | } 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.9" 2 | services: 3 | prometheus: 4 | image: prom/prometheus:latest 5 | ports: 6 | - "9090:9090" 7 | volumes: 8 | - ./prometheus.yml:/etc/prometheus/prometheus.yml 9 | command: 10 | - '--config.file=/etc/prometheus/prometheus.yml' 11 | restart: unless-stopped 12 | 13 | volumes: 14 | prometheus_data: 15 | 16 | -------------------------------------------------------------------------------- /docker/prometheus.yml: -------------------------------------------------------------------------------- 1 | # my global config 2 | global: 3 | scrape_interval: 15s 4 | evaluation_interval: 15s 5 | 6 | # Alertmanager configuration 7 | alerting: 8 | alertmanagers: 9 | - static_configs: 10 | - targets: 11 | # - alertmanager:9093 12 | 13 | # Load rules once and periodically evaluate them according to the global 'evaluation_interval'. 14 | rule_files: 15 | # - "first_rules.yml" 16 | # - "second_rules.yml" 17 | 18 | # A scrape configuration containing exactly one endpoint to scrape: 19 | scrape_configs: 20 | - job_name: "prometheus" 21 | 22 | static_configs: 23 | - targets: ["localhost:9090"] 24 | -------------------------------------------------------------------------------- /docs/imgs/cursor_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CaesarYangs/prometheus_mcp_server/665f962e9596c6d40a8d0eedd6ad3aad93e44a6d/docs/imgs/cursor_screenshot.png -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | 3 | log_cli = true 4 | log_cli_level = info -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mcp 2 | pydantic 3 | requests 4 | numpy 5 | pandas -------------------------------------------------------------------------------- /smithery.yaml: -------------------------------------------------------------------------------- 1 | # Smithery configuration file: https://smithery.ai/docs/config#smitheryyaml 2 | 3 | startCommand: 4 | type: stdio 5 | configSchema: 6 | # JSON Schema defining the configuration options for the MCP. 7 | type: object 8 | properties: 9 | prometheusHost: 10 | type: string 11 | default: http://localhost:9090 12 | description: URL of the Prometheus host to connect to. 13 | commandFunction: 14 | # A JS function that produces the CLI command based on the given config to start the MCP on stdio. 15 | |- 16 | (config) => ({ 17 | command: 'python3', 18 | args: ['src/prometheus_mcp_server/server.py'], 19 | env: { PROMETHEUS_HOST: config.prometheusHost } 20 | }) 21 | exampleConfig: 22 | prometheusHost: http://localhost:9090 23 | -------------------------------------------------------------------------------- /src/prometheus_mcp_server/__init__.py: -------------------------------------------------------------------------------- 1 | from . import server 2 | 3 | 4 | # Expose important items at package level 5 | __all__ = ['main', 'server'] 6 | -------------------------------------------------------------------------------- /src/prometheus_mcp_server/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from server import main 4 | 5 | if __name__ == "__main__": 6 | asyncio.run(main()) 7 | -------------------------------------------------------------------------------- /src/prometheus_mcp_server/db_connector.py: -------------------------------------------------------------------------------- 1 | from prometheus_api import * 2 | # from src.prometheus_mcp_server.prometheus_api import * 3 | 4 | 5 | class PrometheusHandler: 6 | def __init__(self, logger, PROMETHEUS_URL="http://localhost:9090") -> None: 7 | self.prom = PrometheusConnect(url=PROMETHEUS_URL) 8 | self.logger = logger 9 | self.all_metrics_full = [] 10 | self.all_metrics_name = [] 11 | 12 | def get_all_metrics(self): 13 | self.all_metrics_full = self.prom.all_metric_meta() 14 | return self.all_metrics_full 15 | 16 | def get_range_data(self, metric_name, metric_range='10', include_index=False): 17 | """get data from prometheus server 18 | 19 | Args: 20 | metric_name (_type_): _description_ 21 | metric_range (str, optional): _description_. Defaults to 10, the unit is minute, default sets to 10(minutes). 22 | include_index (bool, optional): _description_. Defaults to False. 23 | 24 | Raises: 25 | ValueError: _description_ 26 | 27 | Returns: 28 | _type_: _description_ 29 | """ 30 | metric_data = self.prom.get_metric_range_data(metric_name=metric_name, metric_range=metric_range, logger=self.logger) 31 | 32 | range_data = [] 33 | metric_object_list = MetricsList(metric_data) 34 | 35 | for metric in metric_object_list: 36 | self.logger.info(f"{metric}") 37 | values = [] 38 | for index, row in metric.metric_values.iterrows(): 39 | timestamp = [] 40 | value = [] 41 | try: 42 | timestamp = row['ds'] 43 | value = row['y'] 44 | except Exception as e: 45 | raise ValueError(f"Invalid metric value fetch") 46 | 47 | data_row = (timestamp, value) 48 | if include_index: 49 | data_row = (index, timestamp, value) 50 | values.append(data_row) 51 | range_data.append(values) 52 | return range_data 53 | 54 | def test_prometheus(self, metric_name="go_gc_duration_seconds"): 55 | my_label_config = {'instance': 'instance_id', 'job': 'job_id', 'quantile': 'quantile_value'} 56 | metric_data = self.prom.get_metric_range_data(metric_name=metric_name) 57 | 58 | metric_object_list = MetricsList(metric_data) 59 | for metric in metric_object_list: 60 | self.logger.info(metric.label_config) 61 | -------------------------------------------------------------------------------- /src/prometheus_mcp_server/prometheus_api.py: -------------------------------------------------------------------------------- 1 | from requests import Session 2 | from requests.packages.urllib3.util.retry import Retry 3 | from requests.adapters import HTTPAdapter 4 | import requests 5 | from datetime import datetime, timedelta, timezone 6 | import numpy 7 | import json 8 | import pandas 9 | import copy 10 | import bz2 11 | import os 12 | import asyncio 13 | import logging 14 | from urllib.parse import urlparse 15 | 16 | """A Class for collection of metrics from a Prometheus Host.""" 17 | 18 | 19 | # set up logging 20 | 21 | _LOGGER = logging.getLogger(__name__) 22 | 23 | # In case of a connection failure try 2 more times 24 | MAX_REQUEST_RETRIES = 3 25 | # wait 1 second before retrying in case of an error 26 | RETRY_BACKOFF_FACTOR = 1 27 | # retry only on these status 28 | RETRY_ON_STATUS = [408, 429, 500, 502, 503, 504] 29 | 30 | 31 | class PrometheusConnect: 32 | """ 33 | A Class for collection of metrics from a Prometheus Host. 34 | 35 | :param url: (str) url for the prometheus host 36 | :param headers: (dict) A dictionary of http headers to be used to communicate with 37 | the host. Example: {"Authorization": "bearer my_oauth_token_to_the_host"} 38 | :param disable_ssl: (bool) If set to True, will disable ssl certificate verification 39 | for the http requests made to the prometheus host 40 | :param retry: (Retry) Retry adapter to retry on HTTP errors 41 | :param auth: (optional) Auth tuple to enable Basic/Digest/Custom HTTP Auth. See python 42 | requests library auth parameter for further explanation. 43 | :param proxy: (Optional) Proxies dictionary to enable connection through proxy. 44 | Example: {"http_proxy": "", "https_proxy": ""} 45 | :param session (Optional) Custom requests.Session to enable complex HTTP configuration 46 | """ 47 | 48 | def __init__( 49 | self, 50 | url: str = "http://127.0.0.1:9090", 51 | headers: dict = None, 52 | disable_ssl: bool = False, 53 | retry: Retry = None, 54 | auth: tuple = None, 55 | proxy: dict = None, 56 | session: Session = None, 57 | ): 58 | """Functions as a Constructor for the class PrometheusConnect.""" 59 | if url is None: 60 | raise TypeError("missing url") 61 | 62 | self.headers = headers 63 | self.url = url 64 | self.prometheus_host = urlparse(self.url).netloc 65 | self._all_metrics = None 66 | 67 | if retry is None: 68 | retry = Retry( 69 | total=MAX_REQUEST_RETRIES, 70 | backoff_factor=RETRY_BACKOFF_FACTOR, 71 | status_forcelist=RETRY_ON_STATUS, 72 | ) 73 | 74 | self.auth = auth 75 | 76 | if session is not None: 77 | self._session = session 78 | else: 79 | self._session = requests.Session() 80 | self._session.verify = not disable_ssl 81 | 82 | if proxy is not None: 83 | self._session.proxies = proxy 84 | self._session.mount(self.url, HTTPAdapter(max_retries=retry)) 85 | 86 | def check_prometheus_connection(self, params: dict = None) -> bool: 87 | """ 88 | Check Promethus connection. 89 | 90 | :param params: (dict) Optional dictionary containing parameters to be 91 | sent along with the API request. 92 | :returns: (bool) True if the endpoint can be reached, False if cannot be reached. 93 | """ 94 | response = self._session.get( 95 | "{0}/".format(self.url), 96 | verify=self._session.verify, 97 | headers=self.headers, 98 | params=params, 99 | auth=self.auth, 100 | cert=self._session.cert 101 | ) 102 | return response.ok 103 | 104 | def all_metrics(self, params: dict = None): 105 | """ 106 | Get the list of all the metrics that the prometheus host scrapes. 107 | 108 | :param params: (dict) Optional dictionary containing GET parameters to be 109 | sent along with the API request, such as "time" 110 | :returns: (list) A list of names of all the metrics available from the 111 | specified prometheus host 112 | :raises: 113 | (RequestException) Raises an exception in case of a connection error 114 | (PrometheusApiClientException) Raises in case of non 200 response status code 115 | """ 116 | self._all_metrics = self.get_label_values(label_name="__name__", params=params) 117 | return self._all_metrics 118 | 119 | def all_metric_meta(self, name: str = None): 120 | self._all_metrics_all = self.get_label_values_full(label_name="__name__") 121 | return self._all_metrics_all 122 | 123 | def get_label_names(self, params: dict = None): 124 | """ 125 | Get a list of all labels. 126 | 127 | :param params: (dict) Optional dictionary containing GET parameters to be 128 | sent along with the API request, such as "start", "end" or "match[]". 129 | :returns: (list) A list of labels from the specified prometheus host 130 | :raises: 131 | (RequestException) Raises an exception in case of a connection error 132 | (PrometheusApiClientException) Raises in case of non 200 response status code 133 | """ 134 | params = params or {} 135 | response = self._session.get( 136 | "{0}/api/v1/labels".format(self.url), 137 | verify=self._session.verify, 138 | headers=self.headers, 139 | params=params, 140 | auth=self.auth, 141 | cert=self._session.cert 142 | ) 143 | 144 | if response.status_code == 200: 145 | labels = response.json()["data"] 146 | else: 147 | raise PrometheusApiClientException( 148 | "HTTP Status Code {} ({!r})".format(response.status_code, response.content) 149 | ) 150 | return labels 151 | 152 | def get_label_values(self, label_name: str, params: dict = None): 153 | """ 154 | Get a list of all values for the label. 155 | 156 | :param label_name: (str) The name of the label for which you want to get all the values. 157 | :param params: (dict) Optional dictionary containing GET parameters to be 158 | sent along with the API request, such as "time" 159 | :returns: (list) A list of names for the label from the specified prometheus host 160 | :raises: 161 | (RequestException) Raises an exception in case of a connection error 162 | (PrometheusApiClientException) Raises in case of non 200 response status code 163 | """ 164 | params = params or {} 165 | response = self._session.get( 166 | "{0}/api/v1/label/{1}/values".format(self.url, label_name), 167 | verify=self._session.verify, 168 | headers=self.headers, 169 | params=params, 170 | auth=self.auth, 171 | cert=self._session.cert 172 | ) 173 | 174 | if response.status_code == 200: 175 | labels = response.json()["data"] 176 | else: 177 | raise PrometheusApiClientException( 178 | "HTTP Status Code {} ({!r})".format(response.status_code, response.content) 179 | ) 180 | return labels 181 | 182 | def get_label_values_full(self, label_name: str, params: dict = None): 183 | """ 184 | Get a list of all values for the label. 185 | 186 | :param label_name: (str) The name of the label for which you want to get all the values. 187 | :param params: (dict) Optional dictionary containing GET parameters to be 188 | sent along with the API request, such as "time" 189 | :returns: (list) A list of names for the label from the specified prometheus host 190 | :raises: 191 | (RequestException) Raises an exception in case of a connection error 192 | (PrometheusApiClientException) Raises in case of non 200 response status code 193 | """ 194 | params = params or {} 195 | response = self._session.get( 196 | "{0}/api/v1/metadata".format(self.url), 197 | verify=self._session.verify, 198 | headers=self.headers, 199 | params=params, 200 | auth=self.auth, 201 | cert=self._session.cert 202 | ) 203 | 204 | if response.status_code == 200: 205 | labels = response.json()["data"] 206 | else: 207 | raise PrometheusApiClientException( 208 | "HTTP Status Code {} ({!r})".format(response.status_code, response.content) 209 | ) 210 | return labels 211 | 212 | def get_current_metric_value( 213 | self, metric_name: str, label_config: dict = None, params: dict = None 214 | ): 215 | r""" 216 | Get the current metric value for the specified metric and label configuration. 217 | 218 | :param metric_name: (str) The name of the metric 219 | :param label_config: (dict) A dictionary that specifies metric labels and their 220 | values 221 | :param params: (dict) Optional dictionary containing GET parameters to be sent 222 | along with the API request, such as "time" 223 | :returns: (list) A list of current metric values for the specified metric 224 | :raises: 225 | (RequestException) Raises an exception in case of a connection error 226 | (PrometheusApiClientException) Raises in case of non 200 response status code 227 | 228 | Example Usage: 229 | .. code-block:: python 230 | 231 | prom = PrometheusConnect() 232 | 233 | my_label_config = {'cluster': 'my_cluster_id', 'label_2': 'label_2_value'} 234 | 235 | prom.get_current_metric_value(metric_name='up', label_config=my_label_config) 236 | """ 237 | params = params or {} 238 | data = [] 239 | if label_config: 240 | label_list = [str(key + "=" + "'" + label_config[key] + "'") for key in label_config] 241 | query = metric_name + "{" + ",".join(label_list) + "}" 242 | else: 243 | query = metric_name 244 | 245 | # using the query API to get raw data 246 | response = self._session.get( 247 | "{0}/api/v1/query".format(self.url), 248 | params={**{"query": query}, **params}, 249 | verify=self._session.verify, 250 | headers=self.headers, 251 | auth=self.auth, 252 | cert=self._session.cert 253 | ) 254 | 255 | if response.status_code == 200: 256 | data += response.json()["data"]["result"] 257 | else: 258 | raise PrometheusApiClientException( 259 | "HTTP Status Code {} ({!r})".format(response.status_code, response.content) 260 | ) 261 | return data 262 | 263 | def get_metric_range_data( 264 | self, 265 | metric_name: str, 266 | metric_range: str, 267 | label_config: dict = None, 268 | start_time: datetime = (datetime.now() - timedelta(minutes=1)), 269 | end_time: datetime = datetime.now(), 270 | chunk_size: timedelta = None, 271 | store_locally: bool = False, 272 | params: dict = None, 273 | logger=None 274 | ): 275 | r""" 276 | Get the current metric value for the specified metric and label configuration. 277 | 278 | :param metric_name: (str) The name of the metric. 279 | :param label_config: (dict) A dictionary specifying metric labels and their 280 | values. 281 | :param start_time: (datetime) A datetime object that specifies the metric range start time. 282 | :param end_time: (datetime) A datetime object that specifies the metric range end time. 283 | :param chunk_size: (timedelta) Duration of metric data downloaded in one request. For 284 | example, setting it to timedelta(hours=3) will download 3 hours worth of data in each 285 | request made to the prometheus host 286 | :param store_locally: (bool) If set to True, will store data locally at, 287 | `"./metrics/hostname/metric_date/name_time.json.bz2"` 288 | :param params: (dict) Optional dictionary containing GET parameters to be 289 | sent along with the API request, such as "time" 290 | :return: (list) A list of metric data for the specified metric in the given time 291 | range 292 | :raises: 293 | (RequestException) Raises an exception in case of a connection error 294 | (PrometheusApiClientException) Raises in case of non 200 response status code 295 | 296 | """ 297 | params = params or {} 298 | data = [] 299 | 300 | if metric_range is not None: 301 | now = datetime.now() # 使用 utcnow() 来获取一个 offset-naive 的 UTC 时间 302 | start_time = now - timedelta(minutes=int(metric_range)) 303 | 304 | _LOGGER.debug("start_time: %s", start_time) 305 | _LOGGER.debug("end_time: %s", end_time) 306 | _LOGGER.debug("chunk_size: %s", chunk_size) 307 | 308 | if not (isinstance(start_time, datetime) and isinstance(end_time, datetime)): 309 | raise TypeError("start_time and end_time can only be of type datetime.datetime") 310 | 311 | if not chunk_size: 312 | chunk_size = end_time - start_time 313 | if not isinstance(chunk_size, timedelta): 314 | raise TypeError("chunk_size can only be of type datetime.timedelta") 315 | 316 | start = round(start_time.timestamp()) 317 | end = round(end_time.timestamp()) 318 | 319 | if end_time < start_time: 320 | raise ValueError("end_time must not be before start_time") 321 | 322 | if (end_time - start_time).total_seconds() < chunk_size.total_seconds(): 323 | raise ValueError("specified chunk_size is too big") 324 | chunk_seconds = round(chunk_size.total_seconds()) 325 | 326 | if label_config: 327 | label_list = [str(key + "=" + "'" + label_config[key] + "'") for key in label_config] 328 | query = metric_name + "{" + ",".join(label_list) + "}" 329 | else: 330 | query = metric_name 331 | _LOGGER.debug("Prometheus Query: %s", query) 332 | while start < end: 333 | if start + chunk_seconds > end: 334 | chunk_seconds = end - start 335 | 336 | # using the query API to get raw data 337 | response = self._session.get( 338 | "{0}/api/v1/query".format(self.url), 339 | params={ 340 | **{ 341 | "query": query + "[" + str(chunk_seconds) + "s" + "]", 342 | "time": start + chunk_seconds, 343 | }, 344 | **params, 345 | }, 346 | verify=self._session.verify, 347 | headers=self.headers, 348 | auth=self.auth, 349 | cert=self._session.cert 350 | ) 351 | if response.status_code == 200: 352 | data += response.json()["data"]["result"] 353 | else: 354 | raise PrometheusApiClientException( 355 | "HTTP Status Code {} ({!r})".format(response.status_code, response.content) 356 | ) 357 | if store_locally: 358 | # store it locally 359 | self._store_metric_values_local( 360 | metric_name, 361 | json.dumps(response.json()["data"]["result"]), 362 | start + chunk_seconds, 363 | ) 364 | 365 | start += chunk_seconds 366 | 367 | return data 368 | 369 | def _store_metric_values_local(self, metric_name, values, end_timestamp, compressed=False): 370 | r""" 371 | Store metrics on the local filesystem, optionally with bz2 compression. 372 | 373 | :param metric_name: (str) the name of the metric being saved 374 | :param values: (str) metric data in JSON string format 375 | :param end_timestamp: (int) timestamp in any format understood by \ 376 | datetime.datetime.fromtimestamp() 377 | :param compressed: (bool) whether or not to apply bz2 compression 378 | :returns: (str) path to the saved metric file 379 | """ 380 | if not values: 381 | _LOGGER.debug("No values for %s", metric_name) 382 | return None 383 | 384 | file_path = self._metric_filename(metric_name, end_timestamp) 385 | 386 | if compressed: 387 | payload = bz2.compress(str(values).encode("utf-8")) 388 | file_path = file_path + ".bz2" 389 | else: 390 | payload = str(values).encode("utf-8") 391 | 392 | os.makedirs(os.path.dirname(file_path), exist_ok=True) 393 | with open(file_path, "wb") as file: 394 | file.write(payload) 395 | 396 | return file_path 397 | 398 | def _metric_filename(self, metric_name: str, end_timestamp: int): 399 | r""" 400 | Add a timestamp to the filename before it is stored. 401 | 402 | :param metric_name: (str) the name of the metric being saved 403 | :param end_timestamp: (int) timestamp in any format understood by \ 404 | datetime.datetime.fromtimestamp() 405 | :returns: (str) the generated path 406 | """ 407 | end_time_stamp = datetime.fromtimestamp(end_timestamp) 408 | directory_name = end_time_stamp.strftime("%Y%m%d") 409 | timestamp = end_time_stamp.strftime("%Y%m%d%H%M") 410 | object_path = ( 411 | "./metrics/" 412 | + self.prometheus_host 413 | + "/" 414 | + metric_name 415 | + "/" 416 | + directory_name 417 | + "/" 418 | + timestamp 419 | + ".json" 420 | ) 421 | return object_path 422 | 423 | def custom_query(self, query: str, params: dict = None): 424 | """ 425 | Send a custom query to a Prometheus Host. 426 | 427 | This method takes as input a string which will be sent as a query to 428 | the specified Prometheus Host. This query is a PromQL query. 429 | 430 | :param query: (str) This is a PromQL query, a few examples can be found 431 | at https://prometheus.io/docs/prometheus/latest/querying/examples/ 432 | :param params: (dict) Optional dictionary containing GET parameters to be 433 | sent along with the API request, such as "time" 434 | :returns: (list) A list of metric data received in response of the query sent 435 | :raises: 436 | (RequestException) Raises an exception in case of a connection error 437 | (PrometheusApiClientException) Raises in case of non 200 response status code 438 | """ 439 | params = params or {} 440 | data = None 441 | query = str(query) 442 | # using the query API to get raw data 443 | response = self._session.get( 444 | "{0}/api/v1/query".format(self.url), 445 | params={**{"query": query}, **params}, 446 | verify=self._session.verify, 447 | headers=self.headers, 448 | auth=self.auth, 449 | cert=self._session.cert 450 | ) 451 | if response.status_code == 200: 452 | data = response.json()["data"]["result"] 453 | else: 454 | raise PrometheusApiClientException( 455 | "HTTP Status Code {} ({!r})".format(response.status_code, response.content) 456 | ) 457 | 458 | return data 459 | 460 | def custom_query_range( 461 | self, query: str, start_time: datetime, end_time: datetime, step: str, params: dict = None 462 | ): 463 | """ 464 | Send a query_range to a Prometheus Host. 465 | 466 | This method takes as input a string which will be sent as a query to 467 | the specified Prometheus Host. This query is a PromQL query. 468 | 469 | :param query: (str) This is a PromQL query, a few examples can be found 470 | at https://prometheus.io/docs/prometheus/latest/querying/examples/ 471 | :param start_time: (datetime) A datetime object that specifies the query range start time. 472 | :param end_time: (datetime) A datetime object that specifies the query range end time. 473 | :param step: (str) Query resolution step width in duration format or float number of seconds 474 | :param params: (dict) Optional dictionary containing GET parameters to be 475 | sent along with the API request, such as "timeout" 476 | :returns: (dict) A dict of metric data received in response of the query sent 477 | :raises: 478 | (RequestException) Raises an exception in case of a connection error 479 | (PrometheusApiClientException) Raises in case of non 200 response status code 480 | """ 481 | start = round(start_time.timestamp()) 482 | end = round(end_time.timestamp()) 483 | params = params or {} 484 | data = None 485 | query = str(query) 486 | # using the query_range API to get raw data 487 | response = self._session.get( 488 | "{0}/api/v1/query_range".format(self.url), 489 | params={**{"query": query, "start": start, "end": end, "step": step}, **params}, 490 | verify=self._session.verify, 491 | headers=self.headers, 492 | auth=self.auth, 493 | cert=self._session.cert 494 | ) 495 | if response.status_code == 200: 496 | data = response.json()["data"]["result"] 497 | else: 498 | raise PrometheusApiClientException( 499 | "HTTP Status Code {} ({!r})".format(response.status_code, response.content) 500 | ) 501 | return data 502 | 503 | def get_metric_aggregation( 504 | self, 505 | query: str, 506 | operations: list, 507 | start_time: datetime = None, 508 | end_time: datetime = None, 509 | step: str = "15", 510 | params: dict = None, 511 | ): 512 | """ 513 | Get aggregations on metric values received from PromQL query. 514 | 515 | This method takes as input a string which will be sent as a query to 516 | the specified Prometheus Host. This query is a PromQL query. And, a 517 | list of operations to perform such as- sum, max, min, deviation, etc. 518 | with start_time, end_time and step. 519 | 520 | The received query is passed to the custom_query_range method which returns 521 | the result of the query and the values are extracted from the result. 522 | 523 | :param query: (str) This is a PromQL query, a few examples can be found 524 | at https://prometheus.io/docs/prometheus/latest/querying/examples/ 525 | :param operations: (list) A list of operations to perform on the values. 526 | Operations are specified in string type. 527 | :param start_time: (datetime) A datetime object that specifies the query range start time. 528 | :param end_time: (datetime) A datetime object that specifies the query range end time. 529 | :param step: (str) Query resolution step width in duration format or float number of seconds 530 | :param params: (dict) Optional dictionary containing GET parameters to be 531 | sent along with the API request, such as "timeout" 532 | Available operations - sum, max, min, variance, nth percentile, deviation 533 | and average. 534 | 535 | :returns: (dict) A dict of aggregated values received in response to the operations 536 | performed on the values for the query sent. 537 | 538 | Example output: 539 | .. code-block:: python 540 | 541 | { 542 | 'sum': 18.05674, 543 | 'max': 6.009373 544 | } 545 | """ 546 | if not isinstance(operations, list): 547 | raise TypeError("Operations can be only of type list") 548 | if len(operations) == 0: 549 | _LOGGER.debug("No operations found to perform") 550 | return None 551 | aggregated_values = {} 552 | query_values = [] 553 | if start_time is not None and end_time is not None: 554 | data = self.custom_query_range( 555 | query=query, params=params, start_time=start_time, end_time=end_time, step=step 556 | ) 557 | for result in data: 558 | values = result["values"] 559 | for val in values: 560 | query_values.append(float(val[1])) 561 | else: 562 | data = self.custom_query(query, params) 563 | for result in data: 564 | val = float(result["value"][1]) 565 | query_values.append(val) 566 | 567 | if len(query_values) == 0: 568 | _LOGGER.debug("No values found for given query.") 569 | return None 570 | 571 | np_array = numpy.array(query_values) 572 | for operation in operations: 573 | if operation == "sum": 574 | aggregated_values["sum"] = numpy.sum(np_array) 575 | elif operation == "max": 576 | aggregated_values["max"] = numpy.max(np_array) 577 | elif operation == "min": 578 | aggregated_values["min"] = numpy.min(np_array) 579 | elif operation == "average": 580 | aggregated_values["average"] = numpy.average(np_array) 581 | elif operation.startswith("percentile"): 582 | percentile = float(operation.split("_")[1]) 583 | aggregated_values["percentile_" + str(percentile)] = numpy.percentile( 584 | query_values, percentile 585 | ) 586 | elif operation == "deviation": 587 | aggregated_values["deviation"] = numpy.std(np_array) 588 | elif operation == "variance": 589 | aggregated_values["variance"] = numpy.var(np_array) 590 | else: 591 | raise TypeError("Invalid operation: " + operation) 592 | return aggregated_values 593 | 594 | 595 | class MetricsList(list): 596 | """A Class to initialize a list of Metric objects at once. 597 | 598 | :param metric_data_list: (list|json) This is an individual metric or list of metrics received 599 | from prometheus as a result of a promql query. 600 | 601 | Example Usage: 602 | .. code-block:: python 603 | 604 | prom = PrometheusConnect() 605 | my_label_config = {'cluster': 'my_cluster_id', 'label_2': 'label_2_value'} 606 | metric_data = prom.get_metric_range_data(metric_name='up', label_config=my_label_config) 607 | 608 | metric_object_list = MetricsList(metric_data) # metric_object_list will be initialized as 609 | # a list of Metric objects for all the 610 | # metrics downloaded using get_metric query 611 | 612 | """ 613 | 614 | def __init__(self, metric_data_list): 615 | """Class MetricsList constructor.""" 616 | if not isinstance(metric_data_list, list): 617 | metric_data_list = [metric_data_list] 618 | 619 | metric_object_list = [] 620 | 621 | def add_metric_to_object_list(metric): 622 | metric_object = Metric(metric) 623 | if metric_object in metric_object_list: 624 | metric_object_list[metric_object_list.index(metric_object)] += metric_object 625 | else: 626 | metric_object_list.append(metric_object) 627 | 628 | for i in metric_data_list: 629 | # If it is a list of lists (for example: while reading from multiple json files) 630 | if isinstance(i, list): 631 | for metric in i: 632 | add_metric_to_object_list(metric) 633 | else: 634 | add_metric_to_object_list(i) 635 | 636 | super(MetricsList, self).__init__(metric_object_list) 637 | 638 | 639 | class Metric: 640 | r""" 641 | A Class for `Metric` object. 642 | 643 | :param metric: (dict) A metric item from the list of metrics received from prometheus 644 | :param oldest_data_datetime: (datetime|timedelta) Any metric values in the dataframe that are 645 | older than this value will be deleted when new data is added to the dataframe 646 | using the __add__("+") operator. 647 | 648 | * `oldest_data_datetime=datetime.timedelta(days=2)`, will delete the 649 | metric data that is 2 days older than the latest metric. 650 | The dataframe is pruned only when new data is added to it. 651 | * `oldest_data_datetime=datetime.datetime(2019,5,23,12,0)`, will delete 652 | any data that is older than "23 May 2019 12:00:00" 653 | * `oldest_data_datetime=datetime.datetime.fromtimestamp(1561475156)` 654 | can also be set using the unix timestamp 655 | 656 | Example Usage: 657 | .. code-block:: python 658 | 659 | prom = PrometheusConnect() 660 | 661 | my_label_config = {'cluster': 'my_cluster_id', 'label_2': 'label_2_value'} 662 | 663 | metric_data = prom.get_metric_range_data(metric_name='up', label_config=my_label_config) 664 | # Here metric_data is a list of metrics received from prometheus 665 | 666 | # only for the first item in the list 667 | my_metric_object = Metric(metric_data[0], datetime.timedelta(days=10)) 668 | 669 | """ 670 | 671 | def __init__(self, metric, oldest_data_datetime=None): 672 | """Functions as a Constructor for the Metric object.""" 673 | if not isinstance( 674 | oldest_data_datetime, (datetime, type(None)) # TODO: change detection of time input here 675 | ): 676 | # if it is neither a datetime object nor a timedelta object raise exception 677 | raise TypeError( 678 | "oldest_data_datetime can only be datetime.datetime/ datetime.timedelta or None" 679 | ) 680 | 681 | if isinstance(metric, Metric): 682 | # if metric is a Metric object, just copy the object and update its parameters 683 | self.metric_name = metric.metric_name 684 | self.label_config = metric.label_config 685 | self.metric_values = metric.metric_values 686 | self.oldest_data_datetime = oldest_data_datetime 687 | else: 688 | self.metric_name = metric["metric"]["__name__"] 689 | self.label_config = copy.deepcopy(metric["metric"]) 690 | self.oldest_data_datetime = oldest_data_datetime 691 | del self.label_config["__name__"] 692 | 693 | # if it is a single value metric change key name 694 | if "value" in metric: 695 | datestamp = metric["value"][0] 696 | metric_value = metric["value"][1] 697 | if isinstance(metric_value, str): 698 | try: 699 | metric_value = float(metric_value) 700 | except (TypeError, ValueError): 701 | raise MetricValueConversionError( 702 | "Converting string metric value to float failed." 703 | ) 704 | metric["values"] = [[datestamp, metric_value]] 705 | 706 | self.metric_values = pandas.DataFrame(metric["values"], columns=["ds", "y"]).apply( 707 | pandas.to_numeric, errors="raise" 708 | ) 709 | self.metric_values["ds"] = pandas.to_datetime(self.metric_values["ds"], unit="s") 710 | 711 | # Set the metric start time and the metric end time 712 | self.start_time = self.metric_values.iloc[0, 0] 713 | self.end_time = self.metric_values.iloc[-1, 0] 714 | 715 | # We store the plot information as Class variable 716 | Metric._plot = None 717 | 718 | def __eq__(self, other): 719 | """ 720 | Overloading operator ``=``. 721 | 722 | Check whether two metrics are the same (are the same time-series regardless of their data) 723 | 724 | Example Usage: 725 | .. code-block:: python 726 | 727 | metric_1 = Metric(metric_data_1) 728 | 729 | metric_2 = Metric(metric_data_2) 730 | 731 | print(metric_1 == metric_2) # will print True if they belong to the same time-series 732 | 733 | :return: (bool) If two Metric objects belong to the same time-series, 734 | i.e. same name and label config, it will return True, else False 735 | """ 736 | return bool( 737 | (self.metric_name == other.metric_name) and (self.label_config == other.label_config) 738 | ) 739 | 740 | def __str__(self): 741 | """ 742 | Make it print in a cleaner way when print function is used on a Metric object. 743 | 744 | Example Usage: 745 | .. code-block:: python 746 | 747 | metric_1 = Metric(metric_data_1) 748 | 749 | print(metric_1) # will print the name, labels and the head of the dataframe 750 | 751 | """ 752 | name = "metric_name: " + repr(self.metric_name) + "\n" 753 | labels = "label_config: " + repr(self.label_config) + "\n" 754 | values = "metric_values: " + repr(self.metric_values) 755 | 756 | return "{" + "\n" + name + labels + values + "\n" + "}" 757 | 758 | def __add__(self, other): 759 | r""" 760 | Overloading operator ``+``. 761 | 762 | Add two metric objects for the same time-series 763 | 764 | Example Usage: 765 | .. code-block:: python 766 | 767 | metric_1 = Metric(metric_data_1) 768 | metric_2 = Metric(metric_data_2) 769 | metric_12 = metric_1 + metric_2 # will add the data in ``metric_2`` to ``metric_1`` 770 | # so if any other parameters are set in ``metric_1`` 771 | # will also be set in ``metric_12`` 772 | # (like ``oldest_data_datetime``) 773 | 774 | :return: (`Metric`) Returns a `Metric` object with the combined metric data 775 | of the two added metrics 776 | 777 | :raises: (TypeError) Raises an exception when two metrics being added are 778 | from different metric time-series 779 | """ 780 | if self == other: 781 | new_metric = deepcopy(self) 782 | new_metric.metric_values = pandas.concat([new_metric.metric_values, other.metric_values], ignore_index=True, axis=0) 783 | new_metric.metric_values = new_metric.metric_values.dropna() 784 | new_metric.metric_values = ( 785 | new_metric.metric_values.drop_duplicates("ds") 786 | .sort_values(by=["ds"]) 787 | .reset_index(drop=True) 788 | ) 789 | # if oldest_data_datetime is set, trim the dataframe and only keep the newer data 790 | if new_metric.oldest_data_datetime: 791 | if isinstance(new_metric.oldest_data_datetime, datetime.timedelta): 792 | # create a time range mask 793 | mask = new_metric.metric_values["ds"] >= ( 794 | new_metric.metric_values.iloc[-1, 0] - abs(new_metric.oldest_data_datetime) 795 | ) 796 | else: 797 | # create a time range mask 798 | mask = new_metric.metric_values["ds"] >= new_metric.oldest_data_datetime 799 | # truncate the df within the mask 800 | new_metric.metric_values = new_metric.metric_values.loc[mask] 801 | 802 | # Update the metric start time and the metric end time for the new Metric 803 | new_metric.start_time = new_metric.metric_values.iloc[0, 0] 804 | new_metric.end_time = new_metric.metric_values.iloc[-1, 0] 805 | 806 | return new_metric 807 | 808 | if self.metric_name != other.metric_name: 809 | error_string = "Different metric names" 810 | else: 811 | error_string = "Different metric labels" 812 | raise TypeError("Cannot Add different metric types. " + error_string) 813 | 814 | _metric_plot = None 815 | 816 | def plot(self, *args, **kwargs): 817 | """Plot a very simple line graph for the metric time-series.""" 818 | if not Metric._metric_plot: 819 | from prometheus_api_client.metric_plot import MetricPlot 820 | Metric._metric_plot = MetricPlot(*args, **kwargs) 821 | metric = self 822 | Metric._metric_plot.plot_date(metric) 823 | 824 | def show(self, block=None): 825 | """Plot a very simple line graph for the metric time-series.""" 826 | if not Metric._metric_plot: 827 | # can't show before plot 828 | TypeError("Invalid operation: Can't show() before plot()") 829 | Metric._metric_plot.show(block) 830 | 831 | 832 | class PrometheusApiClientException(Exception): 833 | """API client exception, raises when response status code != 200.""" 834 | 835 | pass 836 | 837 | 838 | class MetricValueConversionError(Exception): 839 | """Raises when we find a metric that is a string where we fail to convert it to a float.""" 840 | 841 | pass 842 | -------------------------------------------------------------------------------- /src/prometheus_mcp_server/pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "prometheus_mcp_server" 3 | version = "0.1" -------------------------------------------------------------------------------- /src/prometheus_mcp_server/server.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | import logging 4 | from pydantic import AnyUrl 5 | from mcp.server import Server 6 | from mcp.types import Resource, Tool, TextContent 7 | from mcp.server.stdio import stdio_server 8 | # from src.prometheus_mcp_server.db_connector import PrometheusHandler 9 | from db_connector import PrometheusHandler 10 | 11 | # config logging 12 | logging.basicConfig( 13 | level=logging.DEBUG, 14 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 15 | ) 16 | logger = logging.getLogger("prometheus_mcp_server") 17 | 18 | 19 | def get_config(): 20 | """Get basic config of prometheus and mcp 21 | """ 22 | config = { 23 | "host": os.getenv("PROMETHEUS_HOST", "http://localhost:9090") 24 | } 25 | return config 26 | 27 | 28 | app = Server("prometheus_mcp_server") 29 | prometheus_handler = PrometheusHandler(logger, get_config()['host']) 30 | 31 | 32 | @app.list_resources() 33 | async def list_resources() -> list[Resource]: 34 | try: 35 | all_metrics = prometheus_handler.get_all_metrics() 36 | resources = [] 37 | 38 | for metric in all_metrics: 39 | resources.append( 40 | Resource( 41 | uri=f"prometheus://{metric}/metric", 42 | name=f"{metric}", 43 | mimeType="text/plain", 44 | description=f"{all_metrics[metric][0]['help']}" 45 | ) 46 | ) 47 | return resources 48 | except Exception as e: 49 | logger.error(f"Failed to list all resources from prometheus {str(e)}") 50 | return [] 51 | 52 | @app.read_resource() 53 | async def read_resource(uri: AnyUrl) -> str | bytes: 54 | try: 55 | uri_str = str(uri) 56 | logger.info(f"Reading resource: {uri_str}") 57 | 58 | if not uri_str.startswith("prometheus://"): 59 | raise ValueError(f"Invalid URI scheme: {uri_str}") 60 | 61 | parts = uri_str[13:].split('/') 62 | metric_name = str(parts[0]) 63 | 64 | logger.info(f"name:{metric_name}") 65 | 66 | value = prometheus_handler.get_range_data(metric_name) 67 | return value 68 | except Exception as e: 69 | logger.error(f"Failed to list all resources from prometheus {str(e)}") 70 | raise RuntimeError(f"Prometheus error") 71 | return [] 72 | 73 | 74 | @app.list_tools() 75 | async def list_tools() -> list[Tool]: 76 | logger.info("Listing tools...") 77 | return [ 78 | Tool( 79 | name="fetch_metric", 80 | description="Fetches metric and returns its content", 81 | inputSchema={ 82 | "type": "object", 83 | "required": ["metric_name", "metric_range"], 84 | "properties": { 85 | "metric_name": { 86 | "type": "string", 87 | "description": "metric to fetch", 88 | }, 89 | "metric_range": { 90 | "type": "int", 91 | "description": "specific range of metric to fetch(number of minutes)", 92 | } 93 | }, 94 | }, 95 | ) 96 | ] 97 | 98 | 99 | @app.call_tool() 100 | async def call_tool( 101 | name: str, arguments: dict 102 | ) -> list[TextContent]: 103 | logger.info(f"Calling tool:{name} with arguments:{arguments}") 104 | 105 | # if name != "fetch_metric": 106 | # raise ValueError(f"Unknown tool:{name}") 107 | 108 | try: 109 | metric_name = arguments['metric_name'] 110 | metric_range = arguments['metric_range'] 111 | 112 | value = prometheus_handler.get_range_data(metric_name=metric_name, metric_range=metric_range) 113 | 114 | return [TextContent(type="text", text=f"metric:{metric_name} range value return:{value}")] 115 | 116 | except Exception as e: 117 | logger.error(f"Error when fetching metric:{name} with arguments:{arguments}") 118 | return [TextContent(type="text", text=f"Error when fetching metric:{name} with arguments:{arguments}. error:{str(e)}")] 119 | 120 | 121 | async def main(): 122 | """Main entry point to run the MCP server.""" 123 | logger.info("starting prometheus mcp server...") 124 | 125 | # for test 126 | # prometheus_handler.get_range_data("go_gc_duration_seconds") 127 | config = get_config() 128 | logger.info(f"Prometheus config:{config}") 129 | 130 | async with stdio_server() as (read_stream, write_stream): 131 | try: 132 | await app.run( 133 | read_stream, 134 | write_stream, 135 | app.create_initialization_options() 136 | ) 137 | except Exception as e: 138 | logger.error(f"Server error:{str(e)}") 139 | raise 140 | 141 | if __name__ == "__main__": 142 | asyncio.run(main()) 143 | -------------------------------------------------------------------------------- /test/prom.py: -------------------------------------------------------------------------------- 1 | from prometheus_client import start_http_server, Summary 2 | 3 | import random 4 | 5 | import time 6 | 7 | 8 | # Create a metric to track time spent and requests made. 9 | 10 | REQUEST_TIME = Summary('request_processing_seconds', 'Time spent processing request') 11 | 12 | 13 | # Decorate function with metric. 14 | 15 | @REQUEST_TIME.time() 16 | def process_request(t): 17 | """A dummy function that takes some time.""" 18 | 19 | time.sleep(t) 20 | 21 | 22 | if __name__ == '__main__': 23 | 24 | # Start up the server to expose the metrics. 25 | 26 | start_http_server(8000) 27 | 28 | # Generate some requests. 29 | 30 | while True: 31 | 32 | process_request(random.random()) 33 | -------------------------------------------------------------------------------- /test/test_db_connector.py: -------------------------------------------------------------------------------- 1 | # import sys 2 | # import pytest 3 | # import logging 4 | # from src.prometheus_mcp_server.server import get_config 5 | # from src.prometheus_mcp_server.db_connector import PrometheusHandler 6 | 7 | 8 | # # config logging 9 | # logging.basicConfig( 10 | # level=logging.DEBUG, 11 | # format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 12 | # ) 13 | # logger = logging.getLogger("prometheus_mcp_server") 14 | # prometheus_handler = PrometheusHandler(logger, get_config()['host']) 15 | 16 | # def test_initialize(): 17 | # get_config()['host'] 18 | 19 | # def test_get_all_metrics(): 20 | # all_metrics = prometheus_handler.get_all_metrics() 21 | # logger.info(all_metrics) 22 | # return 23 | 24 | # def test_get_range_data(metric="go_gc_heap_frees_by_size_bytes_bucket"): 25 | # metric_data = prometheus_handler.get_range_data(metric) 26 | # logger.info(metric_data) 27 | # return 28 | 29 | -------------------------------------------------------------------------------- /test/test_mcp_server.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pytest 3 | import logging 4 | from src.prometheus_mcp_server.server import get_config,list_resources,read_resource,list_tools,call_tool 5 | from src.prometheus_mcp_server.db_connector import PrometheusHandler 6 | 7 | # config logging 8 | logging.basicConfig( 9 | level=logging.DEBUG, 10 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 11 | ) 12 | logger = logging.getLogger("prometheus_mcp_server") 13 | prometheus_handler = PrometheusHandler(logger, get_config()['host']) 14 | 15 | def test_initialize(): 16 | get_config()['host'] 17 | 18 | 19 | @pytest.mark.asyncio 20 | async def test_list_tools(): 21 | tools = await list_tools() 22 | logger.info(tools) 23 | assert tools[0].name == "fetch_metric" 24 | 25 | 26 | @pytest.mark.asyncio 27 | async def test_list_resources(): 28 | resources = await list_resources() 29 | logger.info(resources) 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_read_resource(): 34 | url = "prometheus://go_gc_heap_frees_by_size_bytes_bucket/metric" 35 | resource = await read_resource(url) 36 | logger.info(resource) 37 | 38 | 39 | 40 | @pytest.mark.asyncio 41 | async def test_call_valid_tool(tool_name="fetch_metric"): 42 | argument = { 43 | "metric_name": "go_gc_heap_frees_by_size_bytes_bucket", 44 | "metric_range":"5" 45 | } 46 | res = await call_tool(tool_name, argument) 47 | logger.info(f"res:{res[0]}") 48 | 49 | 50 | @pytest.mark.asyncio 51 | async def test_call_invalid_tool(): 52 | with pytest.raises(ValueError, match="Unknown tool"): 53 | await call_tool("invalid_tool", {}) 54 | --------------------------------------------------------------------------------