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.
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/PKG-INFO +4 -4
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/README.md +3 -3
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/pyproject.toml +1 -1
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/och_client.py +5 -2
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/server.py +19 -9
- rootly_mcp_server-2.2.0/tests/unit/test_http_headers.py +277 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_och_client.py +172 -15
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/uv.lock +1 -1
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.beads/issues.jsonl +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.gitattributes +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.github/dependabot.yml +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.github/workflows/ci.yml +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.github/workflows/lint.yml +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.github/workflows/pypi-release.yml +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.github/workflows/test.yml +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.gitignore +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.semaphore/deploy.yml +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.semaphore/semaphore.yml +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/.semaphore/update-task-definition.sh +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/CONTRIBUTING.md +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/Dockerfile +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/LICENSE +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/examples/skills/README.md +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/rootly-mcp-server-demo.gif +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/rootly_openapi.json +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/scripts/setup-hooks.sh +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/__init__.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/__main__.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/client.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/data/__init__.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/exceptions.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/monitoring.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/pagination.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/security.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/smart_utils.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/texttest.json +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/utils.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/src/rootly_mcp_server/validators.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/README.md +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/conftest.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/integration/local/test_basic.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/integration/local/test_smart_tools.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/integration/remote/test_essential.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/test_client.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_authentication.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_exceptions.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_oncall_handoff.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_oncall_metrics.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_oncall_new_tools.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_security.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_server.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_smart_utils.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_tools.py +0 -0
- {rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/unit/test_utils.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
|
3188
|
-
and checks if they are scheduled during the specified period.
|
|
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
|
|
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
|
-
"
|
|
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
|
|
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
|
|
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
|
-
-
|
|
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 "
|
|
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
|
|
190
|
-
"""Tests for
|
|
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
|
|
250
|
-
"""Tests for
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/integration/local/test_smart_tools.py
RENAMED
|
File without changes
|
{rootly_mcp_server-2.1.3 → rootly_mcp_server-2.2.0}/tests/integration/remote/test_essential.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|