rootly-mcp-server 2.0.14__py3-none-any.whl → 2.1.0__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.
@@ -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.12"
20
+ __version__ = "2.1.0"
17
21
  __all__ = [
18
- 'RootlyMCPServer',
19
- 'RootlyClient',
20
- ]
22
+ "RootlyMCPServer",
23
+ "RootlyClient",
24
+ ]
@@ -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
- logger.debug(f"Environment variables: {', '.join([f'{k}={v[:3]}...' if k.endswith('TOKEN') else f'{k}={v}' for k, v in os.environ.items() if k.startswith('ROOTLY_') or k in ['DEBUG']])}")
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
- api_token = os.environ.get("ROOTLY_API_TOKEN")
108
- if not api_token:
109
- logger.error("ROOTLY_API_TOKEN environment variable is not set.")
110
- print("Error: ROOTLY_API_TOKEN environment variable is not set.", file=sys.stderr)
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 Exception as e:
189
- logger.error(f"Failed to start server: {e}", exc_info=True)
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__":
@@ -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 logging
6
+ from typing import Any
7
+
8
8
  import requests
9
- from typing import Optional, Dict, Any
10
9
 
11
- # Set up logger
12
- logger = logging.getLogger(__name__)
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: Optional[str] = None, hosted: bool = False):
17
- self.base_url = base_url or "https://api.rootly.com"
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
- logger.debug(f"Initialized RootlyClient with base_url: {self.base_url}")
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
- api_token = os.getenv("ROOTLY_API_TOKEN")
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: Optional[Dict[str, Any]] = None,
35
- json_data: Optional[Dict[str, Any]] = None,
36
- json_api_type: Optional[str] = None,
37
- api_token: Optional[str] = None,
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
- return json.dumps({"error": "No API token provided"})
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
- logger.debug(f"Making {method} request to {url}")
85
- logger.debug(f"Headers: {headers}")
86
- logger.debug(f"Query params: {query_params}")
87
- logger.debug(f"JSON data: {json_data}")
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, # Add a timeout to prevent hanging
122
+ timeout=30,
97
123
  )
98
124
 
99
- # Log the response status and headers
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
- logger.error(f"Request failed: {e}")
119
- error_response = {"error": str(e)}
120
-
121
- # Add response details if available
122
- if hasattr(e, "response") and e.response is not None:
123
- try:
124
- error_response["status_code"] = str(e.response.status_code)
125
- error_response["response_text"] = e.response.text
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
- logger.error(f"Unexpected error: {e}")
132
- return json.dumps({"error": f"Unexpected error: {str(e)}"}, indent=2)
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}")
@@ -1,4 +1,4 @@
1
1
  """
2
2
  Data package containing resources for Rootly MCP Server.
3
3
  This package includes the OpenAPI (Swagger) specification for the Rootly API.
4
- """
4
+ """
@@ -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}"