rootly-mcp-server 2.2.11__tar.gz → 2.2.12__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.2.11 → rootly_mcp_server-2.2.12}/PKG-INFO +23 -1
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/README.md +22 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/pyproject.toml +1 -1
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/code_mode.py +9 -4
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/tools/oncall.py +117 -21
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/transport.py +228 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_code_mode_module.py +11 -1
- rootly_mcp_server-2.2.12/tests/unit/test_oncall_handoff.py +475 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_transport_module.py +126 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/uv.lock +1 -1
- rootly_mcp_server-2.2.11/tests/unit/test_oncall_handoff.py +0 -69
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.dockerignore +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.gitattributes +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.github/dependabot.yml +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.github/workflows/ci.yml +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.github/workflows/dependency-review.yml +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.github/workflows/pypi-release.yml +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.gitignore +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.semaphore/deploy-staging.yml +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.semaphore/deploy.yml +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.semaphore/semaphore.yml +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.semaphore/update-task-definition.sh +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/CHANGELOG.md +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/CONTRIBUTING.md +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/Dockerfile +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/LICENSE +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/examples/skills/README.md +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/gemini-extension.json +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/rootly-mcp-server-demo.gif +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/rootly_openapi.json +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/scripts/setup-hooks.sh +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/server.json +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/__init__.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/__main__.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/client.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/data/__init__.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/exceptions.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/legacy_server.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/mcp_error.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/monitoring.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/och_client.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/pagination.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/payload_stripping.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/security.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/server.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/server_defaults.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/smart_utils.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/spec_transform.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/tools/__init__.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/tools/alerts.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/tools/incidents.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/tools/resources.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/utils.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/validators.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/README.md +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/conftest.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/integration/local/test_basic.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/integration/local/test_smart_tools.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/integration/remote/test_essential.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/test_client.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_alert_stripping.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_authentication.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_exceptions.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_http_headers.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_legacy_server_module.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_main_transport.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_mcp_error_module.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_och_client.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_oncall_metrics.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_oncall_new_tools.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_oncall_severity_normalization.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_payload_stripping_module.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_security.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_server.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_server_defaults_module.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_smart_utils.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_tools.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_utils.py +0 -0
- {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/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.2.
|
|
3
|
+
Version: 2.2.12
|
|
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
|
|
@@ -55,6 +55,8 @@ The fastest way to get started is to connect to our hosted MCP server — no ins
|
|
|
55
55
|
|
|
56
56
|
### Claude Code
|
|
57
57
|
|
|
58
|
+
**Hosted Server (Recommended)**
|
|
59
|
+
|
|
58
60
|
```bash
|
|
59
61
|
claude mcp add --transport http rootly https://mcp.rootly.com/mcp \
|
|
60
62
|
--header "Authorization: Bearer YOUR_ROOTLY_API_TOKEN"
|
|
@@ -74,6 +76,26 @@ claude mcp add --transport sse rootly-sse https://mcp.rootly.com/sse \
|
|
|
74
76
|
--header "Authorization: Bearer YOUR_ROOTLY_API_TOKEN"
|
|
75
77
|
```
|
|
76
78
|
|
|
79
|
+
**Manual Configuration**
|
|
80
|
+
|
|
81
|
+
Alternatively, create `.mcp.json` in your project root:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"mcpServers": {
|
|
86
|
+
"rootly": {
|
|
87
|
+
"type": "sse",
|
|
88
|
+
"url": "https://mcp.rootly.com/sse",
|
|
89
|
+
"headers": {
|
|
90
|
+
"Authorization": "Bearer YOUR_ROOTLY_API_TOKEN"
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Then restart Claude Code so it reloads the updated configuration.
|
|
98
|
+
|
|
77
99
|
### Gemini CLI
|
|
78
100
|
|
|
79
101
|
Install the extension:
|
|
@@ -16,6 +16,8 @@ The fastest way to get started is to connect to our hosted MCP server — no ins
|
|
|
16
16
|
|
|
17
17
|
### Claude Code
|
|
18
18
|
|
|
19
|
+
**Hosted Server (Recommended)**
|
|
20
|
+
|
|
19
21
|
```bash
|
|
20
22
|
claude mcp add --transport http rootly https://mcp.rootly.com/mcp \
|
|
21
23
|
--header "Authorization: Bearer YOUR_ROOTLY_API_TOKEN"
|
|
@@ -35,6 +37,26 @@ claude mcp add --transport sse rootly-sse https://mcp.rootly.com/sse \
|
|
|
35
37
|
--header "Authorization: Bearer YOUR_ROOTLY_API_TOKEN"
|
|
36
38
|
```
|
|
37
39
|
|
|
40
|
+
**Manual Configuration**
|
|
41
|
+
|
|
42
|
+
Alternatively, create `.mcp.json` in your project root:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"mcpServers": {
|
|
47
|
+
"rootly": {
|
|
48
|
+
"type": "sse",
|
|
49
|
+
"url": "https://mcp.rootly.com/sse",
|
|
50
|
+
"headers": {
|
|
51
|
+
"Authorization": "Bearer YOUR_ROOTLY_API_TOKEN"
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Then restart Claude Code so it reloads the updated configuration.
|
|
59
|
+
|
|
38
60
|
### Gemini CLI
|
|
39
61
|
|
|
40
62
|
Install the extension:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "rootly-mcp-server"
|
|
3
|
-
version = "2.2.
|
|
3
|
+
version = "2.2.12"
|
|
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.12"
|
|
@@ -127,15 +127,20 @@ def build_code_mode_transform() -> CodeMode:
|
|
|
127
127
|
sandbox_provider=CompatibleMontySandboxProvider(),
|
|
128
128
|
discovery_tools=[
|
|
129
129
|
ListTools(default_detail="brief"),
|
|
130
|
-
Search(default_detail="detailed", default_limit=12),
|
|
130
|
+
Search(name="tool_search", default_detail="detailed", default_limit=12),
|
|
131
131
|
GetSchemas(default_detail="detailed"),
|
|
132
132
|
GetTags(default_detail="brief"),
|
|
133
133
|
],
|
|
134
134
|
execute_description=(
|
|
135
135
|
"Write a short async Python block and chain await call_tool(name, params) calls "
|
|
136
|
-
"to complete a Rootly workflow.
|
|
137
|
-
"
|
|
138
|
-
"
|
|
136
|
+
"to complete a Rootly workflow. Use tool_search only to discover tools and their "
|
|
137
|
+
"schemas, then call the actual Rootly tool with the exact parameter names shown in "
|
|
138
|
+
"the schema. For paginated data tools, pass pagination arguments like page_size, "
|
|
139
|
+
"page_number, and max_results exactly as documented by that tool instead of inventing "
|
|
140
|
+
"alternatives like per_page. Prefer Rootly's higher-level custom tools when they fit "
|
|
141
|
+
"the task, then fall back to lower-level API tools as needed. Example: "
|
|
142
|
+
"await call_tool('search_incidents', {'query': '', 'page_size': 1, 'page_number': 1, "
|
|
143
|
+
"'max_results': 1}). Use return to emit the final result."
|
|
139
144
|
),
|
|
140
145
|
)
|
|
141
146
|
|
|
@@ -12,9 +12,33 @@ import httpx
|
|
|
12
12
|
from pydantic import Field
|
|
13
13
|
|
|
14
14
|
from ..och_client import OnCallHealthClient
|
|
15
|
+
from ..validators import validate_page_params
|
|
15
16
|
|
|
16
17
|
JsonDict = dict[str, Any]
|
|
17
18
|
MakeAuthenticatedRequest = Callable[..., Awaitable[Any]]
|
|
19
|
+
SHIFT_INCIDENT_QUERY_FIELDS = (
|
|
20
|
+
"title,status,started_at,resolved_at,created_at,summary,"
|
|
21
|
+
"customer_impact_summary,mitigation,severity,url"
|
|
22
|
+
)
|
|
23
|
+
DEFAULT_MAX_SHIFT_INCIDENT_RESULTS = 100
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _truncate_text(value: Any, max_length: int = 280) -> str | None:
|
|
27
|
+
"""Keep large narrative fields compact enough for MCP clients."""
|
|
28
|
+
if not value:
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
if not isinstance(value, str):
|
|
32
|
+
value = str(value)
|
|
33
|
+
|
|
34
|
+
value = value.strip()
|
|
35
|
+
if not value:
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
if len(value) <= max_length:
|
|
39
|
+
return value
|
|
40
|
+
|
|
41
|
+
return f"{value[: max_length - 1].rstrip()}…"
|
|
18
42
|
|
|
19
43
|
|
|
20
44
|
def _normalize_incident_severity(severity: Any) -> str:
|
|
@@ -820,6 +844,8 @@ def register_oncall_tools(
|
|
|
820
844
|
severity="",
|
|
821
845
|
status="",
|
|
822
846
|
tags="",
|
|
847
|
+
include_preexisting_active=True,
|
|
848
|
+
max_incidents=25,
|
|
823
849
|
)
|
|
824
850
|
|
|
825
851
|
schedule_info["shift_incidents"] = (
|
|
@@ -872,6 +898,9 @@ def register_oncall_tools(
|
|
|
872
898
|
severity: str = "",
|
|
873
899
|
status: str = "",
|
|
874
900
|
tags: str = "",
|
|
901
|
+
*,
|
|
902
|
+
include_preexisting_active: bool = False,
|
|
903
|
+
max_incidents: int | None = None,
|
|
875
904
|
) -> dict:
|
|
876
905
|
"""Internal helper to fetch incidents - used by both get_shift_incidents and get_oncall_handoff_summary."""
|
|
877
906
|
try:
|
|
@@ -881,10 +910,17 @@ def register_oncall_tools(
|
|
|
881
910
|
# Fetch incidents that:
|
|
882
911
|
# 1. Were created during the shift (created_at in range)
|
|
883
912
|
# 2. OR are currently active/unresolved (started but not resolved yet)
|
|
884
|
-
params = {
|
|
913
|
+
params = {
|
|
914
|
+
"page[size]": 100,
|
|
915
|
+
"sort": "-created_at",
|
|
916
|
+
"fields[incidents]": SHIFT_INCIDENT_QUERY_FIELDS,
|
|
917
|
+
}
|
|
885
918
|
|
|
886
|
-
#
|
|
887
|
-
#
|
|
919
|
+
# Fetch incidents that started before the shift ended, then use
|
|
920
|
+
# in-memory filtering to keep incidents that were created, started,
|
|
921
|
+
# or resolved during the shift. We cannot safely apply a lower
|
|
922
|
+
# started_at bound here without dropping incidents that began
|
|
923
|
+
# before the shift and resolved during it.
|
|
888
924
|
params["filter[started_at][lte]"] = end_time # Started before shift ended
|
|
889
925
|
|
|
890
926
|
# Add severity filter if provided
|
|
@@ -998,7 +1034,12 @@ def register_oncall_tools(
|
|
|
998
1034
|
if resolved_dt and shift_start_dt <= resolved_dt <= shift_end_dt:
|
|
999
1035
|
include_incident = True # Resolved during shift
|
|
1000
1036
|
|
|
1001
|
-
if
|
|
1037
|
+
if (
|
|
1038
|
+
include_preexisting_active
|
|
1039
|
+
and not resolved_dt
|
|
1040
|
+
and started_dt
|
|
1041
|
+
and started_dt <= now_dt
|
|
1042
|
+
):
|
|
1002
1043
|
include_incident = True # Currently active
|
|
1003
1044
|
|
|
1004
1045
|
if not include_incident:
|
|
@@ -1015,6 +1056,9 @@ def register_oncall_tools(
|
|
|
1015
1056
|
# What happened
|
|
1016
1057
|
title = attrs.get("title", "Untitled Incident")
|
|
1017
1058
|
severity = _normalize_incident_severity(attrs.get("severity"))
|
|
1059
|
+
summary_text = _truncate_text(attrs.get("summary"))
|
|
1060
|
+
impact_text = _truncate_text(attrs.get("customer_impact_summary"))
|
|
1061
|
+
mitigation_text = _truncate_text(attrs.get("mitigation"))
|
|
1018
1062
|
narrative_parts.append(f"[{severity.upper()}] {title}")
|
|
1019
1063
|
|
|
1020
1064
|
# When and duration
|
|
@@ -1028,22 +1072,22 @@ def register_oncall_tools(
|
|
|
1028
1072
|
narrative_parts.append(f"Status: {attrs.get('status')}")
|
|
1029
1073
|
|
|
1030
1074
|
# What was the issue
|
|
1031
|
-
if
|
|
1032
|
-
narrative_parts.append(f"Details: {
|
|
1075
|
+
if summary_text:
|
|
1076
|
+
narrative_parts.append(f"Details: {summary_text}")
|
|
1033
1077
|
|
|
1034
1078
|
# Impact
|
|
1035
|
-
if
|
|
1036
|
-
narrative_parts.append(f"Impact: {
|
|
1079
|
+
if impact_text:
|
|
1080
|
+
narrative_parts.append(f"Impact: {impact_text}")
|
|
1037
1081
|
|
|
1038
1082
|
# Resolution (if available)
|
|
1039
|
-
if
|
|
1040
|
-
narrative_parts.append(f"Resolution: {
|
|
1083
|
+
if mitigation_text:
|
|
1084
|
+
narrative_parts.append(f"Resolution: {mitigation_text}")
|
|
1041
1085
|
elif attrs.get("action_items_count") and attrs.get("action_items_count") > 0:
|
|
1042
1086
|
narrative_parts.append(
|
|
1043
1087
|
f"Action items created: {attrs.get('action_items_count')}"
|
|
1044
1088
|
)
|
|
1045
1089
|
|
|
1046
|
-
narrative = " | ".join(narrative_parts)
|
|
1090
|
+
narrative = _truncate_text(" | ".join(narrative_parts), max_length=400)
|
|
1047
1091
|
|
|
1048
1092
|
incidents_summary.append(
|
|
1049
1093
|
{
|
|
@@ -1054,11 +1098,11 @@ def register_oncall_tools(
|
|
|
1054
1098
|
"started_at": started_at,
|
|
1055
1099
|
"resolved_at": resolved_at,
|
|
1056
1100
|
"duration_minutes": duration_minutes,
|
|
1057
|
-
"summary":
|
|
1058
|
-
"impact":
|
|
1059
|
-
"mitigation":
|
|
1101
|
+
"summary": summary_text,
|
|
1102
|
+
"impact": impact_text,
|
|
1103
|
+
"mitigation": mitigation_text,
|
|
1060
1104
|
"narrative": narrative,
|
|
1061
|
-
"incident_url": attrs.get("incident_url"),
|
|
1105
|
+
"incident_url": attrs.get("incident_url") or attrs.get("url"),
|
|
1062
1106
|
}
|
|
1063
1107
|
)
|
|
1064
1108
|
|
|
@@ -1082,6 +1126,12 @@ def register_oncall_tools(
|
|
|
1082
1126
|
if durations:
|
|
1083
1127
|
avg_resolution_time = int(sum(durations) / len(durations))
|
|
1084
1128
|
|
|
1129
|
+
returned_incidents = incidents_summary
|
|
1130
|
+
truncated_count = 0
|
|
1131
|
+
if max_incidents is not None and total_incidents > max_incidents:
|
|
1132
|
+
returned_incidents = incidents_summary[:max_incidents]
|
|
1133
|
+
truncated_count = total_incidents - max_incidents
|
|
1134
|
+
|
|
1085
1135
|
return {
|
|
1086
1136
|
"success": True,
|
|
1087
1137
|
"period": {"start_time": start_time, "end_time": end_time},
|
|
@@ -1092,7 +1142,10 @@ def register_oncall_tools(
|
|
|
1092
1142
|
"average_resolution_minutes": avg_resolution_time,
|
|
1093
1143
|
"by_severity": {k: len(v) for k, v in by_severity.items()},
|
|
1094
1144
|
},
|
|
1095
|
-
"incidents":
|
|
1145
|
+
"incidents": returned_incidents,
|
|
1146
|
+
"returned_incidents": len(returned_incidents),
|
|
1147
|
+
"truncated_incidents": truncated_count,
|
|
1148
|
+
"results_truncated": truncated_count > 0,
|
|
1096
1149
|
}
|
|
1097
1150
|
|
|
1098
1151
|
except Exception as e:
|
|
@@ -1157,7 +1210,14 @@ def register_oncall_tools(
|
|
|
1157
1210
|
Returns incident details including severity, status, duration, and basic summary.
|
|
1158
1211
|
"""
|
|
1159
1212
|
return await _fetch_shift_incidents_internal(
|
|
1160
|
-
start_time,
|
|
1213
|
+
start_time,
|
|
1214
|
+
end_time,
|
|
1215
|
+
schedule_ids,
|
|
1216
|
+
severity,
|
|
1217
|
+
status,
|
|
1218
|
+
tags,
|
|
1219
|
+
include_preexisting_active=False,
|
|
1220
|
+
max_incidents=DEFAULT_MAX_SHIFT_INCIDENT_RESULTS,
|
|
1161
1221
|
)
|
|
1162
1222
|
|
|
1163
1223
|
# Cache for lookup maps (TTL: 5 minutes)
|
|
@@ -1287,20 +1347,41 @@ def register_oncall_tools(
|
|
|
1287
1347
|
bool,
|
|
1288
1348
|
Field(description="Include user name and email in response (default: True)"),
|
|
1289
1349
|
] = True,
|
|
1350
|
+
page_size: Annotated[
|
|
1351
|
+
int,
|
|
1352
|
+
Field(
|
|
1353
|
+
description="Number of enriched shifts to return per page (max: 100). This paginates the MCP response even when the upstream shifts API does not."
|
|
1354
|
+
),
|
|
1355
|
+
] = 25,
|
|
1356
|
+
page_number: Annotated[
|
|
1357
|
+
int,
|
|
1358
|
+
Field(
|
|
1359
|
+
description="Page number of enriched shifts to return (1-indexed). Use this instead of relying on the generated listShifts pagination."
|
|
1360
|
+
),
|
|
1361
|
+
] = 1,
|
|
1290
1362
|
) -> dict:
|
|
1291
1363
|
"""
|
|
1292
|
-
List on-call shifts with
|
|
1364
|
+
List on-call shifts with reliable filtering, enrichment, and MCP-level pagination.
|
|
1293
1365
|
|
|
1294
1366
|
Unlike the raw API, this tool:
|
|
1295
1367
|
- Actually filters by user_ids (client-side filtering)
|
|
1296
1368
|
- Includes user_name, user_email, schedule_name, team_name
|
|
1297
1369
|
- Calculates total_hours for each shift
|
|
1370
|
+
- Returns a predictable paginated result for MCP clients
|
|
1298
1371
|
|
|
1299
|
-
Use this instead of the auto-generated listShifts when you need user filtering
|
|
1372
|
+
Use this instead of the auto-generated listShifts when you need user filtering
|
|
1373
|
+
or dependable pagination on large tenants.
|
|
1300
1374
|
"""
|
|
1301
1375
|
try:
|
|
1302
1376
|
from datetime import datetime
|
|
1303
1377
|
|
|
1378
|
+
page_size, page_number = validate_page_params(page_size, page_number)
|
|
1379
|
+
if page_number == 0:
|
|
1380
|
+
return mcp_error.tool_error(
|
|
1381
|
+
"page_number must be >= 1 for list_shifts",
|
|
1382
|
+
"validation_error",
|
|
1383
|
+
details={"page_number": page_number},
|
|
1384
|
+
)
|
|
1304
1385
|
# Build query parameters
|
|
1305
1386
|
params: dict[str, Any] = {
|
|
1306
1387
|
"from": from_date,
|
|
@@ -1429,14 +1510,29 @@ def register_oncall_tools(
|
|
|
1429
1510
|
|
|
1430
1511
|
enriched_shifts.append(enriched_shift)
|
|
1431
1512
|
|
|
1513
|
+
total_matching_shifts = len(enriched_shifts)
|
|
1514
|
+
start_index = (page_number - 1) * page_size
|
|
1515
|
+
end_index = start_index + page_size
|
|
1516
|
+
paginated_shifts = enriched_shifts[start_index:end_index]
|
|
1517
|
+
has_more = end_index < total_matching_shifts
|
|
1518
|
+
|
|
1432
1519
|
return {
|
|
1433
1520
|
"period": {"from": from_date, "to": to_date},
|
|
1434
|
-
"total_shifts":
|
|
1521
|
+
"total_shifts": total_matching_shifts,
|
|
1522
|
+
"returned_shifts": len(paginated_shifts),
|
|
1435
1523
|
"filters_applied": {
|
|
1436
1524
|
"user_ids": list(user_id_filter) if user_id_filter else None,
|
|
1437
1525
|
"schedule_ids": schedule_ids if schedule_ids else None,
|
|
1438
1526
|
},
|
|
1439
|
-
"
|
|
1527
|
+
"meta": {
|
|
1528
|
+
"page_size": page_size,
|
|
1529
|
+
"page_number": page_number,
|
|
1530
|
+
"total_matching_shifts": total_matching_shifts,
|
|
1531
|
+
"returned_shifts": len(paginated_shifts),
|
|
1532
|
+
"has_more": has_more,
|
|
1533
|
+
"next_page": page_number + 1 if has_more else None,
|
|
1534
|
+
},
|
|
1535
|
+
"shifts": paginated_shifts,
|
|
1440
1536
|
}
|
|
1441
1537
|
|
|
1442
1538
|
except Exception as e:
|
|
@@ -333,6 +333,197 @@ ALERT_ESSENTIAL_ATTRIBUTES = {
|
|
|
333
333
|
"started_at",
|
|
334
334
|
"ended_at",
|
|
335
335
|
}
|
|
336
|
+
USER_ESSENTIAL_ATTRIBUTES = {
|
|
337
|
+
"name",
|
|
338
|
+
"email",
|
|
339
|
+
"phone",
|
|
340
|
+
"phone_2",
|
|
341
|
+
"first_name",
|
|
342
|
+
"last_name",
|
|
343
|
+
"full_name",
|
|
344
|
+
"full_name_with_team",
|
|
345
|
+
"slack_id",
|
|
346
|
+
"time_zone",
|
|
347
|
+
"created_at",
|
|
348
|
+
"updated_at",
|
|
349
|
+
}
|
|
350
|
+
SERVICE_ESSENTIAL_ATTRIBUTES = {
|
|
351
|
+
"name",
|
|
352
|
+
"slug",
|
|
353
|
+
"description",
|
|
354
|
+
"public_description",
|
|
355
|
+
"color",
|
|
356
|
+
"status",
|
|
357
|
+
"show_uptime",
|
|
358
|
+
"show_uptime_last_days",
|
|
359
|
+
"environment_ids",
|
|
360
|
+
"service_ids",
|
|
361
|
+
"owner_group_ids",
|
|
362
|
+
"owners_group_ids",
|
|
363
|
+
"owner_user_ids",
|
|
364
|
+
"owners_user_ids",
|
|
365
|
+
"incidents_count",
|
|
366
|
+
"alert_urgency_id",
|
|
367
|
+
"created_at",
|
|
368
|
+
"updated_at",
|
|
369
|
+
"external_id",
|
|
370
|
+
"backstage_id",
|
|
371
|
+
"cortex_id",
|
|
372
|
+
"opslevel_id",
|
|
373
|
+
}
|
|
374
|
+
SHIFT_ESSENTIAL_ATTRIBUTES = {
|
|
375
|
+
"schedule_id",
|
|
376
|
+
"rotation_id",
|
|
377
|
+
"starts_at",
|
|
378
|
+
"ends_at",
|
|
379
|
+
"is_override",
|
|
380
|
+
}
|
|
381
|
+
MINIMAL_USER_INCLUDED_ATTRIBUTES = {
|
|
382
|
+
"name",
|
|
383
|
+
"email",
|
|
384
|
+
"full_name",
|
|
385
|
+
"time_zone",
|
|
386
|
+
}
|
|
387
|
+
MINIMAL_ROLE_INCLUDED_ATTRIBUTES = {"name", "slug"}
|
|
388
|
+
MINIMAL_SHIFT_OVERRIDE_INCLUDED_ATTRIBUTES = {
|
|
389
|
+
"starts_at",
|
|
390
|
+
"ends_at",
|
|
391
|
+
"created_at",
|
|
392
|
+
"updated_at",
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _collapse_relationship_data(relationship: Any) -> Any:
|
|
397
|
+
"""Reduce relationship payloads to ids/counts while keeping structure predictable."""
|
|
398
|
+
if not isinstance(relationship, dict) or "data" not in relationship:
|
|
399
|
+
return relationship
|
|
400
|
+
|
|
401
|
+
data = relationship.get("data")
|
|
402
|
+
if isinstance(data, list):
|
|
403
|
+
return {"count": len(data)}
|
|
404
|
+
if isinstance(data, dict):
|
|
405
|
+
return {"data": {"id": data.get("id"), "type": data.get("type")}}
|
|
406
|
+
return {"data": data}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _strip_resource_attributes(resource: Any, allowed_attributes: set[str]) -> None:
|
|
410
|
+
"""Trim a resource's attributes to the allowed subset."""
|
|
411
|
+
if not isinstance(resource, dict):
|
|
412
|
+
return
|
|
413
|
+
attrs = resource.get("attributes")
|
|
414
|
+
if not isinstance(attrs, dict):
|
|
415
|
+
return
|
|
416
|
+
for key in [attr_key for attr_key in attrs if attr_key not in allowed_attributes]:
|
|
417
|
+
del attrs[key]
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _strip_minimal_included_resource(resource: Any) -> None:
|
|
421
|
+
"""Trim sideloaded resources for user/shift/service list endpoints."""
|
|
422
|
+
if not isinstance(resource, dict):
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
resource_type = resource.get("type")
|
|
426
|
+
if resource_type == "users":
|
|
427
|
+
_strip_resource_attributes(resource, MINIMAL_USER_INCLUDED_ATTRIBUTES)
|
|
428
|
+
elif resource_type in {"roles", "on_call_roles"}:
|
|
429
|
+
_strip_resource_attributes(resource, MINIMAL_ROLE_INCLUDED_ATTRIBUTES)
|
|
430
|
+
elif resource_type == "shift_overrides":
|
|
431
|
+
_strip_resource_attributes(resource, MINIMAL_SHIFT_OVERRIDE_INCLUDED_ATTRIBUTES)
|
|
432
|
+
else:
|
|
433
|
+
resource.pop("attributes", None)
|
|
434
|
+
resource.pop("relationships", None)
|
|
435
|
+
return
|
|
436
|
+
|
|
437
|
+
resource.pop("relationships", None)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def strip_heavy_user_data(data: dict[str, Any]) -> dict[str, Any]:
|
|
441
|
+
"""Trim user list responses to profile essentials and compact relationship data."""
|
|
442
|
+
if not isinstance(data, dict) or "data" not in data:
|
|
443
|
+
return data
|
|
444
|
+
|
|
445
|
+
def _strip_single_user(user: Any) -> None:
|
|
446
|
+
if not isinstance(user, dict):
|
|
447
|
+
return
|
|
448
|
+
_strip_resource_attributes(user, USER_ESSENTIAL_ATTRIBUTES)
|
|
449
|
+
if "relationships" in user and isinstance(user["relationships"], dict):
|
|
450
|
+
user["relationships"] = {
|
|
451
|
+
rel_key: _collapse_relationship_data(rel_value)
|
|
452
|
+
for rel_key, rel_value in user["relationships"].items()
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if isinstance(data["data"], list):
|
|
456
|
+
for user in data["data"]:
|
|
457
|
+
_strip_single_user(user)
|
|
458
|
+
elif isinstance(data["data"], dict):
|
|
459
|
+
_strip_single_user(data["data"])
|
|
460
|
+
|
|
461
|
+
included = data.get("included")
|
|
462
|
+
if isinstance(included, list):
|
|
463
|
+
for resource in included:
|
|
464
|
+
_strip_minimal_included_resource(resource)
|
|
465
|
+
|
|
466
|
+
return data
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def strip_heavy_service_data(data: dict[str, Any]) -> dict[str, Any]:
|
|
470
|
+
"""Trim service list responses to operational essentials."""
|
|
471
|
+
if not isinstance(data, dict) or "data" not in data:
|
|
472
|
+
return data
|
|
473
|
+
|
|
474
|
+
def _strip_single_service(service: Any) -> None:
|
|
475
|
+
if not isinstance(service, dict):
|
|
476
|
+
return
|
|
477
|
+
_strip_resource_attributes(service, SERVICE_ESSENTIAL_ATTRIBUTES)
|
|
478
|
+
if "relationships" in service and isinstance(service["relationships"], dict):
|
|
479
|
+
service["relationships"] = {
|
|
480
|
+
rel_key: _collapse_relationship_data(rel_value)
|
|
481
|
+
for rel_key, rel_value in service["relationships"].items()
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if isinstance(data["data"], list):
|
|
485
|
+
for service in data["data"]:
|
|
486
|
+
_strip_single_service(service)
|
|
487
|
+
elif isinstance(data["data"], dict):
|
|
488
|
+
_strip_single_service(data["data"])
|
|
489
|
+
|
|
490
|
+
included = data.get("included")
|
|
491
|
+
if isinstance(included, list):
|
|
492
|
+
for resource in included:
|
|
493
|
+
_strip_minimal_included_resource(resource)
|
|
494
|
+
|
|
495
|
+
return data
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def strip_heavy_shift_data(data: dict[str, Any]) -> dict[str, Any]:
|
|
499
|
+
"""Trim shift list responses to schedule/timing essentials plus minimal user refs."""
|
|
500
|
+
if not isinstance(data, dict) or "data" not in data:
|
|
501
|
+
return data
|
|
502
|
+
|
|
503
|
+
def _strip_single_shift(shift: Any) -> None:
|
|
504
|
+
if not isinstance(shift, dict):
|
|
505
|
+
return
|
|
506
|
+
_strip_resource_attributes(shift, SHIFT_ESSENTIAL_ATTRIBUTES)
|
|
507
|
+
relationships = shift.get("relationships")
|
|
508
|
+
if isinstance(relationships, dict):
|
|
509
|
+
kept_relationships = {}
|
|
510
|
+
for rel_key in ("user", "shift_override"):
|
|
511
|
+
if rel_key in relationships:
|
|
512
|
+
kept_relationships[rel_key] = _collapse_relationship_data(relationships[rel_key])
|
|
513
|
+
shift["relationships"] = kept_relationships
|
|
514
|
+
|
|
515
|
+
if isinstance(data["data"], list):
|
|
516
|
+
for shift in data["data"]:
|
|
517
|
+
_strip_single_shift(shift)
|
|
518
|
+
elif isinstance(data["data"], dict):
|
|
519
|
+
_strip_single_shift(data["data"])
|
|
520
|
+
|
|
521
|
+
included = data.get("included")
|
|
522
|
+
if isinstance(included, list):
|
|
523
|
+
for resource in included:
|
|
524
|
+
_strip_minimal_included_resource(resource)
|
|
525
|
+
|
|
526
|
+
return data
|
|
336
527
|
|
|
337
528
|
|
|
338
529
|
def strip_heavy_alert_data(data: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -525,6 +716,7 @@ class AuthenticatedHTTPXClient:
|
|
|
525
716
|
# OpenAPITool.run() calls response.json() after this returns, and
|
|
526
717
|
# there is no other interception point for auto-generated tools.
|
|
527
718
|
response = self._maybe_strip_alert_response(method, url, response)
|
|
719
|
+
response = self._maybe_strip_collection_response(method, url, response)
|
|
528
720
|
|
|
529
721
|
return response
|
|
530
722
|
|
|
@@ -539,6 +731,14 @@ class AuthenticatedHTTPXClient:
|
|
|
539
731
|
for sub in ["/alert_urgencies", "/alert_events", "/alert_sources", "/alert_routing"]
|
|
540
732
|
)
|
|
541
733
|
|
|
734
|
+
@staticmethod
|
|
735
|
+
def _path_for_url(url: str) -> str:
|
|
736
|
+
"""Return the path portion of a URL or path-like string."""
|
|
737
|
+
try:
|
|
738
|
+
return httpx.URL(str(url)).path
|
|
739
|
+
except Exception:
|
|
740
|
+
return str(url).split("?", 1)[0]
|
|
741
|
+
|
|
542
742
|
@staticmethod
|
|
543
743
|
def _maybe_strip_alert_response(
|
|
544
744
|
method: str, url: str, response: httpx.Response
|
|
@@ -558,6 +758,31 @@ class AuthenticatedHTTPXClient:
|
|
|
558
758
|
logger.debug(f"Could not strip alert response for {url}", exc_info=True)
|
|
559
759
|
return response
|
|
560
760
|
|
|
761
|
+
@classmethod
|
|
762
|
+
def _maybe_strip_collection_response(
|
|
763
|
+
cls, method: str, url: str, response: httpx.Response
|
|
764
|
+
) -> httpx.Response:
|
|
765
|
+
"""Trim known high-volume collection endpoints while keeping essential fields."""
|
|
766
|
+
if method.upper() != "GET" or not response.is_success:
|
|
767
|
+
return response
|
|
768
|
+
|
|
769
|
+
path = cls._path_for_url(url)
|
|
770
|
+
if path not in {"/v1/users", "/v1/services", "/v1/shifts"}:
|
|
771
|
+
return response
|
|
772
|
+
|
|
773
|
+
try:
|
|
774
|
+
data = response.json()
|
|
775
|
+
if path == "/v1/users":
|
|
776
|
+
stripped = strip_heavy_user_data(data)
|
|
777
|
+
elif path == "/v1/services":
|
|
778
|
+
stripped = strip_heavy_service_data(data)
|
|
779
|
+
else:
|
|
780
|
+
stripped = strip_heavy_shift_data(data)
|
|
781
|
+
response._content = json.dumps(stripped).encode() # noqa: SLF001
|
|
782
|
+
except Exception:
|
|
783
|
+
logger.debug(f"Could not strip collection response for {url}", exc_info=True)
|
|
784
|
+
return response
|
|
785
|
+
|
|
561
786
|
async def get(self, url: str, **kwargs):
|
|
562
787
|
"""Proxy to request with GET method."""
|
|
563
788
|
return await self.request("GET", url, **kwargs)
|
|
@@ -632,6 +857,9 @@ class AuthenticatedHTTPXClient:
|
|
|
632
857
|
_record_upstream_response_context(request.method, response)
|
|
633
858
|
|
|
634
859
|
response = self._maybe_strip_alert_response(request.method, str(request.url), response)
|
|
860
|
+
response = self._maybe_strip_collection_response(
|
|
861
|
+
request.method, str(request.url), response
|
|
862
|
+
)
|
|
635
863
|
return response
|
|
636
864
|
|
|
637
865
|
async def __aenter__(self):
|
|
@@ -46,7 +46,17 @@ def test_build_code_mode_transform_uses_expected_discovery_tools():
|
|
|
46
46
|
assert isinstance(transform, CodeMode)
|
|
47
47
|
assert isinstance(transform.sandbox_provider, CompatibleMontySandboxProvider)
|
|
48
48
|
discovery_names = [tool.name for tool in transform._build_discovery_tools()] # noqa: SLF001
|
|
49
|
-
assert discovery_names == ["list_tools", "
|
|
49
|
+
assert discovery_names == ["list_tools", "tool_search", "get_schema", "tags"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_build_code_mode_transform_includes_pagination_guidance():
|
|
53
|
+
transform = build_code_mode_transform()
|
|
54
|
+
assert transform.execute_description is not None
|
|
55
|
+
|
|
56
|
+
assert "tool_search only to discover tools" in transform.execute_description
|
|
57
|
+
assert "page_size, page_number, and max_results" in transform.execute_description
|
|
58
|
+
assert "per_page" in transform.execute_description
|
|
59
|
+
assert "await call_tool('search_incidents'" in transform.execute_description
|
|
50
60
|
|
|
51
61
|
|
|
52
62
|
def test_create_rootly_codemode_server_adds_code_mode_transform():
|