prometheus-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3 @@
1
+ """prometheus-mcp — MCP server for Prometheus metrics and observability."""
2
+
3
+ __version__ = "0.1.0"
prometheus_mcp/_mcp.py ADDED
@@ -0,0 +1,55 @@
1
+ """Shared FastMCP instance and client cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from collections.abc import AsyncIterator
8
+ from contextlib import asynccontextmanager
9
+ from typing import Any
10
+
11
+ from mcp.server.fastmcp import FastMCP
12
+
13
+ from prometheus_mcp.client import PrometheusClient
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ _client: PrometheusClient | None = None
18
+ _client_lock = threading.Lock()
19
+
20
+
21
+ @asynccontextmanager
22
+ async def app_lifespan(_app: FastMCP) -> AsyncIterator[dict[str, Any]]:
23
+ """Server lifespan: close HTTP session on shutdown."""
24
+ logger.debug("prometheus_mcp: startup")
25
+ try:
26
+ yield {}
27
+ finally:
28
+ global _client
29
+ with _client_lock:
30
+ if _client is not None:
31
+ try:
32
+ _client.close()
33
+ except Exception:
34
+ pass
35
+ _client = None
36
+ logger.debug("prometheus_mcp: shutdown — HTTP session closed")
37
+
38
+
39
+ mcp = FastMCP("prometheus_mcp", lifespan=app_lifespan)
40
+
41
+
42
+ def get_client() -> PrometheusClient:
43
+ """Return a cached :class:`PrometheusClient` (thread-safe lazy-init).
44
+
45
+ FastMCP runs synchronous tools in worker threads via
46
+ ``anyio.to_thread.run_sync``; concurrent first-calls could otherwise
47
+ race on the ``_client`` global. The lock ensures exactly one instance
48
+ is constructed.
49
+ """
50
+ global _client
51
+ if _client is None:
52
+ with _client_lock:
53
+ if _client is None: # double-checked locking
54
+ _client = PrometheusClient()
55
+ return _client
@@ -0,0 +1,155 @@
1
+ """HTTP client for the Prometheus HTTP API v1.
2
+
3
+ Thin wrapper around :mod:`requests` — reads config from env vars, supports
4
+ Bearer-token auth, HTTP Basic auth, SSL-verify toggling, and exposes get().
5
+ Errors bubble up as :class:`requests.HTTPError` and are mapped to
6
+ user-facing messages by :mod:`prometheus_mcp.errors`.
7
+
8
+ **Auth priority:** PROMETHEUS_TOKEN (Bearer) takes precedence over
9
+ PROMETHEUS_USERNAME/PROMETHEUS_PASSWORD (Basic). If neither is set the session
10
+ is unauthenticated — valid for many internal Prometheus deployments.
11
+
12
+ **Threading model.** The client uses ``requests`` (synchronous). FastMCP
13
+ runs synchronous ``@mcp.tool`` in a worker thread via
14
+ ``anyio.to_thread.run_sync``, so blocking HTTP calls don't block the
15
+ asyncio event loop.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from typing import Any
22
+ from urllib.parse import urlparse
23
+
24
+ import requests
25
+ import urllib3
26
+
27
+ from prometheus_mcp.errors import ConfigError
28
+
29
+
30
+ def _parse_bool(value: str | bool | None, *, default: bool) -> bool:
31
+ """Parse an env-var boolean.
32
+
33
+ Accepts true/false/1/0/yes/no/on/off (case-insensitive). Returns
34
+ ``default`` when ``value`` is ``None`` or empty.
35
+ """
36
+ if value is None or value == "":
37
+ return default
38
+ if isinstance(value, bool):
39
+ return value
40
+ return str(value).strip().lower() not in ("false", "0", "no", "off")
41
+
42
+
43
+ def _validate_url(url: str) -> str:
44
+ """Validate that ``url`` is a well-formed HTTP/HTTPS URL.
45
+
46
+ Returns the URL with leading/trailing whitespace and any trailing slash
47
+ stripped. Raises :class:`ConfigError` if the URL is missing scheme/host
48
+ or uses an unsupported scheme.
49
+ """
50
+ if not url:
51
+ raise ConfigError("PROMETHEUS_URL is not set — configure the env var (e.g. https://prometheus.example.com)")
52
+
53
+ cleaned = url.strip()
54
+ parsed = urlparse(cleaned)
55
+ if parsed.scheme not in ("http", "https"):
56
+ raise ConfigError(f"PROMETHEUS_URL must start with http:// or https:// (got: {url!r})")
57
+ if not parsed.netloc:
58
+ raise ConfigError(f"PROMETHEUS_URL is missing host (got: {url!r})")
59
+ return cleaned.rstrip("/")
60
+
61
+
62
+ class PrometheusClient:
63
+ """Minimal Prometheus HTTP API v1 client.
64
+
65
+ The client reads ``PROMETHEUS_URL``, ``PROMETHEUS_TOKEN``,
66
+ ``PROMETHEUS_USERNAME``, ``PROMETHEUS_PASSWORD``, ``PROMETHEUS_SSL_VERIFY``
67
+ from the environment. Instances are safe to reuse — a single
68
+ :class:`requests.Session` is kept for connection pooling.
69
+
70
+ Auth selection:
71
+ - If ``PROMETHEUS_TOKEN`` is set → Bearer auth (ignores username/password).
72
+ - Else if both ``PROMETHEUS_USERNAME`` and ``PROMETHEUS_PASSWORD`` are set → Basic auth.
73
+ - Else → no auth (valid for internal/unauthenticated Prometheus instances).
74
+
75
+ Args:
76
+ url: Override ``PROMETHEUS_URL``. If ``None``, read from env.
77
+ token: Override ``PROMETHEUS_TOKEN``. If ``None``, read from env.
78
+ username: Override ``PROMETHEUS_USERNAME``. If ``None``, read from env.
79
+ password: Override ``PROMETHEUS_PASSWORD``. If ``None``, read from env.
80
+ ssl_verify: Override ``PROMETHEUS_SSL_VERIFY``. If ``None``, read from env.
81
+
82
+ Raises:
83
+ ConfigError: If PROMETHEUS_URL is missing or malformed.
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ url: str | None = None,
89
+ token: str | None = None,
90
+ username: str | None = None,
91
+ password: str | None = None,
92
+ ssl_verify: bool | None = None,
93
+ ) -> None:
94
+ raw_url = url if url is not None else os.environ.get("PROMETHEUS_URL", "")
95
+ self.url = _validate_url(raw_url)
96
+ self.api_url = f"{self.url}/api/v1"
97
+
98
+ self.token = token if token is not None else os.environ.get("PROMETHEUS_TOKEN", "")
99
+ self.username = username if username is not None else os.environ.get("PROMETHEUS_USERNAME", "")
100
+ self.password = password if password is not None else os.environ.get("PROMETHEUS_PASSWORD", "")
101
+
102
+ if ssl_verify is None:
103
+ ssl_verify = _parse_bool(os.environ.get("PROMETHEUS_SSL_VERIFY"), default=True)
104
+ self.ssl_verify = ssl_verify
105
+
106
+ self.session = requests.Session()
107
+ self.session.verify = self.ssl_verify
108
+ self.session.headers.update(
109
+ {
110
+ "Accept": "application/json",
111
+ "User-Agent": "prometheus-mcp",
112
+ }
113
+ )
114
+ # Prometheus is typically an internal service not reachable via env proxy.
115
+ self.session.trust_env = False
116
+
117
+ # Auth priority: Bearer > Basic > none.
118
+ if self.token:
119
+ self.session.headers["Authorization"] = f"Bearer {self.token}"
120
+ elif self.username and self.password:
121
+ self.session.auth = (self.username, self.password)
122
+
123
+ if not self.ssl_verify:
124
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
125
+
126
+ def _request(
127
+ self,
128
+ method: str,
129
+ endpoint: str,
130
+ *,
131
+ params: dict[str, Any] | None = None,
132
+ ) -> requests.Response:
133
+ response = self.session.request(
134
+ method=method,
135
+ url=f"{self.api_url}{endpoint}",
136
+ params=params,
137
+ timeout=30,
138
+ )
139
+ response.raise_for_status()
140
+ return response
141
+
142
+ def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
143
+ """GET ``{api_url}{endpoint}`` and return parsed JSON.
144
+
145
+ Prometheus always returns JSON for 2xx responses; returns ``None`` for
146
+ empty bodies.
147
+ """
148
+ response = self._request("GET", endpoint, params=params)
149
+ if not response.content:
150
+ return None
151
+ return response.json()
152
+
153
+ def close(self) -> None:
154
+ """Close the underlying HTTP session (called from lifespan on shutdown)."""
155
+ self.session.close()
@@ -0,0 +1,103 @@
1
+ """Actionable error messages for Prometheus HTTP errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import requests
6
+
7
+
8
+ class ConfigError(ValueError):
9
+ """Raised when required environment variables are missing or malformed.
10
+
11
+ Subclass of :class:`ValueError` so callers can continue to use
12
+ ``isinstance(..., ValueError)``, but narrow enough that :func:`handle`
13
+ can distinguish config errors from Pydantic validation errors bubbling
14
+ up from tool input.
15
+ """
16
+
17
+
18
+ def handle(exc: Exception, action: str) -> str:
19
+ """Convert an exception raised while performing ``action`` into an
20
+ LLM-readable string with a suggested next step.
21
+
22
+ The goal is that the agent sees *why* the call failed and *what it could
23
+ do about it* without needing to inspect a Python traceback.
24
+ """
25
+ if isinstance(exc, ConfigError):
26
+ return (
27
+ f"Error: configuration problem while {action} — {exc}. "
28
+ "Check PROMETHEUS_URL, PROMETHEUS_TOKEN, PROMETHEUS_USERNAME, PROMETHEUS_PASSWORD, "
29
+ "PROMETHEUS_SSL_VERIFY environment variables."
30
+ )
31
+
32
+ if isinstance(exc, requests.HTTPError):
33
+ code = exc.response.status_code if exc.response is not None else None
34
+ if code == 401:
35
+ return (
36
+ f"Error: authentication failed (HTTP 401) while {action}. "
37
+ "Verify PROMETHEUS_TOKEN (Bearer) or PROMETHEUS_USERNAME/PROMETHEUS_PASSWORD (Basic auth) "
38
+ "are set correctly. Many internal Prometheus instances require no auth — "
39
+ "try unsetting those env vars if this is an internal deployment."
40
+ )
41
+ if code == 403:
42
+ return (
43
+ f"Error: forbidden (HTTP 403) while {action}. "
44
+ "The provided credentials lack permission for this Prometheus resource. "
45
+ "Check PROMETHEUS_TOKEN or PROMETHEUS_USERNAME/PROMETHEUS_PASSWORD and "
46
+ "Prometheus's auth configuration."
47
+ )
48
+ if code == 404:
49
+ return (
50
+ f"Error: resource not found (HTTP 404) while {action}. "
51
+ "Check PROMETHEUS_URL points to a valid Prometheus instance. "
52
+ "Use prometheus_list_metrics to discover valid metric names."
53
+ )
54
+ if code in (400, 422):
55
+ body = ""
56
+ if exc.response is not None:
57
+ try:
58
+ data = exc.response.json()
59
+ body = data.get("error", exc.response.text[:300])
60
+ except Exception:
61
+ body = exc.response.text[:300]
62
+ return (
63
+ f"Error: bad request (HTTP {code}) while {action}. "
64
+ "Prometheus rejected the parameters — check your PromQL expression syntax, "
65
+ "time range, or step value. "
66
+ f"Prometheus error: {body}"
67
+ )
68
+ if code == 429:
69
+ return (
70
+ f"Error: rate-limited (HTTP 429) while {action}. "
71
+ "Wait 30-60s before retrying; narrow the time range or reduce the step resolution."
72
+ )
73
+ if code is not None and 500 <= code < 600:
74
+ return (
75
+ f"Error: Prometheus server error (HTTP {code}) while {action}. "
76
+ "This is usually transient — retry in a few seconds; "
77
+ "check Prometheus health at PROMETHEUS_URL/-/healthy."
78
+ )
79
+ body = ""
80
+ if exc.response is not None:
81
+ try:
82
+ body = exc.response.text[:200]
83
+ except Exception:
84
+ pass
85
+ return f"Error: HTTP {code} while {action}. Response: {body}"
86
+
87
+ if isinstance(exc, requests.ConnectionError):
88
+ return (
89
+ f"Error: could not connect to Prometheus while {action}. "
90
+ "Check PROMETHEUS_URL is set and reachable (e.g. https://prometheus.example.com). "
91
+ "Prometheus HTTP API runs on port 9090 by default."
92
+ )
93
+
94
+ if isinstance(exc, requests.Timeout):
95
+ return (
96
+ f"Error: request timed out while {action}. "
97
+ "Check network latency and retry; narrow the time range or increase the step for range queries."
98
+ )
99
+
100
+ if isinstance(exc, ValueError):
101
+ return f"Error: invalid input while {action} — {exc}"
102
+
103
+ return f"Error: unexpected {type(exc).__name__} while {action}: {exc}"
@@ -0,0 +1,125 @@
1
+ """TypedDict output schemas for every MCP tool.
2
+
3
+ These schemas are read by FastMCP (``structured_output=True``) to generate
4
+ a JSON-Schema ``outputSchema`` for each tool. Clients that support
5
+ structured data use that schema to validate the ``structuredContent``
6
+ payload; clients that don't use the markdown ``content`` block instead.
7
+
8
+ **Python / Pydantic compat note.** We deliberately avoid
9
+ ``Required`` / ``NotRequired`` qualifiers: Pydantic 2.13+ mishandles them
10
+ during runtime schema generation on Py < 3.12 (see
11
+ https://errors.pydantic.dev/2.13/u/typed-dict-version). Optional fields use
12
+ ``| None`` convention; the code always sets the key (``None`` when absent).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import sys
18
+
19
+ if sys.version_info >= (3, 12):
20
+ from typing import TypedDict
21
+ else:
22
+ from typing_extensions import TypedDict
23
+
24
+
25
+ # ── Metrics list ──────────────────────────────────────────────────────────────
26
+
27
+
28
+ class ListMetricsOutput(TypedDict):
29
+ total_count: int
30
+ returned_count: int
31
+ truncated: bool
32
+ pattern: str | None
33
+ metrics: list[str]
34
+
35
+
36
+ # ── Instant query ─────────────────────────────────────────────────────────────
37
+
38
+
39
+ class InstantSample(TypedDict):
40
+ labels: dict[str, str]
41
+ timestamp: float
42
+ value: str
43
+
44
+
45
+ class QueryOutput(TypedDict):
46
+ query: str
47
+ time: str | None
48
+ result_type: str
49
+ result_count: int
50
+ data: list[InstantSample]
51
+
52
+
53
+ # ── Range query ───────────────────────────────────────────────────────────────
54
+
55
+
56
+ class RangeSeries(TypedDict):
57
+ labels: dict[str, str]
58
+ point_count: int
59
+ values: list[list[float | str]]
60
+
61
+
62
+ class QueryRangeOutput(TypedDict):
63
+ query: str
64
+ start: str
65
+ end: str
66
+ step: str
67
+ result_type: str
68
+ series_count: int
69
+ total_points: int
70
+ truncated: bool
71
+ data: list[RangeSeries]
72
+
73
+
74
+ # ── Alerts ────────────────────────────────────────────────────────────────────
75
+
76
+
77
+ class AlertItem(TypedDict):
78
+ labels: dict[str, str]
79
+ annotations: dict[str, str]
80
+ state: str
81
+ active_at: str
82
+ value: str
83
+
84
+
85
+ class AlertStateSummary(TypedDict):
86
+ state: str
87
+ count: int
88
+
89
+
90
+ class ListAlertsOutput(TypedDict):
91
+ total_count: int
92
+ firing_count: int
93
+ pending_count: int
94
+ state_summary: list[AlertStateSummary]
95
+ alerts: list[AlertItem]
96
+
97
+
98
+ # ── Targets ───────────────────────────────────────────────────────────────────
99
+
100
+
101
+ class TargetItem(TypedDict):
102
+ job: str
103
+ instance: str
104
+ health: str
105
+ last_scrape_duration_ms: float
106
+ last_error: str | None
107
+ labels: dict[str, str]
108
+
109
+
110
+ class TargetJobSummary(TypedDict):
111
+ job: str
112
+ total: int
113
+ up: int
114
+ down: int
115
+ unknown: int
116
+
117
+
118
+ class ListTargetsOutput(TypedDict):
119
+ state_filter: str
120
+ total_count: int
121
+ up_count: int
122
+ down_count: int
123
+ unknown_count: int
124
+ job_summary: list[TargetJobSummary]
125
+ targets: list[TargetItem]
@@ -0,0 +1,28 @@
1
+ """Helpers that produce the dual-channel tool result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any, NoReturn
7
+
8
+ from mcp.server.fastmcp.exceptions import ToolError
9
+ from mcp.types import CallToolResult, TextContent
10
+
11
+ from prometheus_mcp import errors
12
+
13
+
14
+ def ok(data: Mapping[str, Any], markdown: str) -> CallToolResult:
15
+ """Wrap ``data`` + a markdown rendering into a non-error tool result."""
16
+ return CallToolResult(
17
+ content=[TextContent(type="text", text=markdown)],
18
+ structuredContent=dict(data),
19
+ )
20
+
21
+
22
+ def fail(exc: Exception, action: str) -> NoReturn:
23
+ """Raise a ``ToolError`` carrying the actionable error message.
24
+
25
+ The ``NoReturn`` annotation lets type-checkers understand that control
26
+ flow does not return from this function.
27
+ """
28
+ raise ToolError(errors.handle(exc, action)) from exc
File without changes
@@ -0,0 +1,20 @@
1
+ """FastMCP server entry point for Prometheus MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ # Importing the tools module attaches @mcp.tool decorators to the shared
6
+ # FastMCP instance.
7
+ from prometheus_mcp import tools as _tools # noqa: F401
8
+ from prometheus_mcp._mcp import app_lifespan, mcp
9
+
10
+
11
+ def main() -> None:
12
+ """Entry point for the ``prometheus-mcp`` console script (stdio)."""
13
+ mcp.run()
14
+
15
+
16
+ __all__ = ["mcp", "app_lifespan", "main"]
17
+
18
+
19
+ if __name__ == "__main__":
20
+ main()