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.
- prometheus_mcp/__init__.py +3 -0
- prometheus_mcp/_mcp.py +55 -0
- prometheus_mcp/client.py +155 -0
- prometheus_mcp/errors.py +103 -0
- prometheus_mcp/models.py +125 -0
- prometheus_mcp/output.py +28 -0
- prometheus_mcp/py.typed +0 -0
- prometheus_mcp/server.py +20 -0
- prometheus_mcp/tools.py +693 -0
- prometheus_mcp-0.1.0.dist-info/METADATA +199 -0
- prometheus_mcp-0.1.0.dist-info/RECORD +14 -0
- prometheus_mcp-0.1.0.dist-info/WHEEL +4 -0
- prometheus_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- prometheus_mcp-0.1.0.dist-info/licenses/LICENSE +21 -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
|
prometheus_mcp/client.py
ADDED
|
@@ -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()
|
prometheus_mcp/errors.py
ADDED
|
@@ -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}"
|
prometheus_mcp/models.py
ADDED
|
@@ -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]
|
prometheus_mcp/output.py
ADDED
|
@@ -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
|
prometheus_mcp/py.typed
ADDED
|
File without changes
|
prometheus_mcp/server.py
ADDED
|
@@ -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()
|