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.
- {hyperping-1.4.1 → hyperping-1.5.0}/CHANGELOG.md +28 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/PKG-INFO +13 -1
- {hyperping-1.4.1 → hyperping-1.5.0}/README.md +12 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/pyproject.toml +1 -1
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/__init__.py +18 -2
- hyperping-1.5.0/src/hyperping/_async_mcp_client.py +249 -0
- hyperping-1.5.0/src/hyperping/_async_mcp_transport.py +199 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_mcp_transport.py +64 -9
- hyperping-1.5.0/src/hyperping/_version.py +1 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/mcp_client.py +63 -41
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/__init__.py +21 -4
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_healthcheck_models.py +3 -3
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_incident_models.py +2 -2
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_integration_models.py +1 -1
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_maintenance_models.py +1 -1
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_monitor_models.py +5 -5
- hyperping-1.5.0/src/hyperping/models/_observability_models.py +56 -0
- hyperping-1.5.0/src/hyperping/models/_oncall_models.py +42 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_outage_models.py +30 -19
- hyperping-1.5.0/src/hyperping/models/_reporting_models.py +94 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/models/_statuspage_models.py +2 -2
- hyperping-1.5.0/tests/unit/test_async_mcp_client.py +284 -0
- hyperping-1.5.0/tests/unit/test_async_mcp_transport.py +302 -0
- hyperping-1.5.0/tests/unit/test_async_preexisting.py +716 -0
- hyperping-1.5.0/tests/unit/test_client_coverage.py +277 -0
- hyperping-1.5.0/tests/unit/test_mcp_client.py +259 -0
- hyperping-1.5.0/tests/unit/test_mcp_transport.py +333 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_outages.py +85 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_sdk_surface.py +62 -0
- hyperping-1.4.1/src/hyperping/_version.py +0 -1
- hyperping-1.4.1/src/hyperping/models/_observability_models.py +0 -46
- hyperping-1.4.1/src/hyperping/models/_oncall_models.py +0 -27
- hyperping-1.4.1/src/hyperping/models/_reporting_models.py +0 -17
- hyperping-1.4.1/tests/unit/test_async_preexisting.py +0 -325
- hyperping-1.4.1/tests/unit/test_mcp_client.py +0 -161
- hyperping-1.4.1/tests/unit/test_mcp_transport.py +0 -172
- {hyperping-1.4.1 → hyperping-1.5.0}/.gitignore +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/CONTRIBUTING.md +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/LICENSE +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/scripts/verify_endpoints.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_client.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_incidents_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_monitors_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_outages_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_healthchecks_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_incidents_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_internals.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_maintenance_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_monitor_constants.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_monitors_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_outages_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_protocols.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_statuspages_mixin.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/_utils.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/client.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/endpoints.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/exceptions.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/src/hyperping/py.typed +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/tests/__init__.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/__init__.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/conftest.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_async_client.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_healthchecks.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_incidents.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_maintenance.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_monitors.py +0 -0
- {hyperping-1.4.1 → hyperping-1.5.0}/tests/unit/test_pagination.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
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()
|