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.
@@ -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, Tuple, Union, Callable, Annotated, Literal
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 fastmcp.server.dependencies import get_http_request
21
- from starlette.requests import Request
22
- from pydantic import BaseModel, Field
19
+ from pydantic import Field
23
20
 
24
- from .client import RootlyClient
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.base_url = base_url
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 = {"Content-Type": "application/json", "Accept": "application/json"}
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.client
65
-
150
+ return self
151
+
66
152
  async def __aexit__(self, exc_type, exc_val, exc_tb):
67
- await self.client.aclose()
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
- "/incidents",
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
- try:
158
- http_client = AuthenticatedHTTPXClient(
159
- base_url=base_url,
160
- hosted=hosted
161
- )
162
- except Exception as e:
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 list_endpoints() -> str:
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 json.dumps(endpoints, indent=2)
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 search_incidents_paginated(
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: 100)", ge=1, le=100)] = 100,
203
- page_number: Annotated[int, Field(description="Page number to retrieve", ge=1)] = 1,
204
- ) -> str:
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 enhanced pagination control.
207
-
208
- This tool provides better pagination handling than the standard API endpoint.
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
- params = {
211
- "page[size]": min(page_size, 100),
212
- "page[number]": page_number,
213
- }
214
- if query:
215
- params["filter[search]"] = query
216
-
217
- try:
218
- async with http_client as client:
219
- response = await client.get("/v1/incidents", params=params)
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
- result = response.json()
222
- except Exception as e:
223
- result = {"error": str(e)}
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
- @mcp.tool()
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
- page_number = 1
239
- page_size = 100
240
-
355
+ current_page = 1
356
+ effective_page_size = min(page_size, 10)
357
+
241
358
  try:
242
- async with http_client as client:
243
- while len(all_incidents) < max_results:
244
- params = {
245
- "page[size]": page_size,
246
- "page[number]": page_number,
247
- }
248
- if query:
249
- params["filter[search]"] = query
250
-
251
- try:
252
- response = await client.get("/v1/incidents", params=params)
253
- response.raise_for_status()
254
- response_data = response.json()
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
- if "data" in response_data:
257
- incidents = response_data["data"]
258
- if not incidents: # No more results
259
- break
260
- all_incidents.extend(incidents)
261
-
262
- # Check if we have more pages
263
- meta = response_data.get("meta", {})
264
- current_page = meta.get("current_page", page_number)
265
- total_pages = meta.get("total_pages", 1)
266
-
267
- if current_page >= total_pages:
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
- # Limit to max_results
279
- if len(all_incidents) > max_results:
280
- all_incidents = all_incidents[:max_results]
281
-
282
- result = {
283
- "data": all_incidents,
284
- "meta": {
285
- "total_fetched": len(all_incidents),
286
- "max_results": max_results,
287
- "query": query
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
- result = {"error": str(e)}
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(f"Created Rootly MCP Server successfully")
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
- filtered_spec = spec.copy()
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 = spec.get("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
- # Clean parameter schemas
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