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.
Files changed (59) hide show
  1. mcpsonar/__init__.py +3 -0
  2. mcpsonar/adapters/__init__.py +64 -0
  3. mcpsonar/adapters/base.py +28 -0
  4. mcpsonar/adapters/chat.py +80 -0
  5. mcpsonar/adapters/email.py +167 -0
  6. mcpsonar/adapters/generic_webhook.py +82 -0
  7. mcpsonar/adapters/prometheus.py +157 -0
  8. mcpsonar/adapters/uptime_kuma.py +39 -0
  9. mcpsonar/anomaly.py +174 -0
  10. mcpsonar/api.py +344 -0
  11. mcpsonar/auth.py +649 -0
  12. mcpsonar/check_engine.py +641 -0
  13. mcpsonar/cli.py +372 -0
  14. mcpsonar/commands/__init__.py +30 -0
  15. mcpsonar/commands/_remote.py +131 -0
  16. mcpsonar/commands/_target.py +72 -0
  17. mcpsonar/commands/completion.py +231 -0
  18. mcpsonar/commands/config_cmd.py +151 -0
  19. mcpsonar/commands/doctor.py +309 -0
  20. mcpsonar/commands/inspect_cmd.py +178 -0
  21. mcpsonar/commands/monitor_cmd.py +469 -0
  22. mcpsonar/commands/report_cmd.py +105 -0
  23. mcpsonar/commands/servers_cmd.py +346 -0
  24. mcpsonar/commands/version.py +43 -0
  25. mcpsonar/dashboard.py +1289 -0
  26. mcpsonar/demo.py +347 -0
  27. mcpsonar/drift.py +121 -0
  28. mcpsonar/incidents.py +41 -0
  29. mcpsonar/lint.py +396 -0
  30. mcpsonar/loader.py +74 -0
  31. mcpsonar/models.py +525 -0
  32. mcpsonar/monitor.py +253 -0
  33. mcpsonar/netpolicy.py +186 -0
  34. mcpsonar/notify.py +148 -0
  35. mcpsonar/output.py +144 -0
  36. mcpsonar/redact.py +198 -0
  37. mcpsonar/report.py +291 -0
  38. mcpsonar/scan.py +235 -0
  39. mcpsonar/scenarios.py +513 -0
  40. mcpsonar/scheduler.py +174 -0
  41. mcpsonar/secretref.py +189 -0
  42. mcpsonar/services.py +109 -0
  43. mcpsonar/state.py +27 -0
  44. mcpsonar/storage.py +899 -0
  45. mcpsonar/tooldefs.py +118 -0
  46. mcpsonar/trust.py +312 -0
  47. mcpsonar/validators/__init__.py +26 -0
  48. mcpsonar/validators/base.py +103 -0
  49. mcpsonar/validators/v2024_11_05.py +32 -0
  50. mcpsonar/validators/v2025_03_26.py +32 -0
  51. mcpsonar/validators/v2025_06_18.py +33 -0
  52. mcpsonar/validators/v2025_11_25.py +33 -0
  53. mcpsonar/validators/v2026_07_28.py +32 -0
  54. mcpsonar/websec.py +149 -0
  55. mcpsonar-0.9.1.dist-info/METADATA +639 -0
  56. mcpsonar-0.9.1.dist-info/RECORD +59 -0
  57. mcpsonar-0.9.1.dist-info/WHEEL +4 -0
  58. mcpsonar-0.9.1.dist-info/entry_points.txt +2 -0
  59. mcpsonar-0.9.1.dist-info/licenses/LICENSE +21 -0
mcpsonar/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """mcpsonar — uptime monitoring, rug-pull detection and anomaly detection for MCP servers."""
2
+
3
+ __version__ = "0.9.1"
@@ -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)