hyperping 1.4.1__tar.gz → 1.5.0__tar.gz

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 (72) hide show
  1. {hyperping-1.4.1 → hyperping-1.5.0}/CHANGELOG.md +28 -0
  2. {hyperping-1.4.1 → hyperping-1.5.0}/PKG-INFO +13 -1
  3. {hyperping-1.4.1 → hyperping-1.5.0}/README.md +12 -0
  4. {hyperping-1.4.1 → hyperping-1.5.0}/pyproject.toml +1 -1
  5. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/__init__.py +18 -2
  6. hyperping-1.5.0/src/hyperping/_async_mcp_client.py +249 -0
  7. hyperping-1.5.0/src/hyperping/_async_mcp_transport.py +199 -0
  8. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_mcp_transport.py +64 -9
  9. hyperping-1.5.0/src/hyperping/_version.py +1 -0
  10. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/mcp_client.py +63 -41
  11. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/__init__.py +21 -4
  12. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_healthcheck_models.py +3 -3
  13. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_incident_models.py +2 -2
  14. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_integration_models.py +1 -1
  15. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_maintenance_models.py +1 -1
  16. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_monitor_models.py +5 -5
  17. hyperping-1.5.0/src/hyperping/models/_observability_models.py +56 -0
  18. hyperping-1.5.0/src/hyperping/models/_oncall_models.py +42 -0
  19. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_outage_models.py +30 -19
  20. hyperping-1.5.0/src/hyperping/models/_reporting_models.py +94 -0
  21. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_statuspage_models.py +2 -2
  22. hyperping-1.5.0/tests/unit/test_async_mcp_client.py +284 -0
  23. hyperping-1.5.0/tests/unit/test_async_mcp_transport.py +302 -0
  24. hyperping-1.5.0/tests/unit/test_async_preexisting.py +716 -0
  25. hyperping-1.5.0/tests/unit/test_client_coverage.py +277 -0
  26. hyperping-1.5.0/tests/unit/test_mcp_client.py +259 -0
  27. hyperping-1.5.0/tests/unit/test_mcp_transport.py +333 -0
  28. {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_outages.py +85 -0
  29. {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_sdk_surface.py +62 -0
  30. hyperping-1.4.1/src/hyperping/_version.py +0 -1
  31. hyperping-1.4.1/src/hyperping/models/_observability_models.py +0 -46
  32. hyperping-1.4.1/src/hyperping/models/_oncall_models.py +0 -27
  33. hyperping-1.4.1/src/hyperping/models/_reporting_models.py +0 -17
  34. hyperping-1.4.1/tests/unit/test_async_preexisting.py +0 -325
  35. hyperping-1.4.1/tests/unit/test_mcp_client.py +0 -161
  36. hyperping-1.4.1/tests/unit/test_mcp_transport.py +0 -172
  37. {hyperping-1.4.1 → hyperping-1.5.0}/.gitignore +0 -0
  38. {hyperping-1.4.1 → hyperping-1.5.0}/CONTRIBUTING.md +0 -0
  39. {hyperping-1.4.1 → hyperping-1.5.0}/LICENSE +0 -0
  40. {hyperping-1.4.1 → hyperping-1.5.0}/scripts/verify_endpoints.py +0 -0
  41. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_client.py +0 -0
  42. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  43. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_incidents_mixin.py +0 -0
  44. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
  45. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_monitors_mixin.py +0 -0
  46. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_outages_mixin.py +0 -0
  47. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
  48. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_circuit_breaker.py +0 -0
  49. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_healthchecks_mixin.py +0 -0
  50. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_incidents_mixin.py +0 -0
  51. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_internals.py +0 -0
  52. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_maintenance_mixin.py +0 -0
  53. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_monitor_constants.py +0 -0
  54. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_monitors_mixin.py +0 -0
  55. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_outages_mixin.py +0 -0
  56. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_protocols.py +0 -0
  57. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_statuspages_mixin.py +0 -0
  58. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_utils.py +0 -0
  59. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/client.py +0 -0
  60. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/endpoints.py +0 -0
  61. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/exceptions.py +0 -0
  62. {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/py.typed +0 -0
  63. {hyperping-1.4.1 → hyperping-1.5.0}/tests/__init__.py +0 -0
  64. {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/__init__.py +0 -0
  65. {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/conftest.py +0 -0
  66. {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_async_client.py +0 -0
  67. {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_healthchecks.py +0 -0
  68. {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_incidents.py +0 -0
  69. {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_maintenance.py +0 -0
  70. {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_monitors.py +0 -0
  71. {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_pagination.py +0 -0
  72. {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_statuspages.py +0 -0
@@ -5,6 +5,34 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.5.0] - 2026-04-20
9
+
10
+ ### Added
11
+
12
+ - **`AsyncHyperpingMcpClient`** -- full async counterpart to `HyperpingMcpClient`. All 16
13
+ MCP methods available via `await`. Uses `httpx.AsyncClient`, `asyncio.Lock`, and async
14
+ retry with `asyncio.sleep`. Exported from `hyperping` top-level.
15
+ - **Typed MCP returns** -- all 16 MCP client methods now return Pydantic models instead of
16
+ raw `dict[str, Any]`. Models verified against the live API.
17
+ - **New models**: `TimeGroup`, `ResponseTimeReport`, `AlertHistory`, `MonitorMetricsSummary`,
18
+ `MttrReport`, `MttaReport`, `ProbeLogResponse`, `TeamMember`, `OutageMonitorSummary`.
19
+ - **MCP error handling parity** -- both sync and async transports now map HTTP 404, 429,
20
+ 400/422 to the same exception types as the REST client (`HyperpingNotFoundError`,
21
+ `HyperpingRateLimitError`, `HyperpingValidationError`).
22
+ - **MCP retry logic** -- automatic retry with exponential backoff on transient server
23
+ errors (500, 502, 503, 504) up to `max_retries` times (default 2).
24
+ - **Thread safety** -- `threading.Lock` (sync) and `asyncio.Lock` (async) protect the
25
+ request ID counter and initialization flag.
26
+ - **83 new tests** covering async MCP transport, client coverage, sync transport error
27
+ paths, async maintenance/outages, and protocol base classes. Overall coverage: 96%.
28
+
29
+ ### Changed
30
+
31
+ - **Forward-compatible models** -- all 28 response models changed from `extra="ignore"` to
32
+ `extra="allow"`. New API fields are preserved instead of silently dropped.
33
+ - **Typed sub-objects** -- `OutageTimeline.outage` is now typed `Outage` (was `dict`),
34
+ `.monitor` is `OutageMonitorSummary` (was `dict`).
35
+
8
36
  ## [1.4.1] - 2026-04-20
9
37
 
10
38
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperping
3
- Version: 1.4.1
3
+ Version: 1.5.0
4
4
  Summary: Python SDK for the Hyperping uptime monitoring and incident management API
5
5
  Project-URL: Homepage, https://github.com/develeap/hyperping-python
6
6
  Project-URL: Documentation, https://github.com/develeap/hyperping-python#readme
@@ -98,6 +98,18 @@ async def main():
98
98
 
99
99
  The async client supports all the same resources, retry behaviour, and circuit breaker as the sync client. Use `RetryConfig` and `CircuitBreakerConfig` in exactly the same way.
100
100
 
101
+ An async MCP client is also available:
102
+
103
+ ```python
104
+ from hyperping import AsyncHyperpingMcpClient
105
+
106
+ async def main():
107
+ async with AsyncHyperpingMcpClient(api_key="sk_...") as mcp:
108
+ summary = await mcp.get_status_summary()
109
+ members = await mcp.list_team_members()
110
+ anomalies = await mcp.get_monitor_anomalies("mon_uuid")
111
+ ```
112
+
101
113
  ## Authentication
102
114
 
103
115
  Pass your API key directly or via environment variable:
@@ -61,6 +61,18 @@ async def main():
61
61
 
62
62
  The async client supports all the same resources, retry behaviour, and circuit breaker as the sync client. Use `RetryConfig` and `CircuitBreakerConfig` in exactly the same way.
63
63
 
64
+ An async MCP client is also available:
65
+
66
+ ```python
67
+ from hyperping import AsyncHyperpingMcpClient
68
+
69
+ async def main():
70
+ async with AsyncHyperpingMcpClient(api_key="sk_...") as mcp:
71
+ summary = await mcp.get_status_summary()
72
+ members = await mcp.list_team_members()
73
+ anomalies = await mcp.get_monitor_anomalies("mon_uuid")
74
+ ```
75
+
64
76
  ## Authentication
65
77
 
66
78
  Pass your API key directly or via environment variable:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperping"
7
- version = "1.4.1"
7
+ version = "1.5.0"
8
8
  description = "Python SDK for the Hyperping uptime monitoring and incident management API"
9
9
  readme = {file = "README.md", content-type = "text/markdown"}
10
10
  license = {text = "MIT"}
@@ -15,6 +15,7 @@ Quick start::
15
15
  """
16
16
 
17
17
  from hyperping._async_client import AsyncHyperpingClient
18
+ from hyperping._async_mcp_client import AsyncHyperpingMcpClient
18
19
  from hyperping._version import __version__
19
20
  from hyperping.client import (
20
21
  CircuitBreaker,
@@ -40,7 +41,7 @@ from hyperping.mcp_client import HyperpingMcpClient
40
41
  from hyperping.models import (
41
42
  DEFAULT_REGIONS,
42
43
  AddIncidentUpdateRequest,
43
- AlertNotification,
44
+ AlertHistory,
44
45
  DnsRecordType,
45
46
  EscalationPolicy,
46
47
  Healthcheck,
@@ -64,10 +65,13 @@ from hyperping.models import (
64
65
  MonitorCreate,
65
66
  MonitorFrequency,
66
67
  MonitorListResponse,
68
+ MonitorMetricsSummary,
67
69
  MonitorProtocol,
68
70
  MonitorReport,
69
71
  MonitorTimeout,
70
72
  MonitorUpdate,
73
+ MttaReport,
74
+ MttrReport,
71
75
  NotificationOption,
72
76
  OnCallSchedule,
73
77
  Outage,
@@ -77,14 +81,18 @@ from hyperping.models import (
77
81
  OutageTimeline,
78
82
  OutageTimelineEvent,
79
83
  ProbeLog,
84
+ ProbeLogResponse,
80
85
  Region,
81
86
  ReportPeriod,
82
87
  RequestHeader,
88
+ ResponseTimeReport,
83
89
  StatusPage,
84
90
  StatusPageCreate,
85
91
  StatusPageSubscriber,
86
92
  StatusPageUpdate,
87
93
  StatusSummary,
94
+ TeamMember,
95
+ TimeGroup,
88
96
  )
89
97
 
90
98
  __all__ = [
@@ -92,6 +100,7 @@ __all__ = [
92
100
  "__version__",
93
101
  # Clients
94
102
  "AsyncHyperpingClient",
103
+ "AsyncHyperpingMcpClient",
95
104
  "HyperpingClient",
96
105
  "HyperpingMcpClient",
97
106
  # MCP
@@ -156,14 +165,21 @@ __all__ = [
156
165
  # Observability
157
166
  "MonitorAnomaly",
158
167
  "ProbeLog",
159
- "AlertNotification",
168
+ "ProbeLogResponse",
160
169
  # On-call
161
170
  "OnCallSchedule",
162
171
  "EscalationPolicy",
172
+ "TeamMember",
163
173
  # Integrations
164
174
  "Integration",
165
175
  # Reporting
166
176
  "StatusSummary",
177
+ "TimeGroup",
178
+ "ResponseTimeReport",
179
+ "AlertHistory",
180
+ "MonitorMetricsSummary",
181
+ "MttrReport",
182
+ "MttaReport",
167
183
  # Healthchecks
168
184
  "Healthcheck",
169
185
  "HealthcheckCreate",
@@ -0,0 +1,249 @@
1
+ """Async high-level typed MCP client for the Hyperping MCP server.
2
+
3
+ Wraps :class:`~hyperping._async_mcp_transport.AsyncMcpTransport` with typed
4
+ convenience methods that mirror the MCP tool names exposed by the server.
5
+
6
+ Example::
7
+
8
+ from hyperping import AsyncHyperpingMcpClient
9
+
10
+ async with AsyncHyperpingMcpClient(api_key="sk_...") as mcp:
11
+ summary = await mcp.get_status_summary()
12
+ print(summary.total, summary.up, summary.down)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import Any
18
+
19
+ from pydantic import SecretStr
20
+
21
+ from hyperping._async_mcp_transport import AsyncMcpTransport
22
+ from hyperping.endpoints import MCP_URL
23
+ from hyperping.models._integration_models import Integration
24
+ from hyperping.models._monitor_models import Monitor
25
+ from hyperping.models._observability_models import MonitorAnomaly, ProbeLogResponse
26
+ from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember
27
+ from hyperping.models._outage_models import OutageTimeline
28
+ from hyperping.models._reporting_models import (
29
+ AlertHistory,
30
+ MttaReport,
31
+ MttrReport,
32
+ ResponseTimeReport,
33
+ StatusSummary,
34
+ )
35
+
36
+
37
+ class AsyncHyperpingMcpClient:
38
+ """Async high-level client for Hyperping MCP server tools.
39
+
40
+ Provides typed convenience methods for every MCP tool. Methods return
41
+ Pydantic models matching the verified API response shapes.
42
+
43
+ Supports the same ``api_key`` formats (``str`` or ``SecretStr``) and
44
+ async context-manager pattern as
45
+ :class:`~hyperping._async_client.AsyncHyperpingClient`.
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ api_key: str | SecretStr,
51
+ base_url: str = MCP_URL,
52
+ timeout: float = 30.0,
53
+ ) -> None:
54
+ self._transport = AsyncMcpTransport(
55
+ api_key=api_key,
56
+ base_url=base_url,
57
+ timeout=timeout,
58
+ )
59
+
60
+ # ==================== Internal ====================
61
+
62
+ async def _call(self, tool: str, args: dict[str, Any] | None = None) -> Any:
63
+ """Call an MCP tool via the transport."""
64
+ return await self._transport.call_tool(tool, args or {})
65
+
66
+ # ==================== Context Manager ====================
67
+
68
+ async def close(self) -> None:
69
+ """Close the underlying HTTP transport."""
70
+ await self._transport.close()
71
+
72
+ async def __aenter__(self) -> AsyncHyperpingMcpClient:
73
+ return self
74
+
75
+ async def __aexit__(self, *args: object) -> None:
76
+ await self.close()
77
+
78
+ # ==================== Status & Reporting ====================
79
+
80
+ async def get_status_summary(self) -> StatusSummary:
81
+ """Get aggregate monitor status counts."""
82
+ return StatusSummary.model_validate(await self._call("get_status_summary"))
83
+
84
+ async def get_monitor_response_time(
85
+ self,
86
+ monitor_uuid: str,
87
+ **kwargs: Any,
88
+ ) -> ResponseTimeReport:
89
+ """Get response time metrics for a monitor.
90
+
91
+ Args:
92
+ monitor_uuid: Monitor UUID.
93
+ **kwargs: Additional arguments forwarded to the MCP tool.
94
+ """
95
+ return ResponseTimeReport.model_validate(
96
+ await self._call("get_monitor_response_time", {"uuid": monitor_uuid, **kwargs})
97
+ )
98
+
99
+ async def get_monitor_mtta(
100
+ self,
101
+ monitor_uuid: str | None = None,
102
+ **kwargs: Any,
103
+ ) -> MttaReport:
104
+ """Get mean time to acknowledge metrics.
105
+
106
+ Args:
107
+ monitor_uuid: Optional monitor UUID to scope the query.
108
+ **kwargs: Additional arguments forwarded to the MCP tool.
109
+ """
110
+ args: dict[str, Any] = {**kwargs}
111
+ if monitor_uuid is not None:
112
+ args["uuid"] = monitor_uuid
113
+ return MttaReport.model_validate(await self._call("get_monitor_mtta", args))
114
+
115
+ async def get_monitor_mttr(
116
+ self,
117
+ monitor_uuid: str | None = None,
118
+ **kwargs: Any,
119
+ ) -> MttrReport:
120
+ """Get mean time to resolve metrics.
121
+
122
+ Args:
123
+ monitor_uuid: Optional monitor UUID to scope the query.
124
+ **kwargs: Additional arguments forwarded to the MCP tool.
125
+ """
126
+ args: dict[str, Any] = {**kwargs}
127
+ if monitor_uuid is not None:
128
+ args["uuid"] = monitor_uuid
129
+ return MttrReport.model_validate(await self._call("get_monitor_mttr", args))
130
+
131
+ # ==================== Observability ====================
132
+
133
+ async def get_monitor_anomalies(self, monitor_uuid: str) -> list[MonitorAnomaly]:
134
+ """Get anomalies detected for a monitor.
135
+
136
+ Args:
137
+ monitor_uuid: Monitor UUID.
138
+ """
139
+ data = await self._call("get_monitor_anomalies", {"uuid": monitor_uuid})
140
+ raw = data.get("anomalies", []) if isinstance(data, dict) else []
141
+ return [MonitorAnomaly.model_validate(a) for a in raw]
142
+
143
+ async def get_monitor_http_logs(
144
+ self,
145
+ monitor_uuid: str,
146
+ **kwargs: Any,
147
+ ) -> ProbeLogResponse:
148
+ """Get HTTP probe logs for a monitor.
149
+
150
+ Args:
151
+ monitor_uuid: Monitor UUID.
152
+ **kwargs: Additional arguments forwarded to the MCP tool.
153
+ """
154
+ data = await self._call("get_monitor_http_logs", {"uuid": monitor_uuid, **kwargs})
155
+ return ProbeLogResponse.model_validate(data)
156
+
157
+ # ==================== Alerts ====================
158
+
159
+ async def list_recent_alerts(self, **kwargs: Any) -> AlertHistory:
160
+ """List recent alert notifications.
161
+
162
+ Args:
163
+ **kwargs: Additional arguments forwarded to the MCP tool.
164
+ """
165
+ return AlertHistory.model_validate(await self._call("list_recent_alerts", {**kwargs}))
166
+
167
+ # ==================== On-Call ====================
168
+
169
+ async def list_on_call_schedules(self) -> list[OnCallSchedule]:
170
+ """List all on-call schedules."""
171
+ data = await self._call("list_on_call_schedules")
172
+ raw = data.get("schedules", []) if isinstance(data, dict) else []
173
+ return [OnCallSchedule.model_validate(s) for s in raw]
174
+
175
+ async def get_on_call_schedule(self, uuid: str) -> OnCallSchedule:
176
+ """Get a single on-call schedule by UUID.
177
+
178
+ Args:
179
+ uuid: Schedule UUID.
180
+ """
181
+ return OnCallSchedule.model_validate(
182
+ await self._call("get_on_call_schedule", {"uuid": uuid})
183
+ )
184
+
185
+ # ==================== Escalation Policies ====================
186
+
187
+ async def list_escalation_policies(self) -> list[EscalationPolicy]:
188
+ """List all escalation policies."""
189
+ data = await self._call("list_escalation_policies")
190
+ raw = data if isinstance(data, list) else []
191
+ return [EscalationPolicy.model_validate(p) for p in raw]
192
+
193
+ async def get_escalation_policy(self, uuid: str) -> EscalationPolicy:
194
+ """Get a single escalation policy by UUID.
195
+
196
+ Args:
197
+ uuid: Escalation policy UUID.
198
+ """
199
+ return EscalationPolicy.model_validate(
200
+ await self._call("get_escalation_policy", {"uuid": uuid})
201
+ )
202
+
203
+ # ==================== Team ====================
204
+
205
+ async def list_team_members(self) -> list[TeamMember]:
206
+ """List all team members."""
207
+ data = await self._call("list_team_members")
208
+ raw = data if isinstance(data, list) else []
209
+ return [TeamMember.model_validate(m) for m in raw]
210
+
211
+ # ==================== Integrations ====================
212
+
213
+ async def list_integrations(self) -> list[Integration]:
214
+ """List all notification channel integrations."""
215
+ data = await self._call("list_integrations")
216
+ raw = data if isinstance(data, list) else []
217
+ return [Integration.model_validate(i) for i in raw]
218
+
219
+ async def get_integration(self, uuid: str) -> Integration:
220
+ """Get a single integration by UUID.
221
+
222
+ Args:
223
+ uuid: Integration UUID.
224
+ """
225
+ return Integration.model_validate(await self._call("get_integration", {"uuid": uuid}))
226
+
227
+ # ==================== Outages ====================
228
+
229
+ async def get_outage_timeline(self, outage_uuid: str) -> OutageTimeline:
230
+ """Get the lifecycle timeline for an outage.
231
+
232
+ Args:
233
+ outage_uuid: Outage UUID.
234
+ """
235
+ return OutageTimeline.model_validate(
236
+ await self._call("get_outage_timeline", {"uuid": outage_uuid})
237
+ )
238
+
239
+ # ==================== Monitors ====================
240
+
241
+ async def search_monitors_by_name(self, query: str) -> list[Monitor]:
242
+ """Search monitors by name.
243
+
244
+ Args:
245
+ query: Search string to match against monitor names.
246
+ """
247
+ data = await self._call("search_monitors_by_name", {"query": query})
248
+ raw = data if isinstance(data, list) else []
249
+ return [Monitor.model_validate(m) for m in raw]
@@ -0,0 +1,199 @@
1
+ """Async JSON-RPC 2.0 transport for the Hyperping MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from pydantic import SecretStr
11
+
12
+ from hyperping._version import __version__
13
+ from hyperping.endpoints import MCP_URL
14
+ from hyperping.exceptions import (
15
+ HyperpingAPIError,
16
+ HyperpingAuthError,
17
+ HyperpingNotFoundError,
18
+ HyperpingRateLimitError,
19
+ HyperpingValidationError,
20
+ )
21
+
22
+ _PROTOCOL_VERSION = "2025-03-26"
23
+
24
+
25
+ class AsyncMcpTransport:
26
+ """Async low-level JSON-RPC 2.0 client for the Hyperping MCP server.
27
+
28
+ The MCP server exposes tools not available via the REST API: on-call
29
+ schedules, anomalies, alerts, integrations, probe logs, and more.
30
+
31
+ Uses the same Bearer token API key as the REST client.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ api_key: str | SecretStr,
37
+ base_url: str = MCP_URL,
38
+ timeout: float = 30.0,
39
+ max_retries: int = 2,
40
+ ) -> None:
41
+ token = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
42
+ self._url = base_url.rstrip("/")
43
+ self._client = httpx.AsyncClient(
44
+ headers={
45
+ "Authorization": f"Bearer {token}",
46
+ "Content-Type": "application/json",
47
+ "Accept": "application/json, text/event-stream",
48
+ },
49
+ timeout=timeout,
50
+ )
51
+ self._initialized = False
52
+ self._request_id = 0
53
+ self._lock = asyncio.Lock()
54
+ self._max_retries = max_retries
55
+
56
+ async def _next_id(self) -> int:
57
+ async with self._lock:
58
+ self._request_id += 1
59
+ return self._request_id
60
+
61
+ async def _send_rpc(
62
+ self,
63
+ method: str,
64
+ params: dict[str, Any] | None = None,
65
+ *,
66
+ is_notification: bool = False,
67
+ ) -> dict[str, Any] | None:
68
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
69
+ if params is not None:
70
+ payload["params"] = params
71
+ if not is_notification:
72
+ payload["id"] = await self._next_id()
73
+
74
+ resp = await self._client.post(self._url, content=json.dumps(payload))
75
+
76
+ if resp.status_code in (401, 403):
77
+ raise HyperpingAuthError("Invalid or expired API key")
78
+ if resp.status_code == 202:
79
+ return None # Notification accepted
80
+ if resp.status_code == 404:
81
+ raise HyperpingNotFoundError(
82
+ "Resource not found",
83
+ status_code=404,
84
+ )
85
+ if resp.status_code == 429:
86
+ retry_after = None
87
+ raw_retry = resp.headers.get("retry-after")
88
+ if raw_retry:
89
+ try:
90
+ retry_after = int(raw_retry)
91
+ except ValueError:
92
+ pass
93
+ raise HyperpingRateLimitError(
94
+ "Rate limit exceeded",
95
+ retry_after=retry_after,
96
+ status_code=429,
97
+ )
98
+ if resp.status_code in (400, 422):
99
+ raise HyperpingValidationError(
100
+ f"Validation error: HTTP {resp.status_code}",
101
+ status_code=resp.status_code,
102
+ )
103
+ if resp.status_code != 200:
104
+ raise HyperpingAPIError(
105
+ f"MCP server returned HTTP {resp.status_code}",
106
+ status_code=resp.status_code,
107
+ response_body={"raw": resp.text[:500]},
108
+ )
109
+ if is_notification:
110
+ return None
111
+
112
+ data = resp.json()
113
+ if "error" in data:
114
+ err = data["error"]
115
+ raise HyperpingAPIError(
116
+ f"MCP error {err.get('code', '?')}: {err.get('message', 'unknown')}",
117
+ status_code=resp.status_code,
118
+ response_body=err,
119
+ )
120
+ return data # type: ignore[no-any-return]
121
+
122
+ async def initialize(self) -> dict[str, Any]:
123
+ """Perform MCP handshake. Called automatically on first tool call."""
124
+ result = await self._send_rpc(
125
+ "initialize",
126
+ {
127
+ "protocolVersion": _PROTOCOL_VERSION,
128
+ "capabilities": {},
129
+ "clientInfo": {"name": "hyperping-python", "version": __version__},
130
+ },
131
+ )
132
+ await self._send_rpc("notifications/initialized", is_notification=True)
133
+ async with self._lock:
134
+ self._initialized = True
135
+ return result.get("result", {}) if result else {}
136
+
137
+ async def call_tool(
138
+ self,
139
+ tool_name: str,
140
+ arguments: dict[str, Any] | None = None,
141
+ ) -> Any:
142
+ """Call an MCP tool and return parsed response data.
143
+
144
+ Auto-initializes on first call. Extracts and parses the JSON
145
+ string from ``result.content[0].text``.
146
+
147
+ Retries automatically on transient server errors (HTTP 500, 502,
148
+ 503, 504) up to ``max_retries`` times with exponential back-off.
149
+ """
150
+ async with self._lock:
151
+ needs_init = not self._initialized
152
+ if needs_init:
153
+ await self.initialize()
154
+
155
+ last_exc: Exception | None = None
156
+ for attempt in range(self._max_retries + 1):
157
+ try:
158
+ result = await self._send_rpc(
159
+ "tools/call",
160
+ {"name": tool_name, "arguments": arguments or {}},
161
+ )
162
+ break
163
+ except HyperpingAPIError as exc:
164
+ if exc.status_code and exc.status_code in (500, 502, 503, 504):
165
+ last_exc = exc
166
+ if attempt < self._max_retries:
167
+ await asyncio.sleep(min(2**attempt, 10))
168
+ continue
169
+ raise
170
+ else:
171
+ raise last_exc # type: ignore[misc]
172
+ if result is None:
173
+ return None
174
+
175
+ content = result.get("result", {}).get("content", [])
176
+ if not content:
177
+ return None
178
+
179
+ text = content[0].get("text", "")
180
+ if not text:
181
+ return None
182
+
183
+ try:
184
+ return json.loads(text)
185
+ except json.JSONDecodeError as exc:
186
+ raise HyperpingAPIError(
187
+ f"Failed to parse MCP tool response: {exc}",
188
+ status_code=200,
189
+ response_body={"raw": text[:500]},
190
+ ) from exc
191
+
192
+ async def close(self) -> None:
193
+ await self._client.aclose()
194
+
195
+ async def __aenter__(self) -> AsyncMcpTransport:
196
+ return self
197
+
198
+ async def __aexit__(self, *args: object) -> None:
199
+ await self.close()