rootly-mcp-server 2.1.3__tar.gz → 2.1.4__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.1.4}/PKG-INFO +1 -1
  2. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/pyproject.toml +1 -1
  3. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/server.py +11 -1
  4. rootly_mcp_server-2.1.4/tests/unit/test_http_headers.py +277 -0
  5. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/.beads/issues.jsonl +0 -0
  6. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/.gitattributes +0 -0
  7. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/.github/dependabot.yml +0 -0
  8. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/.github/workflows/ci.yml +0 -0
  9. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/.github/workflows/lint.yml +0 -0
  10. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/.github/workflows/pypi-release.yml +0 -0
  11. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/.github/workflows/test.yml +0 -0
  12. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/.gitignore +0 -0
  13. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/.semaphore/deploy.yml +0 -0
  14. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/.semaphore/semaphore.yml +0 -0
  15. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/.semaphore/update-task-definition.sh +0 -0
  16. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/CONTRIBUTING.md +0 -0
  17. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/Dockerfile +0 -0
  18. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/LICENSE +0 -0
  19. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/README.md +0 -0
  20. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/examples/skills/README.md +0 -0
  21. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/rootly-mcp-server-demo.gif +0 -0
  22. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/rootly_openapi.json +0 -0
  23. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/scripts/setup-hooks.sh +0 -0
  24. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/__init__.py +0 -0
  25. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/__main__.py +0 -0
  26. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/client.py +0 -0
  27. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/data/__init__.py +0 -0
  28. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/exceptions.py +0 -0
  29. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/monitoring.py +0 -0
  30. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/och_client.py +0 -0
  31. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/pagination.py +0 -0
  32. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/security.py +0 -0
  33. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/smart_utils.py +0 -0
  34. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/texttest.json +0 -0
  35. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/utils.py +0 -0
  36. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/src/rootly_mcp_server/validators.py +0 -0
  37. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/README.md +0 -0
  38. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/conftest.py +0 -0
  39. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/integration/local/test_basic.py +0 -0
  40. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/integration/local/test_smart_tools.py +0 -0
  41. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/integration/remote/test_essential.py +0 -0
  42. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/test_client.py +0 -0
  43. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_authentication.py +0 -0
  44. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_exceptions.py +0 -0
  45. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_och_client.py +0 -0
  46. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_oncall_handoff.py +0 -0
  47. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_oncall_metrics.py +0 -0
  48. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_oncall_new_tools.py +0 -0
  49. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_security.py +0 -0
  50. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_server.py +0 -0
  51. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_smart_utils.py +0 -0
  52. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_tools.py +0 -0
  53. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_utils.py +0 -0
  54. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/tests/unit/test_validators.py +0 -0
  55. {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.1.4}/uv.lock +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.1.4
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rootly-mcp-server"
3
- version = "2.1.3"
3
+ version = "2.1.4"
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"
@@ -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
 
@@ -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"