hyperping 1.3.0__tar.gz → 1.4.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.3.0 → hyperping-1.4.0}/PKG-INFO +1 -1
- {hyperping-1.3.0 → hyperping-1.4.0}/pyproject.toml +1 -1
- hyperping-1.4.0/scripts/verify_endpoints.py +166 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/__init__.py +5 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_client.py +0 -8
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_incidents_mixin.py +3 -9
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_maintenance_mixin.py +3 -9
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_monitors_mixin.py +0 -13
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_outages_mixin.py +0 -31
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_statuspages_mixin.py +15 -13
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_incidents_mixin.py +3 -1
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_internals.py +1 -4
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_maintenance_mixin.py +3 -1
- hyperping-1.4.0/src/hyperping/_mcp_transport.py +145 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_monitors_mixin.py +0 -20
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_outages_mixin.py +0 -55
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_statuspages_mixin.py +12 -4
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_utils.py +2 -5
- hyperping-1.4.0/src/hyperping/_version.py +1 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/client.py +0 -8
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/endpoints.py +3 -75
- hyperping-1.4.0/src/hyperping/mcp_client.py +221 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_monitor_models.py +3 -8
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_async_client.py +14 -42
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_async_preexisting.py +3 -9
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_healthchecks.py +8 -21
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_incidents.py +1 -3
- hyperping-1.4.0/tests/unit/test_mcp_client.py +161 -0
- hyperping-1.4.0/tests/unit/test_mcp_transport.py +161 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_monitors.py +5 -58
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_outages.py +4 -101
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_pagination.py +1 -3
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_statuspages.py +11 -15
- hyperping-1.3.0/src/hyperping/_async_integrations_mixin.py +0 -26
- hyperping-1.3.0/src/hyperping/_async_observability_mixin.py +0 -65
- hyperping-1.3.0/src/hyperping/_async_oncall_mixin.py +0 -53
- hyperping-1.3.0/src/hyperping/_async_reporting_mixin.py +0 -39
- hyperping-1.3.0/src/hyperping/_integrations_mixin.py +0 -41
- hyperping-1.3.0/src/hyperping/_observability_mixin.py +0 -102
- hyperping-1.3.0/src/hyperping/_oncall_mixin.py +0 -88
- hyperping-1.3.0/src/hyperping/_reporting_mixin.py +0 -71
- hyperping-1.3.0/src/hyperping/_version.py +0 -1
- hyperping-1.3.0/tests/unit/test_async_new_mixins.py +0 -262
- hyperping-1.3.0/tests/unit/test_integrations.py +0 -98
- hyperping-1.3.0/tests/unit/test_observability.py +0 -203
- hyperping-1.3.0/tests/unit/test_oncall.py +0 -217
- hyperping-1.3.0/tests/unit/test_reporting.py +0 -140
- {hyperping-1.3.0 → hyperping-1.4.0}/.gitignore +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/CHANGELOG.md +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/CONTRIBUTING.md +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/LICENSE +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/README.md +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_circuit_breaker.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_healthchecks_mixin.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_monitor_constants.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_protocols.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/exceptions.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/__init__.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_healthcheck_models.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_incident_models.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_integration_models.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_maintenance_models.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_observability_models.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_oncall_models.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_outage_models.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_reporting_models.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_statuspage_models.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/py.typed +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/__init__.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/__init__.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/conftest.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_maintenance.py +0 -0
- {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_sdk_surface.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hyperping
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.4.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
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "hyperping"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.4.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"}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Verify speculative MCP-discovered endpoint paths against the live Hyperping API.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
export HYPERPING_API_KEY=sk_...
|
|
6
|
+
python scripts/verify_endpoints.py
|
|
7
|
+
|
|
8
|
+
All calls are read-only (GET only). Safe to run repeatedly.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
API_BASE = "https://api.hyperping.io"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Result:
|
|
22
|
+
method: str
|
|
23
|
+
path: str
|
|
24
|
+
status: int
|
|
25
|
+
ok: bool
|
|
26
|
+
snippet: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def probe(
|
|
30
|
+
client: httpx.Client,
|
|
31
|
+
method_name: str,
|
|
32
|
+
path: str,
|
|
33
|
+
params: dict | None = None,
|
|
34
|
+
) -> Result:
|
|
35
|
+
"""Send a GET request and return a Result."""
|
|
36
|
+
url = f"{API_BASE}{path}"
|
|
37
|
+
try:
|
|
38
|
+
resp = client.get(url, params=params or {})
|
|
39
|
+
body = resp.text[:120].replace("\n", " ")
|
|
40
|
+
return Result(
|
|
41
|
+
method=method_name,
|
|
42
|
+
path=path,
|
|
43
|
+
status=resp.status_code,
|
|
44
|
+
ok=resp.status_code < 400,
|
|
45
|
+
snippet=body,
|
|
46
|
+
)
|
|
47
|
+
except httpx.HTTPError as exc:
|
|
48
|
+
return Result(
|
|
49
|
+
method=method_name,
|
|
50
|
+
path=path,
|
|
51
|
+
status=0,
|
|
52
|
+
ok=False,
|
|
53
|
+
snippet=f"Connection error: {exc}",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> int:
|
|
58
|
+
api_key = os.environ.get("HYPERPING_API_KEY", "")
|
|
59
|
+
if not api_key:
|
|
60
|
+
print("ERROR: Set HYPERPING_API_KEY environment variable.")
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
64
|
+
client = httpx.Client(headers=headers, timeout=15.0)
|
|
65
|
+
|
|
66
|
+
# -- Fetch real UUIDs for sub-resource endpoints --
|
|
67
|
+
print("Fetching monitor and outage UUIDs for sub-resource tests...")
|
|
68
|
+
monitor_uuid: str | None = None
|
|
69
|
+
outage_uuid: str | None = None
|
|
70
|
+
|
|
71
|
+
resp = client.get(f"{API_BASE}/v1/monitors")
|
|
72
|
+
if resp.status_code == 200:
|
|
73
|
+
data = resp.json()
|
|
74
|
+
monitors = data if isinstance(data, list) else data.get("monitors", [])
|
|
75
|
+
if monitors:
|
|
76
|
+
monitor_uuid = monitors[0].get("uuid") or monitors[0].get("id")
|
|
77
|
+
print(f" Monitor UUID: {monitor_uuid}")
|
|
78
|
+
|
|
79
|
+
resp = client.get(f"{API_BASE}/v2/outages")
|
|
80
|
+
if resp.status_code == 200:
|
|
81
|
+
data = resp.json()
|
|
82
|
+
outages = data if isinstance(data, list) else data.get("outages", [])
|
|
83
|
+
if outages:
|
|
84
|
+
outage_uuid = outages[0].get("uuid") or outages[0].get("id")
|
|
85
|
+
print(f" Outage UUID: {outage_uuid}")
|
|
86
|
+
|
|
87
|
+
print()
|
|
88
|
+
|
|
89
|
+
# -- Define all speculative endpoints to verify --
|
|
90
|
+
checks: list[tuple[str, str, dict | None]] = [
|
|
91
|
+
# Endpoint enum speculative paths
|
|
92
|
+
("get_status_summary", "/v2/status-summary", None),
|
|
93
|
+
("list_recent_alerts", "/v2/alerts", None),
|
|
94
|
+
("list_on_call_schedules", "/v2/on-call-schedules", None),
|
|
95
|
+
("list_escalation_policies", "/v2/escalation-policies", None),
|
|
96
|
+
("list_team_members", "/v2/team-members", None),
|
|
97
|
+
("list_integrations", "/v2/integrations", None),
|
|
98
|
+
# Monitor search
|
|
99
|
+
("search_monitors", "/v1/monitors/search", {"query": "test"}),
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
# Sub-resource paths needing a real UUID
|
|
103
|
+
if monitor_uuid:
|
|
104
|
+
checks.extend([
|
|
105
|
+
(
|
|
106
|
+
"get_monitor_response_time",
|
|
107
|
+
f"/v2/reporting/response-time/{monitor_uuid}",
|
|
108
|
+
{"period": "24h"},
|
|
109
|
+
),
|
|
110
|
+
(
|
|
111
|
+
"get_monitor_mtta",
|
|
112
|
+
f"/v2/reporting/mtta/{monitor_uuid}",
|
|
113
|
+
{"period": "30d"},
|
|
114
|
+
),
|
|
115
|
+
(
|
|
116
|
+
"get_monitor_anomalies",
|
|
117
|
+
f"/v1/monitors/{monitor_uuid}/anomalies",
|
|
118
|
+
None,
|
|
119
|
+
),
|
|
120
|
+
(
|
|
121
|
+
"get_monitor_http_logs",
|
|
122
|
+
f"/v1/monitors/{monitor_uuid}/http-logs",
|
|
123
|
+
{"page": "0", "limit": "5"},
|
|
124
|
+
),
|
|
125
|
+
])
|
|
126
|
+
else:
|
|
127
|
+
print("WARNING: No monitors found; skipping monitor sub-resource checks.\n")
|
|
128
|
+
|
|
129
|
+
if outage_uuid:
|
|
130
|
+
checks.append((
|
|
131
|
+
"get_outage_timeline",
|
|
132
|
+
f"/v2/outages/{outage_uuid}/timeline",
|
|
133
|
+
None,
|
|
134
|
+
))
|
|
135
|
+
else:
|
|
136
|
+
print("WARNING: No outages found; skipping outage sub-resource checks.\n")
|
|
137
|
+
|
|
138
|
+
# -- Run probes --
|
|
139
|
+
results: list[Result] = []
|
|
140
|
+
for method_name, path, params in checks:
|
|
141
|
+
r = probe(client, method_name, path, params)
|
|
142
|
+
results.append(r)
|
|
143
|
+
|
|
144
|
+
client.close()
|
|
145
|
+
|
|
146
|
+
# -- Print results table --
|
|
147
|
+
print(f"{'Method':<30} {'Path':<50} {'Status':<8} {'Result'}")
|
|
148
|
+
print("-" * 120)
|
|
149
|
+
for r in results:
|
|
150
|
+
tag = "OK" if r.ok else ("404" if r.status == 404 else "ERROR")
|
|
151
|
+
print(f"{r.method:<30} {r.path:<50} {r.status:<8} {tag}")
|
|
152
|
+
if not r.ok:
|
|
153
|
+
print(f" {r.snippet}")
|
|
154
|
+
|
|
155
|
+
# -- Summary --
|
|
156
|
+
ok_count = sum(1 for r in results if r.ok)
|
|
157
|
+
fail_count = sum(1 for r in results if not r.ok)
|
|
158
|
+
not_found = sum(1 for r in results if r.status == 404)
|
|
159
|
+
print()
|
|
160
|
+
print(f"Total: {len(results)} | OK: {ok_count} | 404: {not_found} | Other errors: {fail_count - not_found}")
|
|
161
|
+
|
|
162
|
+
return 1 if fail_count > 0 else 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__":
|
|
166
|
+
sys.exit(main())
|
|
@@ -15,6 +15,7 @@ Quick start::
|
|
|
15
15
|
"""
|
|
16
16
|
|
|
17
17
|
from hyperping._async_client import AsyncHyperpingClient
|
|
18
|
+
from hyperping._mcp_transport import MCP_URL
|
|
18
19
|
from hyperping._version import __version__
|
|
19
20
|
from hyperping.client import (
|
|
20
21
|
CircuitBreaker,
|
|
@@ -35,6 +36,7 @@ from hyperping.exceptions import (
|
|
|
35
36
|
HyperpingRateLimitError,
|
|
36
37
|
HyperpingValidationError,
|
|
37
38
|
)
|
|
39
|
+
from hyperping.mcp_client import HyperpingMcpClient
|
|
38
40
|
from hyperping.models import (
|
|
39
41
|
DEFAULT_REGIONS,
|
|
40
42
|
AddIncidentUpdateRequest,
|
|
@@ -91,6 +93,9 @@ __all__ = [
|
|
|
91
93
|
# Clients
|
|
92
94
|
"AsyncHyperpingClient",
|
|
93
95
|
"HyperpingClient",
|
|
96
|
+
"HyperpingMcpClient",
|
|
97
|
+
# MCP
|
|
98
|
+
"MCP_URL",
|
|
94
99
|
# Configuration
|
|
95
100
|
"RetryConfig",
|
|
96
101
|
"CircuitBreakerConfig",
|
|
@@ -21,13 +21,9 @@ from pydantic import SecretStr
|
|
|
21
21
|
|
|
22
22
|
from hyperping._async_healthchecks_mixin import AsyncHealthchecksMixin
|
|
23
23
|
from hyperping._async_incidents_mixin import AsyncIncidentsMixin
|
|
24
|
-
from hyperping._async_integrations_mixin import AsyncIntegrationsMixin
|
|
25
24
|
from hyperping._async_maintenance_mixin import AsyncMaintenanceMixin
|
|
26
25
|
from hyperping._async_monitors_mixin import AsyncMonitorsMixin
|
|
27
|
-
from hyperping._async_observability_mixin import AsyncObservabilityMixin
|
|
28
|
-
from hyperping._async_oncall_mixin import AsyncOnCallMixin
|
|
29
26
|
from hyperping._async_outages_mixin import AsyncOutagesMixin
|
|
30
|
-
from hyperping._async_reporting_mixin import AsyncReportingMixin
|
|
31
27
|
from hyperping._async_statuspages_mixin import AsyncStatusPagesMixin
|
|
32
28
|
from hyperping._circuit_breaker import (
|
|
33
29
|
CircuitBreaker,
|
|
@@ -52,10 +48,6 @@ class AsyncHyperpingClient(
|
|
|
52
48
|
AsyncOutagesMixin,
|
|
53
49
|
AsyncStatusPagesMixin,
|
|
54
50
|
AsyncHealthchecksMixin,
|
|
55
|
-
AsyncReportingMixin,
|
|
56
|
-
AsyncObservabilityMixin,
|
|
57
|
-
AsyncOnCallMixin,
|
|
58
|
-
AsyncIntegrationsMixin,
|
|
59
51
|
):
|
|
60
52
|
"""Async client for interacting with the Hyperping API.
|
|
61
53
|
|
|
@@ -45,9 +45,7 @@ class AsyncIncidentsMixin(_AsyncClientProtocol):
|
|
|
45
45
|
if status:
|
|
46
46
|
params["status"] = status
|
|
47
47
|
|
|
48
|
-
response = await self._request(
|
|
49
|
-
"GET", Endpoint.INCIDENTS, params=params or None
|
|
50
|
-
)
|
|
48
|
+
response = await self._request("GET", Endpoint.INCIDENTS, params=params or None)
|
|
51
49
|
return parse_list(unwrap_list(response, "incidents"), Incident, "incident")
|
|
52
50
|
|
|
53
51
|
async def get_incident(self, incident_id: str) -> Incident:
|
|
@@ -114,9 +112,7 @@ class AsyncIncidentsMixin(_AsyncClientProtocol):
|
|
|
114
112
|
validate_id(incident_id, "incident_id")
|
|
115
113
|
payload = update.model_dump(exclude_none=True, by_alias=True)
|
|
116
114
|
response = expect_dict(
|
|
117
|
-
await self._request(
|
|
118
|
-
"PUT", f"{Endpoint.INCIDENTS}/{incident_id}", json=payload
|
|
119
|
-
),
|
|
115
|
+
await self._request("PUT", f"{Endpoint.INCIDENTS}/{incident_id}", json=payload),
|
|
120
116
|
"update_incident",
|
|
121
117
|
)
|
|
122
118
|
return Incident.model_validate(response)
|
|
@@ -145,9 +141,7 @@ class AsyncIncidentsMixin(_AsyncClientProtocol):
|
|
|
145
141
|
await self._request("POST", url, json=payload)
|
|
146
142
|
return await self.get_incident(incident_id)
|
|
147
143
|
|
|
148
|
-
async def resolve_incident(
|
|
149
|
-
self, incident_id: str, message: str | None = None
|
|
150
|
-
) -> Incident:
|
|
144
|
+
async def resolve_incident(self, incident_id: str, message: str | None = None) -> Incident:
|
|
151
145
|
"""Resolve an incident.
|
|
152
146
|
|
|
153
147
|
Args:
|
|
@@ -41,9 +41,7 @@ class AsyncMaintenanceMixin(_AsyncClientProtocol):
|
|
|
41
41
|
if status:
|
|
42
42
|
params["status"] = status
|
|
43
43
|
|
|
44
|
-
response = await self._request(
|
|
45
|
-
"GET", Endpoint.MAINTENANCE, params=params or None
|
|
46
|
-
)
|
|
44
|
+
response = await self._request("GET", Endpoint.MAINTENANCE, params=params or None)
|
|
47
45
|
|
|
48
46
|
raw = unwrap_list(response, "maintenanceWindows")
|
|
49
47
|
if not raw and isinstance(response, dict) and "maintenance" in response:
|
|
@@ -64,9 +62,7 @@ class AsyncMaintenanceMixin(_AsyncClientProtocol):
|
|
|
64
62
|
HyperpingNotFoundError: If maintenance not found
|
|
65
63
|
"""
|
|
66
64
|
validate_id(maintenance_id, "maintenance_id")
|
|
67
|
-
response = await self._request(
|
|
68
|
-
"GET", f"{Endpoint.MAINTENANCE}/{maintenance_id}"
|
|
69
|
-
)
|
|
65
|
+
response = await self._request("GET", f"{Endpoint.MAINTENANCE}/{maintenance_id}")
|
|
70
66
|
return Maintenance.model_validate(expect_dict(response, "get_maintenance"))
|
|
71
67
|
|
|
72
68
|
async def create_maintenance(self, maintenance: MaintenanceCreate) -> Maintenance:
|
|
@@ -127,9 +123,7 @@ class AsyncMaintenanceMixin(_AsyncClientProtocol):
|
|
|
127
123
|
payload.update(partial)
|
|
128
124
|
|
|
129
125
|
response = expect_dict(
|
|
130
|
-
await self._request(
|
|
131
|
-
"PUT", f"{Endpoint.MAINTENANCE}/{maintenance_id}", json=payload
|
|
132
|
-
),
|
|
126
|
+
await self._request("PUT", f"{Endpoint.MAINTENANCE}/{maintenance_id}", json=payload),
|
|
133
127
|
"update_maintenance",
|
|
134
128
|
)
|
|
135
129
|
return Maintenance.model_validate(response)
|
|
@@ -187,16 +187,3 @@ class AsyncMonitorsMixin(_AsyncClientProtocol):
|
|
|
187
187
|
if r.uuid == monitor_id:
|
|
188
188
|
return r
|
|
189
189
|
raise HyperpingNotFoundError(f"No report found for monitor: {monitor_id}")
|
|
190
|
-
|
|
191
|
-
async def search_monitors_by_name(self, query: str) -> list[Monitor]:
|
|
192
|
-
"""Search monitors by name (case-insensitive substring match)."""
|
|
193
|
-
if not query:
|
|
194
|
-
return []
|
|
195
|
-
try:
|
|
196
|
-
result = await self._request(
|
|
197
|
-
"GET", f"{Endpoint.MONITORS}/search", params={"query": query}
|
|
198
|
-
)
|
|
199
|
-
except HyperpingNotFoundError:
|
|
200
|
-
return []
|
|
201
|
-
items = result if isinstance(result, list) else []
|
|
202
|
-
return parse_list(items, Monitor, "monitor")
|
|
@@ -19,7 +19,6 @@ from hyperping._utils import (
|
|
|
19
19
|
from hyperping.endpoints import Endpoint
|
|
20
20
|
from hyperping.exceptions import HyperpingNotFoundError
|
|
21
21
|
from hyperping.models import Outage, OutageAction
|
|
22
|
-
from hyperping.models._outage_models import OutageTimeline, OutageTimelineEvent
|
|
23
22
|
|
|
24
23
|
logger = logging.getLogger(__name__)
|
|
25
24
|
|
|
@@ -206,33 +205,3 @@ class AsyncOutagesMixin(_AsyncClientProtocol):
|
|
|
206
205
|
validate_id(outage_id, "outage_id")
|
|
207
206
|
result = await self._request("GET", f"{Endpoint.OUTAGES}/{outage_id}")
|
|
208
207
|
return Outage.model_validate(expect_dict(result, "get_outage"))
|
|
209
|
-
|
|
210
|
-
async def get_outage_timeline(self, outage_id: str) -> OutageTimeline:
|
|
211
|
-
"""Get the lifecycle timeline for an outage."""
|
|
212
|
-
validate_id(outage_id, "outage_id")
|
|
213
|
-
result = await self._request("GET", f"{Endpoint.OUTAGES}/{outage_id}/timeline")
|
|
214
|
-
data = expect_dict(result, "get_outage_timeline")
|
|
215
|
-
raw_events = data.get("events", [])
|
|
216
|
-
events = parse_list(raw_events, OutageTimelineEvent, "timeline_event")
|
|
217
|
-
return OutageTimeline.model_validate({"outageUuid": outage_id, "events": events})
|
|
218
|
-
|
|
219
|
-
async def get_monitor_outages(
|
|
220
|
-
self,
|
|
221
|
-
monitor_uuid: str,
|
|
222
|
-
page: int | None = None,
|
|
223
|
-
status: str = "all",
|
|
224
|
-
) -> list[Outage]:
|
|
225
|
-
"""Get outages scoped to a single monitor."""
|
|
226
|
-
validate_id(monitor_uuid, "monitor_uuid")
|
|
227
|
-
params: dict[str, Any] = {
|
|
228
|
-
"monitor_uuid": monitor_uuid,
|
|
229
|
-
"status": status,
|
|
230
|
-
}
|
|
231
|
-
if page is not None:
|
|
232
|
-
params["page"] = page
|
|
233
|
-
try:
|
|
234
|
-
result = await self._request("GET", Endpoint.OUTAGES, params=params)
|
|
235
|
-
except HyperpingNotFoundError:
|
|
236
|
-
return []
|
|
237
|
-
items = result if isinstance(result, list) else []
|
|
238
|
-
return parse_list(items, Outage, "outage")
|
|
@@ -69,8 +69,12 @@ class AsyncStatusPagesMixin(_AsyncClientProtocol):
|
|
|
69
69
|
|
|
70
70
|
try:
|
|
71
71
|
return await collect_all_pages_async(
|
|
72
|
-
self._request,
|
|
73
|
-
|
|
72
|
+
self._request,
|
|
73
|
+
Endpoint.STATUSPAGES,
|
|
74
|
+
"statuspages",
|
|
75
|
+
params or None,
|
|
76
|
+
StatusPage,
|
|
77
|
+
"status page",
|
|
74
78
|
)
|
|
75
79
|
except HyperpingNotFoundError:
|
|
76
80
|
logger.debug("Status pages endpoint not available (404)")
|
|
@@ -131,9 +135,7 @@ class AsyncStatusPagesMixin(_AsyncClientProtocol):
|
|
|
131
135
|
validate_id(status_page_id, "status_page_id")
|
|
132
136
|
payload = update.model_dump(exclude_none=True, by_alias=True)
|
|
133
137
|
response = expect_dict(
|
|
134
|
-
await self._request(
|
|
135
|
-
"PUT", f"{Endpoint.STATUSPAGES}/{status_page_id}", json=payload
|
|
136
|
-
),
|
|
138
|
+
await self._request("PUT", f"{Endpoint.STATUSPAGES}/{status_page_id}", json=payload),
|
|
137
139
|
"update_status_page",
|
|
138
140
|
)
|
|
139
141
|
return StatusPage.model_validate(response)
|
|
@@ -189,13 +191,15 @@ class AsyncStatusPagesMixin(_AsyncClientProtocol):
|
|
|
189
191
|
)
|
|
190
192
|
|
|
191
193
|
return await collect_all_pages_async(
|
|
192
|
-
self._request,
|
|
193
|
-
|
|
194
|
+
self._request,
|
|
195
|
+
endpoint,
|
|
196
|
+
"subscribers",
|
|
197
|
+
params or None,
|
|
198
|
+
StatusPageSubscriber,
|
|
199
|
+
"subscriber",
|
|
194
200
|
)
|
|
195
201
|
|
|
196
|
-
async def add_subscriber(
|
|
197
|
-
self, status_page_id: str, email: str
|
|
198
|
-
) -> StatusPageSubscriber:
|
|
202
|
+
async def add_subscriber(self, status_page_id: str, email: str) -> StatusPageSubscriber:
|
|
199
203
|
"""Add a subscriber to a status page.
|
|
200
204
|
|
|
201
205
|
Args:
|
|
@@ -225,9 +229,7 @@ class AsyncStatusPagesMixin(_AsyncClientProtocol):
|
|
|
225
229
|
)
|
|
226
230
|
return StatusPageSubscriber.model_validate(response)
|
|
227
231
|
|
|
228
|
-
async def remove_subscriber(
|
|
229
|
-
self, status_page_id: str, subscriber_id: str
|
|
230
|
-
) -> None:
|
|
232
|
+
async def remove_subscriber(self, status_page_id: str, subscriber_id: str) -> None:
|
|
231
233
|
"""Remove a subscriber from a status page.
|
|
232
234
|
|
|
233
235
|
Args:
|
|
@@ -48,7 +48,9 @@ class IncidentsMixin(_ClientProtocol):
|
|
|
48
48
|
params["status"] = status
|
|
49
49
|
|
|
50
50
|
response = self._request(
|
|
51
|
-
"GET",
|
|
51
|
+
"GET",
|
|
52
|
+
Endpoint.INCIDENTS,
|
|
53
|
+
params=params or None, # M20
|
|
52
54
|
)
|
|
53
55
|
return parse_list(unwrap_list(response, "incidents"), Incident, "incident")
|
|
54
56
|
|
|
@@ -28,7 +28,4 @@ def sanitize_for_log(data: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
|
28
28
|
"""
|
|
29
29
|
if data is None:
|
|
30
30
|
return None
|
|
31
|
-
return {
|
|
32
|
-
k: "[REDACTED]" if k.lower() in _SENSITIVE_LOG_KEYS else v
|
|
33
|
-
for k, v in data.items()
|
|
34
|
-
}
|
|
31
|
+
return {k: "[REDACTED]" if k.lower() in _SENSITIVE_LOG_KEYS else v for k, v in data.items()}
|
|
@@ -43,7 +43,9 @@ class MaintenanceMixin(_ClientProtocol):
|
|
|
43
43
|
params["status"] = status
|
|
44
44
|
|
|
45
45
|
response = self._request(
|
|
46
|
-
"GET",
|
|
46
|
+
"GET",
|
|
47
|
+
Endpoint.MAINTENANCE,
|
|
48
|
+
params=params or None, # M20
|
|
47
49
|
)
|
|
48
50
|
|
|
49
51
|
# API returns {"maintenanceWindows": [...]} as of current version
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 transport for the Hyperping MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from pydantic import SecretStr
|
|
10
|
+
|
|
11
|
+
from hyperping.exceptions import HyperpingAPIError, HyperpingAuthError
|
|
12
|
+
|
|
13
|
+
MCP_URL = "https://api.hyperping.io/v1/mcp"
|
|
14
|
+
_PROTOCOL_VERSION = "2025-03-26"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class McpTransport:
|
|
18
|
+
"""Low-level JSON-RPC 2.0 client for the Hyperping MCP server.
|
|
19
|
+
|
|
20
|
+
The MCP server exposes tools not available via the REST API: on-call
|
|
21
|
+
schedules, anomalies, alerts, integrations, probe logs, and more.
|
|
22
|
+
|
|
23
|
+
Uses the same Bearer token API key as the REST client.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
api_key: str | SecretStr,
|
|
29
|
+
base_url: str = MCP_URL,
|
|
30
|
+
timeout: float = 30.0,
|
|
31
|
+
) -> None:
|
|
32
|
+
token = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
|
|
33
|
+
self._url = base_url.rstrip("/")
|
|
34
|
+
self._client = httpx.Client(
|
|
35
|
+
headers={
|
|
36
|
+
"Authorization": f"Bearer {token}",
|
|
37
|
+
"Content-Type": "application/json",
|
|
38
|
+
"Accept": "application/json, text/event-stream",
|
|
39
|
+
},
|
|
40
|
+
timeout=timeout,
|
|
41
|
+
)
|
|
42
|
+
self._initialized = False
|
|
43
|
+
self._request_id = 0
|
|
44
|
+
|
|
45
|
+
def _next_id(self) -> int:
|
|
46
|
+
self._request_id += 1
|
|
47
|
+
return self._request_id
|
|
48
|
+
|
|
49
|
+
def _send_rpc(
|
|
50
|
+
self,
|
|
51
|
+
method: str,
|
|
52
|
+
params: dict[str, Any] | None = None,
|
|
53
|
+
*,
|
|
54
|
+
is_notification: bool = False,
|
|
55
|
+
) -> dict[str, Any] | None:
|
|
56
|
+
payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
|
|
57
|
+
if params is not None:
|
|
58
|
+
payload["params"] = params
|
|
59
|
+
if not is_notification:
|
|
60
|
+
payload["id"] = self._next_id()
|
|
61
|
+
|
|
62
|
+
resp = self._client.post(self._url, content=json.dumps(payload))
|
|
63
|
+
|
|
64
|
+
if resp.status_code == 401:
|
|
65
|
+
raise HyperpingAuthError("Invalid or expired API key")
|
|
66
|
+
if resp.status_code == 202:
|
|
67
|
+
return None # Notification accepted
|
|
68
|
+
if resp.status_code != 200:
|
|
69
|
+
raise HyperpingAPIError(
|
|
70
|
+
f"MCP server returned HTTP {resp.status_code}",
|
|
71
|
+
status_code=resp.status_code,
|
|
72
|
+
response_body={"raw": resp.text[:500]},
|
|
73
|
+
)
|
|
74
|
+
if is_notification:
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
data = resp.json()
|
|
78
|
+
if "error" in data:
|
|
79
|
+
err = data["error"]
|
|
80
|
+
raise HyperpingAPIError(
|
|
81
|
+
f"MCP error {err.get('code', '?')}: {err.get('message', 'unknown')}",
|
|
82
|
+
status_code=resp.status_code,
|
|
83
|
+
response_body=err,
|
|
84
|
+
)
|
|
85
|
+
return data # type: ignore[no-any-return]
|
|
86
|
+
|
|
87
|
+
def initialize(self) -> dict[str, Any]:
|
|
88
|
+
"""Perform MCP handshake. Called automatically on first tool call."""
|
|
89
|
+
result = self._send_rpc(
|
|
90
|
+
"initialize",
|
|
91
|
+
{
|
|
92
|
+
"protocolVersion": _PROTOCOL_VERSION,
|
|
93
|
+
"capabilities": {},
|
|
94
|
+
"clientInfo": {"name": "hyperping-python", "version": "1.4.0"},
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
self._send_rpc("notifications/initialized", is_notification=True)
|
|
98
|
+
self._initialized = True
|
|
99
|
+
return result.get("result", {}) if result else {}
|
|
100
|
+
|
|
101
|
+
def call_tool(
|
|
102
|
+
self,
|
|
103
|
+
tool_name: str,
|
|
104
|
+
arguments: dict[str, Any] | None = None,
|
|
105
|
+
) -> Any:
|
|
106
|
+
"""Call an MCP tool and return parsed response data.
|
|
107
|
+
|
|
108
|
+
Auto-initializes on first call. Extracts and parses the JSON
|
|
109
|
+
string from ``result.content[0].text``.
|
|
110
|
+
"""
|
|
111
|
+
if not self._initialized:
|
|
112
|
+
self.initialize()
|
|
113
|
+
|
|
114
|
+
result = self._send_rpc(
|
|
115
|
+
"tools/call",
|
|
116
|
+
{"name": tool_name, "arguments": arguments or {}},
|
|
117
|
+
)
|
|
118
|
+
if result is None:
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
content = result.get("result", {}).get("content", [])
|
|
122
|
+
if not content:
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
text = content[0].get("text", "")
|
|
126
|
+
if not text:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
return json.loads(text)
|
|
131
|
+
except json.JSONDecodeError as exc:
|
|
132
|
+
raise HyperpingAPIError(
|
|
133
|
+
f"Failed to parse MCP tool response: {exc}",
|
|
134
|
+
status_code=200,
|
|
135
|
+
response_body={"raw": text[:500]},
|
|
136
|
+
) from exc
|
|
137
|
+
|
|
138
|
+
def close(self) -> None:
|
|
139
|
+
self._client.close()
|
|
140
|
+
|
|
141
|
+
def __enter__(self) -> McpTransport:
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, *args: object) -> None:
|
|
145
|
+
self.close()
|
|
@@ -213,23 +213,3 @@ class MonitorsMixin(_ClientProtocol):
|
|
|
213
213
|
if r.uuid == monitor_id:
|
|
214
214
|
return r
|
|
215
215
|
raise HyperpingNotFoundError(f"No report found for monitor: {monitor_id}")
|
|
216
|
-
|
|
217
|
-
def search_monitors_by_name(self, query: str) -> list[Monitor]:
|
|
218
|
-
"""Search monitors by name (case-insensitive substring match).
|
|
219
|
-
|
|
220
|
-
Args:
|
|
221
|
-
query: Search string to match against monitor names and URLs.
|
|
222
|
-
|
|
223
|
-
Returns:
|
|
224
|
-
List of matching :class:`~hyperping.models.Monitor` objects.
|
|
225
|
-
Returns empty list on 404 or no matches.
|
|
226
|
-
"""
|
|
227
|
-
if not query:
|
|
228
|
-
return []
|
|
229
|
-
try:
|
|
230
|
-
# Path is speculative; derived from MCP tool name.
|
|
231
|
-
result = self._request("GET", f"{Endpoint.MONITORS}/search", params={"query": query})
|
|
232
|
-
except HyperpingNotFoundError:
|
|
233
|
-
return []
|
|
234
|
-
items = result if isinstance(result, list) else []
|
|
235
|
-
return parse_list(items, Monitor, "monitor")
|