mcpsonar 0.9.1__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.
- mcpsonar/__init__.py +3 -0
- mcpsonar/adapters/__init__.py +64 -0
- mcpsonar/adapters/base.py +28 -0
- mcpsonar/adapters/chat.py +80 -0
- mcpsonar/adapters/email.py +167 -0
- mcpsonar/adapters/generic_webhook.py +82 -0
- mcpsonar/adapters/prometheus.py +157 -0
- mcpsonar/adapters/uptime_kuma.py +39 -0
- mcpsonar/anomaly.py +174 -0
- mcpsonar/api.py +344 -0
- mcpsonar/auth.py +649 -0
- mcpsonar/check_engine.py +641 -0
- mcpsonar/cli.py +372 -0
- mcpsonar/commands/__init__.py +30 -0
- mcpsonar/commands/_remote.py +131 -0
- mcpsonar/commands/_target.py +72 -0
- mcpsonar/commands/completion.py +231 -0
- mcpsonar/commands/config_cmd.py +151 -0
- mcpsonar/commands/doctor.py +309 -0
- mcpsonar/commands/inspect_cmd.py +178 -0
- mcpsonar/commands/monitor_cmd.py +469 -0
- mcpsonar/commands/report_cmd.py +105 -0
- mcpsonar/commands/servers_cmd.py +346 -0
- mcpsonar/commands/version.py +43 -0
- mcpsonar/dashboard.py +1289 -0
- mcpsonar/demo.py +347 -0
- mcpsonar/drift.py +121 -0
- mcpsonar/incidents.py +41 -0
- mcpsonar/lint.py +396 -0
- mcpsonar/loader.py +74 -0
- mcpsonar/models.py +525 -0
- mcpsonar/monitor.py +253 -0
- mcpsonar/netpolicy.py +186 -0
- mcpsonar/notify.py +148 -0
- mcpsonar/output.py +144 -0
- mcpsonar/redact.py +198 -0
- mcpsonar/report.py +291 -0
- mcpsonar/scan.py +235 -0
- mcpsonar/scenarios.py +513 -0
- mcpsonar/scheduler.py +174 -0
- mcpsonar/secretref.py +189 -0
- mcpsonar/services.py +109 -0
- mcpsonar/state.py +27 -0
- mcpsonar/storage.py +899 -0
- mcpsonar/tooldefs.py +118 -0
- mcpsonar/trust.py +312 -0
- mcpsonar/validators/__init__.py +26 -0
- mcpsonar/validators/base.py +103 -0
- mcpsonar/validators/v2024_11_05.py +32 -0
- mcpsonar/validators/v2025_03_26.py +32 -0
- mcpsonar/validators/v2025_06_18.py +33 -0
- mcpsonar/validators/v2025_11_25.py +33 -0
- mcpsonar/validators/v2026_07_28.py +32 -0
- mcpsonar/websec.py +149 -0
- mcpsonar-0.9.1.dist-info/METADATA +639 -0
- mcpsonar-0.9.1.dist-info/RECORD +59 -0
- mcpsonar-0.9.1.dist-info/WHEEL +4 -0
- mcpsonar-0.9.1.dist-info/entry_points.txt +2 -0
- mcpsonar-0.9.1.dist-info/licenses/LICENSE +21 -0
mcpsonar/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Output adapters and the dispatcher that fans check events out to them.
|
|
2
|
+
|
|
3
|
+
Push adapters (Uptime Kuma, generic webhook) implement :class:`BaseAdapter` and share one
|
|
4
|
+
``httpx2.AsyncClient`` owned by :class:`AdapterDispatcher`. The Prometheus exporter is not a
|
|
5
|
+
push adapter — it is scraped via ``/metrics`` and reads the StateStore directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import httpx2
|
|
11
|
+
|
|
12
|
+
from ..models import (
|
|
13
|
+
DiscordWebhook,
|
|
14
|
+
EmailWebhook,
|
|
15
|
+
GenericWebhook,
|
|
16
|
+
ServerConfig,
|
|
17
|
+
SlackWebhook,
|
|
18
|
+
UptimeKumaWebhook,
|
|
19
|
+
)
|
|
20
|
+
from .base import BaseAdapter
|
|
21
|
+
from .chat import DiscordAdapter, SlackAdapter
|
|
22
|
+
from .email import EmailAdapter
|
|
23
|
+
from .generic_webhook import GenericWebhookAdapter
|
|
24
|
+
from .prometheus import CONTENT_TYPE, render_metrics
|
|
25
|
+
from .uptime_kuma import UptimeKumaAdapter
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"CONTENT_TYPE",
|
|
29
|
+
"BaseAdapter",
|
|
30
|
+
"DiscordAdapter",
|
|
31
|
+
"EmailAdapter",
|
|
32
|
+
"GenericWebhookAdapter",
|
|
33
|
+
"SlackAdapter",
|
|
34
|
+
"UptimeKumaAdapter",
|
|
35
|
+
"build_push_adapters",
|
|
36
|
+
"render_metrics",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_push_adapters(
|
|
41
|
+
cfg: ServerConfig, client: httpx2.AsyncClient, name: str = ""
|
|
42
|
+
) -> list[BaseAdapter]:
|
|
43
|
+
"""Construct the push adapters declared in a server's webhook config. The scheduler owns
|
|
44
|
+
the shared client and dispatches events to these per server. ``name``: the server's
|
|
45
|
+
display name, for chat messages."""
|
|
46
|
+
adapters: list[BaseAdapter] = []
|
|
47
|
+
for webhook in cfg.webhooks:
|
|
48
|
+
if isinstance(webhook, UptimeKumaWebhook):
|
|
49
|
+
adapters.append(UptimeKumaAdapter(webhook.push_url, client))
|
|
50
|
+
elif isinstance(webhook, GenericWebhook):
|
|
51
|
+
adapters.append(
|
|
52
|
+
GenericWebhookAdapter(
|
|
53
|
+
webhook.url, webhook.on, client, on_anomaly=webhook.on_anomaly,
|
|
54
|
+
on_recovery=webhook.on_recovery,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
elif isinstance(webhook, SlackWebhook | DiscordWebhook):
|
|
58
|
+
cls = SlackAdapter if isinstance(webhook, SlackWebhook) else DiscordAdapter
|
|
59
|
+
adapters.append(cls(webhook.url, name, webhook.on, client,
|
|
60
|
+
on_anomaly=webhook.on_anomaly,
|
|
61
|
+
on_recovery=webhook.on_recovery))
|
|
62
|
+
elif isinstance(webhook, EmailWebhook):
|
|
63
|
+
adapters.append(EmailAdapter(webhook, name, client))
|
|
64
|
+
return adapters
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Adapter interface: translate a CheckEvent for one external system.
|
|
2
|
+
|
|
3
|
+
Every push adapter implements the same ``async send(event)`` so new destinations (ExeWatch,
|
|
4
|
+
Slack, ...) drop in without touching the core. Adapters receive a shared ``httpx2.AsyncClient``
|
|
5
|
+
at construction — the caller owns its lifecycle — which keeps ``send`` clean and testable
|
|
6
|
+
(inject a client backed by ``httpx2.MockTransport``).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
|
|
13
|
+
import httpx2
|
|
14
|
+
|
|
15
|
+
from ..models import CheckEvent
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaseAdapter(ABC):
|
|
19
|
+
#: short adapter name, for logging
|
|
20
|
+
name: str = "adapter"
|
|
21
|
+
|
|
22
|
+
def __init__(self, client: httpx2.AsyncClient) -> None:
|
|
23
|
+
self._client = client
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def send(self, event: CheckEvent) -> None:
|
|
27
|
+
"""Deliver ``event`` to the external system. May raise on unrecoverable failure."""
|
|
28
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Slack and Discord notifications: one message per change (see :mod:`mcpsonar.notify`).
|
|
2
|
+
|
|
3
|
+
Both POST JSON to an incoming-webhook URL through the shared client, so deliveries obey the
|
|
4
|
+
outbound connection policy like every other request. The URL is a secret: it never appears
|
|
5
|
+
in logs (``redact_url(..., whole_path=True)``) and is masked by the API.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx2
|
|
15
|
+
|
|
16
|
+
from ..models import CheckEvent, CheckStatus
|
|
17
|
+
from ..notify import Notification, discord_payload, notification_for, slack_payload
|
|
18
|
+
from ..redact import redact_url
|
|
19
|
+
from .base import BaseAdapter
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("mcpsonar.adapters.chat")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _ChatAdapter(BaseAdapter):
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
url: str,
|
|
28
|
+
server_name: str,
|
|
29
|
+
on: list[CheckStatus],
|
|
30
|
+
client: httpx2.AsyncClient,
|
|
31
|
+
on_anomaly: bool = True,
|
|
32
|
+
on_recovery: bool = True,
|
|
33
|
+
max_retries: int = 3,
|
|
34
|
+
backoff: float = 0.5,
|
|
35
|
+
) -> None:
|
|
36
|
+
super().__init__(client)
|
|
37
|
+
self._url = url
|
|
38
|
+
self._server_name = server_name
|
|
39
|
+
self._on = list(on)
|
|
40
|
+
self._on_anomaly = on_anomaly
|
|
41
|
+
self._on_recovery = on_recovery
|
|
42
|
+
self._max_retries = max_retries
|
|
43
|
+
self._backoff = backoff
|
|
44
|
+
|
|
45
|
+
def payload(self, note: Notification) -> dict[str, Any]:
|
|
46
|
+
raise NotImplementedError
|
|
47
|
+
|
|
48
|
+
async def send(self, event: CheckEvent) -> None:
|
|
49
|
+
note = notification_for(event, self._server_name or event.server_id, self._on,
|
|
50
|
+
self._on_anomaly, self._on_recovery)
|
|
51
|
+
if note is None:
|
|
52
|
+
return
|
|
53
|
+
body = self.payload(note)
|
|
54
|
+
delay = self._backoff
|
|
55
|
+
for attempt in range(1, self._max_retries + 1):
|
|
56
|
+
try:
|
|
57
|
+
response = await self._client.post(self._url, json=body)
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
logger.debug("%s notification for %s sent to %s", self.name, event.server_id,
|
|
60
|
+
redact_url(self._url, whole_path=True))
|
|
61
|
+
return
|
|
62
|
+
except Exception:
|
|
63
|
+
if attempt == self._max_retries:
|
|
64
|
+
raise
|
|
65
|
+
await asyncio.sleep(delay)
|
|
66
|
+
delay *= 2
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SlackAdapter(_ChatAdapter):
|
|
70
|
+
name = "slack"
|
|
71
|
+
|
|
72
|
+
def payload(self, note: Notification) -> dict[str, Any]:
|
|
73
|
+
return slack_payload(note)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class DiscordAdapter(_ChatAdapter):
|
|
77
|
+
name = "discord"
|
|
78
|
+
|
|
79
|
+
def payload(self, note: Notification) -> dict[str, Any]:
|
|
80
|
+
return discord_payload(note)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Email notifications through SMTP: one message per change (see :mod:`mcpsonar.notify`).
|
|
2
|
+
|
|
3
|
+
* **TLS is required**: STARTTLS must succeed (it is not opportunistic), or implicit TLS on
|
|
4
|
+
port 465. Plain SMTP is accepted only for a relay on localhost (validated by the model).
|
|
5
|
+
* **The outbound policy applies**: the SMTP host is resolved and every address is checked
|
|
6
|
+
against it before connecting, like an HTTP check; the connection then goes to a vetted
|
|
7
|
+
address while TLS still verifies the certificate against the host *name*.
|
|
8
|
+
* **The password** can be a secret reference (``${env:...}`` / ``${file:...}``), resolved for
|
|
9
|
+
each message under the same allowlist as header references.
|
|
10
|
+
|
|
11
|
+
smtplib is blocking, so a delivery runs in a worker thread with a timeout.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
import smtplib
|
|
19
|
+
import socket
|
|
20
|
+
import ssl
|
|
21
|
+
from email.message import EmailMessage
|
|
22
|
+
from email.utils import formatdate, make_msgid
|
|
23
|
+
|
|
24
|
+
import httpx2
|
|
25
|
+
|
|
26
|
+
from ..models import CheckEvent, EmailWebhook
|
|
27
|
+
from ..netpolicy import get_policy, resolve
|
|
28
|
+
from ..notify import notification_for, plain_text
|
|
29
|
+
from ..secretref import resolve_value
|
|
30
|
+
from .base import BaseAdapter
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("mcpsonar.adapters.email")
|
|
33
|
+
|
|
34
|
+
TIMEOUT_S = 20.0
|
|
35
|
+
CONNECT_TIMEOUT_S = 5.0 # per address: one that drops packets must not eat the whole budget
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SMTPDeliveryError(Exception):
|
|
39
|
+
"""The message could not be delivered; the text names the step, never a credential."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _connect(addresses: list[str], port: int, timeout: float) -> socket.socket:
|
|
43
|
+
"""The first vetted address that accepts the connection. Each attempt is short (a host
|
|
44
|
+
name often resolves to IPv6 and IPv4; a broken path must not stall the delivery), then
|
|
45
|
+
the connected socket gets the full ``timeout`` for the conversation."""
|
|
46
|
+
last: OSError | None = None
|
|
47
|
+
for address in addresses:
|
|
48
|
+
try:
|
|
49
|
+
sock = socket.create_connection((address, port), min(timeout, CONNECT_TIMEOUT_S))
|
|
50
|
+
except OSError as exc:
|
|
51
|
+
last = exc
|
|
52
|
+
continue
|
|
53
|
+
sock.settimeout(timeout)
|
|
54
|
+
return sock
|
|
55
|
+
raise last or OSError("no address to connect to")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class _SMTP(smtplib.SMTP):
|
|
59
|
+
"""Connects only to vetted IP addresses; smtplib keeps the host name (``_host``) for
|
|
60
|
+
the certificate check of STARTTLS."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, addresses: list[str], host: str, port: int, timeout: float,
|
|
63
|
+
ehlo_name: str) -> None:
|
|
64
|
+
self.addresses = addresses
|
|
65
|
+
super().__init__(host, port, local_hostname=ehlo_name, timeout=timeout)
|
|
66
|
+
|
|
67
|
+
def _get_socket(self, host, port, timeout): # smtplib hook
|
|
68
|
+
return _connect(self.addresses, port, timeout)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class _SMTPS(smtplib.SMTP_SSL):
|
|
72
|
+
"""Implicit TLS to vetted IP addresses, verified against the host name."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, addresses: list[str], host: str, port: int, timeout: float,
|
|
75
|
+
ehlo_name: str, context: ssl.SSLContext) -> None:
|
|
76
|
+
self.addresses = addresses
|
|
77
|
+
super().__init__(host, port, local_hostname=ehlo_name, timeout=timeout,
|
|
78
|
+
context=context)
|
|
79
|
+
|
|
80
|
+
def _get_socket(self, host, port, timeout):
|
|
81
|
+
sock = _connect(self.addresses, port, timeout)
|
|
82
|
+
return self.context.wrap_socket(sock, server_hostname=self._host)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EmailAdapter(BaseAdapter):
|
|
86
|
+
name = "email"
|
|
87
|
+
|
|
88
|
+
def __init__(
|
|
89
|
+
self,
|
|
90
|
+
config: EmailWebhook,
|
|
91
|
+
server_name: str,
|
|
92
|
+
client: httpx2.AsyncClient,
|
|
93
|
+
ssl_context: ssl.SSLContext | None = None,
|
|
94
|
+
) -> None:
|
|
95
|
+
super().__init__(client)
|
|
96
|
+
self._cfg = config
|
|
97
|
+
self._server_name = server_name
|
|
98
|
+
self._ssl_context = ssl_context
|
|
99
|
+
|
|
100
|
+
async def send(self, event: CheckEvent) -> None:
|
|
101
|
+
cfg = self._cfg
|
|
102
|
+
note = notification_for(event, self._server_name or event.server_id, cfg.on,
|
|
103
|
+
cfg.on_anomaly, cfg.on_recovery)
|
|
104
|
+
if note is None:
|
|
105
|
+
return
|
|
106
|
+
message = EmailMessage()
|
|
107
|
+
message["Subject"] = f"{cfg.subject_prefix} {note.icon} {note.title}".strip()
|
|
108
|
+
message["From"] = cfg.sender
|
|
109
|
+
message["To"] = ", ".join(cfg.to)
|
|
110
|
+
message["Date"] = formatdate(localtime=False)
|
|
111
|
+
message["Message-ID"] = make_msgid(domain=cfg.sender.rsplit("@", 1)[1])
|
|
112
|
+
message.set_content(plain_text(note))
|
|
113
|
+
addresses = await self._vetted_addresses()
|
|
114
|
+
password = resolve_value(cfg.password) if cfg.password else None
|
|
115
|
+
await asyncio.wait_for(
|
|
116
|
+
asyncio.to_thread(self._deliver, addresses, message, password), TIMEOUT_S + 5)
|
|
117
|
+
logger.debug("email notification for %s sent to %d recipient(s)", event.server_id,
|
|
118
|
+
len(cfg.to))
|
|
119
|
+
|
|
120
|
+
async def _vetted_addresses(self) -> list[str]:
|
|
121
|
+
cfg = self._cfg
|
|
122
|
+
try:
|
|
123
|
+
addresses = await asyncio.wait_for(resolve(cfg.host, cfg.port), TIMEOUT_S)
|
|
124
|
+
except (OSError, TimeoutError) as exc:
|
|
125
|
+
raise SMTPDeliveryError(f"could not resolve {cfg.host}: {exc}") from None
|
|
126
|
+
policy = get_policy()
|
|
127
|
+
allowed, reasons = [], []
|
|
128
|
+
for ip in addresses:
|
|
129
|
+
if reason := policy.check_ip(ip):
|
|
130
|
+
reasons.append(reason)
|
|
131
|
+
else:
|
|
132
|
+
allowed.append(str(ip))
|
|
133
|
+
if not allowed:
|
|
134
|
+
raise SMTPDeliveryError(f"outbound policy blocked {cfg.host}: "
|
|
135
|
+
+ "; ".join(dict.fromkeys(reasons)))
|
|
136
|
+
return allowed
|
|
137
|
+
|
|
138
|
+
def _context(self) -> ssl.SSLContext:
|
|
139
|
+
return self._ssl_context or httpx2.create_ssl_context()
|
|
140
|
+
|
|
141
|
+
def _deliver(self, addresses: list[str], message: EmailMessage,
|
|
142
|
+
password: str | None) -> None:
|
|
143
|
+
cfg = self._cfg
|
|
144
|
+
step = "connect"
|
|
145
|
+
# The EHLO name. Left unset, smtplib asks socket.getfqdn(): a reverse DNS lookup that
|
|
146
|
+
# can stall for tens of seconds on some hosts (seen on macOS). The sender's domain
|
|
147
|
+
# is the conventional choice and needs no lookup.
|
|
148
|
+
ehlo_name = cfg.sender.rsplit("@", 1)[1]
|
|
149
|
+
try:
|
|
150
|
+
if cfg.security == "tls":
|
|
151
|
+
smtp: smtplib.SMTP = _SMTPS(addresses, cfg.host, cfg.port, TIMEOUT_S,
|
|
152
|
+
ehlo_name, self._context())
|
|
153
|
+
else:
|
|
154
|
+
smtp = _SMTP(addresses, cfg.host, cfg.port, TIMEOUT_S, ehlo_name)
|
|
155
|
+
with smtp:
|
|
156
|
+
if cfg.security == "starttls":
|
|
157
|
+
step = "STARTTLS"
|
|
158
|
+
smtp.starttls(context=self._context()) # raises if not offered
|
|
159
|
+
if cfg.username:
|
|
160
|
+
step = "login"
|
|
161
|
+
smtp.login(cfg.username, password or "")
|
|
162
|
+
step = "send"
|
|
163
|
+
smtp.send_message(message)
|
|
164
|
+
except (smtplib.SMTPException, OSError, ssl.SSLError) as exc:
|
|
165
|
+
# Exception texts can quote the server's reply, never our password.
|
|
166
|
+
raise SMTPDeliveryError(f"SMTP {step} with {cfg.host}:{cfg.port} failed: "
|
|
167
|
+
f"{type(exc).__name__}: {exc}") from None
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Generic webhook adapter: POST the raw CheckEvent JSON to an arbitrary URL.
|
|
2
|
+
|
|
3
|
+
Fires for the statuses listed in ``on`` (default down + degraded) and, when ``on_anomaly`` is
|
|
4
|
+
set (the default), for any check that detected something new — a latency anomaly, flapping,
|
|
5
|
+
a tool-definition change or a response drift — whatever its status; and, when
|
|
6
|
+
``on_recovery`` is set (the default), once when the server comes back up from one of the
|
|
7
|
+
``on`` statuses (``previous_status`` in the payload says from what). Retries transient
|
|
8
|
+
failures up to ``max_retries`` attempts with exponential backoff, then raises so the caller
|
|
9
|
+
can log the final failure.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
import httpx2
|
|
18
|
+
|
|
19
|
+
from ..models import CheckEvent, CheckStatus
|
|
20
|
+
from ..redact import redact_url, scrub
|
|
21
|
+
from .base import BaseAdapter
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("mcpsonar.adapters.generic_webhook")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class GenericWebhookAdapter(BaseAdapter):
|
|
27
|
+
name = "generic"
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
url: str,
|
|
32
|
+
on: list[CheckStatus],
|
|
33
|
+
client: httpx2.AsyncClient,
|
|
34
|
+
max_retries: int = 3,
|
|
35
|
+
backoff: float = 0.5,
|
|
36
|
+
on_anomaly: bool = True,
|
|
37
|
+
on_recovery: bool = True,
|
|
38
|
+
) -> None:
|
|
39
|
+
super().__init__(client)
|
|
40
|
+
self._url = url
|
|
41
|
+
self._on = set(on)
|
|
42
|
+
self._on_anomaly = on_anomaly
|
|
43
|
+
self._on_recovery = on_recovery
|
|
44
|
+
self._max_retries = max_retries
|
|
45
|
+
self._backoff = backoff
|
|
46
|
+
|
|
47
|
+
async def send(self, event: CheckEvent) -> None:
|
|
48
|
+
new = any(not a.ongoing for a in event.anomalies) # an episode is notified once
|
|
49
|
+
recovered = (self._on_recovery and event.status == CheckStatus.UP
|
|
50
|
+
and event.previous_status in self._on)
|
|
51
|
+
if event.status not in self._on and not (self._on_anomaly and new) and not recovered:
|
|
52
|
+
return # nothing this webhook cares about
|
|
53
|
+
|
|
54
|
+
payload = event.model_dump(mode="json")
|
|
55
|
+
delay = self._backoff
|
|
56
|
+
last_exc: Exception | None = None
|
|
57
|
+
|
|
58
|
+
for attempt in range(1, self._max_retries + 1):
|
|
59
|
+
try:
|
|
60
|
+
response = await self._client.post(self._url, json=payload)
|
|
61
|
+
response.raise_for_status()
|
|
62
|
+
logger.debug("posted %s to %s (attempt %d)", event.server_id,
|
|
63
|
+
redact_url(self._url, whole_path=True), attempt)
|
|
64
|
+
return
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
last_exc = exc
|
|
67
|
+
if attempt < self._max_retries:
|
|
68
|
+
# Retries are troubleshooting detail; the dispatcher logs the final
|
|
69
|
+
# failure once at warning, so keep these at debug to avoid log spam.
|
|
70
|
+
logger.debug(
|
|
71
|
+
"webhook POST to %s failed (attempt %d/%d): %s; retrying in %.1fs",
|
|
72
|
+
redact_url(self._url, whole_path=True),
|
|
73
|
+
attempt,
|
|
74
|
+
self._max_retries,
|
|
75
|
+
scrub(str(exc), [self._url]),
|
|
76
|
+
delay,
|
|
77
|
+
)
|
|
78
|
+
await asyncio.sleep(delay)
|
|
79
|
+
delay *= 2
|
|
80
|
+
|
|
81
|
+
assert last_exc is not None
|
|
82
|
+
raise last_exc
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Prometheus exporter.
|
|
2
|
+
|
|
3
|
+
Not a push adapter: it renders the current StateStore contents as Prometheus text-format
|
|
4
|
+
metrics for a ``GET /metrics`` scrape. Only servers that have been checked at least once are
|
|
5
|
+
emitted (Prometheus prefers absent series over fabricated ones).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from ..models import CheckEvent, CheckStatus, MonitoredServer
|
|
11
|
+
from ..state import StateStore
|
|
12
|
+
|
|
13
|
+
CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
|
|
14
|
+
|
|
15
|
+
_STATUS_CODE = {CheckStatus.UP: 2, CheckStatus.DEGRADED: 1, CheckStatus.DOWN: 0}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _escape(value: str) -> str:
|
|
19
|
+
"""Escape a Prometheus label value."""
|
|
20
|
+
return value.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _metric(name: str, help_text: str, samples: list[str]) -> list[str]:
|
|
24
|
+
return [f"# HELP {name} {help_text}", f"# TYPE {name} gauge", *samples]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def render_metrics(servers: list[MonitoredServer], store: StateStore) -> str:
|
|
28
|
+
snap = store.snapshot()
|
|
29
|
+
names = {s.id: s.name for s in servers}
|
|
30
|
+
events: list[CheckEvent] = [snap[s.id] for s in servers if s.id in snap]
|
|
31
|
+
|
|
32
|
+
lines: list[str] = []
|
|
33
|
+
|
|
34
|
+
def label(event: CheckEvent) -> str:
|
|
35
|
+
return f'server_id="{_escape(event.server_id)}"'
|
|
36
|
+
|
|
37
|
+
lines += _metric(
|
|
38
|
+
"mcpsonar_up",
|
|
39
|
+
"Whether the server's last check was 'up' (1) or not (0).",
|
|
40
|
+
[f"mcpsonar_up{{{label(e)}}} {1 if e.status == CheckStatus.UP else 0}" for e in events],
|
|
41
|
+
)
|
|
42
|
+
lines += _metric(
|
|
43
|
+
"mcpsonar_status_code",
|
|
44
|
+
"Status of the last check: 2=up, 1=degraded, 0=down.",
|
|
45
|
+
[f"mcpsonar_status_code{{{label(e)}}} {_STATUS_CODE[e.status]}" for e in events],
|
|
46
|
+
)
|
|
47
|
+
lines += _metric(
|
|
48
|
+
"mcpsonar_latency_ms",
|
|
49
|
+
"Latency of the last check's initialize handshake, in milliseconds.",
|
|
50
|
+
[f"mcpsonar_latency_ms{{{label(e)}}} {e.latency_ms:.3f}" for e in events],
|
|
51
|
+
)
|
|
52
|
+
lines += _metric(
|
|
53
|
+
"mcpsonar_tools_checked",
|
|
54
|
+
"Number of tools test-called in the last check.",
|
|
55
|
+
[f"mcpsonar_tools_checked{{{label(e)}}} {e.tools_checked}" for e in events],
|
|
56
|
+
)
|
|
57
|
+
lines += _metric(
|
|
58
|
+
"mcpsonar_tools_failed",
|
|
59
|
+
"Number of test-called tools that failed in the last check.",
|
|
60
|
+
[f"mcpsonar_tools_failed{{{label(e)}}} {e.tools_failed}" for e in events],
|
|
61
|
+
)
|
|
62
|
+
lines += _metric(
|
|
63
|
+
"mcpsonar_spec_version_supported",
|
|
64
|
+
"1 if mcpsonar has a version-specific validator for the declared spec version, else 0.",
|
|
65
|
+
[
|
|
66
|
+
f"mcpsonar_spec_version_supported{{{label(e)}}} "
|
|
67
|
+
f"{1 if e.spec_version_supported else 0}"
|
|
68
|
+
for e in events
|
|
69
|
+
],
|
|
70
|
+
)
|
|
71
|
+
lines += _metric(
|
|
72
|
+
"mcpsonar_auth_required",
|
|
73
|
+
"1 if the server refused an anonymous client (401/403): alive but needs credentials.",
|
|
74
|
+
[f"mcpsonar_auth_required{{{label(e)}}} {1 if e.auth_required else 0}" for e in events],
|
|
75
|
+
)
|
|
76
|
+
lines += _metric(
|
|
77
|
+
"mcpsonar_tool_changes_pending",
|
|
78
|
+
"Tool-definition changes detected vs. the pinned baseline and not yet reviewed.",
|
|
79
|
+
[f"mcpsonar_tool_changes_pending{{{label(e)}}} {e.tool_changes}" for e in events],
|
|
80
|
+
)
|
|
81
|
+
lines += _metric(
|
|
82
|
+
"mcpsonar_tool_risks",
|
|
83
|
+
"High/medium tool-poisoning findings in the current tool definitions.",
|
|
84
|
+
[f"mcpsonar_tool_risks{{{label(e)}}} {e.tool_risks}" for e in events],
|
|
85
|
+
)
|
|
86
|
+
lines += _metric(
|
|
87
|
+
"mcpsonar_latency_baseline_ms",
|
|
88
|
+
"Median latency of recent healthy checks (the learned baseline).",
|
|
89
|
+
[
|
|
90
|
+
f"mcpsonar_latency_baseline_ms{{{label(e)}}} {e.latency_baseline_ms:.3f}"
|
|
91
|
+
for e in events
|
|
92
|
+
if e.latency_baseline_ms is not None
|
|
93
|
+
],
|
|
94
|
+
)
|
|
95
|
+
lines += _metric(
|
|
96
|
+
"mcpsonar_latency_anomaly_score",
|
|
97
|
+
"Robust z-score of the last latency vs. the baseline (|score| > 4 is unusual).",
|
|
98
|
+
[
|
|
99
|
+
f"mcpsonar_latency_anomaly_score{{{label(e)}}} {e.latency_score:.3f}"
|
|
100
|
+
for e in events
|
|
101
|
+
if e.latency_score is not None
|
|
102
|
+
],
|
|
103
|
+
)
|
|
104
|
+
lines += _metric(
|
|
105
|
+
"mcpsonar_anomalies",
|
|
106
|
+
"Number of anomalies detected by the last check.",
|
|
107
|
+
[f"mcpsonar_anomalies{{{label(e)}}} {len(e.anomalies)}" for e in events],
|
|
108
|
+
)
|
|
109
|
+
lines += _metric(
|
|
110
|
+
"mcpsonar_check_duration_ms",
|
|
111
|
+
"Duration of the whole last check (connection, handshake, discovery, probes), in ms.",
|
|
112
|
+
[
|
|
113
|
+
f"mcpsonar_check_duration_ms{{{label(e)}}} {e.timings_ms['total']:.3f}"
|
|
114
|
+
for e in events
|
|
115
|
+
if e.timings_ms and "total" in e.timings_ms
|
|
116
|
+
],
|
|
117
|
+
)
|
|
118
|
+
lines += _metric(
|
|
119
|
+
"mcpsonar_phase_latency_ms",
|
|
120
|
+
"Duration of each phase of the last check, in ms (phase=handshake|discovery|probes).",
|
|
121
|
+
[
|
|
122
|
+
f'mcpsonar_phase_latency_ms{{{label(e)},phase="{phase}"}} {ms:.3f}'
|
|
123
|
+
for e in events
|
|
124
|
+
for phase, ms in (e.timings_ms or {}).items()
|
|
125
|
+
if phase != "total"
|
|
126
|
+
],
|
|
127
|
+
)
|
|
128
|
+
lines += _metric(
|
|
129
|
+
"mcpsonar_tool_latency_ms",
|
|
130
|
+
"Latency of each probed tool call in the last check, in ms.",
|
|
131
|
+
[
|
|
132
|
+
f'mcpsonar_tool_latency_ms{{{label(e)},tool="{_escape(tool)}"}} {ms:.3f}'
|
|
133
|
+
for e in events
|
|
134
|
+
for tool, ms in e.tool_latency_ms.items()
|
|
135
|
+
],
|
|
136
|
+
)
|
|
137
|
+
lines += _metric(
|
|
138
|
+
"mcpsonar_last_check_timestamp_seconds",
|
|
139
|
+
"Unix timestamp of the last check.",
|
|
140
|
+
[
|
|
141
|
+
f"mcpsonar_last_check_timestamp_seconds{{{label(e)}}} {e.checked_at.timestamp():.0f}"
|
|
142
|
+
for e in events
|
|
143
|
+
],
|
|
144
|
+
)
|
|
145
|
+
lines += _metric(
|
|
146
|
+
"mcpsonar_server_info",
|
|
147
|
+
"Informational labels for the server (value is always 1).",
|
|
148
|
+
[
|
|
149
|
+
f'mcpsonar_server_info{{{label(e)},'
|
|
150
|
+
f'name="{_escape(names.get(e.server_id, ""))}",'
|
|
151
|
+
f'spec_version="{_escape(e.spec_version or "")}",'
|
|
152
|
+
f'spec_version_supported="{str(e.spec_version_supported).lower()}"}} 1'
|
|
153
|
+
for e in events
|
|
154
|
+
],
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
return "\n".join(lines) + "\n"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Uptime Kuma push adapter.
|
|
2
|
+
|
|
3
|
+
Uptime Kuma has no notion of 'degraded', only up/down. We map ``degraded`` -> ``up`` so a
|
|
4
|
+
mere slowdown does not raise a false down-alarm, but we put the real status and detail in the
|
|
5
|
+
``msg`` field so the degradation is visible in Kuma. Only ``down`` maps to Kuma ``down``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
|
|
12
|
+
import httpx2
|
|
13
|
+
|
|
14
|
+
from ..models import CheckEvent, CheckStatus
|
|
15
|
+
from .base import BaseAdapter
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("mcpsonar.adapters.uptime_kuma")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class UptimeKumaAdapter(BaseAdapter):
|
|
21
|
+
name = "uptime_kuma"
|
|
22
|
+
|
|
23
|
+
def __init__(self, push_url: str, client: httpx2.AsyncClient) -> None:
|
|
24
|
+
super().__init__(client)
|
|
25
|
+
self._push_url = push_url
|
|
26
|
+
|
|
27
|
+
async def send(self, event: CheckEvent) -> None:
|
|
28
|
+
kuma_status = "down" if event.status == CheckStatus.DOWN else "up" # degraded -> up
|
|
29
|
+
msg = event.status.value if not event.detail else f"{event.status.value}: {event.detail}"
|
|
30
|
+
if event.anomalies:
|
|
31
|
+
msg += " | " + "; ".join(f"{a.kind}: {a.detail}" for a in event.anomalies)
|
|
32
|
+
params = {
|
|
33
|
+
"status": kuma_status,
|
|
34
|
+
"msg": msg,
|
|
35
|
+
"ping": round(event.latency_ms),
|
|
36
|
+
}
|
|
37
|
+
response = await self._client.get(self._push_url, params=params)
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
logger.debug("pushed %s (%s) to Uptime Kuma", event.server_id, kuma_status)
|