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.
Files changed (79) hide show
  1. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/PKG-INFO +23 -1
  2. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/README.md +22 -0
  3. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/pyproject.toml +1 -1
  4. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/code_mode.py +9 -4
  5. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/tools/oncall.py +117 -21
  6. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/transport.py +228 -0
  7. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_code_mode_module.py +11 -1
  8. rootly_mcp_server-2.2.12/tests/unit/test_oncall_handoff.py +475 -0
  9. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_transport_module.py +126 -0
  10. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/uv.lock +1 -1
  11. rootly_mcp_server-2.2.11/tests/unit/test_oncall_handoff.py +0 -69
  12. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.dockerignore +0 -0
  13. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.gitattributes +0 -0
  14. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.github/dependabot.yml +0 -0
  15. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.github/workflows/ci.yml +0 -0
  16. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.github/workflows/dependency-review.yml +0 -0
  17. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.github/workflows/pypi-release.yml +0 -0
  18. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.gitignore +0 -0
  19. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.semaphore/deploy-staging.yml +0 -0
  20. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.semaphore/deploy.yml +0 -0
  21. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.semaphore/semaphore.yml +0 -0
  22. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/.semaphore/update-task-definition.sh +0 -0
  23. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/CHANGELOG.md +0 -0
  24. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/CONTRIBUTING.md +0 -0
  25. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/Dockerfile +0 -0
  26. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/LICENSE +0 -0
  27. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/examples/skills/README.md +0 -0
  28. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/gemini-extension.json +0 -0
  29. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/rootly-mcp-server-demo.gif +0 -0
  30. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/rootly_openapi.json +0 -0
  31. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/scripts/setup-hooks.sh +0 -0
  32. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/server.json +0 -0
  33. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/__init__.py +0 -0
  34. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/__main__.py +0 -0
  35. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/client.py +0 -0
  36. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/data/__init__.py +0 -0
  37. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/exceptions.py +0 -0
  38. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/legacy_server.py +0 -0
  39. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/mcp_error.py +0 -0
  40. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/monitoring.py +0 -0
  41. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/och_client.py +0 -0
  42. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/pagination.py +0 -0
  43. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/payload_stripping.py +0 -0
  44. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/security.py +0 -0
  45. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/server.py +0 -0
  46. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/server_defaults.py +0 -0
  47. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/smart_utils.py +0 -0
  48. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/spec_transform.py +0 -0
  49. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/tools/__init__.py +0 -0
  50. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/tools/alerts.py +0 -0
  51. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/tools/incidents.py +0 -0
  52. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/tools/resources.py +0 -0
  53. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/utils.py +0 -0
  54. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/src/rootly_mcp_server/validators.py +0 -0
  55. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/README.md +0 -0
  56. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/conftest.py +0 -0
  57. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/integration/local/test_basic.py +0 -0
  58. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/integration/local/test_smart_tools.py +0 -0
  59. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/integration/remote/test_essential.py +0 -0
  60. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/test_client.py +0 -0
  61. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_alert_stripping.py +0 -0
  62. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_authentication.py +0 -0
  63. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_exceptions.py +0 -0
  64. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_http_headers.py +0 -0
  65. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_legacy_server_module.py +0 -0
  66. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_main_transport.py +0 -0
  67. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_mcp_error_module.py +0 -0
  68. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_och_client.py +0 -0
  69. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_oncall_metrics.py +0 -0
  70. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_oncall_new_tools.py +0 -0
  71. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_oncall_severity_normalization.py +0 -0
  72. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_payload_stripping_module.py +0 -0
  73. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_security.py +0 -0
  74. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_server.py +0 -0
  75. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_server_defaults_module.py +0 -0
  76. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_smart_utils.py +0 -0
  77. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_tools.py +0 -0
  78. {rootly_mcp_server-2.2.11 → rootly_mcp_server-2.2.12}/tests/unit/test_utils.py +0 -0
  79. {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.11
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.11"
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. Prefer Rootly's higher-level custom tools when "
137
- "they fit the task, then fall back to lower-level API tools as needed. Use return "
138
- "to emit the final result."
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 = {"page[size]": 100, "sort": "-created_at"}
913
+ params = {
914
+ "page[size]": 100,
915
+ "sort": "-created_at",
916
+ "fields[incidents]": SHIFT_INCIDENT_QUERY_FIELDS,
917
+ }
885
918
 
886
- # Get incidents created during shift OR still active
887
- # We'll fetch all incidents and filter in-memory for active ones
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 not resolved_dt and started_dt and started_dt <= now_dt:
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 attrs.get("summary"):
1032
- narrative_parts.append(f"Details: {attrs.get('summary')}")
1075
+ if summary_text:
1076
+ narrative_parts.append(f"Details: {summary_text}")
1033
1077
 
1034
1078
  # Impact
1035
- if attrs.get("customer_impact_summary"):
1036
- narrative_parts.append(f"Impact: {attrs.get('customer_impact_summary')}")
1079
+ if impact_text:
1080
+ narrative_parts.append(f"Impact: {impact_text}")
1037
1081
 
1038
1082
  # Resolution (if available)
1039
- if attrs.get("mitigation"):
1040
- narrative_parts.append(f"Resolution: {attrs.get('mitigation')}")
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": attrs.get("summary"),
1058
- "impact": attrs.get("customer_impact_summary"),
1059
- "mitigation": attrs.get("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": incidents_summary,
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, end_time, schedule_ids, severity, status, tags
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 proper user filtering and enriched data.
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": len(enriched_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
- "shifts": enriched_shifts,
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", "search", "get_schema", "tags"]
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():