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.
Files changed (74) hide show
  1. {hyperping-1.3.0 → hyperping-1.4.0}/PKG-INFO +1 -1
  2. {hyperping-1.3.0 → hyperping-1.4.0}/pyproject.toml +1 -1
  3. hyperping-1.4.0/scripts/verify_endpoints.py +166 -0
  4. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/__init__.py +5 -0
  5. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_client.py +0 -8
  6. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_incidents_mixin.py +3 -9
  7. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_maintenance_mixin.py +3 -9
  8. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_monitors_mixin.py +0 -13
  9. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_outages_mixin.py +0 -31
  10. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_statuspages_mixin.py +15 -13
  11. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_incidents_mixin.py +3 -1
  12. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_internals.py +1 -4
  13. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_maintenance_mixin.py +3 -1
  14. hyperping-1.4.0/src/hyperping/_mcp_transport.py +145 -0
  15. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_monitors_mixin.py +0 -20
  16. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_outages_mixin.py +0 -55
  17. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_statuspages_mixin.py +12 -4
  18. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_utils.py +2 -5
  19. hyperping-1.4.0/src/hyperping/_version.py +1 -0
  20. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/client.py +0 -8
  21. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/endpoints.py +3 -75
  22. hyperping-1.4.0/src/hyperping/mcp_client.py +221 -0
  23. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_monitor_models.py +3 -8
  24. {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_async_client.py +14 -42
  25. {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_async_preexisting.py +3 -9
  26. {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_healthchecks.py +8 -21
  27. {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_incidents.py +1 -3
  28. hyperping-1.4.0/tests/unit/test_mcp_client.py +161 -0
  29. hyperping-1.4.0/tests/unit/test_mcp_transport.py +161 -0
  30. {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_monitors.py +5 -58
  31. {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_outages.py +4 -101
  32. {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_pagination.py +1 -3
  33. {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_statuspages.py +11 -15
  34. hyperping-1.3.0/src/hyperping/_async_integrations_mixin.py +0 -26
  35. hyperping-1.3.0/src/hyperping/_async_observability_mixin.py +0 -65
  36. hyperping-1.3.0/src/hyperping/_async_oncall_mixin.py +0 -53
  37. hyperping-1.3.0/src/hyperping/_async_reporting_mixin.py +0 -39
  38. hyperping-1.3.0/src/hyperping/_integrations_mixin.py +0 -41
  39. hyperping-1.3.0/src/hyperping/_observability_mixin.py +0 -102
  40. hyperping-1.3.0/src/hyperping/_oncall_mixin.py +0 -88
  41. hyperping-1.3.0/src/hyperping/_reporting_mixin.py +0 -71
  42. hyperping-1.3.0/src/hyperping/_version.py +0 -1
  43. hyperping-1.3.0/tests/unit/test_async_new_mixins.py +0 -262
  44. hyperping-1.3.0/tests/unit/test_integrations.py +0 -98
  45. hyperping-1.3.0/tests/unit/test_observability.py +0 -203
  46. hyperping-1.3.0/tests/unit/test_oncall.py +0 -217
  47. hyperping-1.3.0/tests/unit/test_reporting.py +0 -140
  48. {hyperping-1.3.0 → hyperping-1.4.0}/.gitignore +0 -0
  49. {hyperping-1.3.0 → hyperping-1.4.0}/CHANGELOG.md +0 -0
  50. {hyperping-1.3.0 → hyperping-1.4.0}/CONTRIBUTING.md +0 -0
  51. {hyperping-1.3.0 → hyperping-1.4.0}/LICENSE +0 -0
  52. {hyperping-1.3.0 → hyperping-1.4.0}/README.md +0 -0
  53. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  54. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_circuit_breaker.py +0 -0
  55. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_healthchecks_mixin.py +0 -0
  56. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_monitor_constants.py +0 -0
  57. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/_protocols.py +0 -0
  58. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/exceptions.py +0 -0
  59. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/__init__.py +0 -0
  60. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_healthcheck_models.py +0 -0
  61. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_incident_models.py +0 -0
  62. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_integration_models.py +0 -0
  63. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_maintenance_models.py +0 -0
  64. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_observability_models.py +0 -0
  65. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_oncall_models.py +0 -0
  66. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_outage_models.py +0 -0
  67. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_reporting_models.py +0 -0
  68. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/models/_statuspage_models.py +0 -0
  69. {hyperping-1.3.0 → hyperping-1.4.0}/src/hyperping/py.typed +0 -0
  70. {hyperping-1.3.0 → hyperping-1.4.0}/tests/__init__.py +0 -0
  71. {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/__init__.py +0 -0
  72. {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/conftest.py +0 -0
  73. {hyperping-1.3.0 → hyperping-1.4.0}/tests/unit/test_maintenance.py +0 -0
  74. {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.0
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.3.0"
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, Endpoint.STATUSPAGES, "statuspages",
73
- params or None, StatusPage, "status page",
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, endpoint, "subscribers",
193
- params or None, StatusPageSubscriber, "subscriber",
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", Endpoint.INCIDENTS, params=params or None # M20
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", Endpoint.MAINTENANCE, params=params or None # M20
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")