devhelm-mcp-server 1.1.0__tar.gz → 1.2.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 (48) hide show
  1. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/PKG-INFO +2 -2
  2. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/pyproject.toml +6 -9
  3. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/server.py +5 -2
  4. devhelm_mcp_server-1.2.0/src/devhelm_mcp/tools/dependencies.py +79 -0
  5. devhelm_mcp_server-1.2.0/src/devhelm_mcp/tools/services.py +182 -0
  6. devhelm_mcp_server-1.2.0/tests/test_services.py +412 -0
  7. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/tests/test_tools.py +14 -0
  8. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/uv.lock +5 -5
  9. devhelm_mcp_server-1.1.0/src/devhelm_mcp/tools/dependencies.py +0 -43
  10. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/.github/workflows/ci.yml +0 -0
  11. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/.github/workflows/release.yml +0 -0
  12. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/.github/workflows/spec-check.yml +0 -0
  13. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/.gitignore +0 -0
  14. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/CHANGELOG.md +0 -0
  15. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/LICENSE +0 -0
  16. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/Makefile +0 -0
  17. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/README.md +0 -0
  18. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/docker/Dockerfile +0 -0
  19. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/docs/openapi/monitoring-api.json +0 -0
  20. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/scripts/regen-from.sh +0 -0
  21. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/scripts/release.sh +0 -0
  22. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/server.json +0 -0
  23. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/__init__.py +0 -0
  24. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/client.py +0 -0
  25. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/__init__.py +0 -0
  26. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/alert_channels.py +0 -0
  27. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/api_keys.py +0 -0
  28. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/deploy_lock.py +0 -0
  29. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/environments.py +0 -0
  30. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/forensics.py +0 -0
  31. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/incidents.py +0 -0
  32. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/maintenance_windows.py +0 -0
  33. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/monitors.py +0 -0
  34. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/notification_policies.py +0 -0
  35. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/resource_groups.py +0 -0
  36. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/secrets.py +0 -0
  37. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/status.py +0 -0
  38. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/status_pages.py +0 -0
  39. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/tags.py +0 -0
  40. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/src/devhelm_mcp/tools/webhooks.py +0 -0
  41. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/tests/__init__.py +0 -0
  42. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/tests/run_mcp.py +0 -0
  43. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/tests/test_auth.py +0 -0
  44. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/tests/test_cli.py +0 -0
  45. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/tests/test_client.py +0 -0
  46. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/tests/test_devex_round3_fixes.py +0 -0
  47. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/tests/test_maintenance_windows.py +0 -0
  48. {devhelm_mcp_server-1.1.0 → devhelm_mcp_server-1.2.0}/tests/test_typed_schemas.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devhelm-mcp-server
3
- Version: 1.1.0
3
+ Version: 1.2.0
4
4
  Summary: DevHelm MCP server — AI agent access to monitors, incidents, alerting, and more
5
5
  Project-URL: Homepage, https://devhelm.io
6
6
  Project-URL: Repository, https://github.com/devhelmhq/mcp-server
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.12
18
18
  Classifier: Programming Language :: Python :: 3.13
19
19
  Classifier: Topic :: Software Development :: Libraries
20
20
  Requires-Python: >=3.11
21
- Requires-Dist: devhelm>=1.0.0
21
+ Requires-Dist: devhelm>=1.3.0
22
22
  Requires-Dist: fastmcp>=2.0.0
23
23
  Description-Content-Type: text/markdown
24
24
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devhelm-mcp-server"
3
- version = "1.1.0"
3
+ version = "1.2.0"
4
4
  description = "DevHelm MCP server — AI agent access to monitors, incidents, alerting, and more"
5
5
  authors = [{ name = "DevHelm", email = "hello@devhelm.io" }]
6
6
  license = "MIT"
@@ -19,14 +19,11 @@ classifiers = [
19
19
  ]
20
20
 
21
21
  dependencies = [
22
- # Floor bumped to 1.0.0: aligns with the GA release of the Python SDK
23
- # cut alongside this server's 1.0.0. The 1.0.0 SDK includes every
24
- # contract the server currently relies on (`managedBy` on resource
25
- # DTOs, the monitor validation contract — frequencySeconds bounds,
26
- # dynamic region whitelist) and commits to semver going forward, so
27
- # `>=1.0.0` lets us pull in compatible patch/minor bumps without
28
- # explicit floor bumps each time.
29
- "devhelm>=1.0.0",
22
+ # Floor bumped to 1.3.0: first SDK release with the full
23
+ # `client.services` catalog resource and the extended
24
+ # `client.dependencies` (component-level track + alert sensitivity)
25
+ # that the services/dependencies tool modules call directly.
26
+ "devhelm>=1.3.0",
30
27
  "fastmcp>=2.0.0",
31
28
  ]
32
29
 
@@ -40,6 +40,7 @@ from devhelm_mcp.tools import (
40
40
  notification_policies,
41
41
  resource_groups,
42
42
  secrets,
43
+ services,
43
44
  status,
44
45
  status_pages,
45
46
  tags,
@@ -77,8 +78,9 @@ mcp = FastMCP(
77
78
  "DevHelm MCP server for monitoring infrastructure. "
78
79
  "Use these tools to manage uptime monitors, incidents, alert channels, "
79
80
  "notification policies, environments, secrets, tags, resource groups, "
80
- "webhooks, API keys, service dependencies, deploy locks, maintenance "
81
- "windows, status pages, and view dashboard status. "
81
+ "webhooks, API keys, service dependencies, the third-party service "
82
+ "catalog (search services, live status, uptime, incidents), deploy "
83
+ "locks, maintenance windows, status pages, and view dashboard status. "
82
84
  "Authentication is handled at the transport layer: "
83
85
  "`Authorization: Bearer <token>` for the hosted endpoint "
84
86
  "(https://mcp.devhelm.io/mcp/), or the `DEVHELM_API_TOKEN` environment "
@@ -100,6 +102,7 @@ ALL_TOOL_MODULES = [
100
102
  webhooks,
101
103
  api_keys,
102
104
  dependencies,
105
+ services,
103
106
  deploy_lock,
104
107
  maintenance_windows,
105
108
  status,
@@ -0,0 +1,79 @@
1
+ """Dependency tools — track third-party service dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from devhelm import DevhelmError
6
+ from fastmcp import FastMCP
7
+
8
+ from devhelm_mcp.client import ToolResult, get_client, raise_tool_error, serialize
9
+
10
+
11
+ def register(mcp: FastMCP) -> None:
12
+ @mcp.tool()
13
+ def list_dependencies(api_token: str | None = None) -> ToolResult:
14
+ """List all tracked service dependencies."""
15
+ try:
16
+ return serialize(get_client(api_token).dependencies.list())
17
+ except DevhelmError as e:
18
+ raise_tool_error(e)
19
+
20
+ @mcp.tool()
21
+ def get_dependency(dependency_id: str, api_token: str | None = None) -> ToolResult:
22
+ """Get a tracked dependency by ID."""
23
+ try:
24
+ return serialize(get_client(api_token).dependencies.get(dependency_id))
25
+ except DevhelmError as e:
26
+ raise_tool_error(e)
27
+
28
+ @mcp.tool()
29
+ def track_dependency(
30
+ slug: str,
31
+ component_id: str | None = None,
32
+ alert_sensitivity: str | None = None,
33
+ api_token: str | None = None,
34
+ ) -> ToolResult:
35
+ """Start tracking a service dependency by its slug (e.g. 'github', 'aws').
36
+
37
+ Optionally track a single component via `component_id` (see
38
+ list_service_components) and set `alert_sensitivity`: AWARENESS
39
+ (silent tracking, default), INCIDENTS_ONLY, MAJOR_ONLY, or ALL."""
40
+ try:
41
+ return serialize(
42
+ get_client(api_token).dependencies.track(
43
+ slug,
44
+ component_id=component_id,
45
+ alert_sensitivity=alert_sensitivity,
46
+ )
47
+ )
48
+ except DevhelmError as e:
49
+ raise_tool_error(e)
50
+
51
+ @mcp.tool()
52
+ def update_dependency_alert_sensitivity(
53
+ subscription_id: str,
54
+ alert_sensitivity: str,
55
+ api_token: str | None = None,
56
+ ) -> ToolResult:
57
+ """Change how loudly a tracked dependency alerts you.
58
+
59
+ Levels: AWARENESS (silent tracking, default — status visible on the
60
+ dashboard but no notifications), INCIDENTS_ONLY (notify on any
61
+ incident), MAJOR_ONLY (notify only on major/critical incidents), and
62
+ ALL (every status change, including maintenance)."""
63
+ try:
64
+ return serialize(
65
+ get_client(api_token).dependencies.update_alert_sensitivity(
66
+ subscription_id, alert_sensitivity
67
+ )
68
+ )
69
+ except DevhelmError as e:
70
+ raise_tool_error(e)
71
+
72
+ @mcp.tool()
73
+ def delete_dependency(dependency_id: str, api_token: str | None = None) -> str:
74
+ """Stop tracking a service dependency."""
75
+ try:
76
+ get_client(api_token).dependencies.delete(dependency_id)
77
+ return "Dependency removed successfully."
78
+ except DevhelmError as e:
79
+ raise_tool_error(e)
@@ -0,0 +1,182 @@
1
+ """Service catalog tools — browse third-party services and their status."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from devhelm import DevhelmError
6
+ from fastmcp import FastMCP
7
+
8
+ from devhelm_mcp.client import ToolResult, get_client, raise_tool_error, serialize
9
+
10
+
11
+ def register(mcp: FastMCP) -> None:
12
+ @mcp.tool()
13
+ def search_services(
14
+ query: str | None = None,
15
+ category: str | None = None,
16
+ limit: int = 20,
17
+ api_token: str | None = None,
18
+ ) -> ToolResult:
19
+ """Search the catalog of third-party services (Stripe, GitHub, AWS, ...)
20
+ that can be tracked as dependencies.
21
+
22
+ Use `query` for free-text search by name (e.g. 'stripe', 'cloudflare')
23
+ and `category` to filter by catalog category (see
24
+ list_service_categories). Results are paginated; raise `limit`
25
+ (default 20) for broader sweeps."""
26
+ try:
27
+ return serialize(
28
+ get_client(api_token).services.list(
29
+ search=query, category=category, limit=limit
30
+ )
31
+ )
32
+ except DevhelmError as e:
33
+ raise_tool_error(e)
34
+
35
+ @mcp.tool()
36
+ def get_service(slug: str, api_token: str | None = None) -> ToolResult:
37
+ """Get a catalog service's summary by slug (e.g. 'github'), including
38
+ its current status, categories, and component overview."""
39
+ try:
40
+ return serialize(get_client(api_token).services.get(slug, summary=True))
41
+ except DevhelmError as e:
42
+ raise_tool_error(e)
43
+
44
+ @mcp.tool()
45
+ def get_service_live_status(slug: str, api_token: str | None = None) -> ToolResult:
46
+ """Get the live (real-time) operational status of a catalog service,
47
+ fetched from its upstream status page. Use this when freshness matters
48
+ more than latency — e.g. 'is Stripe down right now?'."""
49
+ try:
50
+ return serialize(get_client(api_token).services.live_status(slug))
51
+ except DevhelmError as e:
52
+ raise_tool_error(e)
53
+
54
+ @mcp.tool()
55
+ def get_services_summary(api_token: str | None = None) -> ToolResult:
56
+ """Get the global status summary across the entire service catalog —
57
+ counts of operational / degraded / outage services. Use this for a
58
+ quick 'is anything broken on the internet right now?' overview before
59
+ drilling into a specific service."""
60
+ try:
61
+ return serialize(get_client(api_token).services.summary())
62
+ except DevhelmError as e:
63
+ raise_tool_error(e)
64
+
65
+ @mcp.tool()
66
+ def list_service_categories(api_token: str | None = None) -> ToolResult:
67
+ """List all service catalog categories (e.g. cloud, payments, devtools)
68
+ usable as the `category` filter in search_services."""
69
+ try:
70
+ return serialize(get_client(api_token).services.categories())
71
+ except DevhelmError as e:
72
+ raise_tool_error(e)
73
+
74
+ @mcp.tool()
75
+ def list_service_components(slug: str, api_token: str | None = None) -> ToolResult:
76
+ """List a catalog service's components (e.g. 'API', 'Dashboard',
77
+ 'Webhooks') with their individual statuses. Component IDs can be used
78
+ to track a single component via track_dependency."""
79
+ try:
80
+ return serialize(get_client(api_token).services.components(slug))
81
+ except DevhelmError as e:
82
+ raise_tool_error(e)
83
+
84
+ @mcp.tool()
85
+ def get_service_uptime(
86
+ slug: str, period: str = "30d", api_token: str | None = None
87
+ ) -> ToolResult:
88
+ """Get historical uptime stats for a catalog service over a period
89
+ (e.g. '7d', '30d', '90d'; default '30d')."""
90
+ try:
91
+ return serialize(get_client(api_token).services.uptime(slug, period=period))
92
+ except DevhelmError as e:
93
+ raise_tool_error(e)
94
+
95
+ @mcp.tool()
96
+ def list_service_incidents(
97
+ slug: str | None = None,
98
+ status: str | None = None,
99
+ api_token: str | None = None,
100
+ ) -> ToolResult:
101
+ """List incidents for a catalog service, or across all services when
102
+ `slug` is omitted. Filter by `status` (e.g. 'active', 'resolved') to
103
+ answer questions like 'which of my dependencies have open incidents?'."""
104
+ try:
105
+ return serialize(
106
+ get_client(api_token).services.incidents(slug_or_id=slug, status=status)
107
+ )
108
+ except DevhelmError as e:
109
+ raise_tool_error(e)
110
+
111
+ @mcp.tool()
112
+ def get_service_incident(
113
+ slug: str, incident_id: str, api_token: str | None = None
114
+ ) -> ToolResult:
115
+ """Get one vendor incident in full detail, including the vendor's
116
+ timeline of status updates (investigating → identified → resolved).
117
+ Get incident IDs from list_service_incidents."""
118
+ try:
119
+ return serialize(get_client(api_token).services.incident(slug, incident_id))
120
+ except DevhelmError as e:
121
+ raise_tool_error(e)
122
+
123
+ @mcp.tool()
124
+ def get_service_day_rollup(
125
+ slug: str, date: str, api_token: str | None = None
126
+ ) -> ToolResult:
127
+ """Get a one-day rollup for a catalog service on a UTC calendar day
128
+ (ISO YYYY-MM-DD): aggregated uptime, per-component impact windows,
129
+ and the incidents that overlapped that day. Use this to answer
130
+ 'what happened to Stripe on 2026-06-01?'."""
131
+ try:
132
+ return serialize(get_client(api_token).services.day(slug, date))
133
+ except DevhelmError as e:
134
+ raise_tool_error(e)
135
+
136
+ @mcp.tool()
137
+ def get_component_uptime(
138
+ slug: str,
139
+ component_id: str,
140
+ period: str = "30d",
141
+ api_token: str | None = None,
142
+ ) -> ToolResult:
143
+ """Get daily uptime history for a single component of a catalog
144
+ service (e.g. just the 'API' component of Stripe) over a period
145
+ ('7d', '30d', '90d', '1y'; default '30d'). Get component IDs from
146
+ list_service_components."""
147
+ try:
148
+ return serialize(
149
+ get_client(api_token).services.component_uptime(
150
+ slug, component_id, period=period
151
+ )
152
+ )
153
+ except DevhelmError as e:
154
+ raise_tool_error(e)
155
+
156
+ @mcp.tool()
157
+ def get_all_components_uptime(
158
+ slug: str, period: str = "30d", api_token: str | None = None
159
+ ) -> ToolResult:
160
+ """Get daily uptime history for every leaf component of a catalog
161
+ service in one call, keyed by component ID, over a period ('7d',
162
+ '30d', '90d', '1y'; default '30d'). Prefer this over repeated
163
+ get_component_uptime calls when comparing components."""
164
+ try:
165
+ return serialize(
166
+ get_client(api_token).services.batch_component_uptime(
167
+ slug, period=period
168
+ )
169
+ )
170
+ except DevhelmError as e:
171
+ raise_tool_error(e)
172
+
173
+ @mcp.tool()
174
+ def list_service_maintenances(
175
+ slug: str, api_token: str | None = None
176
+ ) -> ToolResult:
177
+ """List scheduled and past maintenance windows announced by a catalog
178
+ service (e.g. upcoming AWS maintenance that could affect you)."""
179
+ try:
180
+ return serialize(get_client(api_token).services.maintenances(slug))
181
+ except DevhelmError as e:
182
+ raise_tool_error(e)
@@ -0,0 +1,412 @@
1
+ """Tests for service catalog MCP tools + dependency alert-sensitivity tools.
2
+
3
+ The ``client.services`` SDK resource and the extended
4
+ ``client.dependencies.track`` / ``update_alert_sensitivity`` signatures ship
5
+ in a parallel SDK release; these tests patch ``get_client`` in the tool
6
+ module's namespace (the same pattern as ``test_devex_round3_fixes.py``) so
7
+ the tools are exercised against the agreed SDK call contract without
8
+ requiring the new SDK methods to be installed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ from typing import Any
15
+ from unittest.mock import MagicMock, patch
16
+
17
+ import pytest
18
+ from devhelm import DevhelmApiError
19
+
20
+ from devhelm_mcp.server import _strip_internal_schema_fields, mcp
21
+
22
+ RegisteredTools = dict[str, Any]
23
+
24
+
25
+ @pytest.fixture(scope="module")
26
+ def registered_tools() -> RegisteredTools:
27
+ asyncio.run(_strip_internal_schema_fields())
28
+ tools = asyncio.run(mcp.list_tools())
29
+ return {t.name: t for t in tools}
30
+
31
+
32
+ _SERVICE_TOOLS = [
33
+ "search_services",
34
+ "get_service",
35
+ "get_service_live_status",
36
+ "get_services_summary",
37
+ "list_service_categories",
38
+ "list_service_components",
39
+ "get_service_uptime",
40
+ "list_service_incidents",
41
+ "get_service_incident",
42
+ "get_service_day_rollup",
43
+ "get_component_uptime",
44
+ "get_all_components_uptime",
45
+ "list_service_maintenances",
46
+ ]
47
+
48
+ _SAMPLE_SERVICE: dict[str, Any] = {
49
+ "slug": "stripe",
50
+ "name": "Stripe",
51
+ "category": "payments",
52
+ "status": "OPERATIONAL",
53
+ }
54
+
55
+ _SAMPLE_PAGE: dict[str, Any] = {
56
+ "data": [_SAMPLE_SERVICE],
57
+ "nextCursor": None,
58
+ "hasMore": False,
59
+ }
60
+
61
+
62
+ def _call_tool(tool_name: str, arguments: dict[str, Any]) -> Any:
63
+ return asyncio.run(mcp.call_tool(tool_name, arguments))
64
+
65
+
66
+ def _mock_services_client() -> MagicMock:
67
+ """SDK client stub whose ``services`` / ``dependencies`` methods return
68
+ JSON-safe dicts (``serialize`` rejects bare MagicMocks)."""
69
+ client = MagicMock()
70
+ client.services.list.return_value = _SAMPLE_PAGE
71
+ client.services.get.return_value = _SAMPLE_SERVICE
72
+ client.services.live_status.return_value = {
73
+ "slug": "stripe",
74
+ "status": "OPERATIONAL",
75
+ }
76
+ client.services.categories.return_value = [{"slug": "payments"}]
77
+ client.services.components.return_value = [{"id": "comp_1", "name": "API"}]
78
+ client.services.uptime.return_value = {"period": "30d", "uptime": 99.99}
79
+ client.services.incidents.return_value = [{"id": "inc_1", "status": "active"}]
80
+ client.services.summary.return_value = {
81
+ "totalServices": 1200,
82
+ "operationalCount": 1190,
83
+ }
84
+ client.services.incident.return_value = {
85
+ "id": "inc_1",
86
+ "status": "resolved",
87
+ "updates": [{"status": "investigating"}, {"status": "resolved"}],
88
+ }
89
+ client.services.day.return_value = {
90
+ "date": "2026-06-01",
91
+ "uptime": 99.5,
92
+ "incidents": [{"id": "inc_1"}],
93
+ }
94
+ client.services.component_uptime.return_value = [
95
+ {"date": "2026-06-01", "uptime": 100.0}
96
+ ]
97
+ client.services.batch_component_uptime.return_value = {
98
+ "comp_1": [{"date": "2026-06-01", "uptime": 100.0}]
99
+ }
100
+ client.services.maintenances.return_value = [{"id": "mw_1"}]
101
+ client.dependencies.track.return_value = {"id": "sub_1", "slug": "stripe"}
102
+ client.dependencies.update_alert_sensitivity.return_value = {
103
+ "id": "sub_1",
104
+ "alertSensitivity": "MAJOR_ONLY",
105
+ }
106
+ return client
107
+
108
+
109
+ # --------------------------------------------------------------------------- #
110
+ # Registration + schema hygiene
111
+ # --------------------------------------------------------------------------- #
112
+
113
+
114
+ class TestServiceToolsRegistered:
115
+ @pytest.mark.parametrize("name", _SERVICE_TOOLS)
116
+ def test_tool_registered_with_description(
117
+ self, registered_tools: RegisteredTools, name: str
118
+ ) -> None:
119
+ assert name in registered_tools, f"Missing tool: {name}"
120
+ desc = registered_tools[name].description
121
+ assert desc and len(desc) >= 10, f"{name} description too short: {desc!r}"
122
+
123
+ def test_search_services_mentions_example_services(
124
+ self, registered_tools: RegisteredTools
125
+ ) -> None:
126
+ # The docstring is the LLM's only hint about what lives in the
127
+ # catalog; the Stripe/GitHub/AWS examples anchor that.
128
+ desc = registered_tools["search_services"].description
129
+ for example in ("Stripe", "GitHub", "AWS"):
130
+ assert example in desc, f"search_services should mention {example}"
131
+
132
+ @pytest.mark.parametrize("name", _SERVICE_TOOLS)
133
+ def test_api_token_hidden_from_input_schema(
134
+ self, registered_tools: RegisteredTools, name: str
135
+ ) -> None:
136
+ properties = registered_tools[name].parameters.get("properties", {})
137
+ assert "api_token" not in properties, (
138
+ f"{name} leaks api_token into the LLM-facing input schema"
139
+ )
140
+ required = registered_tools[name].parameters.get("required", [])
141
+ assert "api_token" not in required
142
+
143
+ def test_slug_required_on_per_service_tools(
144
+ self, registered_tools: RegisteredTools
145
+ ) -> None:
146
+ for name in [
147
+ "get_service",
148
+ "get_service_live_status",
149
+ "list_service_components",
150
+ "get_service_uptime",
151
+ "get_service_incident",
152
+ "get_service_day_rollup",
153
+ "get_component_uptime",
154
+ "get_all_components_uptime",
155
+ "list_service_maintenances",
156
+ ]:
157
+ required = registered_tools[name].parameters.get("required", [])
158
+ assert "slug" in required, f"{name} should require slug"
159
+
160
+ def test_cross_service_tools_have_optional_filters(
161
+ self, registered_tools: RegisteredTools
162
+ ) -> None:
163
+ # search_services, list_service_incidents, and get_services_summary
164
+ # work with no args at all (browse mode / cross-service sweeps /
165
+ # global overview).
166
+ for name in [
167
+ "search_services",
168
+ "list_service_incidents",
169
+ "get_services_summary",
170
+ ]:
171
+ required = registered_tools[name].parameters.get("required", [])
172
+ assert required == [], f"{name} should have no required params"
173
+
174
+ def test_id_params_required_on_detail_tools(
175
+ self, registered_tools: RegisteredTools
176
+ ) -> None:
177
+ required_incident = registered_tools["get_service_incident"].parameters.get(
178
+ "required", []
179
+ )
180
+ assert "incident_id" in required_incident
181
+ required_day = registered_tools["get_service_day_rollup"].parameters.get(
182
+ "required", []
183
+ )
184
+ assert "date" in required_day
185
+ required_comp = registered_tools["get_component_uptime"].parameters.get(
186
+ "required", []
187
+ )
188
+ assert "component_id" in required_comp
189
+
190
+
191
+ # --------------------------------------------------------------------------- #
192
+ # SDK call contract — each tool forwards args to the agreed SDK signature
193
+ # --------------------------------------------------------------------------- #
194
+
195
+
196
+ class TestServiceToolsSdkContract:
197
+ def test_search_services_forwards_query_category_limit(self) -> None:
198
+ client = _mock_services_client()
199
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
200
+ _call_tool(
201
+ "search_services",
202
+ {"query": "stripe", "category": "payments", "limit": 5},
203
+ )
204
+ client.services.list.assert_called_once_with(
205
+ search="stripe", category="payments", limit=5
206
+ )
207
+
208
+ def test_search_services_defaults(self) -> None:
209
+ client = _mock_services_client()
210
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
211
+ _call_tool("search_services", {})
212
+ client.services.list.assert_called_once_with(
213
+ search=None, category=None, limit=20
214
+ )
215
+
216
+ def test_get_service_uses_summary(self) -> None:
217
+ client = _mock_services_client()
218
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
219
+ _call_tool("get_service", {"slug": "stripe"})
220
+ client.services.get.assert_called_once_with("stripe", summary=True)
221
+
222
+ def test_get_service_live_status(self) -> None:
223
+ client = _mock_services_client()
224
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
225
+ _call_tool("get_service_live_status", {"slug": "stripe"})
226
+ client.services.live_status.assert_called_once_with("stripe")
227
+
228
+ def test_list_service_categories(self) -> None:
229
+ client = _mock_services_client()
230
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
231
+ _call_tool("list_service_categories", {})
232
+ client.services.categories.assert_called_once_with()
233
+
234
+ def test_list_service_components(self) -> None:
235
+ client = _mock_services_client()
236
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
237
+ _call_tool("list_service_components", {"slug": "stripe"})
238
+ client.services.components.assert_called_once_with("stripe")
239
+
240
+ def test_get_service_uptime_forwards_period(self) -> None:
241
+ client = _mock_services_client()
242
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
243
+ _call_tool("get_service_uptime", {"slug": "stripe", "period": "90d"})
244
+ client.services.uptime.assert_called_once_with("stripe", period="90d")
245
+
246
+ def test_get_service_uptime_default_period(self) -> None:
247
+ client = _mock_services_client()
248
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
249
+ _call_tool("get_service_uptime", {"slug": "stripe"})
250
+ client.services.uptime.assert_called_once_with("stripe", period="30d")
251
+
252
+ def test_list_service_incidents_per_service(self) -> None:
253
+ client = _mock_services_client()
254
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
255
+ _call_tool("list_service_incidents", {"slug": "stripe", "status": "active"})
256
+ client.services.incidents.assert_called_once_with(
257
+ slug_or_id="stripe", status="active"
258
+ )
259
+
260
+ def test_list_service_incidents_cross_service(self) -> None:
261
+ # Omitting slug must hit the cross-service listing (slug_or_id=None).
262
+ client = _mock_services_client()
263
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
264
+ _call_tool("list_service_incidents", {})
265
+ client.services.incidents.assert_called_once_with(slug_or_id=None, status=None)
266
+
267
+ def test_list_service_maintenances(self) -> None:
268
+ client = _mock_services_client()
269
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
270
+ _call_tool("list_service_maintenances", {"slug": "aws"})
271
+ client.services.maintenances.assert_called_once_with("aws")
272
+
273
+ def test_get_services_summary(self) -> None:
274
+ client = _mock_services_client()
275
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
276
+ _call_tool("get_services_summary", {})
277
+ client.services.summary.assert_called_once_with()
278
+
279
+ def test_get_service_incident(self) -> None:
280
+ client = _mock_services_client()
281
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
282
+ _call_tool(
283
+ "get_service_incident", {"slug": "stripe", "incident_id": "inc_1"}
284
+ )
285
+ client.services.incident.assert_called_once_with("stripe", "inc_1")
286
+
287
+ def test_get_service_day_rollup(self) -> None:
288
+ client = _mock_services_client()
289
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
290
+ _call_tool(
291
+ "get_service_day_rollup", {"slug": "stripe", "date": "2026-06-01"}
292
+ )
293
+ client.services.day.assert_called_once_with("stripe", "2026-06-01")
294
+
295
+ def test_get_component_uptime_forwards_period(self) -> None:
296
+ client = _mock_services_client()
297
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
298
+ _call_tool(
299
+ "get_component_uptime",
300
+ {"slug": "stripe", "component_id": "comp_1", "period": "90d"},
301
+ )
302
+ client.services.component_uptime.assert_called_once_with(
303
+ "stripe", "comp_1", period="90d"
304
+ )
305
+
306
+ def test_get_component_uptime_default_period(self) -> None:
307
+ client = _mock_services_client()
308
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
309
+ _call_tool(
310
+ "get_component_uptime", {"slug": "stripe", "component_id": "comp_1"}
311
+ )
312
+ client.services.component_uptime.assert_called_once_with(
313
+ "stripe", "comp_1", period="30d"
314
+ )
315
+
316
+ def test_get_all_components_uptime_forwards_period(self) -> None:
317
+ client = _mock_services_client()
318
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
319
+ _call_tool("get_all_components_uptime", {"slug": "stripe", "period": "7d"})
320
+ client.services.batch_component_uptime.assert_called_once_with(
321
+ "stripe", period="7d"
322
+ )
323
+
324
+ def test_get_all_components_uptime_default_period(self) -> None:
325
+ client = _mock_services_client()
326
+ with patch("devhelm_mcp.tools.services.get_client", return_value=client):
327
+ _call_tool("get_all_components_uptime", {"slug": "stripe"})
328
+ client.services.batch_component_uptime.assert_called_once_with(
329
+ "stripe", period="30d"
330
+ )
331
+
332
+
333
+ # --------------------------------------------------------------------------- #
334
+ # Dependencies — extended track + alert sensitivity update
335
+ # --------------------------------------------------------------------------- #
336
+
337
+
338
+ class TestDependencyTrackExtensions:
339
+ def test_track_dependency_defaults_pass_none(self) -> None:
340
+ client = _mock_services_client()
341
+ with patch("devhelm_mcp.tools.dependencies.get_client", return_value=client):
342
+ _call_tool("track_dependency", {"slug": "stripe"})
343
+ client.dependencies.track.assert_called_once_with(
344
+ "stripe", component_id=None, alert_sensitivity=None
345
+ )
346
+
347
+ def test_track_dependency_forwards_component_and_sensitivity(self) -> None:
348
+ client = _mock_services_client()
349
+ with patch("devhelm_mcp.tools.dependencies.get_client", return_value=client):
350
+ _call_tool(
351
+ "track_dependency",
352
+ {
353
+ "slug": "stripe",
354
+ "component_id": "comp_1",
355
+ "alert_sensitivity": "INCIDENTS_ONLY",
356
+ },
357
+ )
358
+ client.dependencies.track.assert_called_once_with(
359
+ "stripe", component_id="comp_1", alert_sensitivity="INCIDENTS_ONLY"
360
+ )
361
+
362
+ def test_update_dependency_alert_sensitivity(self) -> None:
363
+ client = _mock_services_client()
364
+ with patch("devhelm_mcp.tools.dependencies.get_client", return_value=client):
365
+ _call_tool(
366
+ "update_dependency_alert_sensitivity",
367
+ {"subscription_id": "sub_1", "alert_sensitivity": "MAJOR_ONLY"},
368
+ )
369
+ client.dependencies.update_alert_sensitivity.assert_called_once_with(
370
+ "sub_1", "MAJOR_ONLY"
371
+ )
372
+
373
+ def test_update_sensitivity_docstring_lists_all_levels(
374
+ self, registered_tools: RegisteredTools
375
+ ) -> None:
376
+ desc = registered_tools["update_dependency_alert_sensitivity"].description
377
+ for level in ("AWARENESS", "INCIDENTS_ONLY", "MAJOR_ONLY", "ALL"):
378
+ assert level in desc, f"description should document level {level}"
379
+
380
+
381
+ # --------------------------------------------------------------------------- #
382
+ # Error surfacing — upstream failures must set isError=true
383
+ # --------------------------------------------------------------------------- #
384
+
385
+
386
+ class TestServiceToolErrorsBubbleUp:
387
+ def test_get_service_propagates_api_error(self) -> None:
388
+ async def go() -> Any:
389
+ from fastmcp import Client
390
+
391
+ async with Client(mcp) as client:
392
+ return await client.call_tool(
393
+ "get_service",
394
+ {"slug": "nonexistent"},
395
+ raise_on_error=False,
396
+ )
397
+
398
+ mock_client = MagicMock()
399
+ mock_client.services.get.side_effect = DevhelmApiError(
400
+ "Service not found",
401
+ status=404,
402
+ code="NOT_FOUND",
403
+ request_id="req_svc",
404
+ )
405
+
406
+ with patch("devhelm_mcp.tools.services.get_client", return_value=mock_client):
407
+ result = asyncio.run(go())
408
+
409
+ assert result.is_error is True
410
+ text = result.content[0].text
411
+ assert "ApiError (404 NOT_FOUND)" in text
412
+ assert "request_id=req_svc" in text
@@ -81,7 +81,21 @@ EXPECTED_TOOLS = [
81
81
  "list_dependencies",
82
82
  "get_dependency",
83
83
  "track_dependency",
84
+ "update_dependency_alert_sensitivity",
84
85
  "delete_dependency",
86
+ "search_services",
87
+ "get_service",
88
+ "get_service_live_status",
89
+ "get_services_summary",
90
+ "list_service_categories",
91
+ "list_service_components",
92
+ "get_service_uptime",
93
+ "list_service_incidents",
94
+ "get_service_incident",
95
+ "get_service_day_rollup",
96
+ "get_component_uptime",
97
+ "get_all_components_uptime",
98
+ "list_service_maintenances",
85
99
  "acquire_deploy_lock",
86
100
  "get_current_deploy_lock",
87
101
  "release_deploy_lock",
@@ -388,20 +388,20 @@ wheels = [
388
388
 
389
389
  [[package]]
390
390
  name = "devhelm"
391
- version = "1.1.0"
391
+ version = "1.3.0"
392
392
  source = { registry = "https://pypi.org/simple" }
393
393
  dependencies = [
394
394
  { name = "httpx" },
395
395
  { name = "pydantic", extra = ["email"] },
396
396
  ]
397
- sdist = { url = "https://files.pythonhosted.org/packages/bb/dc/b333a18a6077a3e80b4c9dfa15cda3b806b1bd7505514daab9f74febe449/devhelm-1.1.0.tar.gz", hash = "sha256:519ea146ac35bba37514984261d7f8edfff02e31c0849360a5a56c84b2991c87", size = 250122, upload-time = "2026-05-11T20:03:43.238Z" }
397
+ sdist = { url = "https://files.pythonhosted.org/packages/2f/2e/317082ad26945cdf815040ce48c45ecde7537ccae89d8011bc02840c9325/devhelm-1.3.0.tar.gz", hash = "sha256:fb270af6919bf6bc426de850b0bbd6251ec72a061f6d3170a79e2ead271283cb", size = 261987, upload-time = "2026-06-10T14:00:21.546Z" }
398
398
  wheels = [
399
- { url = "https://files.pythonhosted.org/packages/64/63/fbe3bddafc8feb743378a47f9426631ff4def731320c37dbf35a31e8aefe/devhelm-1.1.0-py3-none-any.whl", hash = "sha256:45fcf3e31424bb87e8d56784deeb862b7015dbd84af8cdcb1852fdfd3130ee7b", size = 81109, upload-time = "2026-05-11T20:03:41.927Z" },
399
+ { url = "https://files.pythonhosted.org/packages/c5/62/261bc0a6551d8098670e755e4a44a74ea8c032edda472364d9a6bd73992c/devhelm-1.3.0-py3-none-any.whl", hash = "sha256:19fac4faad7af71e384ebcb7afd63c8198ad03367c411f88769ac81a1bcbdea2", size = 88194, upload-time = "2026-06-10T14:00:20.547Z" },
400
400
  ]
401
401
 
402
402
  [[package]]
403
403
  name = "devhelm-mcp-server"
404
- version = "1.1.0"
404
+ version = "1.2.0"
405
405
  source = { editable = "." }
406
406
  dependencies = [
407
407
  { name = "devhelm" },
@@ -419,7 +419,7 @@ dev = [
419
419
 
420
420
  [package.metadata]
421
421
  requires-dist = [
422
- { name = "devhelm", specifier = ">=1.0.0" },
422
+ { name = "devhelm", specifier = ">=1.3.0" },
423
423
  { name = "fastmcp", specifier = ">=2.0.0" },
424
424
  ]
425
425
 
@@ -1,43 +0,0 @@
1
- """Dependency tools — track third-party service dependencies."""
2
-
3
- from __future__ import annotations
4
-
5
- from devhelm import DevhelmError
6
- from fastmcp import FastMCP
7
-
8
- from devhelm_mcp.client import ToolResult, get_client, raise_tool_error, serialize
9
-
10
-
11
- def register(mcp: FastMCP) -> None:
12
- @mcp.tool()
13
- def list_dependencies(api_token: str | None = None) -> ToolResult:
14
- """List all tracked service dependencies."""
15
- try:
16
- return serialize(get_client(api_token).dependencies.list())
17
- except DevhelmError as e:
18
- raise_tool_error(e)
19
-
20
- @mcp.tool()
21
- def get_dependency(dependency_id: str, api_token: str | None = None) -> ToolResult:
22
- """Get a tracked dependency by ID."""
23
- try:
24
- return serialize(get_client(api_token).dependencies.get(dependency_id))
25
- except DevhelmError as e:
26
- raise_tool_error(e)
27
-
28
- @mcp.tool()
29
- def track_dependency(slug: str, api_token: str | None = None) -> ToolResult:
30
- """Start tracking a service dependency by its slug (e.g. 'github', 'aws')."""
31
- try:
32
- return serialize(get_client(api_token).dependencies.track(slug))
33
- except DevhelmError as e:
34
- raise_tool_error(e)
35
-
36
- @mcp.tool()
37
- def delete_dependency(dependency_id: str, api_token: str | None = None) -> str:
38
- """Stop tracking a service dependency."""
39
- try:
40
- get_client(api_token).dependencies.delete(dependency_id)
41
- return "Dependency removed successfully."
42
- except DevhelmError as e:
43
- raise_tool_error(e)