rootly-mcp-server 2.0.14__py3-none-any.whl → 2.0.15__py3-none-any.whl
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/server.py +1014 -28
- rootly_mcp_server/texttest.json +3178 -0
- {rootly_mcp_server-2.0.14.dist-info → rootly_mcp_server-2.0.15.dist-info}/METADATA +121 -44
- {rootly_mcp_server-2.0.14.dist-info → rootly_mcp_server-2.0.15.dist-info}/RECORD +7 -6
- {rootly_mcp_server-2.0.14.dist-info → rootly_mcp_server-2.0.15.dist-info}/WHEEL +0 -0
- {rootly_mcp_server-2.0.14.dist-info → rootly_mcp_server-2.0.15.dist-info}/entry_points.txt +0 -0
- {rootly_mcp_server-2.0.14.dist-info → rootly_mcp_server-2.0.15.dist-info}/licenses/LICENSE +0 -0
rootly_mcp_server/server.py
CHANGED
|
@@ -153,6 +153,22 @@ DEFAULT_ALLOWED_PATHS = [
|
|
|
153
153
|
# Status pages
|
|
154
154
|
"/status_pages",
|
|
155
155
|
"/status_pages/{status_page_id}",
|
|
156
|
+
# On-call schedules and shifts
|
|
157
|
+
"/schedules",
|
|
158
|
+
"/schedules/{schedule_id}",
|
|
159
|
+
"/schedules/{schedule_id}/shifts",
|
|
160
|
+
"/shifts",
|
|
161
|
+
"/schedule_rotations/{schedule_rotation_id}",
|
|
162
|
+
"/schedule_rotations/{schedule_rotation_id}/schedule_rotation_users",
|
|
163
|
+
"/schedule_rotations/{schedule_rotation_id}/schedule_rotation_active_days",
|
|
164
|
+
# On-call overrides
|
|
165
|
+
"/schedules/{schedule_id}/override_shifts",
|
|
166
|
+
"/override_shifts/{override_shift_id}",
|
|
167
|
+
# On-call shadows and roles
|
|
168
|
+
"/schedules/{schedule_id}/on_call_shadows",
|
|
169
|
+
"/on_call_shadows/{on_call_shadow_id}",
|
|
170
|
+
"/on_call_roles",
|
|
171
|
+
"/on_call_roles/{on_call_role_id}",
|
|
156
172
|
]
|
|
157
173
|
|
|
158
174
|
|
|
@@ -487,42 +503,66 @@ def create_rootly_mcp_server(
|
|
|
487
503
|
|
|
488
504
|
@mcp.tool()
|
|
489
505
|
async def find_related_incidents(
|
|
490
|
-
incident_id: str,
|
|
491
|
-
|
|
492
|
-
|
|
506
|
+
incident_id: str = "",
|
|
507
|
+
incident_description: str = "",
|
|
508
|
+
similarity_threshold: Annotated[float, Field(description="Minimum similarity score (0.0-1.0)", ge=0.0, le=1.0)] = 0.15,
|
|
509
|
+
max_results: Annotated[int, Field(description="Maximum number of related incidents to return", ge=1, le=20)] = 5,
|
|
510
|
+
status_filter: Annotated[str, Field(description="Filter incidents by status (empty for all, 'resolved', 'investigating', etc.)")] = ""
|
|
493
511
|
) -> dict:
|
|
494
|
-
"""Find
|
|
512
|
+
"""Find similar incidents to help with context and resolution strategies. Provide either incident_id OR incident_description (e.g., 'website is down', 'database timeout errors'). Use status_filter to limit to specific incident statuses or leave empty for all incidents."""
|
|
495
513
|
try:
|
|
496
|
-
|
|
497
|
-
target_response = await make_authenticated_request("GET", f"/v1/incidents/{incident_id}")
|
|
498
|
-
target_response.raise_for_status()
|
|
499
|
-
target_incident_data = target_response.json()
|
|
500
|
-
target_incident = target_incident_data.get("data", {})
|
|
514
|
+
target_incident = {}
|
|
501
515
|
|
|
502
|
-
if
|
|
503
|
-
|
|
516
|
+
if incident_id:
|
|
517
|
+
# Get the target incident details by ID
|
|
518
|
+
target_response = await make_authenticated_request("GET", f"/v1/incidents/{incident_id}")
|
|
519
|
+
target_response.raise_for_status()
|
|
520
|
+
target_incident_data = target_response.json()
|
|
521
|
+
target_incident = target_incident_data.get("data", {})
|
|
522
|
+
|
|
523
|
+
if not target_incident:
|
|
524
|
+
return MCPError.tool_error("Incident not found", "not_found")
|
|
525
|
+
|
|
526
|
+
elif incident_description:
|
|
527
|
+
# Create synthetic incident for analysis from descriptive text
|
|
528
|
+
target_incident = {
|
|
529
|
+
"id": "synthetic",
|
|
530
|
+
"attributes": {
|
|
531
|
+
"title": incident_description,
|
|
532
|
+
"summary": incident_description,
|
|
533
|
+
"description": incident_description
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
else:
|
|
537
|
+
return MCPError.tool_error("Must provide either incident_id or incident_description", "validation_error")
|
|
504
538
|
|
|
505
|
-
# Get historical incidents for comparison
|
|
506
|
-
|
|
539
|
+
# Get historical incidents for comparison
|
|
540
|
+
params = {
|
|
507
541
|
"page[size]": 100, # Get more incidents for better matching
|
|
508
542
|
"page[number]": 1,
|
|
509
|
-
"filter[status]": "resolved", # Only look at resolved incidents
|
|
510
543
|
"include": ""
|
|
511
|
-
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
# Only add status filter if specified
|
|
547
|
+
if status_filter:
|
|
548
|
+
params["filter[status]"] = status_filter
|
|
549
|
+
|
|
550
|
+
historical_response = await make_authenticated_request("GET", "/v1/incidents", params=params)
|
|
512
551
|
historical_response.raise_for_status()
|
|
513
552
|
historical_data = historical_response.json()
|
|
514
553
|
historical_incidents = historical_data.get("data", [])
|
|
515
554
|
|
|
516
|
-
# Filter out the target incident itself
|
|
517
|
-
|
|
555
|
+
# Filter out the target incident itself if it exists
|
|
556
|
+
if incident_id:
|
|
557
|
+
historical_incidents = [inc for inc in historical_incidents if str(inc.get('id')) != str(incident_id)]
|
|
518
558
|
|
|
519
559
|
if not historical_incidents:
|
|
520
560
|
return {
|
|
521
561
|
"related_incidents": [],
|
|
522
562
|
"message": "No historical incidents found for comparison",
|
|
523
563
|
"target_incident": {
|
|
524
|
-
"id": incident_id,
|
|
525
|
-
"title": target_incident.get("attributes", {}).get("title",
|
|
564
|
+
"id": incident_id or "synthetic",
|
|
565
|
+
"title": target_incident.get("attributes", {}).get("title", incident_description)
|
|
526
566
|
}
|
|
527
567
|
}
|
|
528
568
|
|
|
@@ -550,8 +590,8 @@ def create_rootly_mcp_server(
|
|
|
550
590
|
|
|
551
591
|
return {
|
|
552
592
|
"target_incident": {
|
|
553
|
-
"id": incident_id,
|
|
554
|
-
"title": target_incident.get("attributes", {}).get("title",
|
|
593
|
+
"id": incident_id or "synthetic",
|
|
594
|
+
"title": target_incident.get("attributes", {}).get("title", incident_description)
|
|
555
595
|
},
|
|
556
596
|
"related_incidents": related_incidents,
|
|
557
597
|
"total_found": len(filtered_incidents),
|
|
@@ -568,9 +608,10 @@ def create_rootly_mcp_server(
|
|
|
568
608
|
incident_id: str = "",
|
|
569
609
|
incident_title: str = "",
|
|
570
610
|
incident_description: str = "",
|
|
571
|
-
max_solutions: Annotated[int, Field(description="Maximum number of solution suggestions", ge=1, le=10)] = 3
|
|
611
|
+
max_solutions: Annotated[int, Field(description="Maximum number of solution suggestions", ge=1, le=10)] = 3,
|
|
612
|
+
status_filter: Annotated[str, Field(description="Filter incidents by status (default 'resolved', empty for all, 'investigating', etc.)")] = "resolved"
|
|
572
613
|
) -> dict:
|
|
573
|
-
"""Suggest solutions based on similar
|
|
614
|
+
"""Suggest solutions based on similar incidents. Provide either incident_id OR title/description. Defaults to resolved incidents for solution mining, but can search all statuses."""
|
|
574
615
|
try:
|
|
575
616
|
target_incident = {}
|
|
576
617
|
|
|
@@ -597,13 +638,18 @@ def create_rootly_mcp_server(
|
|
|
597
638
|
else:
|
|
598
639
|
return MCPError.tool_error("Must provide either incident_id or incident_title/description", "validation_error")
|
|
599
640
|
|
|
600
|
-
# Get
|
|
601
|
-
|
|
641
|
+
# Get incidents for solution mining
|
|
642
|
+
params = {
|
|
602
643
|
"page[size]": 150, # Get more incidents for better solution matching
|
|
603
644
|
"page[number]": 1,
|
|
604
|
-
"filter[status]": "resolved",
|
|
605
645
|
"include": ""
|
|
606
|
-
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
# Only add status filter if specified
|
|
649
|
+
if status_filter:
|
|
650
|
+
params["filter[status]"] = status_filter
|
|
651
|
+
|
|
652
|
+
historical_response = await make_authenticated_request("GET", "/v1/incidents", params=params)
|
|
607
653
|
historical_response.raise_for_status()
|
|
608
654
|
historical_data = historical_response.json()
|
|
609
655
|
historical_incidents = historical_data.get("data", [])
|
|
@@ -613,9 +659,10 @@ def create_rootly_mcp_server(
|
|
|
613
659
|
historical_incidents = [inc for inc in historical_incidents if str(inc.get('id')) != str(incident_id)]
|
|
614
660
|
|
|
615
661
|
if not historical_incidents:
|
|
662
|
+
status_msg = f" with status '{status_filter}'" if status_filter else ""
|
|
616
663
|
return {
|
|
617
664
|
"solutions": [],
|
|
618
|
-
"message": "No historical
|
|
665
|
+
"message": f"No historical incidents found{status_msg} for solution mining"
|
|
619
666
|
}
|
|
620
667
|
|
|
621
668
|
# Find similar incidents
|
|
@@ -654,6 +701,945 @@ def create_rootly_mcp_server(
|
|
|
654
701
|
error_type, error_message = MCPError.categorize_error(e)
|
|
655
702
|
return MCPError.tool_error(f"Failed to suggest solutions: {error_message}", error_type)
|
|
656
703
|
|
|
704
|
+
@mcp.tool()
|
|
705
|
+
async def get_oncall_shift_metrics(
|
|
706
|
+
start_date: Annotated[str, Field(description="Start date for metrics (ISO 8601 format, e.g., '2025-10-01' or '2025-10-01T00:00:00Z')")],
|
|
707
|
+
end_date: Annotated[str, Field(description="End date for metrics (ISO 8601 format, e.g., '2025-10-31' or '2025-10-31T23:59:59Z')")],
|
|
708
|
+
user_ids: Annotated[str, Field(description="Comma-separated list of user IDs to filter by (optional)")] = "",
|
|
709
|
+
schedule_ids: Annotated[str, Field(description="Comma-separated list of schedule IDs to filter by (optional)")] = "",
|
|
710
|
+
team_ids: Annotated[str, Field(description="Comma-separated list of team IDs to filter by (requires querying schedules first)")] = "",
|
|
711
|
+
group_by: Annotated[str, Field(description="Group results by: 'user', 'schedule', 'team', or 'none'")] = "user"
|
|
712
|
+
) -> dict:
|
|
713
|
+
"""
|
|
714
|
+
Get on-call shift metrics for a specified time period. Returns shift counts, total hours,
|
|
715
|
+
and other statistics grouped by user, schedule, or team.
|
|
716
|
+
|
|
717
|
+
Examples:
|
|
718
|
+
- Monthly report: start_date='2025-10-01', end_date='2025-10-31'
|
|
719
|
+
- Specific user: start_date='2025-10-01', end_date='2025-10-31', user_ids='123,456'
|
|
720
|
+
- Specific team: team_ids='team-1' (will query schedules for that team first)
|
|
721
|
+
"""
|
|
722
|
+
try:
|
|
723
|
+
from datetime import datetime, timedelta
|
|
724
|
+
from collections import defaultdict
|
|
725
|
+
from typing import Any, Dict
|
|
726
|
+
|
|
727
|
+
# Build query parameters
|
|
728
|
+
params: Dict[str, Any] = {
|
|
729
|
+
"from": start_date,
|
|
730
|
+
"to": end_date,
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
# Fetch schedules (schedules don't have team relationship, they have owner_group_ids)
|
|
734
|
+
schedules_response = await make_authenticated_request("GET", "/v1/schedules", params={"page[size]": 100})
|
|
735
|
+
|
|
736
|
+
if schedules_response is None:
|
|
737
|
+
return MCPError.tool_error("Failed to get schedules: API request returned None", "execution_error")
|
|
738
|
+
|
|
739
|
+
schedules_response.raise_for_status()
|
|
740
|
+
schedules_data = schedules_response.json()
|
|
741
|
+
|
|
742
|
+
all_schedules = schedules_data.get("data", [])
|
|
743
|
+
|
|
744
|
+
# Collect all unique team IDs from schedules' owner_group_ids
|
|
745
|
+
team_ids_set = set()
|
|
746
|
+
for schedule in all_schedules:
|
|
747
|
+
owner_group_ids = schedule.get("attributes", {}).get("owner_group_ids", [])
|
|
748
|
+
team_ids_set.update(owner_group_ids)
|
|
749
|
+
|
|
750
|
+
# Fetch all teams
|
|
751
|
+
teams_map = {}
|
|
752
|
+
if team_ids_set:
|
|
753
|
+
teams_response = await make_authenticated_request("GET", "/v1/teams", params={"page[size]": 100})
|
|
754
|
+
if teams_response and teams_response.status_code == 200:
|
|
755
|
+
teams_data = teams_response.json()
|
|
756
|
+
for team in teams_data.get("data", []):
|
|
757
|
+
teams_map[team.get("id")] = team
|
|
758
|
+
|
|
759
|
+
# Build schedule -> team mapping
|
|
760
|
+
schedule_to_team_map = {}
|
|
761
|
+
for schedule in all_schedules:
|
|
762
|
+
schedule_id = schedule.get("id")
|
|
763
|
+
schedule_name = schedule.get("attributes", {}).get("name", "Unknown")
|
|
764
|
+
owner_group_ids = schedule.get("attributes", {}).get("owner_group_ids", [])
|
|
765
|
+
|
|
766
|
+
# Use the first owner group as the primary team
|
|
767
|
+
if owner_group_ids:
|
|
768
|
+
team_id = owner_group_ids[0]
|
|
769
|
+
team_attrs = teams_map.get(team_id, {}).get("attributes", {})
|
|
770
|
+
team_name = team_attrs.get("name", "Unknown Team")
|
|
771
|
+
schedule_to_team_map[schedule_id] = {
|
|
772
|
+
"team_id": team_id,
|
|
773
|
+
"team_name": team_name,
|
|
774
|
+
"schedule_name": schedule_name
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
# Handle team filtering (requires multi-step query)
|
|
778
|
+
target_schedule_ids = []
|
|
779
|
+
if team_ids:
|
|
780
|
+
team_id_list = [tid.strip() for tid in team_ids.split(",") if tid.strip()]
|
|
781
|
+
|
|
782
|
+
# Filter schedules by team
|
|
783
|
+
for schedule_id, team_info in schedule_to_team_map.items():
|
|
784
|
+
if str(team_info["team_id"]) in team_id_list:
|
|
785
|
+
target_schedule_ids.append(schedule_id)
|
|
786
|
+
|
|
787
|
+
# Apply schedule filtering
|
|
788
|
+
if schedule_ids:
|
|
789
|
+
schedule_id_list = [sid.strip() for sid in schedule_ids.split(",") if sid.strip()]
|
|
790
|
+
target_schedule_ids.extend(schedule_id_list)
|
|
791
|
+
|
|
792
|
+
if target_schedule_ids:
|
|
793
|
+
params["schedule_ids[]"] = target_schedule_ids
|
|
794
|
+
|
|
795
|
+
# Apply user filtering
|
|
796
|
+
if user_ids:
|
|
797
|
+
user_id_list = [uid.strip() for uid in user_ids.split(",") if uid.strip()]
|
|
798
|
+
params["user_ids[]"] = user_id_list
|
|
799
|
+
|
|
800
|
+
# Include relationships for richer data
|
|
801
|
+
params["include"] = "user,shift_override,on_call_role,schedule_rotation"
|
|
802
|
+
|
|
803
|
+
# Query shifts
|
|
804
|
+
try:
|
|
805
|
+
shifts_response = await make_authenticated_request("GET", "/v1/shifts", params=params)
|
|
806
|
+
|
|
807
|
+
if shifts_response is None:
|
|
808
|
+
return MCPError.tool_error("Failed to get shifts: API request returned None", "execution_error")
|
|
809
|
+
|
|
810
|
+
shifts_response.raise_for_status()
|
|
811
|
+
shifts_data = shifts_response.json()
|
|
812
|
+
|
|
813
|
+
if shifts_data is None:
|
|
814
|
+
return MCPError.tool_error("Failed to get shifts: API returned null/empty response", "execution_error", details={"status": shifts_response.status_code})
|
|
815
|
+
|
|
816
|
+
shifts = shifts_data.get("data", [])
|
|
817
|
+
included = shifts_data.get("included", [])
|
|
818
|
+
except AttributeError as e:
|
|
819
|
+
return MCPError.tool_error(f"Failed to get shifts: Response object error - {str(e)}", "execution_error", details={"params": params})
|
|
820
|
+
except Exception as e:
|
|
821
|
+
return MCPError.tool_error(f"Failed to get shifts: {str(e)}", "execution_error", details={"params": params, "error_type": type(e).__name__})
|
|
822
|
+
|
|
823
|
+
# Build lookup maps for included resources
|
|
824
|
+
users_map = {}
|
|
825
|
+
on_call_roles_map = {}
|
|
826
|
+
for resource in included:
|
|
827
|
+
if resource.get("type") == "users":
|
|
828
|
+
users_map[resource.get("id")] = resource
|
|
829
|
+
elif resource.get("type") == "on_call_roles":
|
|
830
|
+
on_call_roles_map[resource.get("id")] = resource
|
|
831
|
+
|
|
832
|
+
# Calculate metrics
|
|
833
|
+
metrics: Dict[str, Dict[str, Any]] = defaultdict(lambda: {
|
|
834
|
+
"shift_count": 0,
|
|
835
|
+
"total_hours": 0.0,
|
|
836
|
+
"override_count": 0,
|
|
837
|
+
"regular_count": 0,
|
|
838
|
+
"primary_count": 0,
|
|
839
|
+
"secondary_count": 0,
|
|
840
|
+
"primary_hours": 0.0,
|
|
841
|
+
"secondary_hours": 0.0,
|
|
842
|
+
"unknown_role_count": 0,
|
|
843
|
+
"unique_days": set(),
|
|
844
|
+
"shifts": []
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
for shift in shifts:
|
|
848
|
+
attrs = shift.get("attributes", {})
|
|
849
|
+
relationships = shift.get("relationships", {})
|
|
850
|
+
|
|
851
|
+
# Parse timestamps
|
|
852
|
+
starts_at = attrs.get("starts_at")
|
|
853
|
+
ends_at = attrs.get("ends_at")
|
|
854
|
+
is_override = attrs.get("is_override", False)
|
|
855
|
+
schedule_id = attrs.get("schedule_id")
|
|
856
|
+
|
|
857
|
+
# Calculate shift duration in hours and track unique days
|
|
858
|
+
duration_hours = 0.0
|
|
859
|
+
shift_days = set()
|
|
860
|
+
if starts_at and ends_at:
|
|
861
|
+
try:
|
|
862
|
+
start_dt = datetime.fromisoformat(starts_at.replace("Z", "+00:00"))
|
|
863
|
+
end_dt = datetime.fromisoformat(ends_at.replace("Z", "+00:00"))
|
|
864
|
+
duration_hours = (end_dt - start_dt).total_seconds() / 3600
|
|
865
|
+
|
|
866
|
+
# Track all unique calendar days this shift spans
|
|
867
|
+
shift_start_date = start_dt.date()
|
|
868
|
+
shift_end_date = end_dt.date()
|
|
869
|
+
while shift_start_date <= shift_end_date:
|
|
870
|
+
shift_days.add(shift_start_date)
|
|
871
|
+
shift_start_date += timedelta(days=1)
|
|
872
|
+
except (ValueError, AttributeError):
|
|
873
|
+
pass
|
|
874
|
+
|
|
875
|
+
# Get user info
|
|
876
|
+
user_rel = relationships.get("user", {}).get("data") or {}
|
|
877
|
+
user_id = user_rel.get("id")
|
|
878
|
+
user_name = "Unknown"
|
|
879
|
+
user_email = ""
|
|
880
|
+
|
|
881
|
+
if user_id and user_id in users_map:
|
|
882
|
+
user_attrs = users_map[user_id].get("attributes", {})
|
|
883
|
+
user_name = user_attrs.get("full_name") or user_attrs.get("email", "Unknown")
|
|
884
|
+
user_email = user_attrs.get("email", "")
|
|
885
|
+
|
|
886
|
+
# Get on-call role info (primary vs secondary)
|
|
887
|
+
role_rel = relationships.get("on_call_role", {}).get("data") or {}
|
|
888
|
+
role_id = role_rel.get("id")
|
|
889
|
+
role_name = "unknown"
|
|
890
|
+
is_primary = False
|
|
891
|
+
|
|
892
|
+
if role_id and role_id in on_call_roles_map:
|
|
893
|
+
role_attrs = on_call_roles_map[role_id].get("attributes", {})
|
|
894
|
+
role_name = role_attrs.get("name", "").lower()
|
|
895
|
+
# Typically primary roles contain "primary" and secondary contain "secondary"
|
|
896
|
+
# Common patterns: "Primary", "Secondary", "L1", "L2", etc.
|
|
897
|
+
is_primary = "primary" in role_name or role_name == "l1" or role_name == "p1"
|
|
898
|
+
|
|
899
|
+
# Determine grouping key
|
|
900
|
+
if group_by == "user":
|
|
901
|
+
key = f"{user_id}|{user_name}"
|
|
902
|
+
elif group_by == "schedule":
|
|
903
|
+
schedule_info = schedule_to_team_map.get(schedule_id, {})
|
|
904
|
+
schedule_name = schedule_info.get("schedule_name", f"schedule_{schedule_id}")
|
|
905
|
+
key = f"{schedule_id}|{schedule_name}"
|
|
906
|
+
elif group_by == "team":
|
|
907
|
+
team_info = schedule_to_team_map.get(schedule_id, {})
|
|
908
|
+
if team_info:
|
|
909
|
+
team_id = team_info["team_id"]
|
|
910
|
+
team_name = team_info["team_name"]
|
|
911
|
+
key = f"{team_id}|{team_name}"
|
|
912
|
+
else:
|
|
913
|
+
key = "unknown_team|Unknown Team"
|
|
914
|
+
else:
|
|
915
|
+
key = "all"
|
|
916
|
+
|
|
917
|
+
# Update metrics
|
|
918
|
+
metrics[key]["shift_count"] += 1
|
|
919
|
+
metrics[key]["total_hours"] += duration_hours
|
|
920
|
+
|
|
921
|
+
if is_override:
|
|
922
|
+
metrics[key]["override_count"] += 1
|
|
923
|
+
else:
|
|
924
|
+
metrics[key]["regular_count"] += 1
|
|
925
|
+
|
|
926
|
+
# Track primary vs secondary
|
|
927
|
+
if role_id:
|
|
928
|
+
if is_primary:
|
|
929
|
+
metrics[key]["primary_count"] += 1
|
|
930
|
+
metrics[key]["primary_hours"] += duration_hours
|
|
931
|
+
else:
|
|
932
|
+
metrics[key]["secondary_count"] += 1
|
|
933
|
+
metrics[key]["secondary_hours"] += duration_hours
|
|
934
|
+
else:
|
|
935
|
+
metrics[key]["unknown_role_count"] += 1
|
|
936
|
+
|
|
937
|
+
# Track unique days
|
|
938
|
+
metrics[key]["unique_days"].update(shift_days)
|
|
939
|
+
|
|
940
|
+
metrics[key]["shifts"].append({
|
|
941
|
+
"shift_id": shift.get("id"),
|
|
942
|
+
"starts_at": starts_at,
|
|
943
|
+
"ends_at": ends_at,
|
|
944
|
+
"duration_hours": round(duration_hours, 2),
|
|
945
|
+
"is_override": is_override,
|
|
946
|
+
"schedule_id": schedule_id,
|
|
947
|
+
"user_id": user_id,
|
|
948
|
+
"user_name": user_name,
|
|
949
|
+
"user_email": user_email,
|
|
950
|
+
"role_name": role_name,
|
|
951
|
+
"is_primary": is_primary
|
|
952
|
+
})
|
|
953
|
+
|
|
954
|
+
# Format results
|
|
955
|
+
results = []
|
|
956
|
+
for key, data in metrics.items():
|
|
957
|
+
if group_by == "user":
|
|
958
|
+
user_id, user_name = key.split("|", 1)
|
|
959
|
+
result = {
|
|
960
|
+
"user_id": user_id,
|
|
961
|
+
"user_name": user_name,
|
|
962
|
+
"shift_count": data["shift_count"],
|
|
963
|
+
"days_on_call": len(data["unique_days"]),
|
|
964
|
+
"total_hours": round(data["total_hours"], 2),
|
|
965
|
+
"regular_shifts": data["regular_count"],
|
|
966
|
+
"override_shifts": data["override_count"],
|
|
967
|
+
"primary_shifts": data["primary_count"],
|
|
968
|
+
"secondary_shifts": data["secondary_count"],
|
|
969
|
+
"primary_hours": round(data["primary_hours"], 2),
|
|
970
|
+
"secondary_hours": round(data["secondary_hours"], 2),
|
|
971
|
+
"unknown_role_shifts": data["unknown_role_count"],
|
|
972
|
+
}
|
|
973
|
+
elif group_by == "schedule":
|
|
974
|
+
schedule_id, schedule_name = key.split("|", 1)
|
|
975
|
+
result = {
|
|
976
|
+
"schedule_id": schedule_id,
|
|
977
|
+
"schedule_name": schedule_name,
|
|
978
|
+
"shift_count": data["shift_count"],
|
|
979
|
+
"days_on_call": len(data["unique_days"]),
|
|
980
|
+
"total_hours": round(data["total_hours"], 2),
|
|
981
|
+
"regular_shifts": data["regular_count"],
|
|
982
|
+
"override_shifts": data["override_count"],
|
|
983
|
+
"primary_shifts": data["primary_count"],
|
|
984
|
+
"secondary_shifts": data["secondary_count"],
|
|
985
|
+
"primary_hours": round(data["primary_hours"], 2),
|
|
986
|
+
"secondary_hours": round(data["secondary_hours"], 2),
|
|
987
|
+
"unknown_role_shifts": data["unknown_role_count"],
|
|
988
|
+
}
|
|
989
|
+
elif group_by == "team":
|
|
990
|
+
team_id, team_name = key.split("|", 1)
|
|
991
|
+
result = {
|
|
992
|
+
"team_id": team_id,
|
|
993
|
+
"team_name": team_name,
|
|
994
|
+
"shift_count": data["shift_count"],
|
|
995
|
+
"days_on_call": len(data["unique_days"]),
|
|
996
|
+
"total_hours": round(data["total_hours"], 2),
|
|
997
|
+
"regular_shifts": data["regular_count"],
|
|
998
|
+
"override_shifts": data["override_count"],
|
|
999
|
+
"primary_shifts": data["primary_count"],
|
|
1000
|
+
"secondary_shifts": data["secondary_count"],
|
|
1001
|
+
"primary_hours": round(data["primary_hours"], 2),
|
|
1002
|
+
"secondary_hours": round(data["secondary_hours"], 2),
|
|
1003
|
+
"unknown_role_shifts": data["unknown_role_count"],
|
|
1004
|
+
}
|
|
1005
|
+
else:
|
|
1006
|
+
result = {
|
|
1007
|
+
"group_key": key,
|
|
1008
|
+
"shift_count": data["shift_count"],
|
|
1009
|
+
"days_on_call": len(data["unique_days"]),
|
|
1010
|
+
"total_hours": round(data["total_hours"], 2),
|
|
1011
|
+
"regular_shifts": data["regular_count"],
|
|
1012
|
+
"override_shifts": data["override_count"],
|
|
1013
|
+
"primary_shifts": data["primary_count"],
|
|
1014
|
+
"secondary_shifts": data["secondary_count"],
|
|
1015
|
+
"primary_hours": round(data["primary_hours"], 2),
|
|
1016
|
+
"secondary_hours": round(data["secondary_hours"], 2),
|
|
1017
|
+
"unknown_role_shifts": data["unknown_role_count"],
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
results.append(result)
|
|
1021
|
+
|
|
1022
|
+
# Sort by shift count descending
|
|
1023
|
+
results.sort(key=lambda x: x["shift_count"], reverse=True)
|
|
1024
|
+
|
|
1025
|
+
return {
|
|
1026
|
+
"period": {
|
|
1027
|
+
"start_date": start_date,
|
|
1028
|
+
"end_date": end_date
|
|
1029
|
+
},
|
|
1030
|
+
"total_shifts": len(shifts),
|
|
1031
|
+
"grouped_by": group_by,
|
|
1032
|
+
"metrics": results,
|
|
1033
|
+
"summary": {
|
|
1034
|
+
"total_hours": round(sum(m["total_hours"] for m in results), 2),
|
|
1035
|
+
"total_regular_shifts": sum(m["regular_shifts"] for m in results),
|
|
1036
|
+
"total_override_shifts": sum(m["override_shifts"] for m in results),
|
|
1037
|
+
"unique_people": len(results) if group_by == "user" else None
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
except Exception as e:
|
|
1042
|
+
import traceback
|
|
1043
|
+
error_type, error_message = MCPError.categorize_error(e)
|
|
1044
|
+
return MCPError.tool_error(
|
|
1045
|
+
f"Failed to get on-call shift metrics: {error_message}",
|
|
1046
|
+
error_type,
|
|
1047
|
+
details={
|
|
1048
|
+
"params": {"start_date": start_date, "end_date": end_date},
|
|
1049
|
+
"exception_type": type(e).__name__,
|
|
1050
|
+
"exception_str": str(e),
|
|
1051
|
+
"traceback": traceback.format_exc()
|
|
1052
|
+
}
|
|
1053
|
+
)
|
|
1054
|
+
|
|
1055
|
+
@mcp.tool()
|
|
1056
|
+
async def get_oncall_handoff_summary(
|
|
1057
|
+
team_ids: Annotated[str, Field(description="Comma-separated list of team IDs to filter schedules (optional)")] = "",
|
|
1058
|
+
schedule_ids: Annotated[str, Field(description="Comma-separated list of schedule IDs (optional)")] = "",
|
|
1059
|
+
timezone: Annotated[str, Field(description="Timezone to use for display and filtering (e.g., 'America/Los_Angeles', 'Europe/London', 'Asia/Tokyo'). IMPORTANT: If user mentions a city, location, or region (e.g., 'Toronto', 'APAC', 'my time'), infer the appropriate IANA timezone. Defaults to UTC if not specified.")] = "UTC",
|
|
1060
|
+
filter_by_region: Annotated[bool, Field(description="If True, only show on-call for people whose shifts are during business hours (9am-5pm) in the specified timezone. Defaults to False.")] = False,
|
|
1061
|
+
include_incidents: Annotated[bool, Field(description="If True, fetch incidents for each shift (slower). If False, only show on-call info (faster). Defaults to False for better performance.")] = False
|
|
1062
|
+
) -> dict:
|
|
1063
|
+
"""
|
|
1064
|
+
Get current on-call handoff summary. Shows who's currently on-call and who's next.
|
|
1065
|
+
Optionally fetch incidents (set include_incidents=True, but slower).
|
|
1066
|
+
|
|
1067
|
+
Timezone handling: If user mentions their location/timezone, infer it (e.g., "Toronto" → "America/Toronto",
|
|
1068
|
+
"my time" → ask clarifying question or use a common timezone).
|
|
1069
|
+
|
|
1070
|
+
Regional filtering: Use timezone + filter_by_region=True to see only people on-call
|
|
1071
|
+
during business hours in that region (e.g., timezone='Asia/Tokyo', filter_by_region=True
|
|
1072
|
+
shows only APAC on-call during APAC business hours).
|
|
1073
|
+
|
|
1074
|
+
Performance: By default, incidents are NOT fetched for faster response. Set include_incidents=True
|
|
1075
|
+
to fetch incidents for each shift (slower, may timeout with many schedules).
|
|
1076
|
+
|
|
1077
|
+
Useful for:
|
|
1078
|
+
- Quick on-call status checks
|
|
1079
|
+
- Daily handoff meetings
|
|
1080
|
+
- Regional on-call status (APAC, EU, Americas)
|
|
1081
|
+
- Team coordination across timezones
|
|
1082
|
+
"""
|
|
1083
|
+
try:
|
|
1084
|
+
from datetime import datetime, timedelta
|
|
1085
|
+
from zoneinfo import ZoneInfo
|
|
1086
|
+
|
|
1087
|
+
# Validate and set timezone
|
|
1088
|
+
try:
|
|
1089
|
+
tz = ZoneInfo(timezone)
|
|
1090
|
+
except Exception:
|
|
1091
|
+
tz = ZoneInfo("UTC") # Fallback to UTC if invalid timezone
|
|
1092
|
+
|
|
1093
|
+
now = datetime.now(tz)
|
|
1094
|
+
|
|
1095
|
+
def convert_to_timezone(iso_string: str) -> str:
|
|
1096
|
+
"""Convert ISO timestamp to target timezone."""
|
|
1097
|
+
if not iso_string:
|
|
1098
|
+
return iso_string
|
|
1099
|
+
try:
|
|
1100
|
+
dt = datetime.fromisoformat(iso_string.replace("Z", "+00:00"))
|
|
1101
|
+
dt_converted = dt.astimezone(tz)
|
|
1102
|
+
return dt_converted.isoformat()
|
|
1103
|
+
except (ValueError, AttributeError):
|
|
1104
|
+
return iso_string # Return original if conversion fails
|
|
1105
|
+
|
|
1106
|
+
# Fetch schedules with team info (with pagination)
|
|
1107
|
+
all_schedules = []
|
|
1108
|
+
page = 1
|
|
1109
|
+
max_pages = 5 # Schedules shouldn't have many pages
|
|
1110
|
+
|
|
1111
|
+
while page <= max_pages:
|
|
1112
|
+
schedules_response = await make_authenticated_request("GET", "/v1/schedules", params={"page[size]": 100, "page[number]": page})
|
|
1113
|
+
if not schedules_response:
|
|
1114
|
+
return MCPError.tool_error("Failed to fetch schedules - no response from API", "execution_error")
|
|
1115
|
+
|
|
1116
|
+
if schedules_response.status_code != 200:
|
|
1117
|
+
return MCPError.tool_error(
|
|
1118
|
+
f"Failed to fetch schedules - API returned status {schedules_response.status_code}",
|
|
1119
|
+
"execution_error",
|
|
1120
|
+
details={"status_code": schedules_response.status_code}
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
schedules_data = schedules_response.json()
|
|
1124
|
+
page_schedules = schedules_data.get("data", [])
|
|
1125
|
+
|
|
1126
|
+
if not page_schedules:
|
|
1127
|
+
break
|
|
1128
|
+
|
|
1129
|
+
all_schedules.extend(page_schedules)
|
|
1130
|
+
|
|
1131
|
+
# Check if there are more pages
|
|
1132
|
+
meta = schedules_data.get("meta", {})
|
|
1133
|
+
total_pages = meta.get("total_pages", 1)
|
|
1134
|
+
|
|
1135
|
+
if page >= total_pages:
|
|
1136
|
+
break
|
|
1137
|
+
|
|
1138
|
+
page += 1
|
|
1139
|
+
|
|
1140
|
+
# Build team mapping
|
|
1141
|
+
team_ids_set = set()
|
|
1142
|
+
for schedule in all_schedules:
|
|
1143
|
+
owner_group_ids = schedule.get("attributes", {}).get("owner_group_ids", [])
|
|
1144
|
+
team_ids_set.update(owner_group_ids)
|
|
1145
|
+
|
|
1146
|
+
teams_map = {}
|
|
1147
|
+
if team_ids_set:
|
|
1148
|
+
teams_response = await make_authenticated_request("GET", "/v1/teams", params={"page[size]": 100})
|
|
1149
|
+
if teams_response and teams_response.status_code == 200:
|
|
1150
|
+
teams_data = teams_response.json()
|
|
1151
|
+
for team in teams_data.get("data", []):
|
|
1152
|
+
teams_map[team.get("id")] = team
|
|
1153
|
+
|
|
1154
|
+
# Filter schedules
|
|
1155
|
+
target_schedules = []
|
|
1156
|
+
team_filter = [tid.strip() for tid in team_ids.split(",") if tid.strip()] if team_ids else []
|
|
1157
|
+
schedule_filter = [sid.strip() for sid in schedule_ids.split(",") if sid.strip()] if schedule_ids else []
|
|
1158
|
+
|
|
1159
|
+
for schedule in all_schedules:
|
|
1160
|
+
schedule_id = schedule.get("id")
|
|
1161
|
+
owner_group_ids = schedule.get("attributes", {}).get("owner_group_ids", [])
|
|
1162
|
+
|
|
1163
|
+
# Apply filters
|
|
1164
|
+
if schedule_filter and schedule_id not in schedule_filter:
|
|
1165
|
+
continue
|
|
1166
|
+
if team_filter and not any(str(tgid) in team_filter for tgid in owner_group_ids):
|
|
1167
|
+
continue
|
|
1168
|
+
|
|
1169
|
+
target_schedules.append(schedule)
|
|
1170
|
+
|
|
1171
|
+
# Get current and upcoming shifts for each schedule
|
|
1172
|
+
handoff_data = []
|
|
1173
|
+
for schedule in target_schedules:
|
|
1174
|
+
schedule_id = schedule.get("id")
|
|
1175
|
+
schedule_attrs = schedule.get("attributes", {})
|
|
1176
|
+
schedule_name = schedule_attrs.get("name", "Unknown Schedule")
|
|
1177
|
+
owner_group_ids = schedule_attrs.get("owner_group_ids", [])
|
|
1178
|
+
|
|
1179
|
+
# Get team info
|
|
1180
|
+
team_name = "No Team"
|
|
1181
|
+
if owner_group_ids:
|
|
1182
|
+
team_id = owner_group_ids[0]
|
|
1183
|
+
team_attrs = teams_map.get(team_id, {}).get("attributes", {})
|
|
1184
|
+
team_name = team_attrs.get("name", "Unknown Team")
|
|
1185
|
+
|
|
1186
|
+
# Query shifts for this schedule
|
|
1187
|
+
shifts_response = await make_authenticated_request(
|
|
1188
|
+
"GET",
|
|
1189
|
+
"/v1/shifts",
|
|
1190
|
+
params={
|
|
1191
|
+
"schedule_ids[]": [schedule_id],
|
|
1192
|
+
"filter[starts_at][gte]": (now - timedelta(days=1)).isoformat(),
|
|
1193
|
+
"filter[starts_at][lte]": (now + timedelta(days=7)).isoformat(),
|
|
1194
|
+
"include": "user,on_call_role",
|
|
1195
|
+
"page[size]": 50
|
|
1196
|
+
}
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
if not shifts_response:
|
|
1200
|
+
continue
|
|
1201
|
+
|
|
1202
|
+
shifts_data = shifts_response.json()
|
|
1203
|
+
shifts = shifts_data.get("data", [])
|
|
1204
|
+
included = shifts_data.get("included", [])
|
|
1205
|
+
|
|
1206
|
+
# Build user and role maps
|
|
1207
|
+
users_map = {}
|
|
1208
|
+
roles_map = {}
|
|
1209
|
+
for resource in included:
|
|
1210
|
+
if resource.get("type") == "users":
|
|
1211
|
+
users_map[resource.get("id")] = resource
|
|
1212
|
+
elif resource.get("type") == "on_call_roles":
|
|
1213
|
+
roles_map[resource.get("id")] = resource
|
|
1214
|
+
|
|
1215
|
+
# Find current and next shifts
|
|
1216
|
+
current_shift = None
|
|
1217
|
+
next_shift = None
|
|
1218
|
+
|
|
1219
|
+
for shift in sorted(shifts, key=lambda s: s.get("attributes", {}).get("starts_at", "")):
|
|
1220
|
+
attrs = shift.get("attributes", {})
|
|
1221
|
+
starts_at_str = attrs.get("starts_at")
|
|
1222
|
+
ends_at_str = attrs.get("ends_at")
|
|
1223
|
+
|
|
1224
|
+
if not starts_at_str or not ends_at_str:
|
|
1225
|
+
continue
|
|
1226
|
+
|
|
1227
|
+
try:
|
|
1228
|
+
starts_at = datetime.fromisoformat(starts_at_str.replace("Z", "+00:00"))
|
|
1229
|
+
ends_at = datetime.fromisoformat(ends_at_str.replace("Z", "+00:00"))
|
|
1230
|
+
|
|
1231
|
+
# Current shift: ongoing now
|
|
1232
|
+
if starts_at <= now <= ends_at:
|
|
1233
|
+
current_shift = shift
|
|
1234
|
+
# Next shift: starts after now and no current shift found yet
|
|
1235
|
+
elif starts_at > now and not next_shift:
|
|
1236
|
+
next_shift = shift
|
|
1237
|
+
|
|
1238
|
+
except (ValueError, AttributeError):
|
|
1239
|
+
continue
|
|
1240
|
+
|
|
1241
|
+
# Build response for this schedule
|
|
1242
|
+
schedule_info = {
|
|
1243
|
+
"schedule_id": schedule_id,
|
|
1244
|
+
"schedule_name": schedule_name,
|
|
1245
|
+
"team_name": team_name,
|
|
1246
|
+
"current_oncall": None,
|
|
1247
|
+
"next_oncall": None
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if current_shift:
|
|
1251
|
+
current_attrs = current_shift.get("attributes", {})
|
|
1252
|
+
current_rels = current_shift.get("relationships", {})
|
|
1253
|
+
user_data = (current_rels.get("user", {}).get("data") or {})
|
|
1254
|
+
user_id = user_data.get("id")
|
|
1255
|
+
role_data = (current_rels.get("on_call_role", {}).get("data") or {})
|
|
1256
|
+
role_id = role_data.get("id")
|
|
1257
|
+
|
|
1258
|
+
user_name = "Unknown"
|
|
1259
|
+
if user_id and user_id in users_map:
|
|
1260
|
+
user_attrs = users_map[user_id].get("attributes", {})
|
|
1261
|
+
user_name = user_attrs.get("full_name") or user_attrs.get("email", "Unknown")
|
|
1262
|
+
|
|
1263
|
+
role_name = "Unknown Role"
|
|
1264
|
+
if role_id and role_id in roles_map:
|
|
1265
|
+
role_attrs = roles_map[role_id].get("attributes", {})
|
|
1266
|
+
role_name = role_attrs.get("name", "Unknown Role")
|
|
1267
|
+
|
|
1268
|
+
schedule_info["current_oncall"] = {
|
|
1269
|
+
"user_name": user_name,
|
|
1270
|
+
"user_id": user_id,
|
|
1271
|
+
"role": role_name,
|
|
1272
|
+
"starts_at": convert_to_timezone(current_attrs.get("starts_at")),
|
|
1273
|
+
"ends_at": convert_to_timezone(current_attrs.get("ends_at")),
|
|
1274
|
+
"is_override": current_attrs.get("is_override", False)
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
if next_shift:
|
|
1278
|
+
next_attrs = next_shift.get("attributes", {})
|
|
1279
|
+
next_rels = next_shift.get("relationships", {})
|
|
1280
|
+
user_data = (next_rels.get("user", {}).get("data") or {})
|
|
1281
|
+
user_id = user_data.get("id")
|
|
1282
|
+
role_data = (next_rels.get("on_call_role", {}).get("data") or {})
|
|
1283
|
+
role_id = role_data.get("id")
|
|
1284
|
+
|
|
1285
|
+
user_name = "Unknown"
|
|
1286
|
+
if user_id and user_id in users_map:
|
|
1287
|
+
user_attrs = users_map[user_id].get("attributes", {})
|
|
1288
|
+
user_name = user_attrs.get("full_name") or user_attrs.get("email", "Unknown")
|
|
1289
|
+
|
|
1290
|
+
role_name = "Unknown Role"
|
|
1291
|
+
if role_id and role_id in roles_map:
|
|
1292
|
+
role_attrs = roles_map[role_id].get("attributes", {})
|
|
1293
|
+
role_name = role_attrs.get("name", "Unknown Role")
|
|
1294
|
+
|
|
1295
|
+
schedule_info["next_oncall"] = {
|
|
1296
|
+
"user_name": user_name,
|
|
1297
|
+
"user_id": user_id,
|
|
1298
|
+
"role": role_name,
|
|
1299
|
+
"starts_at": convert_to_timezone(next_attrs.get("starts_at")),
|
|
1300
|
+
"ends_at": convert_to_timezone(next_attrs.get("ends_at")),
|
|
1301
|
+
"is_override": next_attrs.get("is_override", False)
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
handoff_data.append(schedule_info)
|
|
1305
|
+
|
|
1306
|
+
# Filter by region if requested
|
|
1307
|
+
if filter_by_region:
|
|
1308
|
+
# Define business hours (9am-5pm) in the target timezone
|
|
1309
|
+
business_start_hour = 9
|
|
1310
|
+
business_end_hour = 17
|
|
1311
|
+
|
|
1312
|
+
# Create datetime objects for today's business hours in target timezone
|
|
1313
|
+
today_business_start = now.replace(hour=business_start_hour, minute=0, second=0, microsecond=0)
|
|
1314
|
+
today_business_end = now.replace(hour=business_end_hour, minute=0, second=0, microsecond=0)
|
|
1315
|
+
|
|
1316
|
+
# Filter schedules where current shift overlaps with business hours
|
|
1317
|
+
filtered_data = []
|
|
1318
|
+
for schedule_info in handoff_data:
|
|
1319
|
+
current_oncall = schedule_info.get("current_oncall")
|
|
1320
|
+
if current_oncall:
|
|
1321
|
+
# Parse shift times (already in target timezone)
|
|
1322
|
+
shift_start_str = current_oncall.get("starts_at")
|
|
1323
|
+
shift_end_str = current_oncall.get("ends_at")
|
|
1324
|
+
|
|
1325
|
+
if shift_start_str and shift_end_str:
|
|
1326
|
+
try:
|
|
1327
|
+
shift_start = datetime.fromisoformat(shift_start_str.replace("Z", "+00:00"))
|
|
1328
|
+
shift_end = datetime.fromisoformat(shift_end_str.replace("Z", "+00:00"))
|
|
1329
|
+
|
|
1330
|
+
# Check if shift overlaps with today's business hours
|
|
1331
|
+
# Shift overlaps if: shift_start < business_end AND shift_end > business_start
|
|
1332
|
+
if shift_start < today_business_end and shift_end > today_business_start:
|
|
1333
|
+
filtered_data.append(schedule_info)
|
|
1334
|
+
except (ValueError, AttributeError):
|
|
1335
|
+
# Skip if we can't parse times
|
|
1336
|
+
continue
|
|
1337
|
+
|
|
1338
|
+
handoff_data = filtered_data
|
|
1339
|
+
|
|
1340
|
+
# Fetch incidents for each current shift (only if requested)
|
|
1341
|
+
if include_incidents:
|
|
1342
|
+
for schedule_info in handoff_data:
|
|
1343
|
+
current_oncall = schedule_info.get("current_oncall")
|
|
1344
|
+
if current_oncall:
|
|
1345
|
+
shift_start = current_oncall["starts_at"]
|
|
1346
|
+
shift_end = current_oncall["ends_at"]
|
|
1347
|
+
|
|
1348
|
+
incidents_result = await _fetch_shift_incidents_internal(
|
|
1349
|
+
start_time=shift_start,
|
|
1350
|
+
end_time=shift_end,
|
|
1351
|
+
schedule_ids="",
|
|
1352
|
+
severity="",
|
|
1353
|
+
status="",
|
|
1354
|
+
tags=""
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
schedule_info["shift_incidents"] = incidents_result if incidents_result.get("success") else None
|
|
1358
|
+
else:
|
|
1359
|
+
schedule_info["shift_incidents"] = None
|
|
1360
|
+
else:
|
|
1361
|
+
# Skip incident fetching for better performance
|
|
1362
|
+
for schedule_info in handoff_data:
|
|
1363
|
+
schedule_info["shift_incidents"] = None
|
|
1364
|
+
|
|
1365
|
+
return {
|
|
1366
|
+
"success": True,
|
|
1367
|
+
"timestamp": now.isoformat(),
|
|
1368
|
+
"timezone": timezone,
|
|
1369
|
+
"schedules": handoff_data,
|
|
1370
|
+
"summary": {
|
|
1371
|
+
"total_schedules": len(handoff_data),
|
|
1372
|
+
"schedules_with_current_oncall": sum(1 for s in handoff_data if s["current_oncall"]),
|
|
1373
|
+
"schedules_with_next_oncall": sum(1 for s in handoff_data if s["next_oncall"]),
|
|
1374
|
+
"total_incidents": sum(
|
|
1375
|
+
s.get("shift_incidents", {}).get("summary", {}).get("total_incidents", 0)
|
|
1376
|
+
for s in handoff_data
|
|
1377
|
+
if s.get("shift_incidents")
|
|
1378
|
+
)
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
except Exception as e:
|
|
1383
|
+
import traceback
|
|
1384
|
+
error_type, error_message = MCPError.categorize_error(e)
|
|
1385
|
+
return MCPError.tool_error(
|
|
1386
|
+
f"Failed to get on-call handoff summary: {error_message}",
|
|
1387
|
+
error_type,
|
|
1388
|
+
details={
|
|
1389
|
+
"exception_type": type(e).__name__,
|
|
1390
|
+
"exception_str": str(e),
|
|
1391
|
+
"traceback": traceback.format_exc()
|
|
1392
|
+
}
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
async def _fetch_shift_incidents_internal(
|
|
1396
|
+
start_time: str,
|
|
1397
|
+
end_time: str,
|
|
1398
|
+
schedule_ids: str = "",
|
|
1399
|
+
severity: str = "",
|
|
1400
|
+
status: str = "",
|
|
1401
|
+
tags: str = ""
|
|
1402
|
+
) -> dict:
|
|
1403
|
+
"""Internal helper to fetch incidents - used by both get_shift_incidents and get_oncall_handoff_summary."""
|
|
1404
|
+
try:
|
|
1405
|
+
from datetime import datetime
|
|
1406
|
+
|
|
1407
|
+
# Build query parameters
|
|
1408
|
+
# Fetch incidents that:
|
|
1409
|
+
# 1. Were created during the shift (created_at in range)
|
|
1410
|
+
# 2. OR are currently active/unresolved (started but not resolved yet)
|
|
1411
|
+
params = {
|
|
1412
|
+
"page[size]": 100,
|
|
1413
|
+
"sort": "-created_at"
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
# Get incidents created during shift OR still active
|
|
1417
|
+
# We'll fetch all incidents and filter in-memory for active ones
|
|
1418
|
+
params["filter[started_at][lte]"] = end_time # Started before shift ended
|
|
1419
|
+
|
|
1420
|
+
# Add severity filter if provided
|
|
1421
|
+
if severity:
|
|
1422
|
+
params["filter[severity]"] = severity.lower()
|
|
1423
|
+
|
|
1424
|
+
# Add status filter if provided
|
|
1425
|
+
if status:
|
|
1426
|
+
params["filter[status]"] = status.lower()
|
|
1427
|
+
|
|
1428
|
+
# Add tags filter if provided
|
|
1429
|
+
if tags:
|
|
1430
|
+
tag_list = [t.strip() for t in tags.split(",") if t.strip()]
|
|
1431
|
+
if tag_list:
|
|
1432
|
+
params["filter[tags][]"] = tag_list
|
|
1433
|
+
|
|
1434
|
+
# Query incidents with pagination
|
|
1435
|
+
all_incidents = []
|
|
1436
|
+
page = 1
|
|
1437
|
+
max_pages = 10 # Safety limit to prevent infinite loops
|
|
1438
|
+
|
|
1439
|
+
while page <= max_pages:
|
|
1440
|
+
params["page[number]"] = page
|
|
1441
|
+
incidents_response = await make_authenticated_request("GET", "/v1/incidents", params=params)
|
|
1442
|
+
|
|
1443
|
+
if not incidents_response:
|
|
1444
|
+
return MCPError.tool_error("Failed to fetch incidents - no response from API", "execution_error")
|
|
1445
|
+
|
|
1446
|
+
if incidents_response.status_code != 200:
|
|
1447
|
+
return MCPError.tool_error(
|
|
1448
|
+
f"Failed to fetch incidents - API returned status {incidents_response.status_code}",
|
|
1449
|
+
"execution_error",
|
|
1450
|
+
details={"status_code": incidents_response.status_code, "time_range": f"{start_time} to {end_time}"}
|
|
1451
|
+
)
|
|
1452
|
+
|
|
1453
|
+
incidents_data = incidents_response.json()
|
|
1454
|
+
page_incidents = incidents_data.get("data", [])
|
|
1455
|
+
|
|
1456
|
+
if not page_incidents:
|
|
1457
|
+
break # No more data
|
|
1458
|
+
|
|
1459
|
+
all_incidents.extend(page_incidents)
|
|
1460
|
+
|
|
1461
|
+
# Check if there are more pages
|
|
1462
|
+
meta = incidents_data.get("meta", {})
|
|
1463
|
+
total_pages = meta.get("total_pages", 1)
|
|
1464
|
+
|
|
1465
|
+
if page >= total_pages:
|
|
1466
|
+
break # Reached the last page
|
|
1467
|
+
|
|
1468
|
+
page += 1
|
|
1469
|
+
|
|
1470
|
+
# Filter incidents to include:
|
|
1471
|
+
# 1. Created during shift (created_at between start_time and end_time)
|
|
1472
|
+
# 2. Currently active (started but not resolved, regardless of when created)
|
|
1473
|
+
from datetime import timezone as dt_timezone
|
|
1474
|
+
shift_start_dt = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
|
|
1475
|
+
shift_end_dt = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
|
|
1476
|
+
now_dt = datetime.now(dt_timezone.utc)
|
|
1477
|
+
|
|
1478
|
+
# Format incidents for handoff summary
|
|
1479
|
+
incidents_summary = []
|
|
1480
|
+
for incident in all_incidents:
|
|
1481
|
+
incident_id = incident.get("id")
|
|
1482
|
+
attrs = incident.get("attributes", {})
|
|
1483
|
+
|
|
1484
|
+
# Check if incident is relevant to this shift
|
|
1485
|
+
created_at = attrs.get("created_at")
|
|
1486
|
+
started_at = attrs.get("started_at")
|
|
1487
|
+
resolved_at = attrs.get("resolved_at")
|
|
1488
|
+
|
|
1489
|
+
# Parse timestamps
|
|
1490
|
+
try:
|
|
1491
|
+
created_dt = datetime.fromisoformat(created_at.replace("Z", "+00:00")) if created_at else None
|
|
1492
|
+
started_dt = datetime.fromisoformat(started_at.replace("Z", "+00:00")) if started_at else None
|
|
1493
|
+
resolved_dt = datetime.fromisoformat(resolved_at.replace("Z", "+00:00")) if resolved_at else None
|
|
1494
|
+
except (ValueError, AttributeError):
|
|
1495
|
+
continue # Skip if we can't parse dates
|
|
1496
|
+
|
|
1497
|
+
# Include incident if:
|
|
1498
|
+
# 1. Created during shift
|
|
1499
|
+
# 2. Started during shift
|
|
1500
|
+
# 3. Resolved during shift
|
|
1501
|
+
# 4. Currently active (not resolved and started before now)
|
|
1502
|
+
include_incident = False
|
|
1503
|
+
|
|
1504
|
+
if created_dt and shift_start_dt <= created_dt <= shift_end_dt:
|
|
1505
|
+
include_incident = True # Created during shift
|
|
1506
|
+
|
|
1507
|
+
if started_dt and shift_start_dt <= started_dt <= shift_end_dt:
|
|
1508
|
+
include_incident = True # Started during shift
|
|
1509
|
+
|
|
1510
|
+
if resolved_dt and shift_start_dt <= resolved_dt <= shift_end_dt:
|
|
1511
|
+
include_incident = True # Resolved during shift
|
|
1512
|
+
|
|
1513
|
+
if not resolved_dt and started_dt and started_dt <= now_dt:
|
|
1514
|
+
include_incident = True # Currently active
|
|
1515
|
+
|
|
1516
|
+
if not include_incident:
|
|
1517
|
+
continue
|
|
1518
|
+
|
|
1519
|
+
# Calculate duration if resolved
|
|
1520
|
+
duration_minutes = None
|
|
1521
|
+
if started_dt and resolved_dt:
|
|
1522
|
+
duration_minutes = int((resolved_dt - started_dt).total_seconds() / 60)
|
|
1523
|
+
|
|
1524
|
+
# Build narrative summary
|
|
1525
|
+
narrative_parts = []
|
|
1526
|
+
|
|
1527
|
+
# What happened
|
|
1528
|
+
title = attrs.get("title", "Untitled Incident")
|
|
1529
|
+
severity = attrs.get("severity", "unknown")
|
|
1530
|
+
narrative_parts.append(f"[{severity.upper()}] {title}")
|
|
1531
|
+
|
|
1532
|
+
# When and duration
|
|
1533
|
+
if started_at:
|
|
1534
|
+
narrative_parts.append(f"Started at {started_at}")
|
|
1535
|
+
if resolved_at:
|
|
1536
|
+
narrative_parts.append(f"Resolved at {resolved_at}")
|
|
1537
|
+
if duration_minutes:
|
|
1538
|
+
narrative_parts.append(f"Duration: {duration_minutes} minutes")
|
|
1539
|
+
elif attrs.get("status"):
|
|
1540
|
+
narrative_parts.append(f"Status: {attrs.get('status')}")
|
|
1541
|
+
|
|
1542
|
+
# What was the issue
|
|
1543
|
+
if attrs.get("summary"):
|
|
1544
|
+
narrative_parts.append(f"Details: {attrs.get('summary')}")
|
|
1545
|
+
|
|
1546
|
+
# Impact
|
|
1547
|
+
if attrs.get("customer_impact_summary"):
|
|
1548
|
+
narrative_parts.append(f"Impact: {attrs.get('customer_impact_summary')}")
|
|
1549
|
+
|
|
1550
|
+
# Resolution (if available)
|
|
1551
|
+
if attrs.get("mitigation"):
|
|
1552
|
+
narrative_parts.append(f"Resolution: {attrs.get('mitigation')}")
|
|
1553
|
+
elif attrs.get("action_items_count") and attrs.get("action_items_count") > 0:
|
|
1554
|
+
narrative_parts.append(f"Action items created: {attrs.get('action_items_count')}")
|
|
1555
|
+
|
|
1556
|
+
narrative = " | ".join(narrative_parts)
|
|
1557
|
+
|
|
1558
|
+
incidents_summary.append({
|
|
1559
|
+
"incident_id": incident_id,
|
|
1560
|
+
"title": attrs.get("title", "Untitled Incident"),
|
|
1561
|
+
"severity": attrs.get("severity"),
|
|
1562
|
+
"status": attrs.get("status"),
|
|
1563
|
+
"started_at": started_at,
|
|
1564
|
+
"resolved_at": resolved_at,
|
|
1565
|
+
"duration_minutes": duration_minutes,
|
|
1566
|
+
"summary": attrs.get("summary"),
|
|
1567
|
+
"impact": attrs.get("customer_impact_summary"),
|
|
1568
|
+
"mitigation": attrs.get("mitigation"),
|
|
1569
|
+
"narrative": narrative,
|
|
1570
|
+
"incident_url": attrs.get("incident_url")
|
|
1571
|
+
})
|
|
1572
|
+
|
|
1573
|
+
# Group by severity
|
|
1574
|
+
by_severity = {}
|
|
1575
|
+
for inc in incidents_summary:
|
|
1576
|
+
sev = inc["severity"] or "unknown"
|
|
1577
|
+
if sev not in by_severity:
|
|
1578
|
+
by_severity[sev] = []
|
|
1579
|
+
by_severity[sev].append(inc)
|
|
1580
|
+
|
|
1581
|
+
# Calculate statistics
|
|
1582
|
+
total_incidents = len(incidents_summary)
|
|
1583
|
+
resolved_count = sum(1 for inc in incidents_summary if inc["resolved_at"])
|
|
1584
|
+
ongoing_count = total_incidents - resolved_count
|
|
1585
|
+
|
|
1586
|
+
avg_resolution_time = None
|
|
1587
|
+
durations = [inc["duration_minutes"] for inc in incidents_summary if inc["duration_minutes"]]
|
|
1588
|
+
if durations:
|
|
1589
|
+
avg_resolution_time = int(sum(durations) / len(durations))
|
|
1590
|
+
|
|
1591
|
+
return {
|
|
1592
|
+
"success": True,
|
|
1593
|
+
"period": {
|
|
1594
|
+
"start_time": start_time,
|
|
1595
|
+
"end_time": end_time
|
|
1596
|
+
},
|
|
1597
|
+
"summary": {
|
|
1598
|
+
"total_incidents": total_incidents,
|
|
1599
|
+
"resolved": resolved_count,
|
|
1600
|
+
"ongoing": ongoing_count,
|
|
1601
|
+
"average_resolution_minutes": avg_resolution_time,
|
|
1602
|
+
"by_severity": {k: len(v) for k, v in by_severity.items()}
|
|
1603
|
+
},
|
|
1604
|
+
"incidents": incidents_summary
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
except Exception as e:
|
|
1608
|
+
import traceback
|
|
1609
|
+
error_type, error_message = MCPError.categorize_error(e)
|
|
1610
|
+
return MCPError.tool_error(
|
|
1611
|
+
f"Failed to get shift incidents: {error_message}",
|
|
1612
|
+
error_type,
|
|
1613
|
+
details={
|
|
1614
|
+
"params": {"start_time": start_time, "end_time": end_time},
|
|
1615
|
+
"exception_type": type(e).__name__,
|
|
1616
|
+
"exception_str": str(e),
|
|
1617
|
+
"traceback": traceback.format_exc()
|
|
1618
|
+
}
|
|
1619
|
+
)
|
|
1620
|
+
|
|
1621
|
+
@mcp.tool()
|
|
1622
|
+
async def get_shift_incidents(
|
|
1623
|
+
start_time: Annotated[str, Field(description="Start time for incident search (ISO 8601 format, e.g., '2025-10-01T00:00:00Z')")],
|
|
1624
|
+
end_time: Annotated[str, Field(description="End time for incident search (ISO 8601 format, e.g., '2025-10-01T23:59:59Z')")],
|
|
1625
|
+
schedule_ids: Annotated[str, Field(description="Comma-separated list of schedule IDs to filter incidents (optional)")] = "",
|
|
1626
|
+
severity: Annotated[str, Field(description="Filter by severity: 'critical', 'high', 'medium', 'low' (optional)")] = "",
|
|
1627
|
+
status: Annotated[str, Field(description="Filter by status: 'started', 'detected', 'acknowledged', 'investigating', 'identified', 'monitoring', 'resolved', 'cancelled' (optional)")] = "",
|
|
1628
|
+
tags: Annotated[str, Field(description="Comma-separated list of tag slugs to filter incidents (optional)")] = ""
|
|
1629
|
+
) -> dict:
|
|
1630
|
+
"""
|
|
1631
|
+
Get incidents and alerts that occurred during a specific shift or time period.
|
|
1632
|
+
|
|
1633
|
+
Useful for:
|
|
1634
|
+
- Shift handoff summaries showing what happened during the shift
|
|
1635
|
+
- Post-shift debriefs and reporting
|
|
1636
|
+
- Incident analysis by time period
|
|
1637
|
+
- Understanding team workload during specific shifts
|
|
1638
|
+
|
|
1639
|
+
Returns incident details including severity, status, duration, and basic summary.
|
|
1640
|
+
"""
|
|
1641
|
+
return await _fetch_shift_incidents_internal(start_time, end_time, schedule_ids, severity, status, tags)
|
|
1642
|
+
|
|
657
1643
|
# Add MCP resources for incidents and teams
|
|
658
1644
|
@mcp.resource("incident://{incident_id}")
|
|
659
1645
|
async def get_incident_resource(incident_id: str):
|