rootly-mcp-server 2.0.15__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.
- 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 +1002 -467
- 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.0.dist-info}/METADATA +66 -13
- rootly_mcp_server-2.1.0.dist-info/RECORD +18 -0
- {rootly_mcp_server-2.0.15.dist-info → rootly_mcp_server-2.1.0.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.0.dist-info}/entry_points.txt +0 -0
- {rootly_mcp_server-2.0.15.dist-info → rootly_mcp_server-2.1.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Monitoring, observability, and structured logging for the Rootly MCP Server.
|
|
3
|
+
|
|
4
|
+
This module provides:
|
|
5
|
+
- Structured JSON logging with correlation IDs
|
|
6
|
+
- Request/response logging (sanitized)
|
|
7
|
+
- Performance metrics collection
|
|
8
|
+
- Health check utilities
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from functools import wraps
|
|
19
|
+
from threading import Lock
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .security import mask_sensitive_data
|
|
23
|
+
|
|
24
|
+
# Correlation ID storage (thread-local would be better in production)
|
|
25
|
+
_correlation_ids: dict[int, str] = {}
|
|
26
|
+
_correlation_lock = Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_correlation_id() -> str:
|
|
30
|
+
"""Get or create a correlation ID for the current request."""
|
|
31
|
+
import threading
|
|
32
|
+
|
|
33
|
+
thread_id = threading.get_ident()
|
|
34
|
+
|
|
35
|
+
with _correlation_lock:
|
|
36
|
+
if thread_id not in _correlation_ids:
|
|
37
|
+
_correlation_ids[thread_id] = str(uuid.uuid4())
|
|
38
|
+
return _correlation_ids[thread_id]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def set_correlation_id(correlation_id: str) -> None:
|
|
42
|
+
"""Set the correlation ID for the current request."""
|
|
43
|
+
import threading
|
|
44
|
+
|
|
45
|
+
thread_id = threading.get_ident()
|
|
46
|
+
|
|
47
|
+
with _correlation_lock:
|
|
48
|
+
_correlation_ids[thread_id] = correlation_id
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def clear_correlation_id() -> None:
|
|
52
|
+
"""Clear the correlation ID for the current request."""
|
|
53
|
+
import threading
|
|
54
|
+
|
|
55
|
+
thread_id = threading.get_ident()
|
|
56
|
+
|
|
57
|
+
with _correlation_lock:
|
|
58
|
+
_correlation_ids.pop(thread_id, None)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class StructuredLogger:
|
|
62
|
+
"""
|
|
63
|
+
Structured JSON logger with correlation ID support.
|
|
64
|
+
|
|
65
|
+
Provides consistent structured logging across the application.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
def __init__(self, name: str):
|
|
69
|
+
self.logger = logging.getLogger(name)
|
|
70
|
+
self.name = name
|
|
71
|
+
|
|
72
|
+
def _log_structured(
|
|
73
|
+
self,
|
|
74
|
+
level: int,
|
|
75
|
+
message: str,
|
|
76
|
+
extra: dict[str, Any] | None = None,
|
|
77
|
+
exc_info: Exception | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Log a structured message with correlation ID and metadata."""
|
|
80
|
+
log_data = {
|
|
81
|
+
"message": message,
|
|
82
|
+
"correlation_id": get_correlation_id(),
|
|
83
|
+
"logger": self.name,
|
|
84
|
+
"timestamp": time.time(),
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if extra:
|
|
88
|
+
# Mask sensitive data before logging
|
|
89
|
+
log_data["extra"] = mask_sensitive_data(extra)
|
|
90
|
+
|
|
91
|
+
# Use standard logger with JSON-formatted message
|
|
92
|
+
self.logger.log(
|
|
93
|
+
level,
|
|
94
|
+
json.dumps(log_data),
|
|
95
|
+
exc_info=exc_info,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def debug(self, message: str, **kwargs) -> None:
|
|
99
|
+
"""Log a debug message."""
|
|
100
|
+
self._log_structured(logging.DEBUG, message, extra=kwargs)
|
|
101
|
+
|
|
102
|
+
def info(self, message: str, **kwargs) -> None:
|
|
103
|
+
"""Log an info message."""
|
|
104
|
+
self._log_structured(logging.INFO, message, extra=kwargs)
|
|
105
|
+
|
|
106
|
+
def warning(self, message: str, **kwargs) -> None:
|
|
107
|
+
"""Log a warning message."""
|
|
108
|
+
self._log_structured(logging.WARNING, message, extra=kwargs)
|
|
109
|
+
|
|
110
|
+
def error(self, message: str, exc_info: Exception | None = None, **kwargs) -> None:
|
|
111
|
+
"""Log an error message."""
|
|
112
|
+
self._log_structured(logging.ERROR, message, extra=kwargs, exc_info=exc_info)
|
|
113
|
+
|
|
114
|
+
def critical(self, message: str, exc_info: Exception | None = None, **kwargs) -> None:
|
|
115
|
+
"""Log a critical message."""
|
|
116
|
+
self._log_structured(logging.CRITICAL, message, extra=kwargs, exc_info=exc_info)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MetricsCollector:
|
|
120
|
+
"""
|
|
121
|
+
Simple metrics collector for tracking request statistics.
|
|
122
|
+
|
|
123
|
+
Tracks:
|
|
124
|
+
- Request counts by endpoint and status
|
|
125
|
+
- Response latencies (p50, p95, p99)
|
|
126
|
+
- Error rates
|
|
127
|
+
- Active connections
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self):
|
|
131
|
+
self._lock = Lock()
|
|
132
|
+
self._request_counts: dict[str, int] = defaultdict(int)
|
|
133
|
+
self._error_counts: dict[str, int] = defaultdict(int)
|
|
134
|
+
self._latencies: dict[str, list[float]] = defaultdict(list)
|
|
135
|
+
self._active_requests = 0
|
|
136
|
+
self._max_latency_samples = 1000 # Keep last 1000 samples per endpoint
|
|
137
|
+
|
|
138
|
+
def increment_requests(self, endpoint: str, status_code: int) -> None:
|
|
139
|
+
"""Increment request counter for an endpoint."""
|
|
140
|
+
with self._lock:
|
|
141
|
+
key = f"{endpoint}:{status_code}"
|
|
142
|
+
self._request_counts[key] += 1
|
|
143
|
+
|
|
144
|
+
if status_code >= 400:
|
|
145
|
+
self._error_counts[endpoint] += 1
|
|
146
|
+
|
|
147
|
+
def record_latency(self, endpoint: str, latency_ms: float) -> None:
|
|
148
|
+
"""Record request latency for an endpoint."""
|
|
149
|
+
with self._lock:
|
|
150
|
+
self._latencies[endpoint].append(latency_ms)
|
|
151
|
+
|
|
152
|
+
# Keep only recent samples
|
|
153
|
+
if len(self._latencies[endpoint]) > self._max_latency_samples:
|
|
154
|
+
self._latencies[endpoint] = self._latencies[endpoint][-self._max_latency_samples :]
|
|
155
|
+
|
|
156
|
+
def increment_active_requests(self) -> None:
|
|
157
|
+
"""Increment active request counter."""
|
|
158
|
+
with self._lock:
|
|
159
|
+
self._active_requests += 1
|
|
160
|
+
|
|
161
|
+
def decrement_active_requests(self) -> None:
|
|
162
|
+
"""Decrement active request counter."""
|
|
163
|
+
with self._lock:
|
|
164
|
+
self._active_requests = max(0, self._active_requests - 1)
|
|
165
|
+
|
|
166
|
+
def get_metrics(self) -> dict[str, Any]:
|
|
167
|
+
"""Get current metrics snapshot."""
|
|
168
|
+
with self._lock:
|
|
169
|
+
# Calculate latency percentiles
|
|
170
|
+
latency_stats = {}
|
|
171
|
+
for endpoint, latencies in self._latencies.items():
|
|
172
|
+
if latencies:
|
|
173
|
+
sorted_latencies = sorted(latencies)
|
|
174
|
+
latency_stats[endpoint] = {
|
|
175
|
+
"p50": self._percentile(sorted_latencies, 50),
|
|
176
|
+
"p95": self._percentile(sorted_latencies, 95),
|
|
177
|
+
"p99": self._percentile(sorted_latencies, 99),
|
|
178
|
+
"count": len(latencies),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
"request_counts": dict(self._request_counts),
|
|
183
|
+
"error_counts": dict(self._error_counts),
|
|
184
|
+
"latency_stats": latency_stats,
|
|
185
|
+
"active_requests": self._active_requests,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
def _percentile(self, sorted_values: list[float], percentile: int) -> float:
|
|
189
|
+
"""Calculate percentile from sorted values."""
|
|
190
|
+
if not sorted_values:
|
|
191
|
+
return 0.0
|
|
192
|
+
|
|
193
|
+
index = int(len(sorted_values) * percentile / 100)
|
|
194
|
+
index = min(index, len(sorted_values) - 1)
|
|
195
|
+
return sorted_values[index]
|
|
196
|
+
|
|
197
|
+
def reset(self) -> None:
|
|
198
|
+
"""Reset all metrics."""
|
|
199
|
+
with self._lock:
|
|
200
|
+
self._request_counts.clear()
|
|
201
|
+
self._error_counts.clear()
|
|
202
|
+
self._latencies.clear()
|
|
203
|
+
self._active_requests = 0
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# Global metrics collector
|
|
207
|
+
_metrics_collector = MetricsCollector()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def get_metrics_collector() -> MetricsCollector:
|
|
211
|
+
"""Get the global metrics collector instance."""
|
|
212
|
+
return _metrics_collector
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@contextmanager
|
|
216
|
+
def track_request(endpoint: str):
|
|
217
|
+
"""
|
|
218
|
+
Context manager to track request metrics.
|
|
219
|
+
|
|
220
|
+
Usage:
|
|
221
|
+
with track_request("/api/incidents"):
|
|
222
|
+
# ... make request ...
|
|
223
|
+
pass
|
|
224
|
+
"""
|
|
225
|
+
collector = get_metrics_collector()
|
|
226
|
+
collector.increment_active_requests()
|
|
227
|
+
start_time = time.time()
|
|
228
|
+
status_code = 200 # Default
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
yield
|
|
232
|
+
except Exception:
|
|
233
|
+
status_code = 500
|
|
234
|
+
raise
|
|
235
|
+
finally:
|
|
236
|
+
latency_ms = (time.time() - start_time) * 1000
|
|
237
|
+
collector.record_latency(endpoint, latency_ms)
|
|
238
|
+
collector.increment_requests(endpoint, status_code)
|
|
239
|
+
collector.decrement_active_requests()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def log_request(logger: StructuredLogger):
|
|
243
|
+
"""
|
|
244
|
+
Decorator to log requests and responses.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
logger: StructuredLogger instance to use
|
|
248
|
+
"""
|
|
249
|
+
|
|
250
|
+
def decorator(func: Callable) -> Callable:
|
|
251
|
+
@wraps(func)
|
|
252
|
+
async def async_wrapper(*args, **kwargs):
|
|
253
|
+
# Generate new correlation ID for this request
|
|
254
|
+
set_correlation_id(str(uuid.uuid4()))
|
|
255
|
+
|
|
256
|
+
# Log request
|
|
257
|
+
logger.info(
|
|
258
|
+
f"Request started: {func.__name__}",
|
|
259
|
+
function=func.__name__,
|
|
260
|
+
args_count=len(args),
|
|
261
|
+
kwargs_keys=list(kwargs.keys()),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
start_time = time.time()
|
|
265
|
+
try:
|
|
266
|
+
result = await func(*args, **kwargs)
|
|
267
|
+
|
|
268
|
+
# Log success
|
|
269
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
270
|
+
logger.info(
|
|
271
|
+
f"Request completed: {func.__name__}",
|
|
272
|
+
function=func.__name__,
|
|
273
|
+
duration_ms=duration_ms,
|
|
274
|
+
status="success",
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
return result
|
|
278
|
+
|
|
279
|
+
except Exception as e:
|
|
280
|
+
# Log error
|
|
281
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
282
|
+
logger.error(
|
|
283
|
+
f"Request failed: {func.__name__}",
|
|
284
|
+
exc_info=e,
|
|
285
|
+
function=func.__name__,
|
|
286
|
+
duration_ms=duration_ms,
|
|
287
|
+
status="error",
|
|
288
|
+
error_type=type(e).__name__,
|
|
289
|
+
)
|
|
290
|
+
raise
|
|
291
|
+
|
|
292
|
+
finally:
|
|
293
|
+
clear_correlation_id()
|
|
294
|
+
|
|
295
|
+
@wraps(func)
|
|
296
|
+
def sync_wrapper(*args, **kwargs):
|
|
297
|
+
# Generate new correlation ID for this request
|
|
298
|
+
set_correlation_id(str(uuid.uuid4()))
|
|
299
|
+
|
|
300
|
+
# Log request
|
|
301
|
+
logger.info(
|
|
302
|
+
f"Request started: {func.__name__}",
|
|
303
|
+
function=func.__name__,
|
|
304
|
+
args_count=len(args),
|
|
305
|
+
kwargs_keys=list(kwargs.keys()),
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
start_time = time.time()
|
|
309
|
+
try:
|
|
310
|
+
result = func(*args, **kwargs)
|
|
311
|
+
|
|
312
|
+
# Log success
|
|
313
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
314
|
+
logger.info(
|
|
315
|
+
f"Request completed: {func.__name__}",
|
|
316
|
+
function=func.__name__,
|
|
317
|
+
duration_ms=duration_ms,
|
|
318
|
+
status="success",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
return result
|
|
322
|
+
|
|
323
|
+
except Exception as e:
|
|
324
|
+
# Log error
|
|
325
|
+
duration_ms = (time.time() - start_time) * 1000
|
|
326
|
+
logger.error(
|
|
327
|
+
f"Request failed: {func.__name__}",
|
|
328
|
+
exc_info=e,
|
|
329
|
+
function=func.__name__,
|
|
330
|
+
duration_ms=duration_ms,
|
|
331
|
+
status="error",
|
|
332
|
+
error_type=type(e).__name__,
|
|
333
|
+
)
|
|
334
|
+
raise
|
|
335
|
+
|
|
336
|
+
finally:
|
|
337
|
+
clear_correlation_id()
|
|
338
|
+
|
|
339
|
+
# Return appropriate wrapper based on function type
|
|
340
|
+
import asyncio
|
|
341
|
+
|
|
342
|
+
if asyncio.iscoroutinefunction(func):
|
|
343
|
+
return async_wrapper
|
|
344
|
+
return sync_wrapper
|
|
345
|
+
|
|
346
|
+
return decorator
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def get_health_status() -> dict[str, Any]:
|
|
350
|
+
"""
|
|
351
|
+
Get health check status of the server.
|
|
352
|
+
|
|
353
|
+
Returns:
|
|
354
|
+
Dictionary with health status information
|
|
355
|
+
"""
|
|
356
|
+
metrics = get_metrics_collector().get_metrics()
|
|
357
|
+
|
|
358
|
+
# Calculate overall health based on error rate
|
|
359
|
+
total_requests = sum(metrics["request_counts"].values())
|
|
360
|
+
total_errors = sum(metrics["error_counts"].values())
|
|
361
|
+
|
|
362
|
+
error_rate = (total_errors / total_requests * 100) if total_requests > 0 else 0
|
|
363
|
+
|
|
364
|
+
# Determine status based on error rate
|
|
365
|
+
if error_rate > 50:
|
|
366
|
+
status = "unhealthy"
|
|
367
|
+
elif error_rate > 10:
|
|
368
|
+
status = "degraded"
|
|
369
|
+
else:
|
|
370
|
+
status = "healthy"
|
|
371
|
+
|
|
372
|
+
return {
|
|
373
|
+
"status": status,
|
|
374
|
+
"error_rate_percent": round(error_rate, 2),
|
|
375
|
+
"active_requests": metrics["active_requests"],
|
|
376
|
+
"total_requests": total_requests,
|
|
377
|
+
"total_errors": total_errors,
|
|
378
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pagination utilities for the Rootly MCP Server.
|
|
3
|
+
|
|
4
|
+
This module provides helpers for paginated API requests.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def fetch_all_pages(
|
|
12
|
+
fetch_func: Callable,
|
|
13
|
+
max_results: int,
|
|
14
|
+
page_size: int = 10,
|
|
15
|
+
**kwargs,
|
|
16
|
+
) -> dict[str, Any]:
|
|
17
|
+
"""
|
|
18
|
+
Fetch all pages from a paginated endpoint up to max_results.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
fetch_func: Async function to fetch a single page
|
|
22
|
+
max_results: Maximum total results to fetch
|
|
23
|
+
page_size: Number of items per page
|
|
24
|
+
**kwargs: Additional arguments to pass to fetch_func
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Combined results from all pages
|
|
28
|
+
"""
|
|
29
|
+
all_results = []
|
|
30
|
+
page = 1
|
|
31
|
+
total_fetched = 0
|
|
32
|
+
|
|
33
|
+
while total_fetched < max_results:
|
|
34
|
+
# Fetch one page
|
|
35
|
+
response = await fetch_func(page_size=page_size, page_number=page, **kwargs)
|
|
36
|
+
|
|
37
|
+
# Extract data from response
|
|
38
|
+
if isinstance(response, dict):
|
|
39
|
+
data = response.get("data", [])
|
|
40
|
+
else:
|
|
41
|
+
data = []
|
|
42
|
+
|
|
43
|
+
if not data:
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
# Add results
|
|
47
|
+
remaining = max_results - total_fetched
|
|
48
|
+
all_results.extend(data[:remaining])
|
|
49
|
+
total_fetched += len(data[:remaining])
|
|
50
|
+
|
|
51
|
+
# Check if we got fewer results than page size (last page)
|
|
52
|
+
if len(data) < page_size:
|
|
53
|
+
break
|
|
54
|
+
|
|
55
|
+
page += 1
|
|
56
|
+
|
|
57
|
+
return {"data": all_results, "total_fetched": total_fetched}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_pagination_params(
|
|
61
|
+
page_size: int = 10,
|
|
62
|
+
page_number: int = 1,
|
|
63
|
+
) -> dict[str, Any]:
|
|
64
|
+
"""
|
|
65
|
+
Build pagination parameters for Rootly API.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
page_size: Number of items per page
|
|
69
|
+
page_number: Page number (1-indexed)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Dictionary of pagination parameters
|
|
73
|
+
"""
|
|
74
|
+
return {
|
|
75
|
+
"page[size]": page_size,
|
|
76
|
+
"page[number]": page_number,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def extract_pagination_meta(response: dict[str, Any]) -> dict[str, Any]:
|
|
81
|
+
"""
|
|
82
|
+
Extract pagination metadata from API response.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
response: API response dictionary
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Pagination metadata
|
|
89
|
+
"""
|
|
90
|
+
meta = response.get("meta", {})
|
|
91
|
+
pagination = meta.get("pagination", {})
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
"current_page": pagination.get("current_page", 1),
|
|
95
|
+
"total_pages": pagination.get("total_pages", 1),
|
|
96
|
+
"total_count": pagination.get("total_count", 0),
|
|
97
|
+
"per_page": pagination.get("per_page", 10),
|
|
98
|
+
}
|