hyperping 1.6.0__tar.gz → 1.7.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.6.0 → hyperping-1.7.0}/.gitignore +1 -0
  2. {hyperping-1.6.0 → hyperping-1.7.0}/CHANGELOG.md +37 -0
  3. {hyperping-1.6.0 → hyperping-1.7.0}/PKG-INFO +54 -1
  4. {hyperping-1.6.0 → hyperping-1.7.0}/README.md +53 -0
  5. {hyperping-1.6.0 → hyperping-1.7.0}/SECURITY.md +3 -2
  6. {hyperping-1.6.0 → hyperping-1.7.0}/pyproject.toml +2 -2
  7. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_mcp_client.py +21 -0
  8. hyperping-1.7.0/src/hyperping/_async_mcp_transport.py +308 -0
  9. hyperping-1.7.0/src/hyperping/_mcp_transport.py +309 -0
  10. hyperping-1.7.0/src/hyperping/_version.py +1 -0
  11. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/exceptions.py +16 -3
  12. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/mcp_client.py +23 -0
  13. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_async_mcp_client.py +46 -0
  14. hyperping-1.7.0/tests/unit/test_async_mcp_transport.py +806 -0
  15. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_mcp_client.py +88 -0
  16. hyperping-1.7.0/tests/unit/test_mcp_transport.py +959 -0
  17. hyperping-1.6.0/src/hyperping/_async_mcp_transport.py +0 -199
  18. hyperping-1.6.0/src/hyperping/_mcp_transport.py +0 -201
  19. hyperping-1.6.0/src/hyperping/_version.py +0 -1
  20. hyperping-1.6.0/tests/unit/test_async_mcp_transport.py +0 -302
  21. hyperping-1.6.0/tests/unit/test_mcp_transport.py +0 -333
  22. {hyperping-1.6.0 → hyperping-1.7.0}/CONTRIBUTING.md +0 -0
  23. {hyperping-1.6.0 → hyperping-1.7.0}/LICENSE +0 -0
  24. {hyperping-1.6.0 → hyperping-1.7.0}/scripts/verify_endpoints.py +0 -0
  25. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/__init__.py +0 -0
  26. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_client.py +0 -0
  27. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_healthchecks_mixin.py +0 -0
  28. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_incidents_mixin.py +0 -0
  29. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_maintenance_mixin.py +0 -0
  30. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_monitors_mixin.py +0 -0
  31. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_outages_mixin.py +0 -0
  32. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_async_statuspages_mixin.py +0 -0
  33. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_circuit_breaker.py +0 -0
  34. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_healthchecks_mixin.py +0 -0
  35. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_incidents_mixin.py +0 -0
  36. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_internals.py +0 -0
  37. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_maintenance_mixin.py +0 -0
  38. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_monitor_constants.py +0 -0
  39. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_monitors_mixin.py +0 -0
  40. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_outages_mixin.py +0 -0
  41. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_protocols.py +0 -0
  42. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_statuspages_mixin.py +0 -0
  43. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/_utils.py +0 -0
  44. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/client.py +0 -0
  45. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/endpoints.py +0 -0
  46. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/__init__.py +0 -0
  47. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_healthcheck_models.py +0 -0
  48. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_incident_models.py +0 -0
  49. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_integration_models.py +0 -0
  50. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_maintenance_models.py +0 -0
  51. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_monitor_models.py +0 -0
  52. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_observability_models.py +0 -0
  53. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_oncall_models.py +0 -0
  54. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_outage_models.py +0 -0
  55. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_reporting_models.py +0 -0
  56. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/models/_statuspage_models.py +0 -0
  57. {hyperping-1.6.0 → hyperping-1.7.0}/src/hyperping/py.typed +0 -0
  58. {hyperping-1.6.0 → hyperping-1.7.0}/tests/__init__.py +0 -0
  59. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/__init__.py +0 -0
  60. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/conftest.py +0 -0
  61. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_async_client.py +0 -0
  62. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_async_preexisting.py +0 -0
  63. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_client_coverage.py +0 -0
  64. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_healthchecks.py +0 -0
  65. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_incidents.py +0 -0
  66. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_maintenance.py +0 -0
  67. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_monitors.py +0 -0
  68. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_outages.py +0 -0
  69. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_pagination.py +0 -0
  70. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_per_endpoint_circuit_breaker.py +0 -0
  71. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_sdk_surface.py +0 -0
  72. {hyperping-1.6.0 → hyperping-1.7.0}/tests/unit/test_statuspages.py +0 -0
@@ -36,3 +36,4 @@ htmlcov/
36
36
  # Local dev tooling
37
37
  .claude/
38
38
  dist/
39
+ .worktrees/
@@ -5,6 +5,43 @@ 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.7.0] - 2026-05-21
9
+
10
+ ### Added
11
+
12
+ - `ensure_initialized()` on `HyperpingMcpClient` and `AsyncHyperpingMcpClient` for
13
+ startup health checks. Performs the MCP handshake now if it hasn't happened yet
14
+ and raises `HyperpingRateLimitError` if the server's `initialize` cap is hit.
15
+ - New "MCP rate limits and connection lifecycle" section in README documenting
16
+ Hyperping's stateless MCP server, the undocumented `initialize` cap, and the
17
+ recommended client lifetime per process.
18
+
19
+ ### Fixed
20
+
21
+ - MCP rate-limit errors that the server returns as HTTP 200 with JSON-RPC
22
+ `error.code = -32000` (notably the `initialize` per-minute cap) are now
23
+ classified as `HyperpingRateLimitError` with `retry_after` parsed from the
24
+ message, instead of a generic `HyperpingAPIError`. Existing HTTP 429 handling is
25
+ unchanged.
26
+ - After a rate-limit on `initialize`, the MCP transport latches a cool-off so
27
+ subsequent `call_tool` invocations short-circuit with `HyperpingRateLimitError`
28
+ until the advertised `retry_after` elapses, instead of issuing further HTTP
29
+ requests that would burn more slots from the bucket.
30
+ - TOCTOU race in lazy `initialize` where two concurrent first calls on the same
31
+ `HyperpingMcpClient` could each POST `initialize`. The handshake is now
32
+ performed under a dedicated lock with a double-checked flag, including a
33
+ lockless fast path so post-handshake `call_tool` does not contend on it.
34
+ - Cool-off short-circuit now preserves the originating status code (200 for
35
+ JSON-RPC `-32000`, 429 for HTTP 429) so callers can distinguish buckets, and
36
+ `retry_after` uses `math.ceil` to avoid over-reporting by one second.
37
+ - JSON-RPC rate-limit signals returned on the `notifications/initialized` leg
38
+ are now classified as `HyperpingRateLimitError` (previously they were
39
+ silently treated as a successful notification).
40
+ - Rate-limit detection requires the message to contain `"rate limit exceeded"`
41
+ (the observed phrasing) to avoid false positives on unrelated server messages
42
+ that happen to mention `"rate limit"`. The `Retry-After` parser now also
43
+ accepts `Retry-After:` and `retry after N seconds` variants.
44
+
8
45
  ## [1.6.0] - 2026-05-06
9
46
 
10
47
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperping
3
- Version: 1.6.0
3
+ Version: 1.7.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
@@ -230,6 +230,59 @@ The MCP client uses the same API key as `HyperpingClient`. All methods return
230
230
  plain dicts/lists; use the exported Pydantic models (e.g., `OnCallSchedule`,
231
231
  `EscalationPolicy`) for validation if needed.
232
232
 
233
+ ### MCP rate limits and connection lifecycle
234
+
235
+ The Hyperping MCP server (`https://api.hyperping.io/v1/mcp`) is
236
+ [documented by Hyperping as stateless over HTTP](https://hyperping.com/mcp)
237
+ and rate-limits per API key. The publicly documented limit is 300 requests per
238
+ minute shared with the REST API
239
+ ([rate-limit docs](https://hyperping.com/docs/monitoring/api-rate-limits)), but
240
+ the server also enforces a separate, undocumented cap on the `initialize`
241
+ handshake (observed around 5/minute). Because every new `HyperpingMcpClient`
242
+ instance must perform the MCP `initialize` handshake on its first call,
243
+ instantiating the client in a hot path or running several short-lived processes
244
+ against one key will trip this cap.
245
+
246
+ Operational guidance:
247
+
248
+ - **Create one `HyperpingMcpClient` per process and reuse it.** Do not instantiate
249
+ it inside a loop. The first call performs the handshake; subsequent calls reuse
250
+ it for the life of the client.
251
+ - **Catch `HyperpingRateLimitError` and honour `retry_after`.** Rate-limit signals
252
+ arrive two ways: as HTTP 429 (with a standard `Retry-After` header) and as a
253
+ JSON-RPC server error (`code: -32000`, HTTP 200) on `initialize`. Both surface as
254
+ `HyperpingRateLimitError` with `retry_after` parsed from whichever signal was
255
+ used. The `status_code` attribute is `429` or `200`, matching the underlying
256
+ signal; cool-off short-circuits preserve the originating status code so callers
257
+ can disambiguate the two buckets.
258
+ - **Use `ensure_initialized()` for startup health checks.** Calling it once on
259
+ service boot lets you fail fast if the key is already at the `initialize` cap,
260
+ instead of failing on the first business call.
261
+ - **Several workloads on one key collide on the `initialize` cap.** A weekly cron,
262
+ a watchdog daemon, and a developer running the CLI cannot all warm up the same
263
+ API key inside one minute. Use one long-lived process per workload, or separate
264
+ API keys per workload if your plan allows.
265
+ - **After a rate-limit on `initialize`, the SDK latches a cool-off** so that
266
+ subsequent `call_tool` invocations on the same client fail fast with
267
+ `HyperpingRateLimitError` (no extra HTTP traffic) until `retry_after` elapses.
268
+ This prevents accidentally burning more slots from the bucket. The latch is
269
+ per-`HyperpingMcpClient` instance and per-process; it does not coordinate
270
+ across separate Python processes sharing the same API key, so multi-process
271
+ setups still need the workload-separation advice above.
272
+
273
+ ```python
274
+ from hyperping import HyperpingMcpClient, HyperpingRateLimitError
275
+
276
+ mcp = HyperpingMcpClient(api_key="sk_...")
277
+ try:
278
+ mcp.ensure_initialized()
279
+ except HyperpingRateLimitError as e:
280
+ print(f"MCP cold-start rate-limited; retry in {e.retry_after}s")
281
+ raise
282
+
283
+ summary = mcp.get_status_summary()
284
+ ```
285
+
233
286
  ### Healthchecks
234
287
 
235
288
  ```python
@@ -193,6 +193,59 @@ The MCP client uses the same API key as `HyperpingClient`. All methods return
193
193
  plain dicts/lists; use the exported Pydantic models (e.g., `OnCallSchedule`,
194
194
  `EscalationPolicy`) for validation if needed.
195
195
 
196
+ ### MCP rate limits and connection lifecycle
197
+
198
+ The Hyperping MCP server (`https://api.hyperping.io/v1/mcp`) is
199
+ [documented by Hyperping as stateless over HTTP](https://hyperping.com/mcp)
200
+ and rate-limits per API key. The publicly documented limit is 300 requests per
201
+ minute shared with the REST API
202
+ ([rate-limit docs](https://hyperping.com/docs/monitoring/api-rate-limits)), but
203
+ the server also enforces a separate, undocumented cap on the `initialize`
204
+ handshake (observed around 5/minute). Because every new `HyperpingMcpClient`
205
+ instance must perform the MCP `initialize` handshake on its first call,
206
+ instantiating the client in a hot path or running several short-lived processes
207
+ against one key will trip this cap.
208
+
209
+ Operational guidance:
210
+
211
+ - **Create one `HyperpingMcpClient` per process and reuse it.** Do not instantiate
212
+ it inside a loop. The first call performs the handshake; subsequent calls reuse
213
+ it for the life of the client.
214
+ - **Catch `HyperpingRateLimitError` and honour `retry_after`.** Rate-limit signals
215
+ arrive two ways: as HTTP 429 (with a standard `Retry-After` header) and as a
216
+ JSON-RPC server error (`code: -32000`, HTTP 200) on `initialize`. Both surface as
217
+ `HyperpingRateLimitError` with `retry_after` parsed from whichever signal was
218
+ used. The `status_code` attribute is `429` or `200`, matching the underlying
219
+ signal; cool-off short-circuits preserve the originating status code so callers
220
+ can disambiguate the two buckets.
221
+ - **Use `ensure_initialized()` for startup health checks.** Calling it once on
222
+ service boot lets you fail fast if the key is already at the `initialize` cap,
223
+ instead of failing on the first business call.
224
+ - **Several workloads on one key collide on the `initialize` cap.** A weekly cron,
225
+ a watchdog daemon, and a developer running the CLI cannot all warm up the same
226
+ API key inside one minute. Use one long-lived process per workload, or separate
227
+ API keys per workload if your plan allows.
228
+ - **After a rate-limit on `initialize`, the SDK latches a cool-off** so that
229
+ subsequent `call_tool` invocations on the same client fail fast with
230
+ `HyperpingRateLimitError` (no extra HTTP traffic) until `retry_after` elapses.
231
+ This prevents accidentally burning more slots from the bucket. The latch is
232
+ per-`HyperpingMcpClient` instance and per-process; it does not coordinate
233
+ across separate Python processes sharing the same API key, so multi-process
234
+ setups still need the workload-separation advice above.
235
+
236
+ ```python
237
+ from hyperping import HyperpingMcpClient, HyperpingRateLimitError
238
+
239
+ mcp = HyperpingMcpClient(api_key="sk_...")
240
+ try:
241
+ mcp.ensure_initialized()
242
+ except HyperpingRateLimitError as e:
243
+ print(f"MCP cold-start rate-limited; retry in {e.retry_after}s")
244
+ raise
245
+
246
+ summary = mcp.get_status_summary()
247
+ ```
248
+
196
249
  ### Healthchecks
197
250
 
198
251
  ```python
@@ -6,8 +6,9 @@ We release patches for security vulnerabilities for the following versions:
6
6
 
7
7
  | Version | Supported |
8
8
  | ------- | ------------------ |
9
- | 1.5.x | :white_check_mark: |
10
- | < 1.5 | :x: |
9
+ | 1.7.x | :white_check_mark: |
10
+ | 1.6.x | :white_check_mark: |
11
+ | < 1.6 | :x: |
11
12
 
12
13
  Older releases may receive a fix at maintainers' discretion when the issue is severe and an upgrade is not feasible. The latest 1.x release is always the recommended target.
13
14
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "hyperping"
7
- version = "1.6.0"
7
+ version = "1.7.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"}
@@ -51,7 +51,7 @@ Issues = "https://github.com/develeap/hyperping-python/issues"
51
51
  packages = ["src/hyperping"]
52
52
 
53
53
  [tool.hatch.build.targets.sdist]
54
- exclude = [".claude/", ".github/", "dist/", "uv.lock", "BACKLOG.md"]
54
+ exclude = [".claude/", ".github/", ".worktrees/", "dist/", "docs/plans/", "uv.lock", "BACKLOG.md"]
55
55
 
56
56
  [tool.pytest.ini_options]
57
57
  testpaths = ["tests"]
@@ -63,6 +63,27 @@ class AsyncHyperpingMcpClient:
63
63
  """Call an MCP tool via the transport."""
64
64
  return await self._transport.call_tool(tool, args or {})
65
65
 
66
+ async def ensure_initialized(self) -> None:
67
+ """Perform the MCP handshake now if it hasn't happened yet.
68
+
69
+ Async counterpart to
70
+ :meth:`hyperping.mcp_client.HyperpingMcpClient.ensure_initialized`.
71
+ Idempotent.
72
+
73
+ Raises:
74
+ HyperpingRateLimitError: If the server rate-limits ``initialize``,
75
+ either via HTTP 429 or via the JSON-RPC ``-32000`` rate-limit
76
+ payload. Inspect ``.retry_after`` to back off.
77
+ HyperpingAuthError: If the API key is invalid (HTTP 401/403).
78
+ HyperpingNotFoundError: If the MCP endpoint URL is wrong
79
+ (HTTP 404).
80
+ HyperpingValidationError: If the server rejects the handshake
81
+ payload (HTTP 400/422; unusual on initialize).
82
+ HyperpingAPIError: Any other transport-level error (HTTP 5xx,
83
+ malformed body, etc.).
84
+ """
85
+ await self._transport.initialize()
86
+
66
87
  # ==================== Context Manager ====================
67
88
 
68
89
  async def close(self) -> None:
@@ -0,0 +1,308 @@
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
+ import math
8
+ import re
9
+ import time
10
+ from typing import Any
11
+
12
+ import httpx
13
+ from pydantic import SecretStr
14
+
15
+ from hyperping._version import __version__
16
+ from hyperping.endpoints import MCP_URL
17
+ from hyperping.exceptions import (
18
+ HyperpingAPIError,
19
+ HyperpingAuthError,
20
+ HyperpingNotFoundError,
21
+ HyperpingRateLimitError,
22
+ HyperpingValidationError,
23
+ )
24
+
25
+ _PROTOCOL_VERSION = "2025-03-26"
26
+
27
+ # Tight marker: the server's observed phrasing is "rate limit exceeded ...".
28
+ # Bare "rate limit" would risk classifying messages like "rate limit
29
+ # configuration invalid" as a rate-limit error.
30
+ _MCP_RATE_LIMIT_MARKER = "rate limit exceeded"
31
+ # Accept "Retry after Ns", "Retry-After: Ns", "retry after 30 seconds", etc.
32
+ # Captures only the integer; sub-second values are floored.
33
+ _MCP_RATE_LIMIT_RETRY_AFTER_RE = re.compile(
34
+ r"retry[\s\-]after[:\s]+(\d+)",
35
+ re.IGNORECASE,
36
+ )
37
+ # Default cool-off when the server fails to advertise one.
38
+ _COOLOFF_DEFAULT_SECONDS = 30
39
+
40
+
41
+ class AsyncMcpTransport:
42
+ """Async low-level JSON-RPC 2.0 client for the Hyperping MCP server.
43
+
44
+ The MCP server exposes tools not available via the REST API: on-call
45
+ schedules, anomalies, alerts, integrations, probe logs, and more.
46
+
47
+ Uses the same Bearer token API key as the REST client.
48
+ """
49
+
50
+ def __init__(
51
+ self,
52
+ api_key: str | SecretStr,
53
+ base_url: str = MCP_URL,
54
+ timeout: float = 30.0,
55
+ max_retries: int = 2,
56
+ ) -> None:
57
+ token = api_key.get_secret_value() if isinstance(api_key, SecretStr) else api_key
58
+ self._url = base_url.rstrip("/")
59
+ self._client = httpx.AsyncClient(
60
+ headers={
61
+ "Authorization": f"Bearer {token}",
62
+ "Content-Type": "application/json",
63
+ "Accept": "application/json, text/event-stream",
64
+ },
65
+ timeout=timeout,
66
+ )
67
+ self._initialized = False
68
+ self._request_id = 0
69
+ self._lock = asyncio.Lock()
70
+ # Separate lock for the handshake so request-id increment and the
71
+ # initialize() critical section don't contend.
72
+ self._init_lock = asyncio.Lock()
73
+ # Monotonic deadline (process-local). 0.0 means no latch.
74
+ self._init_blocked_until: float = 0.0
75
+ # Status code of the original rate-limit response that armed the
76
+ # latch, propagated through short-circuit raises so callers can tell
77
+ # whether they hit HTTP 429 or HTTP 200 + JSON-RPC -32000.
78
+ self._init_blocked_status_code: int = 200
79
+ self._init_result: dict[str, Any] = {}
80
+ self._max_retries = max_retries
81
+
82
+ async def _next_id(self) -> int:
83
+ async with self._lock:
84
+ self._request_id += 1
85
+ return self._request_id
86
+
87
+ async def _send_rpc(
88
+ self,
89
+ method: str,
90
+ params: dict[str, Any] | None = None,
91
+ *,
92
+ is_notification: bool = False,
93
+ ) -> dict[str, Any] | None:
94
+ payload: dict[str, Any] = {"jsonrpc": "2.0", "method": method}
95
+ if params is not None:
96
+ payload["params"] = params
97
+ if not is_notification:
98
+ payload["id"] = await self._next_id()
99
+
100
+ resp = await self._client.post(self._url, content=json.dumps(payload))
101
+
102
+ if resp.status_code in (401, 403):
103
+ raise HyperpingAuthError("Invalid or expired API key")
104
+ if resp.status_code == 202:
105
+ return None # Notification accepted
106
+ if resp.status_code == 404:
107
+ raise HyperpingNotFoundError(
108
+ "Resource not found",
109
+ status_code=404,
110
+ )
111
+ if resp.status_code == 429:
112
+ retry_after = None
113
+ raw_retry = resp.headers.get("retry-after")
114
+ if raw_retry:
115
+ try:
116
+ retry_after = int(raw_retry)
117
+ except ValueError:
118
+ pass
119
+ raise HyperpingRateLimitError(
120
+ "Rate limit exceeded",
121
+ retry_after=retry_after,
122
+ status_code=429,
123
+ response_body={"raw": resp.text[:500]},
124
+ )
125
+ if resp.status_code in (400, 422):
126
+ raise HyperpingValidationError(
127
+ f"Validation error: HTTP {resp.status_code}",
128
+ status_code=resp.status_code,
129
+ )
130
+ if resp.status_code != 200:
131
+ raise HyperpingAPIError(
132
+ f"MCP server returned HTTP {resp.status_code}",
133
+ status_code=resp.status_code,
134
+ response_body={"raw": resp.text[:500]},
135
+ )
136
+
137
+ # HTTP 200. Parse the body so we classify JSON-RPC errors (including
138
+ # rate-limit signals) on notification responses too -- the server can
139
+ # return 200 + JSON-RPC error on a "notifications/initialized" leg.
140
+ try:
141
+ data = resp.json()
142
+ except (json.JSONDecodeError, ValueError):
143
+ if is_notification:
144
+ return None
145
+ raise HyperpingAPIError(
146
+ "MCP server returned 200 with non-JSON body",
147
+ status_code=200,
148
+ response_body={"raw": resp.text[:500]},
149
+ ) from None
150
+
151
+ if isinstance(data, dict) and "error" in data:
152
+ self._raise_for_jsonrpc_error(data["error"], resp.status_code)
153
+
154
+ if is_notification:
155
+ return None
156
+ return data # type: ignore[no-any-return]
157
+
158
+ @staticmethod
159
+ def _raise_for_jsonrpc_error(err: Any, status_code: int) -> None:
160
+ """Map a JSON-RPC ``error`` payload to a typed exception and raise it."""
161
+ if (
162
+ isinstance(err, dict)
163
+ and err.get("code") == -32000
164
+ and isinstance(err.get("message"), str)
165
+ and _MCP_RATE_LIMIT_MARKER in err["message"].lower()
166
+ ):
167
+ rl_retry_after: int | None = None
168
+ match = _MCP_RATE_LIMIT_RETRY_AFTER_RE.search(err["message"])
169
+ if match:
170
+ rl_retry_after = int(match.group(1))
171
+ raise HyperpingRateLimitError(
172
+ err["message"],
173
+ retry_after=rl_retry_after,
174
+ status_code=status_code,
175
+ response_body=err if isinstance(err, dict) else None,
176
+ )
177
+ code = err.get("code", "?") if isinstance(err, dict) else "?"
178
+ message = err.get("message", "unknown") if isinstance(err, dict) else str(err)
179
+ raise HyperpingAPIError(
180
+ f"MCP error {code}: {message}",
181
+ status_code=status_code,
182
+ response_body=err if isinstance(err, dict) else None,
183
+ )
184
+
185
+ async def initialize(self) -> dict[str, Any]:
186
+ """Async idempotent and concurrency-safe MCP handshake.
187
+
188
+ Calling this more than once on the same transport is a no-op after the
189
+ first successful handshake. While an ``initialize`` cool-off latch is
190
+ active, raises :class:`HyperpingRateLimitError` without issuing any
191
+ HTTP request.
192
+
193
+ The cool-off latch is per-transport-instance and per-process. It does
194
+ not coordinate across separate Python processes sharing the same API
195
+ key; each process keeps its own latch.
196
+ """
197
+ # Fast path: avoid lock acquisition on every call after the handshake
198
+ # has succeeded. ``_initialized`` is only assigned True under the lock
199
+ # after both legs of the handshake, so a True read here is safe.
200
+ if self._initialized:
201
+ return self._init_result
202
+ async with self._init_lock:
203
+ if self._initialized:
204
+ return self._init_result
205
+ return await self._initialize_locked()
206
+
207
+ async def _initialize_locked(self) -> dict[str, Any]:
208
+ """Perform the handshake. Assumes ``self._init_lock`` is held."""
209
+ # ``time.monotonic`` is used deliberately over ``time.time`` so the
210
+ # latch is immune to wall-clock jumps (NTP adjustments, suspend/resume).
211
+ remaining = self._init_blocked_until - time.monotonic()
212
+ if remaining > 0:
213
+ raise HyperpingRateLimitError(
214
+ "MCP initialize rate limit cool-off active; retry later",
215
+ retry_after=max(math.ceil(remaining), 1),
216
+ status_code=self._init_blocked_status_code,
217
+ )
218
+ try:
219
+ result = await self._send_rpc(
220
+ "initialize",
221
+ {
222
+ "protocolVersion": _PROTOCOL_VERSION,
223
+ "capabilities": {},
224
+ "clientInfo": {"name": "hyperping-python", "version": __version__},
225
+ },
226
+ )
227
+ await self._send_rpc("notifications/initialized", is_notification=True)
228
+ except HyperpingRateLimitError as exc:
229
+ # retry_after=None -> default cool-off; retry_after=0 -> no latch
230
+ # (the server is telling us we may retry immediately); positive
231
+ # values are honoured verbatim.
232
+ if exc.retry_after is None:
233
+ wait = _COOLOFF_DEFAULT_SECONDS
234
+ else:
235
+ wait = max(int(exc.retry_after), 0)
236
+ self._init_blocked_until = time.monotonic() + wait
237
+ self._init_blocked_status_code = exc.status_code or 200
238
+ raise
239
+ self._init_result = result.get("result", {}) if result else {}
240
+ self._init_blocked_until = 0.0
241
+ # Set last so the fast path in initialize() never returns a stale
242
+ # ``_init_result``.
243
+ self._initialized = True
244
+ return self._init_result
245
+
246
+ async def call_tool(
247
+ self,
248
+ tool_name: str,
249
+ arguments: dict[str, Any] | None = None,
250
+ ) -> Any:
251
+ """Call an MCP tool and return parsed response data.
252
+
253
+ Auto-initializes on first call. Extracts and parses the JSON
254
+ string from ``result.content[0].text``.
255
+
256
+ Retries automatically on transient HTTP server errors (500, 502, 503, 504)
257
+ up to ``max_retries`` times with exponential back-off. Rate-limit errors
258
+ (HTTP 429 or JSON-RPC -32000) are NEVER retried at this layer; they raise
259
+ :class:`HyperpingRateLimitError` immediately so callers can honour
260
+ ``retry_after``.
261
+ """
262
+ await self.initialize()
263
+
264
+ last_exc: Exception | None = None
265
+ for attempt in range(self._max_retries + 1):
266
+ try:
267
+ result = await self._send_rpc(
268
+ "tools/call",
269
+ {"name": tool_name, "arguments": arguments or {}},
270
+ )
271
+ break
272
+ except HyperpingAPIError as exc:
273
+ if exc.status_code and exc.status_code in (500, 502, 503, 504):
274
+ last_exc = exc
275
+ if attempt < self._max_retries:
276
+ await asyncio.sleep(min(2**attempt, 10))
277
+ continue
278
+ raise
279
+ else:
280
+ raise last_exc # type: ignore[misc]
281
+ if result is None:
282
+ return None
283
+
284
+ content = result.get("result", {}).get("content", [])
285
+ if not content:
286
+ return None
287
+
288
+ text = content[0].get("text", "")
289
+ if not text:
290
+ return None
291
+
292
+ try:
293
+ return json.loads(text)
294
+ except json.JSONDecodeError as exc:
295
+ raise HyperpingAPIError(
296
+ f"Failed to parse MCP tool response: {exc}",
297
+ status_code=200,
298
+ response_body={"raw": text[:500]},
299
+ ) from exc
300
+
301
+ async def close(self) -> None:
302
+ await self._client.aclose()
303
+
304
+ async def __aenter__(self) -> AsyncMcpTransport:
305
+ return self
306
+
307
+ async def __aexit__(self, *args: object) -> None:
308
+ await self.close()