rootly-mcp-server 2.0.6__py3-none-any.whl → 2.0.9__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 +1 -1
- rootly_mcp_server/__main__.py +6 -6
- rootly_mcp_server/client.py +2 -2
- rootly_mcp_server/routemap_server.py +57 -142
- rootly_mcp_server/server.py +318 -184
- rootly_mcp_server/test_client.py +11 -9
- rootly_mcp_server/utils.py +105 -0
- {rootly_mcp_server-2.0.6.dist-info → rootly_mcp_server-2.0.9.dist-info}/METADATA +28 -26
- rootly_mcp_server-2.0.9.dist-info/RECORD +13 -0
- rootly_mcp_server/rootly_openapi_loader.py +0 -109
- rootly_mcp_server-2.0.6.dist-info/RECORD +0 -13
- {rootly_mcp_server-2.0.6.dist-info → rootly_mcp_server-2.0.9.dist-info}/WHEEL +0 -0
- {rootly_mcp_server-2.0.6.dist-info → rootly_mcp_server-2.0.9.dist-info}/entry_points.txt +0 -0
- {rootly_mcp_server-2.0.6.dist-info → rootly_mcp_server-2.0.9.dist-info}/licenses/LICENSE +0 -0
rootly_mcp_server/server.py
CHANGED
|
@@ -7,21 +7,18 @@ the Rootly API's OpenAPI (Swagger) specification using FastMCP's OpenAPI integra
|
|
|
7
7
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
|
-
import re
|
|
11
10
|
import logging
|
|
11
|
+
from copy import deepcopy
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
import requests
|
|
14
14
|
import httpx
|
|
15
|
-
from typing import Any, Dict, List, Optional,
|
|
16
|
-
from enum import Enum
|
|
15
|
+
from typing import Any, Dict, List, Optional, Annotated
|
|
17
16
|
|
|
18
17
|
from fastmcp import FastMCP
|
|
19
18
|
|
|
20
|
-
from
|
|
21
|
-
from starlette.requests import Request
|
|
22
|
-
from pydantic import BaseModel, Field
|
|
19
|
+
from pydantic import Field
|
|
23
20
|
|
|
24
|
-
from .
|
|
21
|
+
from .utils import sanitize_parameters_in_spec
|
|
25
22
|
|
|
26
23
|
# Set up logger
|
|
27
24
|
logger = logging.getLogger(__name__)
|
|
@@ -29,29 +26,75 @@ logger = logging.getLogger(__name__)
|
|
|
29
26
|
# Default Swagger URL
|
|
30
27
|
SWAGGER_URL = "https://rootly-heroku.s3.amazonaws.com/swagger/v1/swagger.json"
|
|
31
28
|
|
|
29
|
+
# Default allowed API paths
|
|
30
|
+
DEFAULT_ALLOWED_PATHS = [
|
|
31
|
+
"/incidents/{incident_id}/alerts",
|
|
32
|
+
"/alerts",
|
|
33
|
+
"/alerts/{alert_id}",
|
|
34
|
+
"/severities",
|
|
35
|
+
"/severities/{severity_id}",
|
|
36
|
+
"/teams",
|
|
37
|
+
"/teams/{team_id}",
|
|
38
|
+
"/services",
|
|
39
|
+
"/services/{service_id}",
|
|
40
|
+
"/functionalities",
|
|
41
|
+
"/functionalities/{functionality_id}",
|
|
42
|
+
# Incident types
|
|
43
|
+
"/incident_types",
|
|
44
|
+
"/incident_types/{incident_type_id}",
|
|
45
|
+
# Action items (all, by id, by incident)
|
|
46
|
+
"/incident_action_items",
|
|
47
|
+
"/incident_action_items/{incident_action_item_id}",
|
|
48
|
+
"/incidents/{incident_id}/action_items",
|
|
49
|
+
# Workflows
|
|
50
|
+
"/workflows",
|
|
51
|
+
"/workflows/{workflow_id}",
|
|
52
|
+
# Workflow runs
|
|
53
|
+
"/workflow_runs",
|
|
54
|
+
"/workflow_runs/{workflow_run_id}",
|
|
55
|
+
# Environments
|
|
56
|
+
"/environments",
|
|
57
|
+
"/environments/{environment_id}",
|
|
58
|
+
# Users
|
|
59
|
+
"/users",
|
|
60
|
+
"/users/{user_id}",
|
|
61
|
+
"/users/me",
|
|
62
|
+
# Status pages
|
|
63
|
+
"/status_pages",
|
|
64
|
+
"/status_pages/{status_page_id}",
|
|
65
|
+
]
|
|
66
|
+
|
|
32
67
|
|
|
33
68
|
class AuthenticatedHTTPXClient:
|
|
34
|
-
"""An HTTPX client wrapper that handles Rootly API authentication."""
|
|
35
|
-
|
|
36
|
-
def __init__(self, base_url: str = "https://api.rootly.com", hosted: bool = False):
|
|
37
|
-
self.
|
|
69
|
+
"""An HTTPX client wrapper that handles Rootly API authentication and parameter transformation."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, base_url: str = "https://api.rootly.com", hosted: bool = False, parameter_mapping: Optional[Dict[str, str]] = None):
|
|
72
|
+
self._base_url = base_url
|
|
38
73
|
self.hosted = hosted
|
|
39
74
|
self._api_token = None
|
|
40
|
-
|
|
75
|
+
self.parameter_mapping = parameter_mapping or {}
|
|
76
|
+
|
|
41
77
|
if not self.hosted:
|
|
42
78
|
self._api_token = self._get_api_token()
|
|
43
|
-
|
|
44
|
-
# Create the HTTPX client
|
|
45
|
-
headers = {
|
|
79
|
+
|
|
80
|
+
# Create the HTTPX client
|
|
81
|
+
headers = {
|
|
82
|
+
"Content-Type": "application/vnd.api+json",
|
|
83
|
+
"Accept": "application/vnd.api+json"
|
|
84
|
+
# Let httpx handle Accept-Encoding automatically with all supported formats
|
|
85
|
+
}
|
|
46
86
|
if self._api_token:
|
|
47
87
|
headers["Authorization"] = f"Bearer {self._api_token}"
|
|
48
|
-
|
|
88
|
+
|
|
49
89
|
self.client = httpx.AsyncClient(
|
|
50
90
|
base_url=base_url,
|
|
51
91
|
headers=headers,
|
|
52
|
-
timeout=30.0
|
|
92
|
+
timeout=30.0,
|
|
93
|
+
follow_redirects=True,
|
|
94
|
+
# Ensure proper handling of compressed responses
|
|
95
|
+
limits=httpx.Limits(max_keepalive_connections=5, max_connections=10)
|
|
53
96
|
)
|
|
54
|
-
|
|
97
|
+
|
|
55
98
|
def _get_api_token(self) -> Optional[str]:
|
|
56
99
|
"""Get the API token from environment variables."""
|
|
57
100
|
api_token = os.getenv("ROOTLY_API_TOKEN")
|
|
@@ -59,16 +102,70 @@ class AuthenticatedHTTPXClient:
|
|
|
59
102
|
logger.warning("ROOTLY_API_TOKEN environment variable is not set")
|
|
60
103
|
return None
|
|
61
104
|
return api_token
|
|
62
|
-
|
|
105
|
+
|
|
106
|
+
def _transform_params(self, params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
107
|
+
"""Transform sanitized parameter names back to original names."""
|
|
108
|
+
if not params or not self.parameter_mapping:
|
|
109
|
+
return params
|
|
110
|
+
|
|
111
|
+
transformed = {}
|
|
112
|
+
for key, value in params.items():
|
|
113
|
+
# Use the original name if we have a mapping, otherwise keep the sanitized name
|
|
114
|
+
original_key = self.parameter_mapping.get(key, key)
|
|
115
|
+
transformed[original_key] = value
|
|
116
|
+
if original_key != key:
|
|
117
|
+
logger.debug(f"Transformed parameter: '{key}' -> '{original_key}'")
|
|
118
|
+
return transformed
|
|
119
|
+
|
|
120
|
+
async def request(self, method: str, url: str, **kwargs):
|
|
121
|
+
"""Override request to transform parameters."""
|
|
122
|
+
# Transform query parameters
|
|
123
|
+
if 'params' in kwargs:
|
|
124
|
+
kwargs['params'] = self._transform_params(kwargs['params'])
|
|
125
|
+
|
|
126
|
+
# Call the underlying client's request method and let it handle everything
|
|
127
|
+
return await self.client.request(method, url, **kwargs)
|
|
128
|
+
|
|
129
|
+
async def get(self, url: str, **kwargs):
|
|
130
|
+
"""Proxy to request with GET method."""
|
|
131
|
+
return await self.request('GET', url, **kwargs)
|
|
132
|
+
|
|
133
|
+
async def post(self, url: str, **kwargs):
|
|
134
|
+
"""Proxy to request with POST method."""
|
|
135
|
+
return await self.request('POST', url, **kwargs)
|
|
136
|
+
|
|
137
|
+
async def put(self, url: str, **kwargs):
|
|
138
|
+
"""Proxy to request with PUT method."""
|
|
139
|
+
return await self.request('PUT', url, **kwargs)
|
|
140
|
+
|
|
141
|
+
async def patch(self, url: str, **kwargs):
|
|
142
|
+
"""Proxy to request with PATCH method."""
|
|
143
|
+
return await self.request('PATCH', url, **kwargs)
|
|
144
|
+
|
|
145
|
+
async def delete(self, url: str, **kwargs):
|
|
146
|
+
"""Proxy to request with DELETE method."""
|
|
147
|
+
return await self.request('DELETE', url, **kwargs)
|
|
148
|
+
|
|
63
149
|
async def __aenter__(self):
|
|
64
|
-
return self
|
|
65
|
-
|
|
150
|
+
return self
|
|
151
|
+
|
|
66
152
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
67
|
-
|
|
68
|
-
|
|
153
|
+
pass
|
|
154
|
+
|
|
69
155
|
def __getattr__(self, name):
|
|
70
|
-
# Delegate all other attributes to the underlying client
|
|
156
|
+
# Delegate all other attributes to the underlying client, except for request methods
|
|
157
|
+
if name in ['request', 'get', 'post', 'put', 'patch', 'delete']:
|
|
158
|
+
# Use our overridden methods instead
|
|
159
|
+
return getattr(self, name)
|
|
71
160
|
return getattr(self.client, name)
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def base_url(self):
|
|
164
|
+
return self._base_url
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def headers(self):
|
|
168
|
+
return self.client.headers
|
|
72
169
|
|
|
73
170
|
|
|
74
171
|
def create_rootly_mcp_server(
|
|
@@ -93,44 +190,8 @@ def create_rootly_mcp_server(
|
|
|
93
190
|
"""
|
|
94
191
|
# Set default allowed paths if none provided
|
|
95
192
|
if allowed_paths is None:
|
|
96
|
-
allowed_paths =
|
|
97
|
-
|
|
98
|
-
"/incidents/{incident_id}/alerts",
|
|
99
|
-
"/alerts",
|
|
100
|
-
"/alerts/{alert_id}",
|
|
101
|
-
"/severities",
|
|
102
|
-
"/severities/{severity_id}",
|
|
103
|
-
"/teams",
|
|
104
|
-
"/teams/{team_id}",
|
|
105
|
-
"/services",
|
|
106
|
-
"/services/{service_id}",
|
|
107
|
-
"/functionalities",
|
|
108
|
-
"/functionalities/{functionality_id}",
|
|
109
|
-
# Incident types
|
|
110
|
-
"/incident_types",
|
|
111
|
-
"/incident_types/{incident_type_id}",
|
|
112
|
-
# Action items (all, by id, by incident)
|
|
113
|
-
"/incident_action_items",
|
|
114
|
-
"/incident_action_items/{incident_action_item_id}",
|
|
115
|
-
"/incidents/{incident_id}/action_items",
|
|
116
|
-
# Workflows
|
|
117
|
-
"/workflows",
|
|
118
|
-
"/workflows/{workflow_id}",
|
|
119
|
-
# Workflow runs
|
|
120
|
-
"/workflow_runs",
|
|
121
|
-
"/workflow_runs/{workflow_run_id}",
|
|
122
|
-
# Environments
|
|
123
|
-
"/environments",
|
|
124
|
-
"/environments/{environment_id}",
|
|
125
|
-
# Users
|
|
126
|
-
"/users",
|
|
127
|
-
"/users/{user_id}",
|
|
128
|
-
"/users/me",
|
|
129
|
-
# Status pages
|
|
130
|
-
"/status_pages",
|
|
131
|
-
"/status_pages/{status_page_id}",
|
|
132
|
-
]
|
|
133
|
-
|
|
193
|
+
allowed_paths = DEFAULT_ALLOWED_PATHS
|
|
194
|
+
|
|
134
195
|
# Add /v1 prefix to paths if not present
|
|
135
196
|
allowed_paths_v1 = [
|
|
136
197
|
f"/v1{path}" if not path.startswith("/v1") else path
|
|
@@ -147,22 +208,23 @@ def create_rootly_mcp_server(
|
|
|
147
208
|
filtered_spec = _filter_openapi_spec(swagger_spec, allowed_paths_v1)
|
|
148
209
|
logger.info(f"Filtered spec to {len(filtered_spec.get('paths', {}))} allowed paths")
|
|
149
210
|
|
|
211
|
+
# Sanitize all parameter names in the filtered spec to be MCP-compliant
|
|
212
|
+
parameter_mapping = sanitize_parameters_in_spec(filtered_spec)
|
|
213
|
+
logger.info(f"Sanitized parameter names for MCP compatibility (mapped {len(parameter_mapping)} parameters)")
|
|
214
|
+
|
|
150
215
|
# Determine the base URL
|
|
151
216
|
if base_url is None:
|
|
152
217
|
base_url = os.getenv("ROOTLY_BASE_URL", "https://api.rootly.com")
|
|
153
|
-
|
|
218
|
+
|
|
154
219
|
logger.info(f"Using Rootly API base URL: {base_url}")
|
|
155
220
|
|
|
156
|
-
# Create the authenticated HTTP client
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
logger.warning(f"Failed to create authenticated client: {e}")
|
|
164
|
-
# Create a mock client for testing
|
|
165
|
-
http_client = httpx.AsyncClient(base_url=base_url)
|
|
221
|
+
# Create the authenticated HTTP client with parameter mapping
|
|
222
|
+
|
|
223
|
+
http_client = AuthenticatedHTTPXClient(
|
|
224
|
+
base_url=base_url,
|
|
225
|
+
hosted=hosted,
|
|
226
|
+
parameter_mapping=parameter_mapping
|
|
227
|
+
)
|
|
166
228
|
|
|
167
229
|
# Create the MCP server using OpenAPI integration
|
|
168
230
|
# By default, all routes become tools which is what we want
|
|
@@ -173,10 +235,53 @@ def create_rootly_mcp_server(
|
|
|
173
235
|
timeout=30.0,
|
|
174
236
|
tags={"rootly", "incident-management"},
|
|
175
237
|
)
|
|
176
|
-
|
|
238
|
+
|
|
239
|
+
@mcp.custom_route("/healthz", methods=["GET"])
|
|
240
|
+
@mcp.custom_route("/health", methods=["GET"])
|
|
241
|
+
async def health_check(request):
|
|
242
|
+
from starlette.responses import PlainTextResponse
|
|
243
|
+
return PlainTextResponse("OK")
|
|
244
|
+
|
|
177
245
|
# Add some custom tools for enhanced functionality
|
|
178
246
|
@mcp.tool()
|
|
179
|
-
def
|
|
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
|
+
|
|
283
|
+
@mcp.tool()
|
|
284
|
+
def list_endpoints() -> list:
|
|
180
285
|
"""List all available Rootly API endpoints with their descriptions."""
|
|
181
286
|
endpoints = []
|
|
182
287
|
for path, path_item in filtered_spec.get("paths", {}).items():
|
|
@@ -186,7 +291,7 @@ def create_rootly_mcp_server(
|
|
|
186
291
|
|
|
187
292
|
summary = operation.get("summary", "")
|
|
188
293
|
description = operation.get("description", "")
|
|
189
|
-
|
|
294
|
+
|
|
190
295
|
endpoints.append({
|
|
191
296
|
"path": path,
|
|
192
297
|
"method": method.upper(),
|
|
@@ -194,106 +299,121 @@ def create_rootly_mcp_server(
|
|
|
194
299
|
"description": description,
|
|
195
300
|
})
|
|
196
301
|
|
|
197
|
-
return
|
|
302
|
+
return endpoints
|
|
303
|
+
|
|
304
|
+
async def make_authenticated_request(method: str, url: str, **kwargs):
|
|
305
|
+
"""Make an authenticated request, extracting token from MCP headers in hosted mode."""
|
|
306
|
+
# In hosted mode, get token from MCP request headers
|
|
307
|
+
if hosted:
|
|
308
|
+
try:
|
|
309
|
+
from fastmcp.server.dependencies import get_http_headers
|
|
310
|
+
request_headers = get_http_headers()
|
|
311
|
+
auth_header = request_headers.get("authorization", "")
|
|
312
|
+
if auth_header:
|
|
313
|
+
# Add authorization header to the request
|
|
314
|
+
if "headers" not in kwargs:
|
|
315
|
+
kwargs["headers"] = {}
|
|
316
|
+
kwargs["headers"]["Authorization"] = auth_header
|
|
317
|
+
except Exception:
|
|
318
|
+
pass # Fallback to default client behavior
|
|
319
|
+
|
|
320
|
+
# Use our custom client with proper error handling instead of bypassing it
|
|
321
|
+
return await http_client.request(method, url, **kwargs)
|
|
198
322
|
|
|
199
323
|
@mcp.tool()
|
|
200
|
-
async def
|
|
324
|
+
async def search_incidents(
|
|
201
325
|
query: Annotated[str, Field(description="Search query to filter incidents by title/summary")] = "",
|
|
202
|
-
page_size: Annotated[int, Field(description="Number of results per page (max:
|
|
203
|
-
page_number: Annotated[int, Field(description="Page number to retrieve", ge=
|
|
204
|
-
|
|
326
|
+
page_size: Annotated[int, Field(description="Number of results per page (max: 20)", ge=1, le=20)] = 10,
|
|
327
|
+
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,
|
|
329
|
+
) -> dict:
|
|
205
330
|
"""
|
|
206
|
-
Search incidents with
|
|
207
|
-
|
|
208
|
-
|
|
331
|
+
Search incidents with flexible pagination control.
|
|
332
|
+
|
|
333
|
+
Use page_number=0 to fetch all matching results across multiple pages up to max_results.
|
|
334
|
+
Use page_number>0 to fetch a specific page.
|
|
209
335
|
"""
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
336
|
+
# Single page mode
|
|
337
|
+
if page_number > 0:
|
|
338
|
+
params = {
|
|
339
|
+
"page[size]": min(page_size, 20),
|
|
340
|
+
"page[number]": page_number,
|
|
341
|
+
"include": "",
|
|
342
|
+
}
|
|
343
|
+
if query:
|
|
344
|
+
params["filter[search]"] = query
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
response = await make_authenticated_request("GET", "/v1/incidents", params=params)
|
|
220
348
|
response.raise_for_status()
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return json.dumps(result, indent=2)
|
|
349
|
+
return response.json()
|
|
350
|
+
except Exception as e:
|
|
351
|
+
return {"error": str(e)}
|
|
226
352
|
|
|
227
|
-
|
|
228
|
-
async def get_all_incidents_matching(
|
|
229
|
-
query: Annotated[str, Field(description="Search query to filter incidents by title/summary")] = "",
|
|
230
|
-
max_results: Annotated[int, Field(description="Maximum number of results to return", ge=1, le=1000)] = 500,
|
|
231
|
-
) -> str:
|
|
232
|
-
"""
|
|
233
|
-
Get all incidents matching a query by automatically fetching multiple pages.
|
|
234
|
-
|
|
235
|
-
This tool automatically handles pagination to fetch multiple pages of results.
|
|
236
|
-
"""
|
|
353
|
+
# Multi-page mode (page_number = 0)
|
|
237
354
|
all_incidents = []
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
355
|
+
current_page = 1
|
|
356
|
+
effective_page_size = min(page_size, 10)
|
|
357
|
+
|
|
241
358
|
try:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
359
|
+
while len(all_incidents) < max_results:
|
|
360
|
+
params = {
|
|
361
|
+
"page[size]": effective_page_size,
|
|
362
|
+
"page[number]": current_page,
|
|
363
|
+
"include": "",
|
|
364
|
+
}
|
|
365
|
+
if query:
|
|
366
|
+
params["filter[search]"] = query
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
response = await make_authenticated_request("GET", "/v1/incidents", params=params)
|
|
370
|
+
response.raise_for_status()
|
|
371
|
+
response_data = response.json()
|
|
372
|
+
|
|
373
|
+
if "data" in response_data:
|
|
374
|
+
incidents = response_data["data"]
|
|
375
|
+
if not incidents:
|
|
376
|
+
break
|
|
255
377
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
break # No more pages
|
|
269
|
-
|
|
270
|
-
page_number += 1
|
|
271
|
-
else:
|
|
272
|
-
break # Unexpected response format
|
|
273
|
-
|
|
274
|
-
except Exception as e:
|
|
275
|
-
logger.error(f"Error fetching incidents page {page_number}: {e}")
|
|
378
|
+
all_incidents.extend(incidents)
|
|
379
|
+
|
|
380
|
+
# Check if we have more pages
|
|
381
|
+
meta = response_data.get("meta", {})
|
|
382
|
+
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:
|
|
386
|
+
break
|
|
387
|
+
|
|
388
|
+
current_page += 1
|
|
389
|
+
else:
|
|
276
390
|
break
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
391
|
+
|
|
392
|
+
except Exception as e:
|
|
393
|
+
# Re-raise authentication or critical errors
|
|
394
|
+
if "401" in str(e) or "Unauthorized" in str(e) or "authentication" in str(e).lower():
|
|
395
|
+
raise e
|
|
396
|
+
break
|
|
397
|
+
|
|
398
|
+
# Limit to max_results
|
|
399
|
+
if len(all_incidents) > max_results:
|
|
400
|
+
all_incidents = all_incidents[:max_results]
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
"data": all_incidents,
|
|
404
|
+
"meta": {
|
|
405
|
+
"total_fetched": len(all_incidents),
|
|
406
|
+
"max_results": max_results,
|
|
407
|
+
"query": query,
|
|
408
|
+
"pages_fetched": current_page - 1,
|
|
409
|
+
"page_size": effective_page_size
|
|
289
410
|
}
|
|
411
|
+
}
|
|
290
412
|
except Exception as e:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return json.dumps(result, indent=2)
|
|
413
|
+
return {"error": str(e)}
|
|
294
414
|
|
|
295
415
|
# Log server creation (tool count will be shown when tools are accessed)
|
|
296
|
-
logger.info(
|
|
416
|
+
logger.info("Created Rootly MCP Server successfully")
|
|
297
417
|
return mcp
|
|
298
418
|
|
|
299
419
|
|
|
@@ -312,7 +432,7 @@ def _load_swagger_spec(swagger_path: Optional[str] = None) -> Dict[str, Any]:
|
|
|
312
432
|
logger.info(f"Using provided Swagger path: {swagger_path}")
|
|
313
433
|
if not os.path.isfile(swagger_path):
|
|
314
434
|
raise FileNotFoundError(f"Swagger file not found at {swagger_path}")
|
|
315
|
-
with open(swagger_path, "r") as f:
|
|
435
|
+
with open(swagger_path, "r", encoding="utf-8") as f:
|
|
316
436
|
return json.load(f)
|
|
317
437
|
else:
|
|
318
438
|
# First, check in the package data directory
|
|
@@ -320,7 +440,7 @@ def _load_swagger_spec(swagger_path: Optional[str] = None) -> Dict[str, Any]:
|
|
|
320
440
|
package_data_path = Path(__file__).parent / "data" / "swagger.json"
|
|
321
441
|
if package_data_path.is_file():
|
|
322
442
|
logger.info(f"Found Swagger file in package data: {package_data_path}")
|
|
323
|
-
with open(package_data_path, "r") as f:
|
|
443
|
+
with open(package_data_path, "r", encoding="utf-8") as f:
|
|
324
444
|
return json.load(f)
|
|
325
445
|
except Exception as e:
|
|
326
446
|
logger.debug(f"Could not load Swagger file from package data: {e}")
|
|
@@ -333,7 +453,7 @@ def _load_swagger_spec(swagger_path: Optional[str] = None) -> Dict[str, Any]:
|
|
|
333
453
|
swagger_path = current_dir / "swagger.json"
|
|
334
454
|
if swagger_path.is_file():
|
|
335
455
|
logger.info(f"Found Swagger file at {swagger_path}")
|
|
336
|
-
with open(swagger_path, "r") as f:
|
|
456
|
+
with open(swagger_path, "r", encoding="utf-8") as f:
|
|
337
457
|
return json.load(f)
|
|
338
458
|
|
|
339
459
|
# Check parent directories
|
|
@@ -341,7 +461,7 @@ def _load_swagger_spec(swagger_path: Optional[str] = None) -> Dict[str, Any]:
|
|
|
341
461
|
swagger_path = parent / "swagger.json"
|
|
342
462
|
if swagger_path.is_file():
|
|
343
463
|
logger.info(f"Found Swagger file at {swagger_path}")
|
|
344
|
-
with open(swagger_path, "r") as f:
|
|
464
|
+
with open(swagger_path, "r", encoding="utf-8") as f:
|
|
345
465
|
return json.load(f)
|
|
346
466
|
|
|
347
467
|
# If the file wasn't found, fetch it from the URL and save it
|
|
@@ -352,7 +472,7 @@ def _load_swagger_spec(swagger_path: Optional[str] = None) -> Dict[str, Any]:
|
|
|
352
472
|
swagger_path = current_dir / "swagger.json"
|
|
353
473
|
logger.info(f"Saving Swagger file to {swagger_path}")
|
|
354
474
|
try:
|
|
355
|
-
with open(swagger_path, "w") as f:
|
|
475
|
+
with open(swagger_path, "w", encoding="utf-8") as f:
|
|
356
476
|
json.dump(swagger_spec, f)
|
|
357
477
|
logger.info(f"Saved Swagger file to {swagger_path}")
|
|
358
478
|
except Exception as e:
|
|
@@ -395,25 +515,26 @@ def _filter_openapi_spec(spec: Dict[str, Any], allowed_paths: List[str]) -> Dict
|
|
|
395
515
|
Returns:
|
|
396
516
|
A filtered OpenAPI specification with cleaned schema references.
|
|
397
517
|
"""
|
|
398
|
-
|
|
399
|
-
|
|
518
|
+
# Use deepcopy to ensure all nested structures are properly copied
|
|
519
|
+
filtered_spec = deepcopy(spec)
|
|
520
|
+
|
|
400
521
|
# Filter paths
|
|
401
|
-
original_paths =
|
|
522
|
+
original_paths = filtered_spec.get("paths", {})
|
|
402
523
|
filtered_paths = {
|
|
403
524
|
path: path_item
|
|
404
525
|
for path, path_item in original_paths.items()
|
|
405
526
|
if path in allowed_paths
|
|
406
527
|
}
|
|
407
|
-
|
|
528
|
+
|
|
408
529
|
filtered_spec["paths"] = filtered_paths
|
|
409
|
-
|
|
530
|
+
|
|
410
531
|
# Clean up schema references that might be broken
|
|
411
532
|
# Remove problematic schema references from request bodies and parameters
|
|
412
533
|
for path, path_item in filtered_paths.items():
|
|
413
534
|
for method, operation in path_item.items():
|
|
414
535
|
if method.lower() not in ["get", "post", "put", "delete", "patch"]:
|
|
415
536
|
continue
|
|
416
|
-
|
|
537
|
+
|
|
417
538
|
# Clean request body schemas
|
|
418
539
|
if "requestBody" in operation:
|
|
419
540
|
request_body = operation["requestBody"]
|
|
@@ -429,8 +550,21 @@ def _filter_openapi_spec(spec: Dict[str, Any], allowed_paths: List[str]) -> Dict
|
|
|
429
550
|
"description": "Request parameters for this endpoint",
|
|
430
551
|
"additionalProperties": True
|
|
431
552
|
}
|
|
432
|
-
|
|
433
|
-
#
|
|
553
|
+
|
|
554
|
+
# Remove response schemas to avoid validation issues
|
|
555
|
+
# FastMCP will still return the data, just without strict validation
|
|
556
|
+
if "responses" in operation:
|
|
557
|
+
for status_code, response in operation["responses"].items():
|
|
558
|
+
if "content" in response:
|
|
559
|
+
for content_type, content_info in response["content"].items():
|
|
560
|
+
if "schema" in content_info:
|
|
561
|
+
# Replace with a simple schema that accepts any response
|
|
562
|
+
content_info["schema"] = {
|
|
563
|
+
"type": "object",
|
|
564
|
+
"additionalProperties": True
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
# Clean parameter schemas (parameter names are already sanitized)
|
|
434
568
|
if "parameters" in operation:
|
|
435
569
|
for param in operation["parameters"]:
|
|
436
570
|
if "schema" in param and "$ref" in param["schema"]:
|
|
@@ -441,7 +575,7 @@ def _filter_openapi_spec(spec: Dict[str, Any], allowed_paths: List[str]) -> Dict
|
|
|
441
575
|
"type": "string",
|
|
442
576
|
"description": param.get("description", "Parameter value")
|
|
443
577
|
}
|
|
444
|
-
|
|
578
|
+
|
|
445
579
|
# Also clean up any remaining broken references in components
|
|
446
580
|
if "components" in filtered_spec and "schemas" in filtered_spec["components"]:
|
|
447
581
|
schemas = filtered_spec["components"]["schemas"]
|
|
@@ -450,11 +584,11 @@ def _filter_openapi_spec(spec: Dict[str, Any], allowed_paths: List[str]) -> Dict
|
|
|
450
584
|
for schema_name, schema_def in schemas.items():
|
|
451
585
|
if isinstance(schema_def, dict) and _has_broken_references(schema_def):
|
|
452
586
|
schemas_to_remove.append(schema_name)
|
|
453
|
-
|
|
587
|
+
|
|
454
588
|
for schema_name in schemas_to_remove:
|
|
455
589
|
logger.warning(f"Removing schema with broken references: {schema_name}")
|
|
456
590
|
del schemas[schema_name]
|
|
457
|
-
|
|
591
|
+
|
|
458
592
|
return filtered_spec
|
|
459
593
|
|
|
460
594
|
|
|
@@ -465,13 +599,13 @@ def _has_broken_references(schema_def: Dict[str, Any]) -> bool:
|
|
|
465
599
|
# List of known broken references in the Rootly API spec
|
|
466
600
|
broken_refs = [
|
|
467
601
|
"incident_trigger_params",
|
|
468
|
-
"new_workflow",
|
|
602
|
+
"new_workflow",
|
|
469
603
|
"update_workflow",
|
|
470
604
|
"workflow"
|
|
471
605
|
]
|
|
472
606
|
if any(broken_ref in ref_path for broken_ref in broken_refs):
|
|
473
607
|
return True
|
|
474
|
-
|
|
608
|
+
|
|
475
609
|
# Recursively check nested schemas
|
|
476
610
|
for key, value in schema_def.items():
|
|
477
611
|
if isinstance(value, dict):
|
|
@@ -481,7 +615,7 @@ def _has_broken_references(schema_def: Dict[str, Any]) -> bool:
|
|
|
481
615
|
for item in value:
|
|
482
616
|
if isinstance(item, dict) and _has_broken_references(item):
|
|
483
617
|
return True
|
|
484
|
-
|
|
618
|
+
|
|
485
619
|
return False
|
|
486
620
|
|
|
487
621
|
|
|
@@ -489,10 +623,10 @@ def _has_broken_references(schema_def: Dict[str, Any]) -> bool:
|
|
|
489
623
|
class RootlyMCPServer(FastMCP):
|
|
490
624
|
"""
|
|
491
625
|
Legacy Rootly MCP Server class for backward compatibility.
|
|
492
|
-
|
|
626
|
+
|
|
493
627
|
This class is deprecated. Use create_rootly_mcp_server() instead.
|
|
494
628
|
"""
|
|
495
|
-
|
|
629
|
+
|
|
496
630
|
def __init__(
|
|
497
631
|
self,
|
|
498
632
|
swagger_path: Optional[str] = None,
|
|
@@ -506,7 +640,7 @@ class RootlyMCPServer(FastMCP):
|
|
|
506
640
|
logger.warning(
|
|
507
641
|
"RootlyMCPServer class is deprecated. Use create_rootly_mcp_server() function instead."
|
|
508
642
|
)
|
|
509
|
-
|
|
643
|
+
|
|
510
644
|
# Create the server using the new function
|
|
511
645
|
server = create_rootly_mcp_server(
|
|
512
646
|
swagger_path=swagger_path,
|
|
@@ -514,7 +648,7 @@ class RootlyMCPServer(FastMCP):
|
|
|
514
648
|
allowed_paths=allowed_paths,
|
|
515
649
|
hosted=hosted
|
|
516
650
|
)
|
|
517
|
-
|
|
651
|
+
|
|
518
652
|
# Copy the server's state to this instance
|
|
519
653
|
super().__init__(name, *args, **kwargs)
|
|
520
654
|
# For compatibility, store reference to the new server
|