rootly-mcp-server 2.0.6__py3-none-any.whl → 2.0.8__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 +254 -175
- 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.8.dist-info}/METADATA +27 -26
- rootly_mcp_server-2.0.8.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.8.dist-info}/WHEEL +0 -0
- {rootly_mcp_server-2.0.6.dist-info → rootly_mcp_server-2.0.8.dist-info}/entry_points.txt +0 -0
- {rootly_mcp_server-2.0.6.dist-info → rootly_mcp_server-2.0.8.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,68 @@ 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):
|
|
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):
|
|
37
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
|
-
|
|
79
|
+
|
|
44
80
|
# Create the HTTPX client
|
|
45
|
-
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
|
81
|
+
headers = {"Content-Type": "application/vnd.api+json", "Accept": "application/vnd.api+json"}
|
|
46
82
|
if self._api_token:
|
|
47
83
|
headers["Authorization"] = f"Bearer {self._api_token}"
|
|
48
|
-
|
|
84
|
+
|
|
49
85
|
self.client = httpx.AsyncClient(
|
|
50
86
|
base_url=base_url,
|
|
51
87
|
headers=headers,
|
|
52
88
|
timeout=30.0
|
|
53
89
|
)
|
|
54
|
-
|
|
90
|
+
|
|
55
91
|
def _get_api_token(self) -> Optional[str]:
|
|
56
92
|
"""Get the API token from environment variables."""
|
|
57
93
|
api_token = os.getenv("ROOTLY_API_TOKEN")
|
|
@@ -59,13 +95,56 @@ class AuthenticatedHTTPXClient:
|
|
|
59
95
|
logger.warning("ROOTLY_API_TOKEN environment variable is not set")
|
|
60
96
|
return None
|
|
61
97
|
return api_token
|
|
62
|
-
|
|
98
|
+
|
|
99
|
+
def _transform_params(self, params: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
|
100
|
+
"""Transform sanitized parameter names back to original names."""
|
|
101
|
+
if not params or not self.parameter_mapping:
|
|
102
|
+
return params
|
|
103
|
+
|
|
104
|
+
transformed = {}
|
|
105
|
+
for key, value in params.items():
|
|
106
|
+
# Use the original name if we have a mapping, otherwise keep the sanitized name
|
|
107
|
+
original_key = self.parameter_mapping.get(key, key)
|
|
108
|
+
transformed[original_key] = value
|
|
109
|
+
if original_key != key:
|
|
110
|
+
logger.debug(f"Transformed parameter: '{key}' -> '{original_key}'")
|
|
111
|
+
return transformed
|
|
112
|
+
|
|
113
|
+
async def request(self, method: str, url: str, **kwargs):
|
|
114
|
+
"""Override request to transform parameters."""
|
|
115
|
+
# Transform query parameters
|
|
116
|
+
if 'params' in kwargs:
|
|
117
|
+
kwargs['params'] = self._transform_params(kwargs['params'])
|
|
118
|
+
|
|
119
|
+
# Call the underlying client's request method
|
|
120
|
+
return await self.client.request(method, url, **kwargs)
|
|
121
|
+
|
|
122
|
+
async def get(self, url: str, **kwargs):
|
|
123
|
+
"""Proxy to request with GET method."""
|
|
124
|
+
return await self.request('GET', url, **kwargs)
|
|
125
|
+
|
|
126
|
+
async def post(self, url: str, **kwargs):
|
|
127
|
+
"""Proxy to request with POST method."""
|
|
128
|
+
return await self.request('POST', url, **kwargs)
|
|
129
|
+
|
|
130
|
+
async def put(self, url: str, **kwargs):
|
|
131
|
+
"""Proxy to request with PUT method."""
|
|
132
|
+
return await self.request('PUT', url, **kwargs)
|
|
133
|
+
|
|
134
|
+
async def patch(self, url: str, **kwargs):
|
|
135
|
+
"""Proxy to request with PATCH method."""
|
|
136
|
+
return await self.request('PATCH', url, **kwargs)
|
|
137
|
+
|
|
138
|
+
async def delete(self, url: str, **kwargs):
|
|
139
|
+
"""Proxy to request with DELETE method."""
|
|
140
|
+
return await self.request('DELETE', url, **kwargs)
|
|
141
|
+
|
|
63
142
|
async def __aenter__(self):
|
|
64
|
-
return self
|
|
65
|
-
|
|
143
|
+
return self
|
|
144
|
+
|
|
66
145
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
67
|
-
|
|
68
|
-
|
|
146
|
+
pass
|
|
147
|
+
|
|
69
148
|
def __getattr__(self, name):
|
|
70
149
|
# Delegate all other attributes to the underlying client
|
|
71
150
|
return getattr(self.client, name)
|
|
@@ -93,44 +172,8 @@ def create_rootly_mcp_server(
|
|
|
93
172
|
"""
|
|
94
173
|
# Set default allowed paths if none provided
|
|
95
174
|
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
|
-
|
|
175
|
+
allowed_paths = DEFAULT_ALLOWED_PATHS
|
|
176
|
+
|
|
134
177
|
# Add /v1 prefix to paths if not present
|
|
135
178
|
allowed_paths_v1 = [
|
|
136
179
|
f"/v1{path}" if not path.startswith("/v1") else path
|
|
@@ -147,22 +190,23 @@ def create_rootly_mcp_server(
|
|
|
147
190
|
filtered_spec = _filter_openapi_spec(swagger_spec, allowed_paths_v1)
|
|
148
191
|
logger.info(f"Filtered spec to {len(filtered_spec.get('paths', {}))} allowed paths")
|
|
149
192
|
|
|
193
|
+
# Sanitize all parameter names in the filtered spec to be MCP-compliant
|
|
194
|
+
parameter_mapping = sanitize_parameters_in_spec(filtered_spec)
|
|
195
|
+
logger.info(f"Sanitized parameter names for MCP compatibility (mapped {len(parameter_mapping)} parameters)")
|
|
196
|
+
|
|
150
197
|
# Determine the base URL
|
|
151
198
|
if base_url is None:
|
|
152
199
|
base_url = os.getenv("ROOTLY_BASE_URL", "https://api.rootly.com")
|
|
153
|
-
|
|
200
|
+
|
|
154
201
|
logger.info(f"Using Rootly API base URL: {base_url}")
|
|
155
202
|
|
|
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)
|
|
203
|
+
# Create the authenticated HTTP client with parameter mapping
|
|
204
|
+
|
|
205
|
+
http_client = AuthenticatedHTTPXClient(
|
|
206
|
+
base_url=base_url,
|
|
207
|
+
hosted=hosted,
|
|
208
|
+
parameter_mapping=parameter_mapping
|
|
209
|
+
)
|
|
166
210
|
|
|
167
211
|
# Create the MCP server using OpenAPI integration
|
|
168
212
|
# By default, all routes become tools which is what we want
|
|
@@ -173,10 +217,16 @@ def create_rootly_mcp_server(
|
|
|
173
217
|
timeout=30.0,
|
|
174
218
|
tags={"rootly", "incident-management"},
|
|
175
219
|
)
|
|
176
|
-
|
|
220
|
+
|
|
221
|
+
@mcp.custom_route("/healthz", methods=["GET"])
|
|
222
|
+
@mcp.custom_route("/health", methods=["GET"])
|
|
223
|
+
async def health_check(request):
|
|
224
|
+
from starlette.responses import PlainTextResponse
|
|
225
|
+
return PlainTextResponse("OK")
|
|
226
|
+
|
|
177
227
|
# Add some custom tools for enhanced functionality
|
|
178
228
|
@mcp.tool()
|
|
179
|
-
def list_endpoints() ->
|
|
229
|
+
def list_endpoints() -> list:
|
|
180
230
|
"""List all available Rootly API endpoints with their descriptions."""
|
|
181
231
|
endpoints = []
|
|
182
232
|
for path, path_item in filtered_spec.get("paths", {}).items():
|
|
@@ -186,7 +236,7 @@ def create_rootly_mcp_server(
|
|
|
186
236
|
|
|
187
237
|
summary = operation.get("summary", "")
|
|
188
238
|
description = operation.get("description", "")
|
|
189
|
-
|
|
239
|
+
|
|
190
240
|
endpoints.append({
|
|
191
241
|
"path": path,
|
|
192
242
|
"method": method.upper(),
|
|
@@ -194,106 +244,121 @@ def create_rootly_mcp_server(
|
|
|
194
244
|
"description": description,
|
|
195
245
|
})
|
|
196
246
|
|
|
197
|
-
return
|
|
247
|
+
return endpoints
|
|
248
|
+
|
|
249
|
+
async def make_authenticated_request(method: str, url: str, **kwargs):
|
|
250
|
+
"""Make an authenticated request, extracting token from MCP headers in hosted mode."""
|
|
251
|
+
# In hosted mode, get token from MCP request headers
|
|
252
|
+
if hosted:
|
|
253
|
+
try:
|
|
254
|
+
from fastmcp.server.dependencies import get_http_headers
|
|
255
|
+
request_headers = get_http_headers()
|
|
256
|
+
auth_header = request_headers.get("authorization", "")
|
|
257
|
+
if auth_header:
|
|
258
|
+
# Add authorization header to the request
|
|
259
|
+
if "headers" not in kwargs:
|
|
260
|
+
kwargs["headers"] = {}
|
|
261
|
+
kwargs["headers"]["Authorization"] = auth_header
|
|
262
|
+
except Exception:
|
|
263
|
+
pass # Fallback to default client behavior
|
|
264
|
+
|
|
265
|
+
# Make the request using the underlying httpx client
|
|
266
|
+
return await http_client.client.request(method, url, **kwargs)
|
|
198
267
|
|
|
199
268
|
@mcp.tool()
|
|
200
|
-
async def
|
|
269
|
+
async def search_incidents(
|
|
201
270
|
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
|
-
|
|
271
|
+
page_size: Annotated[int, Field(description="Number of results per page (max: 20)", ge=1, le=20)] = 10,
|
|
272
|
+
page_number: Annotated[int, Field(description="Page number to retrieve (use 0 for all pages)", ge=0)] = 1,
|
|
273
|
+
max_results: Annotated[int, Field(description="Maximum total results when fetching all pages (ignored if page_number > 0)", ge=1, le=100)] = 20,
|
|
274
|
+
) -> dict:
|
|
205
275
|
"""
|
|
206
|
-
Search incidents with
|
|
207
|
-
|
|
208
|
-
|
|
276
|
+
Search incidents with flexible pagination control.
|
|
277
|
+
|
|
278
|
+
Use page_number=0 to fetch all matching results across multiple pages up to max_results.
|
|
279
|
+
Use page_number>0 to fetch a specific page.
|
|
209
280
|
"""
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
281
|
+
# Single page mode
|
|
282
|
+
if page_number > 0:
|
|
283
|
+
params = {
|
|
284
|
+
"page[size]": min(page_size, 20),
|
|
285
|
+
"page[number]": page_number,
|
|
286
|
+
"include": "",
|
|
287
|
+
}
|
|
288
|
+
if query:
|
|
289
|
+
params["filter[search]"] = query
|
|
290
|
+
|
|
291
|
+
try:
|
|
292
|
+
response = await make_authenticated_request("GET", "/v1/incidents", params=params)
|
|
220
293
|
response.raise_for_status()
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
return json.dumps(result, indent=2)
|
|
294
|
+
return response.json()
|
|
295
|
+
except Exception as e:
|
|
296
|
+
return {"error": str(e)}
|
|
226
297
|
|
|
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
|
-
"""
|
|
298
|
+
# Multi-page mode (page_number = 0)
|
|
237
299
|
all_incidents = []
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
300
|
+
current_page = 1
|
|
301
|
+
effective_page_size = min(page_size, 10)
|
|
302
|
+
|
|
241
303
|
try:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
304
|
+
while len(all_incidents) < max_results:
|
|
305
|
+
params = {
|
|
306
|
+
"page[size]": effective_page_size,
|
|
307
|
+
"page[number]": current_page,
|
|
308
|
+
"include": "",
|
|
309
|
+
}
|
|
310
|
+
if query:
|
|
311
|
+
params["filter[search]"] = query
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
response = await make_authenticated_request("GET", "/v1/incidents", params=params)
|
|
315
|
+
response.raise_for_status()
|
|
316
|
+
response_data = response.json()
|
|
317
|
+
|
|
318
|
+
if "data" in response_data:
|
|
319
|
+
incidents = response_data["data"]
|
|
320
|
+
if not incidents:
|
|
321
|
+
break
|
|
255
322
|
|
|
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}")
|
|
323
|
+
all_incidents.extend(incidents)
|
|
324
|
+
|
|
325
|
+
# Check if we have more pages
|
|
326
|
+
meta = response_data.get("meta", {})
|
|
327
|
+
current_page_meta = meta.get("current_page", current_page)
|
|
328
|
+
total_pages = meta.get("total_pages", 1)
|
|
329
|
+
|
|
330
|
+
if current_page_meta >= total_pages:
|
|
331
|
+
break
|
|
332
|
+
|
|
333
|
+
current_page += 1
|
|
334
|
+
else:
|
|
276
335
|
break
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
336
|
+
|
|
337
|
+
except Exception as e:
|
|
338
|
+
# Re-raise authentication or critical errors
|
|
339
|
+
if "401" in str(e) or "Unauthorized" in str(e) or "authentication" in str(e).lower():
|
|
340
|
+
raise e
|
|
341
|
+
break
|
|
342
|
+
|
|
343
|
+
# Limit to max_results
|
|
344
|
+
if len(all_incidents) > max_results:
|
|
345
|
+
all_incidents = all_incidents[:max_results]
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
"data": all_incidents,
|
|
349
|
+
"meta": {
|
|
350
|
+
"total_fetched": len(all_incidents),
|
|
351
|
+
"max_results": max_results,
|
|
352
|
+
"query": query,
|
|
353
|
+
"pages_fetched": current_page - 1,
|
|
354
|
+
"page_size": effective_page_size
|
|
289
355
|
}
|
|
356
|
+
}
|
|
290
357
|
except Exception as e:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return json.dumps(result, indent=2)
|
|
358
|
+
return {"error": str(e)}
|
|
294
359
|
|
|
295
360
|
# Log server creation (tool count will be shown when tools are accessed)
|
|
296
|
-
logger.info(
|
|
361
|
+
logger.info("Created Rootly MCP Server successfully")
|
|
297
362
|
return mcp
|
|
298
363
|
|
|
299
364
|
|
|
@@ -395,25 +460,26 @@ def _filter_openapi_spec(spec: Dict[str, Any], allowed_paths: List[str]) -> Dict
|
|
|
395
460
|
Returns:
|
|
396
461
|
A filtered OpenAPI specification with cleaned schema references.
|
|
397
462
|
"""
|
|
398
|
-
|
|
399
|
-
|
|
463
|
+
# Use deepcopy to ensure all nested structures are properly copied
|
|
464
|
+
filtered_spec = deepcopy(spec)
|
|
465
|
+
|
|
400
466
|
# Filter paths
|
|
401
|
-
original_paths =
|
|
467
|
+
original_paths = filtered_spec.get("paths", {})
|
|
402
468
|
filtered_paths = {
|
|
403
469
|
path: path_item
|
|
404
470
|
for path, path_item in original_paths.items()
|
|
405
471
|
if path in allowed_paths
|
|
406
472
|
}
|
|
407
|
-
|
|
473
|
+
|
|
408
474
|
filtered_spec["paths"] = filtered_paths
|
|
409
|
-
|
|
475
|
+
|
|
410
476
|
# Clean up schema references that might be broken
|
|
411
477
|
# Remove problematic schema references from request bodies and parameters
|
|
412
478
|
for path, path_item in filtered_paths.items():
|
|
413
479
|
for method, operation in path_item.items():
|
|
414
480
|
if method.lower() not in ["get", "post", "put", "delete", "patch"]:
|
|
415
481
|
continue
|
|
416
|
-
|
|
482
|
+
|
|
417
483
|
# Clean request body schemas
|
|
418
484
|
if "requestBody" in operation:
|
|
419
485
|
request_body = operation["requestBody"]
|
|
@@ -429,8 +495,21 @@ def _filter_openapi_spec(spec: Dict[str, Any], allowed_paths: List[str]) -> Dict
|
|
|
429
495
|
"description": "Request parameters for this endpoint",
|
|
430
496
|
"additionalProperties": True
|
|
431
497
|
}
|
|
432
|
-
|
|
433
|
-
#
|
|
498
|
+
|
|
499
|
+
# Remove response schemas to avoid validation issues
|
|
500
|
+
# FastMCP will still return the data, just without strict validation
|
|
501
|
+
if "responses" in operation:
|
|
502
|
+
for status_code, response in operation["responses"].items():
|
|
503
|
+
if "content" in response:
|
|
504
|
+
for content_type, content_info in response["content"].items():
|
|
505
|
+
if "schema" in content_info:
|
|
506
|
+
# Replace with a simple schema that accepts any response
|
|
507
|
+
content_info["schema"] = {
|
|
508
|
+
"type": "object",
|
|
509
|
+
"additionalProperties": True
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
# Clean parameter schemas (parameter names are already sanitized)
|
|
434
513
|
if "parameters" in operation:
|
|
435
514
|
for param in operation["parameters"]:
|
|
436
515
|
if "schema" in param and "$ref" in param["schema"]:
|
|
@@ -441,7 +520,7 @@ def _filter_openapi_spec(spec: Dict[str, Any], allowed_paths: List[str]) -> Dict
|
|
|
441
520
|
"type": "string",
|
|
442
521
|
"description": param.get("description", "Parameter value")
|
|
443
522
|
}
|
|
444
|
-
|
|
523
|
+
|
|
445
524
|
# Also clean up any remaining broken references in components
|
|
446
525
|
if "components" in filtered_spec and "schemas" in filtered_spec["components"]:
|
|
447
526
|
schemas = filtered_spec["components"]["schemas"]
|
|
@@ -450,11 +529,11 @@ def _filter_openapi_spec(spec: Dict[str, Any], allowed_paths: List[str]) -> Dict
|
|
|
450
529
|
for schema_name, schema_def in schemas.items():
|
|
451
530
|
if isinstance(schema_def, dict) and _has_broken_references(schema_def):
|
|
452
531
|
schemas_to_remove.append(schema_name)
|
|
453
|
-
|
|
532
|
+
|
|
454
533
|
for schema_name in schemas_to_remove:
|
|
455
534
|
logger.warning(f"Removing schema with broken references: {schema_name}")
|
|
456
535
|
del schemas[schema_name]
|
|
457
|
-
|
|
536
|
+
|
|
458
537
|
return filtered_spec
|
|
459
538
|
|
|
460
539
|
|
|
@@ -465,13 +544,13 @@ def _has_broken_references(schema_def: Dict[str, Any]) -> bool:
|
|
|
465
544
|
# List of known broken references in the Rootly API spec
|
|
466
545
|
broken_refs = [
|
|
467
546
|
"incident_trigger_params",
|
|
468
|
-
"new_workflow",
|
|
547
|
+
"new_workflow",
|
|
469
548
|
"update_workflow",
|
|
470
549
|
"workflow"
|
|
471
550
|
]
|
|
472
551
|
if any(broken_ref in ref_path for broken_ref in broken_refs):
|
|
473
552
|
return True
|
|
474
|
-
|
|
553
|
+
|
|
475
554
|
# Recursively check nested schemas
|
|
476
555
|
for key, value in schema_def.items():
|
|
477
556
|
if isinstance(value, dict):
|
|
@@ -481,7 +560,7 @@ def _has_broken_references(schema_def: Dict[str, Any]) -> bool:
|
|
|
481
560
|
for item in value:
|
|
482
561
|
if isinstance(item, dict) and _has_broken_references(item):
|
|
483
562
|
return True
|
|
484
|
-
|
|
563
|
+
|
|
485
564
|
return False
|
|
486
565
|
|
|
487
566
|
|
|
@@ -489,10 +568,10 @@ def _has_broken_references(schema_def: Dict[str, Any]) -> bool:
|
|
|
489
568
|
class RootlyMCPServer(FastMCP):
|
|
490
569
|
"""
|
|
491
570
|
Legacy Rootly MCP Server class for backward compatibility.
|
|
492
|
-
|
|
571
|
+
|
|
493
572
|
This class is deprecated. Use create_rootly_mcp_server() instead.
|
|
494
573
|
"""
|
|
495
|
-
|
|
574
|
+
|
|
496
575
|
def __init__(
|
|
497
576
|
self,
|
|
498
577
|
swagger_path: Optional[str] = None,
|
|
@@ -506,7 +585,7 @@ class RootlyMCPServer(FastMCP):
|
|
|
506
585
|
logger.warning(
|
|
507
586
|
"RootlyMCPServer class is deprecated. Use create_rootly_mcp_server() function instead."
|
|
508
587
|
)
|
|
509
|
-
|
|
588
|
+
|
|
510
589
|
# Create the server using the new function
|
|
511
590
|
server = create_rootly_mcp_server(
|
|
512
591
|
swagger_path=swagger_path,
|
|
@@ -514,7 +593,7 @@ class RootlyMCPServer(FastMCP):
|
|
|
514
593
|
allowed_paths=allowed_paths,
|
|
515
594
|
hosted=hosted
|
|
516
595
|
)
|
|
517
|
-
|
|
596
|
+
|
|
518
597
|
# Copy the server's state to this instance
|
|
519
598
|
super().__init__(name, *args, **kwargs)
|
|
520
599
|
# For compatibility, store reference to the new server
|
rootly_mcp_server/test_client.py
CHANGED
|
@@ -33,7 +33,7 @@ async def test_server():
|
|
|
33
33
|
hosted=False # Use local API token
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
-
print(
|
|
36
|
+
print("✅ Server created successfully")
|
|
37
37
|
print(f"Server type: {type(server)}")
|
|
38
38
|
|
|
39
39
|
# Use the get_tools method to access tools
|
|
@@ -78,11 +78,11 @@ async def test_server():
|
|
|
78
78
|
if props:
|
|
79
79
|
print(f" Parameters: {', '.join(props.keys())}")
|
|
80
80
|
else:
|
|
81
|
-
print(
|
|
81
|
+
print("\n⚠️ No tools found")
|
|
82
82
|
|
|
83
83
|
# Test accessing a specific tool
|
|
84
84
|
if tool_count > 0:
|
|
85
|
-
print(
|
|
85
|
+
print("\n🔍 Testing tool access...")
|
|
86
86
|
if isinstance(tools, dict):
|
|
87
87
|
first_tool_name = tools_names[0]
|
|
88
88
|
first_tool = tools[first_tool_name]
|
|
@@ -93,15 +93,17 @@ async def test_server():
|
|
|
93
93
|
print(f" ✅ First tool: {first_tool_name}")
|
|
94
94
|
print(f" Tool details: {first_tool}")
|
|
95
95
|
|
|
96
|
-
# Try to get
|
|
96
|
+
# Try to get tools and find the specific tool
|
|
97
97
|
try:
|
|
98
|
-
|
|
99
|
-
if
|
|
98
|
+
all_tools = await server.get_tools()
|
|
99
|
+
if first_tool_name in all_tools:
|
|
100
|
+
retrieved_tool = all_tools[first_tool_name]
|
|
100
101
|
print(f" ✅ Successfully retrieved tool by name: {first_tool_name}")
|
|
102
|
+
print(f" Retrieved tool type: {type(retrieved_tool)}")
|
|
101
103
|
else:
|
|
102
|
-
print(f" ❌ Could not
|
|
104
|
+
print(f" ❌ Could not find tool by name: {first_tool_name}")
|
|
103
105
|
except Exception as e:
|
|
104
|
-
print(f" ❌ Error retrieving
|
|
106
|
+
print(f" ❌ Error retrieving tools: {e}")
|
|
105
107
|
|
|
106
108
|
except Exception as e:
|
|
107
109
|
print(f"❌ Error accessing tools: {e}")
|
|
@@ -109,7 +111,7 @@ async def test_server():
|
|
|
109
111
|
traceback.print_exc()
|
|
110
112
|
tool_count = 0
|
|
111
113
|
|
|
112
|
-
print(
|
|
114
|
+
print("\n🎉 Test completed successfully!")
|
|
113
115
|
print(f"Total tools found: {tool_count}")
|
|
114
116
|
|
|
115
117
|
except Exception as e:
|