rootly-mcp-server 2.0.15__py3-none-any.whl → 2.1.1__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/__init__.py +9 -5
- rootly_mcp_server/__main__.py +44 -29
- rootly_mcp_server/client.py +98 -44
- rootly_mcp_server/data/__init__.py +1 -1
- rootly_mcp_server/exceptions.py +148 -0
- rootly_mcp_server/monitoring.py +378 -0
- rootly_mcp_server/pagination.py +98 -0
- rootly_mcp_server/security.py +404 -0
- rootly_mcp_server/server.py +877 -464
- rootly_mcp_server/smart_utils.py +294 -209
- rootly_mcp_server/utils.py +48 -33
- rootly_mcp_server/validators.py +147 -0
- {rootly_mcp_server-2.0.15.dist-info → rootly_mcp_server-2.1.1.dist-info}/METADATA +66 -13
- rootly_mcp_server-2.1.1.dist-info/RECORD +18 -0
- {rootly_mcp_server-2.0.15.dist-info → rootly_mcp_server-2.1.1.dist-info}/WHEEL +1 -1
- rootly_mcp_server-2.0.15.dist-info/RECORD +0 -13
- {rootly_mcp_server-2.0.15.dist-info → rootly_mcp_server-2.1.1.dist-info}/entry_points.txt +0 -0
- {rootly_mcp_server-2.0.15.dist-info → rootly_mcp_server-2.1.1.dist-info}/licenses/LICENSE +0 -0
rootly_mcp_server/__init__.py
CHANGED
|
@@ -8,13 +8,17 @@ Features:
|
|
|
8
8
|
- Automatic tool generation from Swagger spec
|
|
9
9
|
- Authentication via ROOTLY_API_TOKEN environment variable
|
|
10
10
|
- Default pagination (10 items) for incidents endpoints to prevent context window overflow
|
|
11
|
+
- Comprehensive security: HTTPS enforcement, input sanitization, rate limiting
|
|
12
|
+
- Structured logging with correlation IDs and metrics collection
|
|
13
|
+
- Custom exception hierarchy for better error handling
|
|
14
|
+
- Input validation and sensitive data masking
|
|
11
15
|
"""
|
|
12
16
|
|
|
13
|
-
from .server import RootlyMCPServer
|
|
14
17
|
from .client import RootlyClient
|
|
18
|
+
from .server import RootlyMCPServer
|
|
15
19
|
|
|
16
|
-
__version__ = "2.0
|
|
20
|
+
__version__ = "2.1.0"
|
|
17
21
|
__all__ = [
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
]
|
|
22
|
+
"RootlyMCPServer",
|
|
23
|
+
"RootlyClient",
|
|
24
|
+
]
|
rootly_mcp_server/__main__.py
CHANGED
|
@@ -10,14 +10,15 @@ import logging
|
|
|
10
10
|
import os
|
|
11
11
|
import sys
|
|
12
12
|
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .exceptions import RootlyConfigurationError, RootlyMCPError
|
|
15
|
+
from .security import validate_api_token
|
|
13
16
|
from .server import create_rootly_mcp_server
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
def parse_args():
|
|
17
20
|
"""Parse command-line arguments."""
|
|
18
|
-
parser = argparse.ArgumentParser(
|
|
19
|
-
description="Start the Rootly MCP server for API integration."
|
|
20
|
-
)
|
|
21
|
+
parser = argparse.ArgumentParser(description="Start the Rootly MCP server for API integration.")
|
|
21
22
|
parser.add_argument(
|
|
22
23
|
"--swagger-path",
|
|
23
24
|
type=str,
|
|
@@ -76,44 +77,47 @@ def setup_logging(log_level, debug=False):
|
|
|
76
77
|
"""Set up logging configuration."""
|
|
77
78
|
if debug or os.getenv("DEBUG", "").lower() in ("true", "1", "yes"):
|
|
78
79
|
log_level = "DEBUG"
|
|
79
|
-
|
|
80
|
+
|
|
80
81
|
numeric_level = getattr(logging, log_level.upper(), None)
|
|
81
82
|
if not isinstance(numeric_level, int):
|
|
82
83
|
raise ValueError(f"Invalid log level: {log_level}")
|
|
83
|
-
|
|
84
|
+
|
|
84
85
|
# Configure root logger
|
|
85
86
|
logging.basicConfig(
|
|
86
87
|
level=numeric_level,
|
|
87
88
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
88
89
|
handlers=[logging.StreamHandler(sys.stderr)], # Log to stderr for stdio transport
|
|
89
90
|
)
|
|
90
|
-
|
|
91
|
+
|
|
91
92
|
# Set specific logger levels
|
|
92
93
|
logging.getLogger("rootly_mcp_server").setLevel(numeric_level)
|
|
93
94
|
logging.getLogger("mcp").setLevel(numeric_level)
|
|
94
|
-
|
|
95
|
+
|
|
95
96
|
# Log the configuration
|
|
96
97
|
logger = logging.getLogger(__name__)
|
|
97
98
|
logger.info(f"Logging configured with level: {log_level}")
|
|
98
99
|
logger.debug(f"Python version: {sys.version}")
|
|
99
100
|
logger.debug(f"Current directory: {Path.cwd()}")
|
|
100
|
-
|
|
101
|
+
# SECURITY: Never log actual token values or prefixes
|
|
102
|
+
logger.debug(
|
|
103
|
+
f"Environment variables configured: {', '.join([k for k in os.environ.keys() if k.startswith('ROOTLY_') or k in ['DEBUG']])}"
|
|
104
|
+
)
|
|
101
105
|
|
|
102
106
|
|
|
103
107
|
def check_api_token():
|
|
104
|
-
"""Check if the Rootly API token is set."""
|
|
108
|
+
"""Check if the Rootly API token is set and valid."""
|
|
105
109
|
logger = logging.getLogger(__name__)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
api_token = os.environ.get("ROOTLY_API_TOKEN")
|
|
113
|
+
validate_api_token(api_token)
|
|
114
|
+
# SECURITY: Never log token values or prefixes
|
|
115
|
+
logger.info("ROOTLY_API_TOKEN is configured and valid")
|
|
116
|
+
except RootlyConfigurationError as e:
|
|
117
|
+
logger.error(str(e))
|
|
118
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
111
119
|
print("Please set it with: export ROOTLY_API_TOKEN='your-api-token-here'", file=sys.stderr)
|
|
112
120
|
sys.exit(1)
|
|
113
|
-
else:
|
|
114
|
-
logger.info("ROOTLY_API_TOKEN is set")
|
|
115
|
-
# Log the first few characters of the token for debugging
|
|
116
|
-
logger.debug(f"Token starts with: {api_token[:5]}...")
|
|
117
121
|
|
|
118
122
|
|
|
119
123
|
# Create the server instance for FastMCP CLI (follows quickstart pattern)
|
|
@@ -124,13 +128,13 @@ def get_server():
|
|
|
124
128
|
server_name = os.getenv("ROOTLY_SERVER_NAME", "Rootly")
|
|
125
129
|
hosted = os.getenv("ROOTLY_HOSTED", "false").lower() in ("true", "1", "yes")
|
|
126
130
|
base_url = os.getenv("ROOTLY_BASE_URL")
|
|
127
|
-
|
|
131
|
+
|
|
128
132
|
# Parse allowed paths from environment variable
|
|
129
133
|
allowed_paths = None
|
|
130
134
|
allowed_paths_env = os.getenv("ROOTLY_ALLOWED_PATHS")
|
|
131
135
|
if allowed_paths_env:
|
|
132
136
|
allowed_paths = [path.strip() for path in allowed_paths_env.split(",")]
|
|
133
|
-
|
|
137
|
+
|
|
134
138
|
# Create and return the server
|
|
135
139
|
return create_rootly_mcp_server(
|
|
136
140
|
swagger_path=swagger_path,
|
|
@@ -149,26 +153,26 @@ def main():
|
|
|
149
153
|
"""Main entry point for the Rootly MCP Server."""
|
|
150
154
|
args = parse_args()
|
|
151
155
|
setup_logging(args.log_level, args.debug)
|
|
152
|
-
|
|
156
|
+
|
|
153
157
|
logger = logging.getLogger(__name__)
|
|
154
158
|
logger.info("Starting Rootly MCP Server")
|
|
155
|
-
|
|
159
|
+
|
|
156
160
|
# Handle backward compatibility for --host argument
|
|
157
161
|
hosted_mode = args.hosted
|
|
158
162
|
if args.host:
|
|
159
163
|
logger.warning("--host argument is deprecated, use --hosted instead")
|
|
160
164
|
hosted_mode = True
|
|
161
|
-
|
|
165
|
+
|
|
162
166
|
# Only check API token if not in hosted mode
|
|
163
167
|
if not hosted_mode:
|
|
164
168
|
check_api_token()
|
|
165
|
-
|
|
169
|
+
|
|
166
170
|
try:
|
|
167
171
|
# Parse allowed paths from command line argument
|
|
168
172
|
allowed_paths = None
|
|
169
173
|
if args.allowed_paths:
|
|
170
174
|
allowed_paths = [path.strip() for path in args.allowed_paths.split(",")]
|
|
171
|
-
|
|
175
|
+
|
|
172
176
|
logger.info(f"Initializing server with name: {args.name}")
|
|
173
177
|
server = create_rootly_mcp_server(
|
|
174
178
|
swagger_path=args.swagger_path,
|
|
@@ -177,18 +181,29 @@ def main():
|
|
|
177
181
|
hosted=hosted_mode,
|
|
178
182
|
base_url=args.base_url,
|
|
179
183
|
)
|
|
180
|
-
|
|
184
|
+
|
|
181
185
|
logger.info(f"Running server with transport: {args.transport}...")
|
|
182
186
|
server.run(transport=args.transport)
|
|
183
|
-
|
|
187
|
+
|
|
184
188
|
except FileNotFoundError as e:
|
|
185
189
|
logger.error(f"File not found: {e}")
|
|
186
190
|
print(f"Error: {e}", file=sys.stderr)
|
|
187
191
|
sys.exit(1)
|
|
188
|
-
except
|
|
189
|
-
logger.error(f"
|
|
192
|
+
except RootlyConfigurationError as e:
|
|
193
|
+
logger.error(f"Configuration error: {e}")
|
|
194
|
+
print(f"Configuration Error: {e}", file=sys.stderr)
|
|
195
|
+
sys.exit(1)
|
|
196
|
+
except RootlyMCPError as e:
|
|
197
|
+
logger.error(f"Rootly MCP error: {e}", exc_info=True)
|
|
190
198
|
print(f"Error: {e}", file=sys.stderr)
|
|
191
199
|
sys.exit(1)
|
|
200
|
+
except KeyboardInterrupt:
|
|
201
|
+
logger.info("Server stopped by user")
|
|
202
|
+
sys.exit(0)
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.error(f"Unexpected error: {e}", exc_info=True)
|
|
205
|
+
print(f"Unexpected Error: {e}", file=sys.stderr)
|
|
206
|
+
sys.exit(1)
|
|
192
207
|
|
|
193
208
|
|
|
194
209
|
if __name__ == "__main__":
|
rootly_mcp_server/client.py
CHANGED
|
@@ -2,39 +2,58 @@
|
|
|
2
2
|
Rootly API client for making authenticated requests to the Rootly API.
|
|
3
3
|
"""
|
|
4
4
|
|
|
5
|
-
import os
|
|
6
5
|
import json
|
|
7
|
-
import
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
8
|
import requests
|
|
9
|
-
from typing import Optional, Dict, Any
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
from .exceptions import (
|
|
11
|
+
RootlyAuthenticationError,
|
|
12
|
+
RootlyAuthorizationError,
|
|
13
|
+
RootlyClientError,
|
|
14
|
+
RootlyNetworkError,
|
|
15
|
+
RootlyServerError,
|
|
16
|
+
RootlyTimeoutError,
|
|
17
|
+
categorize_exception,
|
|
18
|
+
)
|
|
19
|
+
from .monitoring import StructuredLogger
|
|
20
|
+
from .security import (
|
|
21
|
+
enforce_https,
|
|
22
|
+
get_api_token_from_env,
|
|
23
|
+
sanitize_error_message,
|
|
24
|
+
validate_url,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Set up structured logger
|
|
28
|
+
logger = StructuredLogger(__name__)
|
|
13
29
|
|
|
14
30
|
|
|
15
31
|
class RootlyClient:
|
|
16
|
-
def __init__(self, base_url:
|
|
17
|
-
|
|
32
|
+
def __init__(self, base_url: str | None = None, hosted: bool = False):
|
|
33
|
+
# Enforce HTTPS for security
|
|
34
|
+
if base_url:
|
|
35
|
+
self.base_url = enforce_https(base_url)
|
|
36
|
+
else:
|
|
37
|
+
self.base_url = "https://api.rootly.com"
|
|
38
|
+
|
|
18
39
|
self.hosted = hosted
|
|
19
40
|
if not self.hosted:
|
|
20
41
|
self._api_token = self._get_api_token()
|
|
21
|
-
|
|
42
|
+
|
|
43
|
+
logger.info("Initialized RootlyClient", base_url=self.base_url, hosted=hosted)
|
|
22
44
|
|
|
23
45
|
def _get_api_token(self) -> str:
|
|
24
|
-
"""Get the API token from environment variables."""
|
|
25
|
-
|
|
26
|
-
if not api_token:
|
|
27
|
-
raise ValueError("ROOTLY_API_TOKEN environment variable is not set")
|
|
28
|
-
return api_token
|
|
46
|
+
"""Get the API token from environment variables with validation."""
|
|
47
|
+
return get_api_token_from_env()
|
|
29
48
|
|
|
30
49
|
def make_request(
|
|
31
50
|
self,
|
|
32
51
|
method: str,
|
|
33
52
|
path: str,
|
|
34
|
-
query_params:
|
|
35
|
-
json_data:
|
|
36
|
-
json_api_type:
|
|
37
|
-
api_token:
|
|
53
|
+
query_params: dict[str, Any] | None = None,
|
|
54
|
+
json_data: dict[str, Any] | None = None,
|
|
55
|
+
json_api_type: str | None = None,
|
|
56
|
+
api_token: str | None = None,
|
|
38
57
|
) -> str:
|
|
39
58
|
"""
|
|
40
59
|
Make an authenticated request to the Rootly API.
|
|
@@ -45,13 +64,20 @@ class RootlyClient:
|
|
|
45
64
|
query_params: Query parameters for the request.
|
|
46
65
|
json_data: JSON data for the request body.
|
|
47
66
|
json_api_type: If set, use JSON-API format and this type value.
|
|
67
|
+
api_token: Optional API token (for hosted mode).
|
|
48
68
|
|
|
49
69
|
Returns:
|
|
50
70
|
The API response as a JSON string.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
RootlyAuthenticationError: If authentication fails
|
|
74
|
+
RootlyNetworkError: If network issues occur
|
|
75
|
+
RootlyServerError: If API returns 5xx error
|
|
76
|
+
RootlyClientError: If API returns 4xx error
|
|
51
77
|
"""
|
|
52
78
|
if self.hosted:
|
|
53
79
|
if not api_token:
|
|
54
|
-
|
|
80
|
+
raise RootlyAuthenticationError("No API token provided")
|
|
55
81
|
else:
|
|
56
82
|
api_token = self._api_token
|
|
57
83
|
|
|
@@ -81,10 +107,10 @@ class RootlyClient:
|
|
|
81
107
|
|
|
82
108
|
url = f"{self.base_url}{path}"
|
|
83
109
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
logger.debug(
|
|
110
|
+
# Validate URL for security
|
|
111
|
+
validate_url(url, allowed_domains=["api.rootly.com", "rootly.com"])
|
|
112
|
+
|
|
113
|
+
logger.debug("Making API request", method=method, url=url)
|
|
88
114
|
|
|
89
115
|
try:
|
|
90
116
|
response = requests.request(
|
|
@@ -93,40 +119,68 @@ class RootlyClient:
|
|
|
93
119
|
headers=headers,
|
|
94
120
|
params=query_params,
|
|
95
121
|
json=json_data,
|
|
96
|
-
timeout=30,
|
|
122
|
+
timeout=30,
|
|
97
123
|
)
|
|
98
124
|
|
|
99
|
-
|
|
100
|
-
logger.debug(f"Response status: {response.status_code}")
|
|
101
|
-
logger.debug(f"Response headers: {response.headers}")
|
|
125
|
+
logger.debug("Received API response", status_code=response.status_code)
|
|
102
126
|
|
|
103
127
|
# Try to parse the response as JSON
|
|
104
128
|
try:
|
|
105
129
|
response_json = response.json()
|
|
106
|
-
logger.debug(
|
|
107
|
-
f"Response parsed as JSON: {json.dumps(response_json)[:200]}..."
|
|
108
|
-
)
|
|
109
130
|
response.raise_for_status()
|
|
110
131
|
return json.dumps(response_json, indent=2)
|
|
111
132
|
except ValueError:
|
|
112
133
|
# If the response is not JSON, return the text
|
|
113
|
-
logger.debug(f"Response is not JSON: {response.text[:200]}...")
|
|
114
134
|
response.raise_for_status()
|
|
115
135
|
return json.dumps({"text": response.text}, indent=2)
|
|
116
136
|
|
|
137
|
+
except requests.exceptions.Timeout as e:
|
|
138
|
+
logger.error("Request timed out", exc_info=e)
|
|
139
|
+
raise RootlyTimeoutError("Request timed out after 30 seconds")
|
|
140
|
+
|
|
141
|
+
except requests.exceptions.ConnectionError as e:
|
|
142
|
+
logger.error("Connection error", exc_info=e)
|
|
143
|
+
raise RootlyNetworkError(f"Failed to connect to {self.base_url}")
|
|
144
|
+
|
|
145
|
+
except requests.exceptions.HTTPError as e:
|
|
146
|
+
status_code = e.response.status_code if e.response else None
|
|
147
|
+
|
|
148
|
+
# Sanitize error message to remove stack traces
|
|
149
|
+
error_msg = sanitize_error_message(str(e))
|
|
150
|
+
|
|
151
|
+
logger.error("HTTP error", status_code=status_code, error=error_msg)
|
|
152
|
+
|
|
153
|
+
# Categorize HTTP errors
|
|
154
|
+
if status_code == 401:
|
|
155
|
+
raise RootlyAuthenticationError(f"Authentication failed: {error_msg}")
|
|
156
|
+
elif status_code == 403:
|
|
157
|
+
raise RootlyAuthorizationError(f"Access forbidden: {error_msg}")
|
|
158
|
+
elif status_code == 429:
|
|
159
|
+
from .exceptions import RootlyRateLimitError
|
|
160
|
+
|
|
161
|
+
raise RootlyRateLimitError(f"Rate limit exceeded: {error_msg}")
|
|
162
|
+
elif status_code is not None and 400 <= status_code < 500:
|
|
163
|
+
raise RootlyClientError(
|
|
164
|
+
f"Client error ({status_code}): {error_msg}", status_code=status_code
|
|
165
|
+
)
|
|
166
|
+
elif status_code is not None and 500 <= status_code < 600:
|
|
167
|
+
raise RootlyServerError(
|
|
168
|
+
f"Server error ({status_code}): {error_msg}", status_code=status_code
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
raise RootlyNetworkError(f"HTTP error: {error_msg}")
|
|
172
|
+
|
|
117
173
|
except requests.exceptions.RequestException as e:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
except Exception:
|
|
127
|
-
pass
|
|
128
|
-
|
|
129
|
-
return json.dumps(error_response, indent=2)
|
|
174
|
+
# Sanitize error message
|
|
175
|
+
error_msg = sanitize_error_message(str(e))
|
|
176
|
+
logger.error("Request failed", exc_info=e)
|
|
177
|
+
|
|
178
|
+
# Try to categorize the exception
|
|
179
|
+
exception_class, message = categorize_exception(e)
|
|
180
|
+
raise exception_class(message)
|
|
181
|
+
|
|
130
182
|
except Exception as e:
|
|
131
|
-
|
|
132
|
-
|
|
183
|
+
# Sanitize error message for unexpected errors
|
|
184
|
+
error_msg = sanitize_error_message(str(e))
|
|
185
|
+
logger.error("Unexpected error", exc_info=e)
|
|
186
|
+
raise RootlyNetworkError(f"Unexpected error: {error_msg}")
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exception classes for the Rootly MCP Server.
|
|
3
|
+
|
|
4
|
+
This module defines specific exception types for better error handling
|
|
5
|
+
and debugging throughout the application.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RootlyMCPError(Exception):
|
|
10
|
+
"""Base exception for all Rootly MCP Server errors."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, message: str, details: dict | None = None):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.message = message
|
|
15
|
+
self.details = details or {}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RootlyAuthenticationError(RootlyMCPError):
|
|
19
|
+
"""Raised when API authentication fails."""
|
|
20
|
+
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class RootlyAuthorizationError(RootlyMCPError):
|
|
25
|
+
"""Raised when the user lacks permissions for the requested resource."""
|
|
26
|
+
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class RootlyNetworkError(RootlyMCPError):
|
|
31
|
+
"""Raised when network connectivity issues occur."""
|
|
32
|
+
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RootlyTimeoutError(RootlyNetworkError):
|
|
37
|
+
"""Raised when a request times out."""
|
|
38
|
+
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RootlyValidationError(RootlyMCPError):
|
|
43
|
+
"""Raised when input validation fails."""
|
|
44
|
+
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RootlyRateLimitError(RootlyMCPError):
|
|
49
|
+
"""Raised when API rate limits are exceeded."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, message: str, retry_after: int | None = None, details: dict | None = None):
|
|
52
|
+
super().__init__(message, details)
|
|
53
|
+
self.retry_after = retry_after
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RootlyAPIError(RootlyMCPError):
|
|
57
|
+
"""Raised when the Rootly API returns an error response."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, message: str, status_code: int | None = None, details: dict | None = None):
|
|
60
|
+
super().__init__(message, details)
|
|
61
|
+
self.status_code = status_code
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class RootlyServerError(RootlyAPIError):
|
|
65
|
+
"""Raised when the Rootly API returns a 5xx server error."""
|
|
66
|
+
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class RootlyClientError(RootlyAPIError):
|
|
71
|
+
"""Raised when the Rootly API returns a 4xx client error."""
|
|
72
|
+
|
|
73
|
+
pass
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class RootlyConfigurationError(RootlyMCPError):
|
|
77
|
+
"""Raised when there's a configuration error (e.g., missing API token)."""
|
|
78
|
+
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class RootlyResourceNotFoundError(RootlyClientError):
|
|
83
|
+
"""Raised when a requested resource is not found (404)."""
|
|
84
|
+
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def categorize_exception(exception: Exception) -> tuple[type[RootlyMCPError], str]:
|
|
89
|
+
"""
|
|
90
|
+
Categorize a generic exception into a specific Rootly exception type.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
exception: The exception to categorize
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Tuple of (exception_class, error_message)
|
|
97
|
+
"""
|
|
98
|
+
error_str = str(exception).lower()
|
|
99
|
+
exception_type = type(exception).__name__.lower()
|
|
100
|
+
|
|
101
|
+
# Authentication errors (401)
|
|
102
|
+
if any(
|
|
103
|
+
keyword in error_str
|
|
104
|
+
for keyword in ["401", "unauthorized", "authentication failed", "invalid token"]
|
|
105
|
+
):
|
|
106
|
+
return RootlyAuthenticationError, f"Authentication failed: {exception}"
|
|
107
|
+
|
|
108
|
+
# Authorization errors (403)
|
|
109
|
+
if any(
|
|
110
|
+
keyword in error_str
|
|
111
|
+
for keyword in ["403", "forbidden", "permission denied", "access denied"]
|
|
112
|
+
):
|
|
113
|
+
return RootlyAuthorizationError, f"Authorization failed: {exception}"
|
|
114
|
+
|
|
115
|
+
# Rate limit errors (429)
|
|
116
|
+
if any(keyword in error_str for keyword in ["429", "rate limit", "too many requests"]):
|
|
117
|
+
return RootlyRateLimitError, f"Rate limit exceeded: {exception}"
|
|
118
|
+
|
|
119
|
+
# Resource not found (404)
|
|
120
|
+
if any(keyword in error_str for keyword in ["404", "not found"]):
|
|
121
|
+
return RootlyResourceNotFoundError, f"Resource not found: {exception}"
|
|
122
|
+
|
|
123
|
+
# Client errors (4xx)
|
|
124
|
+
if any(keyword in error_str for keyword in ["400", "bad request", "invalid"]):
|
|
125
|
+
return RootlyClientError, f"Client error: {exception}"
|
|
126
|
+
|
|
127
|
+
# Server errors (5xx)
|
|
128
|
+
if any(keyword in error_str for keyword in ["500", "502", "503", "504", "server error"]):
|
|
129
|
+
return RootlyServerError, f"Server error: {exception}"
|
|
130
|
+
|
|
131
|
+
# Timeout errors
|
|
132
|
+
if any(keyword in exception_type for keyword in ["timeout", "timedout"]):
|
|
133
|
+
return RootlyTimeoutError, f"Request timed out: {exception}"
|
|
134
|
+
|
|
135
|
+
# Network/Connection errors
|
|
136
|
+
if any(keyword in exception_type for keyword in ["connection", "network"]):
|
|
137
|
+
return RootlyNetworkError, f"Network error: {exception}"
|
|
138
|
+
|
|
139
|
+
# Validation errors
|
|
140
|
+
if any(keyword in exception_type for keyword in ["validation", "pydantic", "field"]):
|
|
141
|
+
return RootlyValidationError, f"Validation error: {exception}"
|
|
142
|
+
|
|
143
|
+
# Configuration errors
|
|
144
|
+
if any(keyword in error_str for keyword in ["not set", "missing", "configuration"]):
|
|
145
|
+
return RootlyConfigurationError, f"Configuration error: {exception}"
|
|
146
|
+
|
|
147
|
+
# Default to generic API error
|
|
148
|
+
return RootlyAPIError, f"API error: {exception}"
|