rootly-mcp-server 2.1.3__tar.gz → 2.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 (55) hide show
  1. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/PKG-INFO +4 -4
  2. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/README.md +3 -3
  3. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/pyproject.toml +1 -1
  4. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/och_client.py +5 -2
  5. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/server.py +19 -9
  6. rootly_mcp_server-2.2.0/tests/unit/test_http_headers.py +277 -0
  7. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_och_client.py +172 -15
  8. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/uv.lock +1 -1
  9. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.beads/issues.jsonl +0 -0
  10. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.gitattributes +0 -0
  11. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.github/dependabot.yml +0 -0
  12. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.github/workflows/ci.yml +0 -0
  13. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.github/workflows/lint.yml +0 -0
  14. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.github/workflows/pypi-release.yml +0 -0
  15. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.github/workflows/test.yml +0 -0
  16. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.gitignore +0 -0
  17. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.semaphore/deploy.yml +0 -0
  18. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.semaphore/semaphore.yml +0 -0
  19. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.semaphore/update-task-definition.sh +0 -0
  20. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/CONTRIBUTING.md +0 -0
  21. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/Dockerfile +0 -0
  22. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/LICENSE +0 -0
  23. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/examples/skills/README.md +0 -0
  24. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/rootly-mcp-server-demo.gif +0 -0
  25. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/rootly_openapi.json +0 -0
  26. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/scripts/setup-hooks.sh +0 -0
  27. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/__init__.py +0 -0
  28. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/__main__.py +0 -0
  29. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/client.py +0 -0
  30. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/data/__init__.py +0 -0
  31. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/exceptions.py +0 -0
  32. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/monitoring.py +0 -0
  33. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/pagination.py +0 -0
  34. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/security.py +0 -0
  35. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/smart_utils.py +0 -0
  36. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/texttest.json +0 -0
  37. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/utils.py +0 -0
  38. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/validators.py +0 -0
  39. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/README.md +0 -0
  40. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/conftest.py +0 -0
  41. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/integration/local/test_basic.py +0 -0
  42. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/integration/local/test_smart_tools.py +0 -0
  43. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/integration/remote/test_essential.py +0 -0
  44. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/test_client.py +0 -0
  45. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_authentication.py +0 -0
  46. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_exceptions.py +0 -0
  47. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_oncall_handoff.py +0 -0
  48. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_oncall_metrics.py +0 -0
  49. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_oncall_new_tools.py +0 -0
  50. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_security.py +0 -0
  51. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_server.py +0 -0
  52. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_smart_utils.py +0 -0
  53. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_tools.py +0 -0
  54. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_utils.py +0 -0
  55. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_validators.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rootly-mcp-server
3
- Version: 2.1.3
3
+ Version: 2.2.0
4
4
  Summary: Secure Model Context Protocol server for Rootly APIs with AI SRE capabilities, comprehensive error handling, and input validation
5
5
  Project-URL: Homepage, https://github.com/Rootly-AI-Labs/Rootly-MCP-server
6
6
  Project-URL: Issues, https://github.com/Rootly-AI-Labs/Rootly-MCP-server/issues
@@ -147,11 +147,11 @@ Alternatively, connect directly to our hosted MCP server:
147
147
  - **`suggest_solutions`**: Mines past incident resolutions to recommend actionable solutions
148
148
  - **MCP Resources**: Exposes incident and team data as structured resources for easy AI reference
149
149
  - **Intelligent Pattern Recognition**: Automatically identifies services, error types, and resolution patterns
150
- - **On-Call Health Integration**: Detects burnout risk in scheduled responders
150
+ - **On-Call Health Integration**: Detects workload health risk in scheduled responders
151
151
 
152
152
  ## On-Call Health Integration
153
153
 
154
- Rootly MCP integrates with [On-Call Health](https://oncallhealth.ai) to detect burnout risk in scheduled responders.
154
+ Rootly MCP integrates with [On-Call Health](https://oncallhealth.ai) to detect workload health risk in scheduled responders.
155
155
 
156
156
  ### Setup
157
157
 
@@ -175,7 +175,7 @@ Set the `ONCALLHEALTH_API_KEY` environment variable:
175
175
  ### Usage
176
176
 
177
177
  ```
178
- check_oncall_burnout_risk(
178
+ check_oncall_health_risk(
179
179
  start_date="2026-02-09",
180
180
  end_date="2026-02-15"
181
181
  )
@@ -108,11 +108,11 @@ Alternatively, connect directly to our hosted MCP server:
108
108
  - **`suggest_solutions`**: Mines past incident resolutions to recommend actionable solutions
109
109
  - **MCP Resources**: Exposes incident and team data as structured resources for easy AI reference
110
110
  - **Intelligent Pattern Recognition**: Automatically identifies services, error types, and resolution patterns
111
- - **On-Call Health Integration**: Detects burnout risk in scheduled responders
111
+ - **On-Call Health Integration**: Detects workload health risk in scheduled responders
112
112
 
113
113
  ## On-Call Health Integration
114
114
 
115
- Rootly MCP integrates with [On-Call Health](https://oncallhealth.ai) to detect burnout risk in scheduled responders.
115
+ Rootly MCP integrates with [On-Call Health](https://oncallhealth.ai) to detect workload health risk in scheduled responders.
116
116
 
117
117
  ### Setup
118
118
 
@@ -136,7 +136,7 @@ Set the `ONCALLHEALTH_API_KEY` environment variable:
136
136
  ### Usage
137
137
 
138
138
  ```
139
- check_oncall_burnout_risk(
139
+ check_oncall_health_risk(
140
140
  start_date="2026-02-09",
141
141
  end_date="2026-02-15"
142
142
  )
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rootly-mcp-server"
3
- version = "2.1.3"
3
+ version = "2.2.0"
4
4
  description = "Secure Model Context Protocol server for Rootly APIs with AI SRE capabilities, comprehensive error handling, and input validation"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -1,10 +1,13 @@
1
- """On-Call Health API client for burnout risk analysis."""
1
+ """On-Call Health API client for workload health risk analysis."""
2
2
 
3
3
  import os
4
4
  from typing import Any
5
5
 
6
6
  import httpx
7
7
 
8
+ # External API field mapping (On-Call Health API response field)
9
+ _OCH_RISK_SCORE_FIELD = "burnout_score"
10
+
8
11
 
9
12
  class OnCallHealthClient:
10
13
  def __init__(self, api_key: str | None = None, base_url: str | None = None):
@@ -55,7 +58,7 @@ class OnCallHealthClient:
55
58
  "rootly_user_id": member.get("rootly_user_id"),
56
59
  "och_score": member.get("och_score", 0),
57
60
  "risk_level": member.get("risk_level", "unknown"),
58
- "burnout_score": member.get("burnout_score", 0),
61
+ "health_risk_score": member.get(_OCH_RISK_SCORE_FIELD, 0),
59
62
  "incident_count": member.get("incident_count", 0),
60
63
  }
61
64
 
@@ -381,11 +381,21 @@ class AuthenticatedHTTPXClient:
381
381
  return transformed
382
382
 
383
383
  async def request(self, method: str, url: str, **kwargs):
384
- """Override request to transform parameters."""
384
+ """Override request to transform parameters and ensure correct headers."""
385
385
  # Transform query parameters
386
386
  if "params" in kwargs:
387
387
  kwargs["params"] = self._transform_params(kwargs["params"])
388
388
 
389
+ # Ensure Content-Type and Accept headers are always set correctly for Rootly API
390
+ # This is critical because FastMCP may pass headers from the MCP client request
391
+ # (e.g., Content-Type: application/json from SSE) which would override our defaults
392
+ if "headers" in kwargs:
393
+ headers = dict(kwargs["headers"]) if kwargs["headers"] else {}
394
+ # Always use JSON-API content type for Rootly API
395
+ headers["Content-Type"] = "application/vnd.api+json"
396
+ headers["Accept"] = "application/vnd.api+json"
397
+ kwargs["headers"] = headers
398
+
389
399
  # Call the underlying client's request method and let it handle everything
390
400
  return await self.client.request(method, url, **kwargs)
391
401
 
@@ -3158,7 +3168,7 @@ Updated: {attributes.get("updated_at", "N/A")}"""
3158
3168
  }
3159
3169
 
3160
3170
  @mcp.tool()
3161
- async def check_oncall_burnout_risk(
3171
+ async def check_oncall_health_risk(
3162
3172
  start_date: Annotated[
3163
3173
  str,
3164
3174
  Field(description="Start date for the on-call period (ISO 8601, e.g., '2026-02-09')"),
@@ -3182,11 +3192,11 @@ Updated: {attributes.get("updated_at", "N/A")}"""
3182
3192
  Field(description="Include recommended replacement responders (default: true)"),
3183
3193
  ] = True,
3184
3194
  ) -> dict:
3185
- """Check if any at-risk responders (based on On-Call Health burnout analysis) are scheduled for on-call.
3195
+ """Check if any at-risk responders (based on On-Call Health analysis) are scheduled for on-call.
3186
3196
 
3187
- Integrates with On-Call Health (oncallhealth.ai) to identify responders at risk of burnout
3188
- and checks if they are scheduled during the specified period. Optionally recommends
3189
- safe replacement responders.
3197
+ Integrates with On-Call Health (oncallhealth.ai) to identify responders with elevated
3198
+ workload health risk and checks if they are scheduled during the specified period.
3199
+ Optionally recommends safe replacement responders.
3190
3200
 
3191
3201
  Requires ONCALLHEALTH_API_KEY environment variable.
3192
3202
  """
@@ -3229,7 +3239,7 @@ Updated: {attributes.get("updated_at", "N/A")}"""
3229
3239
  "total_at_risk": 0,
3230
3240
  "at_risk_scheduled": 0,
3231
3241
  "action_required": False,
3232
- "message": "No users above burnout threshold.",
3242
+ "message": "No users above health risk threshold.",
3233
3243
  },
3234
3244
  }
3235
3245
 
@@ -3340,7 +3350,7 @@ Updated: {attributes.get("updated_at", "N/A")}"""
3340
3350
  "user_id": int(rootly_id),
3341
3351
  "och_score": user["och_score"],
3342
3352
  "risk_level": user["risk_level"],
3343
- "burnout_score": user["burnout_score"],
3353
+ "health_risk_score": user["health_risk_score"],
3344
3354
  "total_hours": round(total_hours, 1),
3345
3355
  "shifts": user_shifts,
3346
3356
  }
@@ -3441,7 +3451,7 @@ Updated: {attributes.get("updated_at", "N/A")}"""
3441
3451
 
3442
3452
  error_type, error_message = MCPError.categorize_error(e)
3443
3453
  return MCPError.tool_error(
3444
- f"Failed to check burnout risk: {error_message}",
3454
+ f"Failed to check health risk: {error_message}",
3445
3455
  error_type,
3446
3456
  details={
3447
3457
  "params": {
@@ -0,0 +1,277 @@
1
+ """
2
+ Unit tests for HTTP header handling in AuthenticatedHTTPXClient.
3
+
4
+ Tests cover:
5
+ - Content-Type header override for Rootly JSON-API format
6
+ - Header handling when FastMCP passes MCP client headers
7
+ - Ensuring correct headers reach the Rootly API
8
+ """
9
+
10
+ from unittest.mock import AsyncMock, MagicMock, patch
11
+
12
+ import pytest
13
+
14
+
15
+ class TestAuthenticatedHTTPXClientHeaders:
16
+ """Tests for header handling in AuthenticatedHTTPXClient."""
17
+
18
+ @pytest.fixture
19
+ def mock_httpx_client(self):
20
+ """Create a mock httpx.AsyncClient."""
21
+ mock_client = AsyncMock()
22
+ mock_response = MagicMock()
23
+ mock_response.status_code = 200
24
+ mock_response.json.return_value = {"data": []}
25
+ mock_client.request.return_value = mock_response
26
+ return mock_client
27
+
28
+ @pytest.mark.asyncio
29
+ async def test_overrides_content_type_from_mcp_client(self, mock_httpx_client):
30
+ """Test that Content-Type is overridden when MCP client sends application/json."""
31
+ from rootly_mcp_server.server import AuthenticatedHTTPXClient
32
+
33
+ with patch.object(AuthenticatedHTTPXClient, "_get_api_token", return_value="test-token"):
34
+ client = AuthenticatedHTTPXClient()
35
+ client.client = mock_httpx_client
36
+
37
+ # Simulate FastMCP passing headers from MCP client request
38
+ # This is what causes the 415 error - MCP client sends application/json
39
+ mcp_headers = {
40
+ "Content-Type": "application/json",
41
+ "Accept": "application/json",
42
+ "Authorization": "Bearer user-token",
43
+ }
44
+
45
+ await client.request("GET", "/v1/teams", headers=mcp_headers)
46
+
47
+ # Verify the request was made with correct JSON-API headers
48
+ call_kwargs = mock_httpx_client.request.call_args[1]
49
+ assert call_kwargs["headers"]["Content-Type"] == "application/vnd.api+json"
50
+ assert call_kwargs["headers"]["Accept"] == "application/vnd.api+json"
51
+ # Authorization should be preserved
52
+ assert call_kwargs["headers"]["Authorization"] == "Bearer user-token"
53
+
54
+ @pytest.mark.asyncio
55
+ async def test_sets_headers_when_empty_headers_passed(self, mock_httpx_client):
56
+ """Test that headers are set correctly when empty headers dict is passed."""
57
+ from rootly_mcp_server.server import AuthenticatedHTTPXClient
58
+
59
+ with patch.object(AuthenticatedHTTPXClient, "_get_api_token", return_value="test-token"):
60
+ client = AuthenticatedHTTPXClient()
61
+ client.client = mock_httpx_client
62
+
63
+ # FastMCP might pass empty headers
64
+ await client.request("GET", "/v1/incidents", headers={})
65
+
66
+ call_kwargs = mock_httpx_client.request.call_args[1]
67
+ assert call_kwargs["headers"]["Content-Type"] == "application/vnd.api+json"
68
+ assert call_kwargs["headers"]["Accept"] == "application/vnd.api+json"
69
+
70
+ @pytest.mark.asyncio
71
+ async def test_preserves_other_headers(self, mock_httpx_client):
72
+ """Test that non-content-type headers are preserved."""
73
+ from rootly_mcp_server.server import AuthenticatedHTTPXClient
74
+
75
+ with patch.object(AuthenticatedHTTPXClient, "_get_api_token", return_value="test-token"):
76
+ client = AuthenticatedHTTPXClient()
77
+ client.client = mock_httpx_client
78
+
79
+ custom_headers = {
80
+ "Content-Type": "application/json", # Should be overridden
81
+ "X-Custom-Header": "custom-value", # Should be preserved
82
+ "X-Request-ID": "12345", # Should be preserved
83
+ }
84
+
85
+ await client.request("POST", "/v1/incidents", headers=custom_headers)
86
+
87
+ call_kwargs = mock_httpx_client.request.call_args[1]
88
+ assert call_kwargs["headers"]["Content-Type"] == "application/vnd.api+json"
89
+ assert call_kwargs["headers"]["X-Custom-Header"] == "custom-value"
90
+ assert call_kwargs["headers"]["X-Request-ID"] == "12345"
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_no_headers_kwarg_works(self, mock_httpx_client):
94
+ """Test that requests without headers kwarg still work."""
95
+ from rootly_mcp_server.server import AuthenticatedHTTPXClient
96
+
97
+ with patch.object(AuthenticatedHTTPXClient, "_get_api_token", return_value="test-token"):
98
+ client = AuthenticatedHTTPXClient()
99
+ client.client = mock_httpx_client
100
+
101
+ # Request without headers kwarg (relies on client defaults)
102
+ await client.request("GET", "/v1/users")
103
+
104
+ # Should still make the request successfully
105
+ mock_httpx_client.request.assert_called_once()
106
+
107
+ @pytest.mark.asyncio
108
+ async def test_none_headers_handled(self, mock_httpx_client):
109
+ """Test that None headers are handled gracefully."""
110
+ from rootly_mcp_server.server import AuthenticatedHTTPXClient
111
+
112
+ with patch.object(AuthenticatedHTTPXClient, "_get_api_token", return_value="test-token"):
113
+ client = AuthenticatedHTTPXClient()
114
+ client.client = mock_httpx_client
115
+
116
+ # FastMCP might pass headers=None
117
+ await client.request("GET", "/v1/schedules", headers=None)
118
+
119
+ call_kwargs = mock_httpx_client.request.call_args[1]
120
+ assert call_kwargs["headers"]["Content-Type"] == "application/vnd.api+json"
121
+ assert call_kwargs["headers"]["Accept"] == "application/vnd.api+json"
122
+
123
+
124
+ class TestHTTPMethodsWithHeaders:
125
+ """Test all HTTP methods correctly handle headers."""
126
+
127
+ @pytest.fixture
128
+ def mock_httpx_client(self):
129
+ """Create a mock httpx.AsyncClient."""
130
+ mock_client = AsyncMock()
131
+ mock_response = MagicMock()
132
+ mock_response.status_code = 200
133
+ mock_response.json.return_value = {"data": []}
134
+ mock_client.request.return_value = mock_response
135
+ return mock_client
136
+
137
+ @pytest.mark.asyncio
138
+ async def test_get_method_headers(self, mock_httpx_client):
139
+ """Test GET method correctly overrides headers."""
140
+ from rootly_mcp_server.server import AuthenticatedHTTPXClient
141
+
142
+ with patch.object(AuthenticatedHTTPXClient, "_get_api_token", return_value="test-token"):
143
+ client = AuthenticatedHTTPXClient()
144
+ client.client = mock_httpx_client
145
+
146
+ await client.get("/v1/teams", headers={"Content-Type": "application/json"})
147
+
148
+ call_kwargs = mock_httpx_client.request.call_args[1]
149
+ assert call_kwargs["headers"]["Content-Type"] == "application/vnd.api+json"
150
+
151
+ @pytest.mark.asyncio
152
+ async def test_post_method_headers(self, mock_httpx_client):
153
+ """Test POST method correctly overrides headers."""
154
+ from rootly_mcp_server.server import AuthenticatedHTTPXClient
155
+
156
+ with patch.object(AuthenticatedHTTPXClient, "_get_api_token", return_value="test-token"):
157
+ client = AuthenticatedHTTPXClient()
158
+ client.client = mock_httpx_client
159
+
160
+ await client.post(
161
+ "/v1/incidents",
162
+ headers={"Content-Type": "application/json"},
163
+ json={"title": "Test"},
164
+ )
165
+
166
+ call_kwargs = mock_httpx_client.request.call_args[1]
167
+ assert call_kwargs["headers"]["Content-Type"] == "application/vnd.api+json"
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_patch_method_headers(self, mock_httpx_client):
171
+ """Test PATCH method correctly overrides headers."""
172
+ from rootly_mcp_server.server import AuthenticatedHTTPXClient
173
+
174
+ with patch.object(AuthenticatedHTTPXClient, "_get_api_token", return_value="test-token"):
175
+ client = AuthenticatedHTTPXClient()
176
+ client.client = mock_httpx_client
177
+
178
+ await client.patch(
179
+ "/v1/incidents/123",
180
+ headers={"Content-Type": "application/json"},
181
+ json={"status": "resolved"},
182
+ )
183
+
184
+ call_kwargs = mock_httpx_client.request.call_args[1]
185
+ assert call_kwargs["headers"]["Content-Type"] == "application/vnd.api+json"
186
+
187
+ @pytest.mark.asyncio
188
+ async def test_delete_method_headers(self, mock_httpx_client):
189
+ """Test DELETE method correctly overrides headers."""
190
+ from rootly_mcp_server.server import AuthenticatedHTTPXClient
191
+
192
+ with patch.object(AuthenticatedHTTPXClient, "_get_api_token", return_value="test-token"):
193
+ client = AuthenticatedHTTPXClient()
194
+ client.client = mock_httpx_client
195
+
196
+ await client.delete("/v1/incidents/123", headers={"Content-Type": "application/json"})
197
+
198
+ call_kwargs = mock_httpx_client.request.call_args[1]
199
+ assert call_kwargs["headers"]["Content-Type"] == "application/vnd.api+json"
200
+
201
+
202
+ class TestFastMCPIntegrationScenario:
203
+ """Test scenarios that simulate FastMCP's behavior."""
204
+
205
+ @pytest.fixture
206
+ def mock_httpx_client(self):
207
+ """Create a mock httpx.AsyncClient."""
208
+ mock_client = AsyncMock()
209
+ mock_response = MagicMock()
210
+ mock_response.status_code = 200
211
+ mock_response.json.return_value = {"data": [{"id": "1", "type": "teams"}]}
212
+ mock_client.request.return_value = mock_response
213
+ return mock_client
214
+
215
+ @pytest.mark.asyncio
216
+ async def test_simulated_fastmcp_listteams_call(self, mock_httpx_client):
217
+ """Simulate the exact scenario that causes 415 error with listTeams."""
218
+ from rootly_mcp_server.server import AuthenticatedHTTPXClient
219
+
220
+ with patch.object(AuthenticatedHTTPXClient, "_get_api_token", return_value="test-token"):
221
+ client = AuthenticatedHTTPXClient()
222
+ client.client = mock_httpx_client
223
+
224
+ # This simulates what FastMCP does:
225
+ # 1. Gets headers from MCP client HTTP request (SSE connection)
226
+ # 2. These headers include Content-Type: application/json
227
+ # 3. Passes them to our client
228
+ mcp_client_headers = {
229
+ "host": "mcp.rootly.com",
230
+ "content-type": "application/json", # From MCP client
231
+ "accept": "text/event-stream",
232
+ "authorization": "Bearer user-api-token",
233
+ }
234
+
235
+ # Make request like FastMCP would
236
+ await client.request(
237
+ method="GET",
238
+ url="/v1/teams",
239
+ params={"page[size]": 10},
240
+ headers=mcp_client_headers,
241
+ )
242
+
243
+ # Verify correct headers were sent to Rootly API
244
+ call_kwargs = mock_httpx_client.request.call_args[1]
245
+ assert call_kwargs["headers"]["Content-Type"] == "application/vnd.api+json"
246
+ assert call_kwargs["headers"]["Accept"] == "application/vnd.api+json"
247
+ # Auth header should be preserved for hosted mode
248
+ assert call_kwargs["headers"]["authorization"] == "Bearer user-api-token"
249
+
250
+ @pytest.mark.asyncio
251
+ async def test_simulated_fastmcp_getcurrentuser_call(self, mock_httpx_client):
252
+ """Simulate the exact scenario that causes 415 error with getCurrentUser."""
253
+ from rootly_mcp_server.server import AuthenticatedHTTPXClient
254
+
255
+ mock_httpx_client.request.return_value.json.return_value = {
256
+ "data": {"id": "123", "type": "users", "attributes": {"name": "Test User"}}
257
+ }
258
+
259
+ with patch.object(AuthenticatedHTTPXClient, "_get_api_token", return_value="test-token"):
260
+ client = AuthenticatedHTTPXClient()
261
+ client.client = mock_httpx_client
262
+
263
+ # Simulate FastMCP headers for getCurrentUser
264
+ mcp_client_headers = {
265
+ "content-type": "application/json",
266
+ "accept": "application/json",
267
+ }
268
+
269
+ await client.request(
270
+ method="GET",
271
+ url="/v1/users/me",
272
+ headers=mcp_client_headers,
273
+ )
274
+
275
+ call_kwargs = mock_httpx_client.request.call_args[1]
276
+ assert call_kwargs["headers"]["Content-Type"] == "application/vnd.api+json"
277
+ assert call_kwargs["headers"]["Accept"] == "application/vnd.api+json"
@@ -1,12 +1,16 @@
1
1
  """
2
- Unit tests for On-Call Health client and burnout risk tool.
2
+ Unit tests for On-Call Health client and health risk tool.
3
3
 
4
4
  Tests cover:
5
5
  - OnCallHealthClient initialization
6
6
  - extract_at_risk_users logic
7
- - check_oncall_burnout_risk tool behavior
7
+ - check_oncall_health_risk tool behavior
8
+ - Field mapping from external API to internal names
9
+ - Tool registration verification
8
10
  """
9
11
 
12
+ from unittest.mock import patch
13
+
10
14
  import pytest
11
15
 
12
16
 
@@ -40,7 +44,7 @@ class TestExtractAtRiskUsers:
40
44
 
41
45
  @pytest.fixture
42
46
  def mock_analysis(self):
43
- """Mock OCH analysis response."""
47
+ """Mock OCH analysis response (uses external API field names)."""
44
48
  return {
45
49
  "id": 1226,
46
50
  "analysis_data": {
@@ -51,7 +55,7 @@ class TestExtractAtRiskUsers:
51
55
  "rootly_user_id": "2381",
52
56
  "och_score": 86.5,
53
57
  "risk_level": "high",
54
- "burnout_score": 3.5,
58
+ "burnout_score": 3.5, # External API field name
55
59
  "incident_count": 15,
56
60
  },
57
61
  {
@@ -59,7 +63,7 @@ class TestExtractAtRiskUsers:
59
63
  "rootly_user_id": "94178",
60
64
  "och_score": 55.0,
61
65
  "risk_level": "medium",
62
- "burnout_score": 2.0,
66
+ "burnout_score": 2.0, # External API field name
63
67
  "incident_count": 8,
64
68
  },
65
69
  {
@@ -67,7 +71,7 @@ class TestExtractAtRiskUsers:
67
71
  "rootly_user_id": "62208",
68
72
  "och_score": 15.0,
69
73
  "risk_level": "low",
70
- "burnout_score": 0.5,
74
+ "burnout_score": 0.5, # External API field name
71
75
  "incident_count": 2,
72
76
  },
73
77
  {
@@ -75,7 +79,7 @@ class TestExtractAtRiskUsers:
75
79
  "rootly_user_id": "12345",
76
80
  "och_score": 35.0,
77
81
  "risk_level": "moderate",
78
- "burnout_score": 1.0,
82
+ "burnout_score": 1.0, # External API field name
79
83
  "incident_count": 5,
80
84
  },
81
85
  ]
@@ -134,7 +138,7 @@ class TestExtractAtRiskUsers:
134
138
  "rootly_user_id": "99999",
135
139
  "och_score": 5.0,
136
140
  "risk_level": "low",
137
- "burnout_score": 0.1,
141
+ "burnout_score": 0.1, # External API field name
138
142
  "incident_count": 0,
139
143
  },
140
144
  ]
@@ -158,7 +162,7 @@ class TestExtractAtRiskUsers:
158
162
  assert "rootly_user_id" in user
159
163
  assert "och_score" in user
160
164
  assert "risk_level" in user
161
- assert "burnout_score" in user
165
+ assert "health_risk_score" in user
162
166
  assert "incident_count" in user
163
167
 
164
168
  def test_empty_members_list(self):
@@ -186,8 +190,8 @@ class TestExtractAtRiskUsers:
186
190
  assert safe == []
187
191
 
188
192
 
189
- class TestCheckOncallBurnoutRiskLogic:
190
- """Tests for check_oncall_burnout_risk tool logic."""
193
+ class TestCheckOncallHealthRiskLogic:
194
+ """Tests for check_oncall_health_risk tool logic."""
191
195
 
192
196
  def test_no_at_risk_users_returns_empty(self):
193
197
  """Test response when no users are above threshold."""
@@ -204,7 +208,7 @@ class TestCheckOncallBurnoutRiskLogic:
204
208
  "rootly_user_id": "123",
205
209
  "och_score": 10.0,
206
210
  "risk_level": "low",
207
- "burnout_score": 0.1,
211
+ "burnout_score": 0.1, # External API field name
208
212
  "incident_count": 1,
209
213
  }
210
214
  ]
@@ -231,7 +235,7 @@ class TestCheckOncallBurnoutRiskLogic:
231
235
  "rootly_user_id": None,
232
236
  "och_score": 80.0,
233
237
  "risk_level": "high",
234
- "burnout_score": 3.0,
238
+ "burnout_score": 3.0, # External API field name
235
239
  "incident_count": 10,
236
240
  }
237
241
  ]
@@ -246,8 +250,8 @@ class TestCheckOncallBurnoutRiskLogic:
246
250
  assert at_risk[0]["rootly_user_id"] is None
247
251
 
248
252
 
249
- class TestBurnoutRiskSummaryLogic:
250
- """Tests for burnout risk summary generation logic."""
253
+ class TestHealthRiskSummaryLogic:
254
+ """Tests for health risk summary generation logic."""
251
255
 
252
256
  def test_action_required_when_at_risk_scheduled(self):
253
257
  """Test that action_required is True when at-risk users are scheduled."""
@@ -280,3 +284,156 @@ class TestBurnoutRiskSummaryLogic:
280
284
  assert "2 at-risk user(s)" in message
281
285
  assert "64 hours" in message
282
286
  assert "Consider reassignment" in message
287
+
288
+
289
+ class TestToolRegistration:
290
+ """Tests for tool registration with correct naming."""
291
+
292
+ def test_health_risk_tool_is_registered(self):
293
+ """Verify check_oncall_health_risk tool is registered."""
294
+ with patch("rootly_mcp_server.server._load_swagger_spec") as mock_load_spec:
295
+ mock_spec = {
296
+ "openapi": "3.0.0",
297
+ "info": {"title": "Test API", "version": "1.0.0"},
298
+ "paths": {},
299
+ "components": {"schemas": {}},
300
+ }
301
+ mock_load_spec.return_value = mock_spec
302
+
303
+ from rootly_mcp_server.server import create_rootly_mcp_server
304
+
305
+ server = create_rootly_mcp_server()
306
+ assert server is not None
307
+
308
+ # Get all registered tools
309
+ tools = server._tool_manager._tools
310
+ tool_names = list(tools.keys())
311
+
312
+ # Verify new tool name exists
313
+ assert "check_oncall_health_risk" in tool_names
314
+
315
+ def test_old_burnout_tool_name_not_registered(self):
316
+ """Verify old check_oncall_burnout_risk tool name does NOT exist."""
317
+ with patch("rootly_mcp_server.server._load_swagger_spec") as mock_load_spec:
318
+ mock_spec = {
319
+ "openapi": "3.0.0",
320
+ "info": {"title": "Test API", "version": "1.0.0"},
321
+ "paths": {},
322
+ "components": {"schemas": {}},
323
+ }
324
+ mock_load_spec.return_value = mock_spec
325
+
326
+ from rootly_mcp_server.server import create_rootly_mcp_server
327
+
328
+ server = create_rootly_mcp_server()
329
+ tools = server._tool_manager._tools
330
+ tool_names = list(tools.keys())
331
+
332
+ # Verify old tool name does NOT exist
333
+ assert "check_oncall_burnout_risk" not in tool_names
334
+
335
+
336
+ class TestFieldMapping:
337
+ """Tests for field name mapping from external API to internal names."""
338
+
339
+ def test_external_api_field_mapped_to_health_risk_score(self):
340
+ """Verify external API's field is mapped to health_risk_score."""
341
+ from rootly_mcp_server.och_client import OnCallHealthClient
342
+
343
+ # Simulate external API response with their field name
344
+ api_response = {
345
+ "analysis_data": {
346
+ "team_analysis": {
347
+ "members": [
348
+ {
349
+ "user_name": "Test User",
350
+ "rootly_user_id": "123",
351
+ "och_score": 75.0,
352
+ "risk_level": "high",
353
+ "burnout_score": 3.2, # External API field
354
+ "incident_count": 10,
355
+ }
356
+ ]
357
+ }
358
+ }
359
+ }
360
+
361
+ client = OnCallHealthClient(api_key="test")
362
+ at_risk, _ = client.extract_at_risk_users(api_response)
363
+
364
+ # Verify the extracted data uses our field name
365
+ assert len(at_risk) == 1
366
+ assert "health_risk_score" in at_risk[0]
367
+ assert at_risk[0]["health_risk_score"] == 3.2
368
+
369
+ # Verify old field name is NOT in output
370
+ assert "burnout_score" not in at_risk[0]
371
+
372
+ def test_response_schema_uses_health_risk_score(self):
373
+ """Verify all extracted users have health_risk_score field."""
374
+ from rootly_mcp_server.och_client import OnCallHealthClient
375
+
376
+ api_response = {
377
+ "analysis_data": {
378
+ "team_analysis": {
379
+ "members": [
380
+ {
381
+ "user_name": "User 1",
382
+ "rootly_user_id": "1",
383
+ "och_score": 80.0,
384
+ "risk_level": "high",
385
+ "burnout_score": 3.5,
386
+ "incident_count": 15,
387
+ },
388
+ {
389
+ "user_name": "User 2",
390
+ "rootly_user_id": "2",
391
+ "och_score": 10.0,
392
+ "risk_level": "low",
393
+ "burnout_score": 0.5,
394
+ "incident_count": 2,
395
+ },
396
+ ]
397
+ }
398
+ }
399
+ }
400
+
401
+ client = OnCallHealthClient(api_key="test")
402
+ at_risk, safe = client.extract_at_risk_users(api_response)
403
+
404
+ # Check at-risk users
405
+ for user in at_risk:
406
+ assert "health_risk_score" in user
407
+ assert "burnout_score" not in user
408
+
409
+ # Check safe users
410
+ for user in safe:
411
+ assert "health_risk_score" in user
412
+ assert "burnout_score" not in user
413
+
414
+ def test_missing_external_field_defaults_to_zero(self):
415
+ """Verify missing external API field defaults to 0."""
416
+ from rootly_mcp_server.och_client import OnCallHealthClient
417
+
418
+ api_response = {
419
+ "analysis_data": {
420
+ "team_analysis": {
421
+ "members": [
422
+ {
423
+ "user_name": "User Without Score",
424
+ "rootly_user_id": "999",
425
+ "och_score": 60.0,
426
+ "risk_level": "medium",
427
+ # No burnout_score field
428
+ "incident_count": 5,
429
+ }
430
+ ]
431
+ }
432
+ }
433
+ }
434
+
435
+ client = OnCallHealthClient(api_key="test")
436
+ at_risk, _ = client.extract_at_risk_users(api_response)
437
+
438
+ assert len(at_risk) == 1
439
+ assert at_risk[0]["health_risk_score"] == 0
@@ -1658,7 +1658,7 @@ wheels = [
1658
1658
 
1659
1659
  [[package]]
1660
1660
  name = "rootly-mcp-server"
1661
- version = "2.1.0"
1661
+ version = "2.2.0"
1662
1662
  source = { editable = "." }
1663
1663
  dependencies = [
1664
1664
  { name = "brotli" },