From 8422fcb5e621482e4c0fada824be9b4dadb6a1f1 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 01:52:57 +0300 Subject: [PATCH 1/4] test(mcp): add failing tests for write tool methods (#139561) Cover create/update/pause/resume/delete monitor for both sync and async MCP clients. Red phase: tests fail until implementation lands. --- tests/unit/test_async_mcp_client.py | 83 +++++++++++++++++++++++++++++ tests/unit/test_mcp_client.py | 77 ++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) diff --git a/tests/unit/test_async_mcp_client.py b/tests/unit/test_async_mcp_client.py index 3c76a2d..1c3806b 100644 --- a/tests/unit/test_async_mcp_client.py +++ b/tests/unit/test_async_mcp_client.py @@ -328,3 +328,86 @@ async def test_ensure_initialized_real_transport_is_idempotent(): await client.ensure_initialized() assert route.call_count == 2 await client.close() + + +# -- MCP write tools (ticket #139561) ---------------------------------------- + +from hyperping.models._monitor_models import MonitorCreate # noqa: E402 + + +_MONITOR_PAYLOAD = { + "uuid": "mon_abc", + "name": "My API", + "url": "https://api.example.com", + "protocol": "http", +} + + +@pytest.mark.asyncio +async def test_create_monitor(): + client = make_client() + client._transport.call_tool.return_value = _MONITOR_PAYLOAD + monitor = MonitorCreate(name="My API", url="https://api.example.com") + result = await client.create_monitor(monitor) + assert isinstance(result, Monitor) + assert result.uuid == "mon_abc" + client._transport.call_tool.assert_called_once_with( + "create_monitor", monitor.model_dump(exclude_none=True) + ) + + +@pytest.mark.asyncio +async def test_update_monitor(): + client = make_client() + client._transport.call_tool.return_value = _MONITOR_PAYLOAD + result = await client.update_monitor("mon_abc", name="New Name", check_frequency=60) + assert isinstance(result, Monitor) + client._transport.call_tool.assert_called_once_with( + "update_monitor", {"uuid": "mon_abc", "name": "New Name", "check_frequency": 60} + ) + + +@pytest.mark.asyncio +async def test_update_monitor_no_kwargs(): + client = make_client() + client._transport.call_tool.return_value = _MONITOR_PAYLOAD + result = await client.update_monitor("mon_abc") + assert isinstance(result, Monitor) + client._transport.call_tool.assert_called_once_with( + "update_monitor", {"uuid": "mon_abc"} + ) + + +@pytest.mark.asyncio +async def test_pause_monitor(): + client = make_client() + client._transport.call_tool.return_value = {**_MONITOR_PAYLOAD, "paused": True} + result = await client.pause_monitor("mon_abc") + assert isinstance(result, Monitor) + assert result.paused is True + client._transport.call_tool.assert_called_once_with( + "pause_monitor", {"uuid": "mon_abc"} + ) + + +@pytest.mark.asyncio +async def test_resume_monitor(): + client = make_client() + client._transport.call_tool.return_value = {**_MONITOR_PAYLOAD, "paused": False} + result = await client.resume_monitor("mon_abc") + assert isinstance(result, Monitor) + assert result.paused is False + client._transport.call_tool.assert_called_once_with( + "resume_monitor", {"uuid": "mon_abc"} + ) + + +@pytest.mark.asyncio +async def test_delete_monitor(): + client = make_client() + client._transport.call_tool.return_value = None + result = await client.delete_monitor("mon_abc") + assert result is None + client._transport.call_tool.assert_called_once_with( + "delete_monitor", {"uuid": "mon_abc"} + ) diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py index b55d608..767a1a2 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -334,3 +334,80 @@ def test_changelog_documents_mcp_rate_limit_work(): assert "rate limit" in changelog.lower(), ( "CHANGELOG must mention rate-limit handling somewhere" ) + + +# -- MCP write tools (ticket #139561) ---------------------------------------- + +from hyperping.models._monitor_models import MonitorCreate # noqa: E402 + + +_MONITOR_PAYLOAD = { + "uuid": "mon_abc", + "name": "My API", + "url": "https://api.example.com", + "protocol": "http", +} + + +def test_create_monitor(): + client = make_client() + client._transport.call_tool.return_value = _MONITOR_PAYLOAD + monitor = MonitorCreate(name="My API", url="https://api.example.com") + result = client.create_monitor(monitor) + assert isinstance(result, Monitor) + assert result.uuid == "mon_abc" + client._transport.call_tool.assert_called_once_with( + "create_monitor", monitor.model_dump(exclude_none=True) + ) + + +def test_update_monitor(): + client = make_client() + client._transport.call_tool.return_value = _MONITOR_PAYLOAD + result = client.update_monitor("mon_abc", name="New Name", check_frequency=60) + assert isinstance(result, Monitor) + client._transport.call_tool.assert_called_once_with( + "update_monitor", {"uuid": "mon_abc", "name": "New Name", "check_frequency": 60} + ) + + +def test_update_monitor_no_kwargs(): + client = make_client() + client._transport.call_tool.return_value = _MONITOR_PAYLOAD + result = client.update_monitor("mon_abc") + assert isinstance(result, Monitor) + client._transport.call_tool.assert_called_once_with( + "update_monitor", {"uuid": "mon_abc"} + ) + + +def test_pause_monitor(): + client = make_client() + client._transport.call_tool.return_value = {**_MONITOR_PAYLOAD, "paused": True} + result = client.pause_monitor("mon_abc") + assert isinstance(result, Monitor) + assert result.paused is True + client._transport.call_tool.assert_called_once_with( + "pause_monitor", {"uuid": "mon_abc"} + ) + + +def test_resume_monitor(): + client = make_client() + client._transport.call_tool.return_value = {**_MONITOR_PAYLOAD, "paused": False} + result = client.resume_monitor("mon_abc") + assert isinstance(result, Monitor) + assert result.paused is False + client._transport.call_tool.assert_called_once_with( + "resume_monitor", {"uuid": "mon_abc"} + ) + + +def test_delete_monitor(): + client = make_client() + client._transport.call_tool.return_value = None + result = client.delete_monitor("mon_abc") + assert result is None + client._transport.call_tool.assert_called_once_with( + "delete_monitor", {"uuid": "mon_abc"} + ) From fc1a84f0d5af3a17b649b12922a6b07e8e0689a3 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 01:53:03 +0300 Subject: [PATCH 2/4] feat(mcp): add write tools to MCP clients (#139561) Add create_monitor, update_monitor, pause_monitor, resume_monitor, and delete_monitor to HyperpingMcpClient and AsyncHyperpingMcpClient. Each method is a thin typed wrapper around _call(), following the same pattern as existing read methods. create_monitor accepts a MonitorCreate model serialized via model_dump(exclude_none=True). update_monitor takes a uuid plus **kwargs for partial updates. pause/resume call dedicated MCP tools directly, avoiding a read-modify-write roundtrip. --- src/hyperping/_async_mcp_client.py | 51 +++++++++++++++++++++++++++++- src/hyperping/mcp_client.py | 51 +++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/src/hyperping/_async_mcp_client.py b/src/hyperping/_async_mcp_client.py index 6440c94..2718983 100644 --- a/src/hyperping/_async_mcp_client.py +++ b/src/hyperping/_async_mcp_client.py @@ -21,7 +21,7 @@ from hyperping._async_mcp_transport import AsyncMcpTransport from hyperping.endpoints import MCP_URL from hyperping.models._integration_models import Integration -from hyperping.models._monitor_models import Monitor +from hyperping.models._monitor_models import Monitor, MonitorCreate from hyperping.models._observability_models import MonitorAnomaly, ProbeLogResponse from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember from hyperping.models._outage_models import OutageTimeline @@ -270,3 +270,52 @@ async def search_monitors_by_name(self, query: str) -> list[Monitor]: data = await self._call("search_monitors_by_name", {"query": query}) raw = data if isinstance(data, list) else [] return [Monitor.model_validate(m) for m in raw] + + async def create_monitor(self, monitor: MonitorCreate) -> Monitor: + """Create a new monitor. + + Args: + monitor: Monitor configuration. + """ + return Monitor.model_validate( + await self._call("create_monitor", monitor.model_dump(exclude_none=True)) + ) + + async def update_monitor(self, monitor_uuid: str, **kwargs: Any) -> Monitor: + """Update an existing monitor. + + Args: + monitor_uuid: Monitor UUID. + **kwargs: Fields to update. + """ + return Monitor.model_validate( + await self._call("update_monitor", {"uuid": monitor_uuid, **kwargs}) + ) + + async def pause_monitor(self, monitor_uuid: str) -> Monitor: + """Pause a monitor. + + Args: + monitor_uuid: Monitor UUID. + """ + return Monitor.model_validate( + await self._call("pause_monitor", {"uuid": monitor_uuid}) + ) + + async def resume_monitor(self, monitor_uuid: str) -> Monitor: + """Resume a paused monitor. + + Args: + monitor_uuid: Monitor UUID. + """ + return Monitor.model_validate( + await self._call("resume_monitor", {"uuid": monitor_uuid}) + ) + + async def delete_monitor(self, monitor_uuid: str) -> None: + """Delete a monitor. + + Args: + monitor_uuid: Monitor UUID. + """ + await self._call("delete_monitor", {"uuid": monitor_uuid}) diff --git a/src/hyperping/mcp_client.py b/src/hyperping/mcp_client.py index 70de527..3d6e063 100644 --- a/src/hyperping/mcp_client.py +++ b/src/hyperping/mcp_client.py @@ -21,7 +21,7 @@ from hyperping._mcp_transport import McpTransport from hyperping.endpoints import MCP_URL from hyperping.models._integration_models import Integration -from hyperping.models._monitor_models import Monitor +from hyperping.models._monitor_models import Monitor, MonitorCreate from hyperping.models._observability_models import MonitorAnomaly, ProbeLogResponse from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember from hyperping.models._outage_models import OutageTimeline @@ -267,3 +267,52 @@ def search_monitors_by_name(self, query: str) -> list[Monitor]: data = self._call("search_monitors_by_name", {"query": query}) raw = data if isinstance(data, list) else [] return [Monitor.model_validate(m) for m in raw] + + def create_monitor(self, monitor: MonitorCreate) -> Monitor: + """Create a new monitor. + + Args: + monitor: Monitor configuration. + """ + return Monitor.model_validate( + self._call("create_monitor", monitor.model_dump(exclude_none=True)) + ) + + def update_monitor(self, monitor_uuid: str, **kwargs: Any) -> Monitor: + """Update an existing monitor. + + Args: + monitor_uuid: Monitor UUID. + **kwargs: Fields to update. + """ + return Monitor.model_validate( + self._call("update_monitor", {"uuid": monitor_uuid, **kwargs}) + ) + + def pause_monitor(self, monitor_uuid: str) -> Monitor: + """Pause a monitor. + + Args: + monitor_uuid: Monitor UUID. + """ + return Monitor.model_validate( + self._call("pause_monitor", {"uuid": monitor_uuid}) + ) + + def resume_monitor(self, monitor_uuid: str) -> Monitor: + """Resume a paused monitor. + + Args: + monitor_uuid: Monitor UUID. + """ + return Monitor.model_validate( + self._call("resume_monitor", {"uuid": monitor_uuid}) + ) + + def delete_monitor(self, monitor_uuid: str) -> None: + """Delete a monitor. + + Args: + monitor_uuid: Monitor UUID. + """ + self._call("delete_monitor", {"uuid": monitor_uuid}) From b68282b9f5a05ec26e308862cefa2b3633799c47 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 10:21:35 +0300 Subject: [PATCH 3/4] chore: update uv.lock Co-Authored-By: Claude Haiku 4.5 --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index f7589d6..827b823 100644 --- a/uv.lock +++ b/uv.lock @@ -335,7 +335,7 @@ wheels = [ [[package]] name = "hyperping" -version = "1.7.0" +version = "1.8.0" source = { editable = "." } dependencies = [ { name = "httpx" }, From 4bbcb99cfc18feece8f2ba131e358da197d8c501 Mon Sep 17 00:00:00 2001 From: Khaled Salhab Date: Sat, 13 Jun 2026 10:23:37 +0300 Subject: [PATCH 4/4] test(mcp): hoist MonitorCreate import to fix ruff I001 The MonitorCreate import was placed mid-file after the write-tools section divider, which trips ruff's I001 import-sorting rule. Fold it into the existing _monitor_models import at the top of each test file. --- tests/unit/test_async_mcp_client.py | 4 +--- tests/unit/test_mcp_client.py | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/unit/test_async_mcp_client.py b/tests/unit/test_async_mcp_client.py index 1c3806b..937f97d 100644 --- a/tests/unit/test_async_mcp_client.py +++ b/tests/unit/test_async_mcp_client.py @@ -10,7 +10,7 @@ from hyperping._async_mcp_transport import MCP_URL from hyperping.exceptions import HyperpingRateLimitError from hyperping.models._integration_models import Integration -from hyperping.models._monitor_models import Monitor +from hyperping.models._monitor_models import Monitor, MonitorCreate from hyperping.models._observability_models import MonitorAnomaly, ProbeLogResponse from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember from hyperping.models._outage_models import OutageTimeline @@ -332,8 +332,6 @@ async def test_ensure_initialized_real_transport_is_idempotent(): # -- MCP write tools (ticket #139561) ---------------------------------------- -from hyperping.models._monitor_models import MonitorCreate # noqa: E402 - _MONITOR_PAYLOAD = { "uuid": "mon_abc", diff --git a/tests/unit/test_mcp_client.py b/tests/unit/test_mcp_client.py index 767a1a2..ea617cd 100644 --- a/tests/unit/test_mcp_client.py +++ b/tests/unit/test_mcp_client.py @@ -11,7 +11,7 @@ from hyperping.exceptions import HyperpingRateLimitError from hyperping.mcp_client import HyperpingMcpClient from hyperping.models._integration_models import Integration -from hyperping.models._monitor_models import Monitor +from hyperping.models._monitor_models import Monitor, MonitorCreate from hyperping.models._observability_models import MonitorAnomaly, ProbeLogResponse from hyperping.models._oncall_models import EscalationPolicy, OnCallSchedule, TeamMember from hyperping.models._outage_models import OutageTimeline @@ -338,8 +338,6 @@ def test_changelog_documents_mcp_rate_limit_work(): # -- MCP write tools (ticket #139561) ---------------------------------------- -from hyperping.models._monitor_models import MonitorCreate # noqa: E402 - _MONITOR_PAYLOAD = { "uuid": "mon_abc",