rootly-mcp-server 2.0.9__py3-none-any.whl → 2.0.11__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.
@@ -121,7 +121,7 @@ class RootlyClient:
121
121
  # Add response details if available
122
122
  if hasattr(e, "response") and e.response is not None:
123
123
  try:
124
- error_response["status_code"] = e.response.status_code
124
+ error_response["status_code"] = str(e.response.status_code)
125
125
  error_response["response_text"] = e.response.text
126
126
  except Exception:
127
127
  pass
@@ -19,13 +19,104 @@ from fastmcp import FastMCP
19
19
  from pydantic import Field
20
20
 
21
21
  from .utils import sanitize_parameters_in_spec
22
+ from .smart_utils import TextSimilarityAnalyzer, SolutionExtractor
22
23
 
23
24
  # Set up logger
24
25
  logger = logging.getLogger(__name__)
25
26
 
27
+
28
+ class MCPError:
29
+ """Enhanced error handling for MCP protocol compliance."""
30
+
31
+ @staticmethod
32
+ def protocol_error(code: int, message: str, data: Optional[Dict] = None):
33
+ """Create a JSON-RPC protocol-level error response."""
34
+ error_response = {
35
+ "jsonrpc": "2.0",
36
+ "error": {
37
+ "code": code,
38
+ "message": message
39
+ }
40
+ }
41
+ if data:
42
+ error_response["error"]["data"] = data
43
+ return error_response
44
+
45
+ @staticmethod
46
+ def tool_error(error_message: str, error_type: str = "execution_error", details: Optional[Dict] = None):
47
+ """Create a tool-level error response (returned as successful tool result)."""
48
+ error_response = {
49
+ "error": True,
50
+ "error_type": error_type,
51
+ "message": error_message
52
+ }
53
+ if details:
54
+ error_response["details"] = details
55
+ return error_response
56
+
57
+ @staticmethod
58
+ def categorize_error(exception: Exception) -> tuple[str, str]:
59
+ """Categorize an exception into error type and appropriate message."""
60
+ error_str = str(exception)
61
+ exception_type = type(exception).__name__
62
+
63
+ # Authentication/Authorization errors
64
+ if any(keyword in error_str.lower() for keyword in ["401", "unauthorized", "authentication", "token", "forbidden"]):
65
+ return "authentication_error", f"Authentication failed: {error_str}"
66
+
67
+ # Network/Connection errors
68
+ if any(keyword in exception_type.lower() for keyword in ["connection", "timeout", "network"]):
69
+ return "network_error", f"Network error: {error_str}"
70
+
71
+ # HTTP errors
72
+ if "40" in error_str[:10]: # 4xx client errors
73
+ return "client_error", f"Client error: {error_str}"
74
+ elif "50" in error_str[:10]: # 5xx server errors
75
+ return "server_error", f"Server error: {error_str}"
76
+
77
+ # Validation errors
78
+ if any(keyword in exception_type.lower() for keyword in ["validation", "pydantic", "field"]):
79
+ return "validation_error", f"Input validation error: {error_str}"
80
+
81
+ # Generic execution errors
82
+ return "execution_error", f"Tool execution error: {error_str}"
83
+
26
84
  # Default Swagger URL
27
85
  SWAGGER_URL = "https://rootly-heroku.s3.amazonaws.com/swagger/v1/swagger.json"
28
86
 
87
+ # Default allowed API paths
88
+ def _generate_recommendation(solution_data: dict) -> str:
89
+ """Generate a high-level recommendation based on solution analysis."""
90
+ solutions = solution_data.get("solutions", [])
91
+ avg_time = solution_data.get("average_resolution_time")
92
+
93
+ if not solutions:
94
+ return "No similar incidents found. This may be a novel issue requiring escalation."
95
+
96
+ recommendation_parts = []
97
+
98
+ # Time expectation
99
+ if avg_time:
100
+ if avg_time < 1:
101
+ recommendation_parts.append("Similar incidents typically resolve quickly (< 1 hour).")
102
+ elif avg_time > 4:
103
+ recommendation_parts.append("Similar incidents typically require more time (> 4 hours).")
104
+
105
+ # Top solution
106
+ if solutions:
107
+ top_solution = solutions[0]
108
+ if top_solution.get("suggested_actions"):
109
+ actions = top_solution["suggested_actions"][:2] # Top 2 actions
110
+ recommendation_parts.append(f"Consider trying: {', '.join(actions)}")
111
+
112
+ # Pattern insights
113
+ patterns = solution_data.get("common_patterns", [])
114
+ if patterns:
115
+ recommendation_parts.append(f"Common patterns: {patterns[0]}")
116
+
117
+ return " ".join(recommendation_parts) if recommendation_parts else "Review similar incidents above for resolution guidance."
118
+
119
+
29
120
  # Default allowed API paths
30
121
  DEFAULT_ALLOWED_PATHS = [
31
122
  "/incidents/{incident_id}/alerts",
@@ -230,7 +321,7 @@ def create_rootly_mcp_server(
230
321
  # By default, all routes become tools which is what we want
231
322
  mcp = FastMCP.from_openapi(
232
323
  openapi_spec=filtered_spec,
233
- client=http_client,
324
+ client=http_client.client,
234
325
  name=name,
235
326
  timeout=30.0,
236
327
  tags={"rootly", "incident-management"},
@@ -243,42 +334,6 @@ def create_rootly_mcp_server(
243
334
  return PlainTextResponse("OK")
244
335
 
245
336
  # Add some custom tools for enhanced functionality
246
- @mcp.tool()
247
- async def debug_incidents() -> dict:
248
- """Debug tool to inspect incidents endpoint response."""
249
- try:
250
- response = await make_authenticated_request("GET", "/v1/incidents", params={"page[size]": 1})
251
- response.raise_for_status()
252
-
253
- return {
254
- "status_code": response.status_code,
255
- "headers": dict(response.headers),
256
- "content_length": len(response.content) if response.content else 0,
257
- "content_preview": response.content[:500].decode('utf-8', errors='ignore') if response.content else "No content",
258
- "text_preview": response.text[:500] if hasattr(response, 'text') else "No text",
259
- "encoding": response.encoding,
260
- "content_type": response.headers.get('content-type', 'unknown')
261
- }
262
- except Exception as e:
263
- return {"error": str(e), "error_type": type(e).__name__}
264
-
265
- @mcp.tool()
266
- async def debug_headers() -> dict:
267
- """Debug tool to inspect request/response headers for troubleshooting."""
268
- try:
269
- response = await make_authenticated_request("GET", "/v1/teams", params={"page[size]": 1})
270
- response.raise_for_status()
271
-
272
- return {
273
- "request_headers": dict(response.request.headers) if response.request else {},
274
- "response_headers": dict(response.headers),
275
- "status_code": response.status_code,
276
- "content_type": response.headers.get('content-type', 'unknown'),
277
- "encoding": response.encoding,
278
- "content_preview": str(response.content[:200]) if response.content else "No content"
279
- }
280
- except Exception as e:
281
- return {"error": str(e), "error_type": type(e).__name__}
282
337
 
283
338
  @mcp.tool()
284
339
  def list_endpoints() -> list:
@@ -325,7 +380,7 @@ def create_rootly_mcp_server(
325
380
  query: Annotated[str, Field(description="Search query to filter incidents by title/summary")] = "",
326
381
  page_size: Annotated[int, Field(description="Number of results per page (max: 20)", ge=1, le=20)] = 10,
327
382
  page_number: Annotated[int, Field(description="Page number to retrieve (use 0 for all pages)", ge=0)] = 1,
328
- max_results: Annotated[int, Field(description="Maximum total results when fetching all pages (ignored if page_number > 0)", ge=1, le=100)] = 20,
383
+ max_results: Annotated[int, Field(description="Maximum total results when fetching all pages (ignored if page_number > 0)", ge=1, le=10)] = 5,
329
384
  ) -> dict:
330
385
  """
331
386
  Search incidents with flexible pagination control.
@@ -336,7 +391,7 @@ def create_rootly_mcp_server(
336
391
  # Single page mode
337
392
  if page_number > 0:
338
393
  params = {
339
- "page[size]": min(page_size, 20),
394
+ "page[size]": min(page_size, 5), # Keep responses very small to avoid errors
340
395
  "page[number]": page_number,
341
396
  "include": "",
342
397
  }
@@ -348,15 +403,17 @@ def create_rootly_mcp_server(
348
403
  response.raise_for_status()
349
404
  return response.json()
350
405
  except Exception as e:
351
- return {"error": str(e)}
406
+ error_type, error_message = MCPError.categorize_error(e)
407
+ return MCPError.tool_error(error_message, error_type)
352
408
 
353
409
  # Multi-page mode (page_number = 0)
354
410
  all_incidents = []
355
411
  current_page = 1
356
- effective_page_size = min(page_size, 10)
412
+ effective_page_size = min(page_size, 5) # Keep responses very small to avoid errors
413
+ max_pages = 10 # Safety limit to prevent infinite loops
357
414
 
358
415
  try:
359
- while len(all_incidents) < max_results:
416
+ while len(all_incidents) < max_results and current_page <= max_pages:
360
417
  params = {
361
418
  "page[size]": effective_page_size,
362
419
  "page[number]": current_page,
@@ -373,16 +430,23 @@ def create_rootly_mcp_server(
373
430
  if "data" in response_data:
374
431
  incidents = response_data["data"]
375
432
  if not incidents:
433
+ # No more incidents available
434
+ break
435
+
436
+ # Check if we got fewer incidents than requested (last page)
437
+ if len(incidents) < effective_page_size:
438
+ all_incidents.extend(incidents)
376
439
  break
377
440
 
378
441
  all_incidents.extend(incidents)
379
442
 
380
- # Check if we have more pages
443
+ # Check metadata if available
381
444
  meta = response_data.get("meta", {})
382
445
  current_page_meta = meta.get("current_page", current_page)
383
- total_pages = meta.get("total_pages", 1)
384
-
385
- if current_page_meta >= total_pages:
446
+ total_pages = meta.get("total_pages")
447
+
448
+ # If we have reliable metadata, use it
449
+ if total_pages and current_page_meta >= total_pages:
386
450
  break
387
451
 
388
452
  current_page += 1
@@ -390,9 +454,11 @@ def create_rootly_mcp_server(
390
454
  break
391
455
 
392
456
  except Exception as e:
393
- # Re-raise authentication or critical errors
457
+ # Re-raise authentication or critical errors for immediate handling
394
458
  if "401" in str(e) or "Unauthorized" in str(e) or "authentication" in str(e).lower():
395
- raise e
459
+ error_type, error_message = MCPError.categorize_error(e)
460
+ return MCPError.tool_error(error_message, error_type)
461
+ # For other errors, break loop and return partial results
396
462
  break
397
463
 
398
464
  # Limit to max_results
@@ -410,7 +476,287 @@ def create_rootly_mcp_server(
410
476
  }
411
477
  }
412
478
  except Exception as e:
413
- return {"error": str(e)}
479
+ error_type, error_message = MCPError.categorize_error(e)
480
+ return MCPError.tool_error(error_message, error_type)
481
+
482
+ # Initialize smart analysis tools
483
+ similarity_analyzer = TextSimilarityAnalyzer()
484
+ solution_extractor = SolutionExtractor()
485
+
486
+ @mcp.tool()
487
+ async def find_related_incidents(
488
+ incident_id: str,
489
+ similarity_threshold: Annotated[float, Field(description="Minimum similarity score (0.0-1.0)", ge=0.0, le=1.0)] = 0.3,
490
+ max_results: Annotated[int, Field(description="Maximum number of related incidents to return", ge=1, le=20)] = 5
491
+ ) -> dict:
492
+ """Find historically similar incidents to help with context and resolution strategies."""
493
+ try:
494
+ # Get the target incident details
495
+ target_response = await make_authenticated_request("GET", f"/v1/incidents/{incident_id}")
496
+ target_response.raise_for_status()
497
+ target_incident_data = target_response.json()
498
+ target_incident = target_incident_data.get("data", {})
499
+
500
+ if not target_incident:
501
+ return MCPError.tool_error("Incident not found", "not_found")
502
+
503
+ # Get historical incidents for comparison (resolved incidents from last 6 months)
504
+ historical_response = await make_authenticated_request("GET", "/v1/incidents", params={
505
+ "page[size]": 100, # Get more incidents for better matching
506
+ "page[number]": 1,
507
+ "filter[status]": "resolved", # Only look at resolved incidents
508
+ "include": ""
509
+ })
510
+ historical_response.raise_for_status()
511
+ historical_data = historical_response.json()
512
+ historical_incidents = historical_data.get("data", [])
513
+
514
+ # Filter out the target incident itself
515
+ historical_incidents = [inc for inc in historical_incidents if str(inc.get('id')) != str(incident_id)]
516
+
517
+ if not historical_incidents:
518
+ return {
519
+ "related_incidents": [],
520
+ "message": "No historical incidents found for comparison",
521
+ "target_incident": {
522
+ "id": incident_id,
523
+ "title": target_incident.get("attributes", {}).get("title", "")
524
+ }
525
+ }
526
+
527
+ # Calculate similarities
528
+ similar_incidents = similarity_analyzer.calculate_similarity(historical_incidents, target_incident)
529
+
530
+ # Filter by threshold and limit results
531
+ filtered_incidents = [
532
+ inc for inc in similar_incidents
533
+ if inc.similarity_score >= similarity_threshold
534
+ ][:max_results]
535
+
536
+ # Format response
537
+ related_incidents = []
538
+ for incident in filtered_incidents:
539
+ related_incidents.append({
540
+ "incident_id": incident.incident_id,
541
+ "title": incident.title,
542
+ "similarity_score": round(incident.similarity_score, 3),
543
+ "matched_services": incident.matched_services,
544
+ "matched_keywords": incident.matched_keywords,
545
+ "resolution_summary": incident.resolution_summary,
546
+ "resolution_time_hours": incident.resolution_time_hours
547
+ })
548
+
549
+ return {
550
+ "target_incident": {
551
+ "id": incident_id,
552
+ "title": target_incident.get("attributes", {}).get("title", "")
553
+ },
554
+ "related_incidents": related_incidents,
555
+ "total_found": len(filtered_incidents),
556
+ "similarity_threshold": similarity_threshold,
557
+ "analysis_summary": f"Found {len(filtered_incidents)} similar incidents out of {len(historical_incidents)} historical incidents"
558
+ }
559
+
560
+ except Exception as e:
561
+ error_type, error_message = MCPError.categorize_error(e)
562
+ return MCPError.tool_error(f"Failed to find related incidents: {error_message}", error_type)
563
+
564
+ @mcp.tool()
565
+ async def suggest_solutions(
566
+ incident_id: str = "",
567
+ incident_title: str = "",
568
+ incident_description: str = "",
569
+ max_solutions: Annotated[int, Field(description="Maximum number of solution suggestions", ge=1, le=10)] = 3
570
+ ) -> dict:
571
+ """Suggest solutions based on similar resolved incidents. Provide either incident_id OR title/description."""
572
+ try:
573
+ target_incident = {}
574
+
575
+ if incident_id:
576
+ # Get incident details by ID
577
+ response = await make_authenticated_request("GET", f"/v1/incidents/{incident_id}")
578
+ response.raise_for_status()
579
+ incident_data = response.json()
580
+ target_incident = incident_data.get("data", {})
581
+
582
+ if not target_incident:
583
+ return MCPError.tool_error("Incident not found", "not_found")
584
+
585
+ elif incident_title or incident_description:
586
+ # Create synthetic incident for analysis
587
+ target_incident = {
588
+ "id": "synthetic",
589
+ "attributes": {
590
+ "title": incident_title,
591
+ "summary": incident_description,
592
+ "description": incident_description
593
+ }
594
+ }
595
+ else:
596
+ return MCPError.tool_error("Must provide either incident_id or incident_title/description", "validation_error")
597
+
598
+ # Get resolved incidents for solution mining
599
+ historical_response = await make_authenticated_request("GET", "/v1/incidents", params={
600
+ "page[size]": 150, # Get more incidents for better solution matching
601
+ "page[number]": 1,
602
+ "filter[status]": "resolved",
603
+ "include": ""
604
+ })
605
+ historical_response.raise_for_status()
606
+ historical_data = historical_response.json()
607
+ historical_incidents = historical_data.get("data", [])
608
+
609
+ # Filter out target incident if it exists
610
+ if incident_id:
611
+ historical_incidents = [inc for inc in historical_incidents if str(inc.get('id')) != str(incident_id)]
612
+
613
+ if not historical_incidents:
614
+ return {
615
+ "solutions": [],
616
+ "message": "No historical resolved incidents found for solution mining"
617
+ }
618
+
619
+ # Find similar incidents
620
+ similar_incidents = similarity_analyzer.calculate_similarity(historical_incidents, target_incident)
621
+
622
+ # Filter to reasonably similar incidents (lower threshold for solution suggestions)
623
+ relevant_incidents = [inc for inc in similar_incidents if inc.similarity_score >= 0.2][:max_solutions * 2]
624
+
625
+ if not relevant_incidents:
626
+ return {
627
+ "solutions": [],
628
+ "message": "No sufficiently similar incidents found for solution suggestions",
629
+ "suggestion": "This appears to be a unique incident. Consider escalating or consulting documentation."
630
+ }
631
+
632
+ # Extract solutions
633
+ solution_data = solution_extractor.extract_solutions(relevant_incidents)
634
+
635
+ # Format response
636
+ return {
637
+ "target_incident": {
638
+ "id": incident_id or "synthetic",
639
+ "title": target_incident.get("attributes", {}).get("title", incident_title),
640
+ "description": target_incident.get("attributes", {}).get("summary", incident_description)
641
+ },
642
+ "solutions": solution_data["solutions"][:max_solutions],
643
+ "insights": {
644
+ "common_patterns": solution_data["common_patterns"],
645
+ "average_resolution_time_hours": solution_data["average_resolution_time"],
646
+ "total_similar_incidents": solution_data["total_similar_incidents"]
647
+ },
648
+ "recommendation": _generate_recommendation(solution_data)
649
+ }
650
+
651
+ except Exception as e:
652
+ error_type, error_message = MCPError.categorize_error(e)
653
+ return MCPError.tool_error(f"Failed to suggest solutions: {error_message}", error_type)
654
+
655
+ # Add MCP resources for incidents and teams
656
+ @mcp.resource("incident://{incident_id}")
657
+ async def get_incident_resource(incident_id: str):
658
+ """Expose incident details as an MCP resource for easy reference and context."""
659
+ try:
660
+ response = await make_authenticated_request("GET", f"/v1/incidents/{incident_id}")
661
+ response.raise_for_status()
662
+ incident_data = response.json()
663
+
664
+ # Format incident data as readable text
665
+ incident = incident_data.get("data", {})
666
+ attributes = incident.get("attributes", {})
667
+
668
+ text_content = f"""Incident #{incident_id}
669
+ Title: {attributes.get('title', 'N/A')}
670
+ Status: {attributes.get('status', 'N/A')}
671
+ Severity: {attributes.get('severity', 'N/A')}
672
+ Created: {attributes.get('created_at', 'N/A')}
673
+ Updated: {attributes.get('updated_at', 'N/A')}
674
+ Summary: {attributes.get('summary', 'N/A')}
675
+ URL: {attributes.get('url', 'N/A')}"""
676
+
677
+ return {
678
+ "uri": f"incident://{incident_id}",
679
+ "name": f"Incident #{incident_id}",
680
+ "text": text_content,
681
+ "mimeType": "text/plain"
682
+ }
683
+ except Exception as e:
684
+ error_type, error_message = MCPError.categorize_error(e)
685
+ return {
686
+ "uri": f"incident://{incident_id}",
687
+ "name": f"Incident #{incident_id} (Error)",
688
+ "text": f"Error ({error_type}): {error_message}",
689
+ "mimeType": "text/plain"
690
+ }
691
+
692
+ @mcp.resource("team://{team_id}")
693
+ async def get_team_resource(team_id: str):
694
+ """Expose team details as an MCP resource for easy reference and context."""
695
+ try:
696
+ response = await make_authenticated_request("GET", f"/v1/teams/{team_id}")
697
+ response.raise_for_status()
698
+ team_data = response.json()
699
+
700
+ # Format team data as readable text
701
+ team = team_data.get("data", {})
702
+ attributes = team.get("attributes", {})
703
+
704
+ text_content = f"""Team #{team_id}
705
+ Name: {attributes.get('name', 'N/A')}
706
+ Color: {attributes.get('color', 'N/A')}
707
+ Slug: {attributes.get('slug', 'N/A')}
708
+ Created: {attributes.get('created_at', 'N/A')}
709
+ Updated: {attributes.get('updated_at', 'N/A')}"""
710
+
711
+ return {
712
+ "uri": f"team://{team_id}",
713
+ "name": f"Team: {attributes.get('name', team_id)}",
714
+ "text": text_content,
715
+ "mimeType": "text/plain"
716
+ }
717
+ except Exception as e:
718
+ error_type, error_message = MCPError.categorize_error(e)
719
+ return {
720
+ "uri": f"team://{team_id}",
721
+ "name": f"Team #{team_id} (Error)",
722
+ "text": f"Error ({error_type}): {error_message}",
723
+ "mimeType": "text/plain"
724
+ }
725
+
726
+ @mcp.resource("rootly://incidents")
727
+ async def list_incidents_resource():
728
+ """List recent incidents as an MCP resource for quick reference."""
729
+ try:
730
+ response = await make_authenticated_request("GET", "/v1/incidents", params={
731
+ "page[size]": 10,
732
+ "page[number]": 1,
733
+ "include": ""
734
+ })
735
+ response.raise_for_status()
736
+ data = response.json()
737
+
738
+ incidents = data.get("data", [])
739
+ text_lines = ["Recent Incidents:\n"]
740
+
741
+ for incident in incidents:
742
+ attrs = incident.get("attributes", {})
743
+ text_lines.append(f"• #{incident.get('id', 'N/A')} - {attrs.get('title', 'N/A')} [{attrs.get('status', 'N/A')}]")
744
+
745
+ return {
746
+ "uri": "rootly://incidents",
747
+ "name": "Recent Incidents",
748
+ "text": "\n".join(text_lines),
749
+ "mimeType": "text/plain"
750
+ }
751
+ except Exception as e:
752
+ error_type, error_message = MCPError.categorize_error(e)
753
+ return {
754
+ "uri": "rootly://incidents",
755
+ "name": "Recent Incidents (Error)",
756
+ "text": f"Error ({error_type}): {error_message}",
757
+ "mimeType": "text/plain"
758
+ }
759
+
414
760
 
415
761
  # Log server creation (tool count will be shown when tools are accessed)
416
762
  logger.info("Created Rootly MCP Server successfully")
@@ -450,18 +796,18 @@ def _load_swagger_spec(swagger_path: Optional[str] = None) -> Dict[str, Any]:
450
796
  current_dir = Path.cwd()
451
797
 
452
798
  # Check current directory first
453
- swagger_path = current_dir / "swagger.json"
454
- if swagger_path.is_file():
455
- logger.info(f"Found Swagger file at {swagger_path}")
456
- with open(swagger_path, "r", encoding="utf-8") as f:
799
+ local_swagger_path = current_dir / "swagger.json"
800
+ if local_swagger_path.is_file():
801
+ logger.info(f"Found Swagger file at {local_swagger_path}")
802
+ with open(local_swagger_path, "r", encoding="utf-8") as f:
457
803
  return json.load(f)
458
804
 
459
805
  # Check parent directories
460
806
  for parent in current_dir.parents:
461
- swagger_path = parent / "swagger.json"
462
- if swagger_path.is_file():
463
- logger.info(f"Found Swagger file at {swagger_path}")
464
- with open(swagger_path, "r", encoding="utf-8") as f:
807
+ parent_swagger_path = parent / "swagger.json"
808
+ if parent_swagger_path.is_file():
809
+ logger.info(f"Found Swagger file at {parent_swagger_path}")
810
+ with open(parent_swagger_path, "r", encoding="utf-8") as f:
465
811
  return json.load(f)
466
812
 
467
813
  # If the file wasn't found, fetch it from the URL and save it
@@ -469,12 +815,12 @@ def _load_swagger_spec(swagger_path: Optional[str] = None) -> Dict[str, Any]:
469
815
  swagger_spec = _fetch_swagger_from_url()
470
816
 
471
817
  # Save the fetched spec to the current directory
472
- swagger_path = current_dir / "swagger.json"
473
- logger.info(f"Saving Swagger file to {swagger_path}")
818
+ save_swagger_path = current_dir / "swagger.json"
819
+ logger.info(f"Saving Swagger file to {save_swagger_path}")
474
820
  try:
475
- with open(swagger_path, "w", encoding="utf-8") as f:
821
+ with open(save_swagger_path, "w", encoding="utf-8") as f:
476
822
  json.dump(swagger_spec, f)
477
- logger.info(f"Saved Swagger file to {swagger_path}")
823
+ logger.info(f"Saved Swagger file to {save_swagger_path}")
478
824
  except Exception as e:
479
825
  logger.warning(f"Failed to save Swagger file: {e}")
480
826