rootly-mcp-server 2.0.5__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.
@@ -13,7 +13,7 @@ Features:
13
13
  from .server import RootlyMCPServer
14
14
  from .client import RootlyClient
15
15
 
16
- __version__ = "2.0.0"
16
+ __version__ = "2.0.1"
17
17
  __all__ = [
18
18
  'RootlyMCPServer',
19
19
  'RootlyClient',
@@ -10,9 +10,7 @@ import logging
10
10
  import os
11
11
  import sys
12
12
  from pathlib import Path
13
- from typing import Optional, List
14
-
15
- from .routemap_server import create_rootly_mcp_server
13
+ from .server import create_rootly_mcp_server
16
14
 
17
15
 
18
16
  def parse_args():
@@ -137,7 +135,7 @@ def get_server():
137
135
  return create_rootly_mcp_server(
138
136
  swagger_path=swagger_path,
139
137
  name=server_name,
140
- custom_allowed_paths=allowed_paths,
138
+ allowed_paths=allowed_paths,
141
139
  hosted=hosted,
142
140
  base_url=base_url,
143
141
  )
@@ -161,7 +159,9 @@ def main():
161
159
  logger.warning("--host argument is deprecated, use --hosted instead")
162
160
  hosted_mode = True
163
161
 
164
- check_api_token()
162
+ # Only check API token if not in hosted mode
163
+ if not hosted_mode:
164
+ check_api_token()
165
165
 
166
166
  try:
167
167
  # Parse allowed paths from command line argument
@@ -173,7 +173,7 @@ def main():
173
173
  server = create_rootly_mcp_server(
174
174
  swagger_path=args.swagger_path,
175
175
  name=args.name,
176
- custom_allowed_paths=allowed_paths,
176
+ allowed_paths=allowed_paths,
177
177
  hosted=hosted_mode,
178
178
  base_url=args.base_url,
179
179
  )
@@ -6,7 +6,7 @@ import os
6
6
  import json
7
7
  import logging
8
8
  import requests
9
- from typing import Optional, Dict, Any, Union
9
+ from typing import Optional, Dict, Any
10
10
 
11
11
  # Set up logger
12
12
  logger = logging.getLogger(__name__)
@@ -123,7 +123,7 @@ class RootlyClient:
123
123
  try:
124
124
  error_response["status_code"] = e.response.status_code
125
125
  error_response["response_text"] = e.response.text
126
- except:
126
+ except Exception:
127
127
  pass
128
128
 
129
129
  return json.dumps(error_response, indent=2)
@@ -11,11 +11,13 @@ from fastmcp import FastMCP
11
11
  from fastmcp.server.openapi import RouteMap, MCPType
12
12
  import os
13
13
  import logging
14
+ import sys
14
15
  from pathlib import Path
15
16
  from typing import Optional, List
16
17
 
17
18
  # Import the shared OpenAPI loader
18
- from .rootly_openapi_loader import load_rootly_openapi_spec
19
+ sys.path.append(str(Path(__file__).parent.parent.parent))
20
+ from rootly_openapi_loader import load_rootly_openapi_spec
19
21
 
20
22
  # Configure logging
21
23
  logging.basicConfig(level=logging.INFO)
@@ -24,7 +26,7 @@ logger = logging.getLogger(__name__)
24
26
  def create_rootly_mcp_server(
25
27
  swagger_path: Optional[str] = None,
26
28
  name: str = "Rootly API Server (RouteMap Filtered)",
27
- custom_allowed_paths: Optional[List[str]] = None,
29
+ allowed_paths: Optional[List[str]] = None,
28
30
  hosted: bool = False,
29
31
  base_url: Optional[str] = None,
30
32
  ):
@@ -73,76 +75,108 @@ def create_rootly_mcp_server(
73
75
  fix_array_types(openapi_spec)
74
76
  logger.info("✅ Fixed OpenAPI spec compatibility issues")
75
77
 
76
- logger.info("Creating FastMCP server with pre-filtered OpenAPI spec...")
78
+ logger.info("Creating FastMCP server with RouteMap filtering...")
77
79
 
78
- # Define the specific endpoints we want to include
79
- if custom_allowed_paths:
80
- allowed_paths = set(custom_allowed_paths)
81
- else:
82
- allowed_paths = {
83
- # Core incident management
84
- "/v1/incidents",
85
- "/v1/incidents/{incident_id}/alerts",
86
- "/v1/incidents/{incident_id}/action_items",
87
-
88
- # Alert management
89
- "/v1/alerts",
90
- "/v1/alerts/{id}",
91
-
92
- # Configuration entities
93
- "/v1/severities",
94
- "/v1/severities/{id}",
95
- "/v1/incident_types",
96
- "/v1/incident_types/{id}",
97
- "/v1/functionalities",
98
- "/v1/functionalities/{id}",
99
-
100
- # Organization
101
- "/v1/teams",
102
- "/v1/teams/{id}",
103
- "/v1/users",
104
- "/v1/users/me",
105
- "/v1/users/{id}",
106
-
107
- # Infrastructure
108
- "/v1/services",
109
- "/v1/services/{id}",
110
- "/v1/environments",
111
- "/v1/environments/{id}",
112
-
113
- # Action items
114
- "/v1/action_items",
115
- "/v1/action_items/{id}",
116
-
117
- # Workflows
118
- "/v1/workflows",
119
- "/v1/workflows/{id}",
120
-
121
- # Status pages
122
- "/v1/status-pages",
123
- "/v1/status-pages/{id}"
124
- }
125
-
126
- # Filter the OpenAPI spec to only include allowed paths
127
- original_paths = openapi_spec.get("paths", {})
128
- filtered_paths = {path: spec for path, spec in original_paths.items() if path in allowed_paths}
129
-
130
- logger.info(f"📊 Filtered OpenAPI spec from {len(original_paths)} paths to {len(filtered_paths)} paths")
131
- logger.info(f"🔍 Allowed paths: {sorted(allowed_paths)}")
132
- logger.info(f"✅ Filtered paths: {sorted(filtered_paths.keys())}")
133
-
134
- openapi_spec["paths"] = filtered_paths
80
+ # Define custom route maps for filtering specific endpoints
81
+ route_maps = [
82
+ # Core incident management
83
+ RouteMap(
84
+ pattern=r"^/v1/incidents$",
85
+ mcp_type=MCPType.TOOL
86
+ ),
87
+ RouteMap(
88
+ pattern=r"^/v1/incidents/\{incident_id\}/alerts$",
89
+ mcp_type=MCPType.TOOL
90
+ ),
91
+ RouteMap(
92
+ pattern=r"^/v1/incidents/\{incident_id\}/action_items$",
93
+ mcp_type=MCPType.TOOL
94
+ ),
95
+
96
+ # Alert management
97
+ RouteMap(
98
+ pattern=r"^/v1/alerts$",
99
+ mcp_type=MCPType.TOOL
100
+ ),
101
+ RouteMap(
102
+ pattern=r"^/v1/alerts/\{id\}$",
103
+ mcp_type=MCPType.TOOL
104
+ ),
105
+
106
+ # Configuration entities
107
+ RouteMap(
108
+ pattern=r"^/v1/severities(\{id\})?$",
109
+ mcp_type=MCPType.TOOL
110
+ ),
111
+ RouteMap(
112
+ pattern=r"^/v1/incident_types(\{id\})?$",
113
+ mcp_type=MCPType.TOOL
114
+ ),
115
+ RouteMap(
116
+ pattern=r"^/v1/functionalities(\{id\})?$",
117
+ mcp_type=MCPType.TOOL
118
+ ),
119
+
120
+ # Organization
121
+ RouteMap(
122
+ pattern=r"^/v1/teams(\{id\})?$",
123
+ mcp_type=MCPType.TOOL
124
+ ),
125
+ RouteMap(
126
+ pattern=r"^/v1/users(\{id\}|/me)?$",
127
+ mcp_type=MCPType.TOOL
128
+ ),
129
+
130
+ # Infrastructure
131
+ RouteMap(
132
+ pattern=r"^/v1/services(\{id\})?$",
133
+ mcp_type=MCPType.TOOL
134
+ ),
135
+ RouteMap(
136
+ pattern=r"^/v1/environments(\{id\})?$",
137
+ mcp_type=MCPType.TOOL
138
+ ),
139
+
140
+ # Action items
141
+ RouteMap(
142
+ pattern=r"^/v1/incident_action_items(\{id\})?$",
143
+ mcp_type=MCPType.TOOL
144
+ ),
145
+
146
+ # Workflows
147
+ RouteMap(
148
+ pattern=r"^/v1/workflows(\{id\})?$",
149
+ mcp_type=MCPType.TOOL
150
+ ),
151
+ RouteMap(
152
+ pattern=r"^/v1/workflow_runs(\{id\})?$",
153
+ mcp_type=MCPType.TOOL
154
+ ),
155
+
156
+ # Status pages
157
+ RouteMap(
158
+ pattern=r"^/v1/status_pages(\{id\})?$",
159
+ mcp_type=MCPType.TOOL
160
+ ),
161
+
162
+ # Exclude everything else
163
+ RouteMap(
164
+ pattern=r".*",
165
+ mcp_type=MCPType.EXCLUDE
166
+ )
167
+ ]
135
168
 
136
- # Create MCP server without route maps for now to test basic functionality
169
+ # Create MCP server with custom route maps
137
170
  mcp = FastMCP.from_openapi(
138
171
  openapi_spec=openapi_spec,
139
172
  client=client,
140
173
  name=name,
141
174
  timeout=30.0,
142
- tags={"rootly", "incident-management", "evaluation"}
175
+ tags={"rootly", "incident-management", "evaluation"},
176
+ route_maps=route_maps
143
177
  )
144
178
 
145
- logger.info(f"✅ Created MCP server with RouteMap filtering successfully")
179
+ logger.info("✅ Created MCP server with RouteMap filtering successfully")
146
180
  logger.info("🚀 Selected Rootly API endpoints are now available as MCP tools")
147
181
 
148
182
  return mcp
@@ -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,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.client
65
-
143
+ return self
144
+
66
145
  async def __aexit__(self, exc_type, exc_val, exc_tb):
67
- await self.client.aclose()
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
- "/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
-
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
- 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)
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() -> str:
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 json.dumps(endpoints, indent=2)
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 search_incidents_paginated(
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: 100)", ge=1, le=100)] = 100,
203
- page_number: Annotated[int, Field(description="Page number to retrieve", ge=1)] = 1,
204
- ) -> str:
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 enhanced pagination control.
207
-
208
- This tool provides better pagination handling than the standard API endpoint.
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
- 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)
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
- result = response.json()
222
- except Exception as e:
223
- result = {"error": str(e)}
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
- @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
- """
298
+ # Multi-page mode (page_number = 0)
237
299
  all_incidents = []
238
- page_number = 1
239
- page_size = 100
240
-
300
+ current_page = 1
301
+ effective_page_size = min(page_size, 10)
302
+
241
303
  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()
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
- 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}")
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
- # 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
- }
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
- result = {"error": str(e)}
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(f"Created Rootly MCP Server successfully")
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
- filtered_spec = spec.copy()
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 = spec.get("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
- # Clean parameter schemas
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
@@ -33,7 +33,7 @@ async def test_server():
33
33
  hosted=False # Use local API token
34
34
  )
35
35
 
36
- print(f"✅ Server created successfully")
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(f"\n⚠️ No tools found")
81
+ print("\n⚠️ No tools found")
82
82
 
83
83
  # Test accessing a specific tool
84
84
  if tool_count > 0:
85
- print(f"\n🔍 Testing tool access...")
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 tool by name
96
+ # Try to get tools and find the specific tool
97
97
  try:
98
- retrieved_tool = await server.get_tool(first_tool_name)
99
- if retrieved_tool:
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 retrieve tool by name: {first_tool_name}")
104
+ print(f" ❌ Could not find tool by name: {first_tool_name}")
103
105
  except Exception as e:
104
- print(f" ❌ Error retrieving tool: {e}")
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(f"\n🎉 Test completed successfully!")
114
+ print("\n🎉 Test completed successfully!")
113
115
  print(f"Total tools found: {tool_count}")
114
116
 
115
117
  except Exception as e:
@@ -0,0 +1,105 @@
1
+ """
2
+ Shared utilities for Rootly MCP Server.
3
+ """
4
+
5
+ import re
6
+ import logging
7
+ from typing import Dict, Any
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def sanitize_parameter_name(name: str) -> str:
13
+ """
14
+ Sanitize parameter names to match MCP property key pattern ^[a-zA-Z0-9_.-]{1,64}$.
15
+
16
+ Args:
17
+ name: Original parameter name
18
+
19
+ Returns:
20
+ Sanitized parameter name
21
+ """
22
+ # Replace square brackets with underscores: filter[kind] -> filter_kind
23
+ sanitized = re.sub(r'\[([^\]]+)\]', r'_\1', name)
24
+
25
+ # Replace any remaining invalid characters with underscores
26
+ sanitized = re.sub(r'[^a-zA-Z0-9_.-]', '_', sanitized)
27
+
28
+ # Remove multiple consecutive underscores
29
+ sanitized = re.sub(r'_{2,}', '_', sanitized)
30
+
31
+ # Remove leading/trailing underscores
32
+ sanitized = sanitized.strip('_')
33
+
34
+ # Ensure the name doesn't exceed 64 characters
35
+ if len(sanitized) > 64:
36
+ sanitized = sanitized[:64].rstrip('_')
37
+
38
+ # Ensure the name is not empty and starts with a letter or underscore
39
+ if not sanitized or sanitized[0].isdigit():
40
+ sanitized = "param_" + sanitized if sanitized else "param"
41
+
42
+ return sanitized
43
+
44
+
45
+ def sanitize_parameters_in_spec(spec: Dict[str, Any]) -> Dict[str, str]:
46
+ """
47
+ Sanitize all parameter names in an OpenAPI specification.
48
+
49
+ This function modifies the spec in-place and builds a mapping
50
+ of sanitized names to original names.
51
+
52
+ Args:
53
+ spec: OpenAPI specification dictionary
54
+
55
+ Returns:
56
+ Dictionary mapping sanitized names to original names
57
+ """
58
+ parameter_mapping = {}
59
+
60
+ # Sanitize parameters in paths
61
+ if "paths" in spec:
62
+ for path, path_item in spec["paths"].items():
63
+ if not isinstance(path_item, dict):
64
+ continue
65
+
66
+ # Sanitize path-level parameters
67
+ if "parameters" in path_item:
68
+ for param in path_item["parameters"]:
69
+ if "name" in param:
70
+ original_name = param["name"]
71
+ sanitized_name = sanitize_parameter_name(original_name)
72
+ if sanitized_name != original_name:
73
+ logger.debug(f"Sanitized path-level parameter: '{original_name}' -> '{sanitized_name}'")
74
+ param["name"] = sanitized_name
75
+ parameter_mapping[sanitized_name] = original_name
76
+
77
+ # Sanitize operation-level parameters
78
+ for method, operation in path_item.items():
79
+ if method.lower() not in ["get", "post", "put", "delete", "patch", "options", "head", "trace"]:
80
+ continue
81
+ if not isinstance(operation, dict):
82
+ continue
83
+
84
+ if "parameters" in operation:
85
+ for param in operation["parameters"]:
86
+ if "name" in param:
87
+ original_name = param["name"]
88
+ sanitized_name = sanitize_parameter_name(original_name)
89
+ if sanitized_name != original_name:
90
+ logger.debug(f"Sanitized operation parameter: '{original_name}' -> '{sanitized_name}'")
91
+ param["name"] = sanitized_name
92
+ parameter_mapping[sanitized_name] = original_name
93
+
94
+ # Sanitize parameters in components (OpenAPI 3.0)
95
+ if "components" in spec and "parameters" in spec["components"]:
96
+ for param_name, param_def in spec["components"]["parameters"].items():
97
+ if isinstance(param_def, dict) and "name" in param_def:
98
+ original_name = param_def["name"]
99
+ sanitized_name = sanitize_parameter_name(original_name)
100
+ if sanitized_name != original_name:
101
+ logger.debug(f"Sanitized component parameter: '{original_name}' -> '{sanitized_name}'")
102
+ param_def["name"] = sanitized_name
103
+ parameter_mapping[sanitized_name] = original_name
104
+
105
+ return parameter_mapping
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rootly-mcp-server
3
- Version: 2.0.5
3
+ Version: 2.0.8
4
4
  Summary: A Model Context Protocol server for Rootly APIs using OpenAPI spec
5
5
  Project-URL: Homepage, https://github.com/Rootly-AI-Labs/Rootly-MCP-server
6
6
  Project-URL: Issues, https://github.com/Rootly-AI-Labs/Rootly-MCP-server/issues
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3
14
14
  Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Topic :: Software Development :: Build Tools
16
16
  Requires-Python: >=3.12
17
- Requires-Dist: fastmcp==2.10.5
17
+ Requires-Dist: fastmcp>=2.9.0
18
18
  Requires-Dist: httpx>=0.24.0
19
19
  Requires-Dist: pydantic>=2.0.0
20
20
  Requires-Dist: requests>=2.28.0
@@ -109,6 +109,30 @@ To customize `allowed_paths` and access additional Rootly API paths, clone the r
109
109
  }
110
110
  ```
111
111
 
112
+ ### Connect to Hosted MCP Server
113
+
114
+ Alternatively, connect directly to our hosted MCP server:
115
+
116
+ ```json
117
+ {
118
+ "mcpServers": {
119
+ "rootly": {
120
+ "command": "npx",
121
+ "args": [
122
+ "-y",
123
+ "mcp-remote",
124
+ "https://mcp.rootly.com/sse",
125
+ "--header",
126
+ "Authorization:${ROOTLY_AUTH_HEADER}"
127
+ ],
128
+ "env": {
129
+ "ROOTLY_AUTH_HEADER": "Bearer <YOUR_ROOTLY_API_TOKEN>"
130
+ }
131
+ }
132
+ }
133
+ }
134
+ ```
135
+
112
136
  ## Features
113
137
 
114
138
  - **Dynamic Tool Generation**: Automatically creates MCP resources from Rootly's OpenAPI (Swagger) specification
@@ -197,29 +221,6 @@ uv pip install <package>
197
221
  Run the test client to ensure everything is configured correctly:
198
222
 
199
223
  ```bash
200
- python test_mcp_client.py
224
+ python src/rootly_mcp_server/test_client.py
201
225
  ```
202
226
 
203
- ### Connect to Hosted MCP Server
204
-
205
- Alternatively, connect directly to our hosted MCP server:
206
-
207
- ```json
208
- {
209
- "mcpServers": {
210
- "rootly": {
211
- "command": "npx",
212
- "args": [
213
- "-y",
214
- "mcp-remote",
215
- "https://mcp.rootly.com/sse",
216
- "--header",
217
- "Authorization:${ROOTLY_AUTH_HEADER}"
218
- ],
219
- "env": {
220
- "ROOTLY_AUTH_HEADER": "Bearer <YOUR_ROOTLY_API_TOKEN>"
221
- }
222
- }
223
- }
224
- }
225
- ```
@@ -0,0 +1,13 @@
1
+ rootly_mcp_server/__init__.py,sha256=6pLh19IFyqE-Cve9zergkD-X_yApEkInREKmRa73T6s,628
2
+ rootly_mcp_server/__main__.py,sha256=_F4p65_VjnN84RtmEdESVLLH0tO5tL9qBfb2Xdvbj2E,6480
3
+ rootly_mcp_server/client.py,sha256=diIBINJP_z4nnQIAC1b70vQSiHaNojEfUDARC2nrKHU,4681
4
+ rootly_mcp_server/routemap_server.py,sha256=0LfK2EzwkFQF9SpHNvGcca5ZaxkBC80gIdDojE0aUcs,6100
5
+ rootly_mcp_server/server.py,sha256=os6uxXUOGeaPrPlq3r2GNLAM-iJYVIe2W5loASMkaBc,22953
6
+ rootly_mcp_server/test_client.py,sha256=Ytd5ZP7vImm12CT97k3p9tlkY_JNcXHSzcGGnHCBqv0,5275
7
+ rootly_mcp_server/utils.py,sha256=NyxdcDiFGlV2a8eBO4lKgZg0D7Gxr6xUIB0YyJGgpPA,4165
8
+ rootly_mcp_server/data/__init__.py,sha256=fO8a0bQnRVEoRMHKvhFzj10bhoaw7VsI51czc2MsUm4,143
9
+ rootly_mcp_server-2.0.8.dist-info/METADATA,sha256=F1BHBKiIdJmlblKhzWDX55KLNFPXn4FfomeisilOlrw,6159
10
+ rootly_mcp_server-2.0.8.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
+ rootly_mcp_server-2.0.8.dist-info/entry_points.txt,sha256=NE33b8VgigVPGBkboyo6pvN1Vz35HZtLybxMO4Q03PI,70
12
+ rootly_mcp_server-2.0.8.dist-info/licenses/LICENSE,sha256=c9w9ZZGl14r54tsP40oaq5adTVX_HMNHozPIH2ymzmw,11341
13
+ rootly_mcp_server-2.0.8.dist-info/RECORD,,
@@ -1,96 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Rootly OpenAPI Loader Utility
4
-
5
- Shared utility for loading Rootly's OpenAPI specification with smart fallback logic.
6
- """
7
-
8
- import json
9
- import logging
10
- from pathlib import Path
11
- import httpx
12
-
13
- logger = logging.getLogger(__name__)
14
-
15
-
16
- def load_rootly_openapi_spec() -> dict:
17
- """
18
- Load Rootly OpenAPI spec with smart fallback logic.
19
-
20
- Loading priority:
21
- 1. Check current directory for rootly_openapi.json
22
- 2. Check parent directories for rootly_openapi.json
23
- 3. Check for swagger.json files
24
- 4. Only as last resort, fetch from URL and cache locally
25
-
26
- Returns:
27
- dict: The OpenAPI specification
28
-
29
- Raises:
30
- RuntimeError: If the specification cannot be loaded
31
- """
32
- current_dir = Path.cwd()
33
-
34
- # Check for rootly_openapi.json in current directory and parents
35
- for check_dir in [current_dir] + list(current_dir.parents):
36
- spec_file = check_dir / "rootly_openapi.json"
37
- if spec_file.is_file():
38
- logger.info(f"Found OpenAPI spec at {spec_file}")
39
- try:
40
- with open(spec_file, "r") as f:
41
- return json.load(f)
42
- except Exception as e:
43
- logger.warning(f"Failed to load {spec_file}: {e}")
44
- continue
45
-
46
- # Check for swagger.json in current directory and parents
47
- for check_dir in [current_dir] + list(current_dir.parents):
48
- spec_file = check_dir / "swagger.json"
49
- if spec_file.is_file():
50
- logger.info(f"Found Swagger spec at {spec_file}")
51
- try:
52
- with open(spec_file, "r") as f:
53
- return json.load(f)
54
- except Exception as e:
55
- logger.warning(f"Failed to load {spec_file}: {e}")
56
- continue
57
-
58
- # Last resort: fetch from URL and cache
59
- logger.warning("OpenAPI spec not found locally, fetching from URL (this should only happen once)")
60
- return _fetch_and_cache_spec()
61
-
62
-
63
- def _fetch_and_cache_spec() -> dict:
64
- """
65
- Fetch OpenAPI spec from Rootly's URL and cache it locally.
66
-
67
- Returns:
68
- dict: The OpenAPI specification
69
-
70
- Raises:
71
- RuntimeError: If the specification cannot be fetched
72
- """
73
- SWAGGER_URL = "https://rootly-heroku.s3.amazonaws.com/swagger/v1/swagger.json"
74
-
75
- try:
76
- logger.info(f"Fetching OpenAPI spec from {SWAGGER_URL}")
77
- response = httpx.get(SWAGGER_URL, timeout=30.0)
78
- response.raise_for_status()
79
- spec_data = response.json()
80
-
81
- # Cache the spec for next time
82
- current_dir = Path.cwd()
83
- cache_file = current_dir / "rootly_openapi.json"
84
-
85
- try:
86
- with open(cache_file, "w") as f:
87
- json.dump(spec_data, f, indent=2)
88
- logger.info(f"Cached OpenAPI spec to {cache_file} for future use")
89
- except Exception as e:
90
- logger.warning(f"Failed to cache OpenAPI spec: {e}")
91
-
92
- return spec_data
93
-
94
- except Exception as e:
95
- logger.error(f"Failed to fetch OpenAPI spec: {e}")
96
- raise RuntimeError(f"Could not load OpenAPI specification: {e}")
@@ -1,13 +0,0 @@
1
- rootly_mcp_server/__init__.py,sha256=H3Dd4Od-PEff8VdbMgnblS6cCEBCb9XqYdI_-wmtQqk,628
2
- rootly_mcp_server/__main__.py,sha256=fQLaI6b91ujqhgK9hiFN9xdGfFQC52a8S9Vk310bJXQ,6461
3
- rootly_mcp_server/client.py,sha256=05TsHVJ3WtLH0k4R19Yzwwx4xcmjCKH6hS3uKcTMwRA,4678
4
- rootly_mcp_server/rootly_openapi_loader.py,sha256=GebIDHzEMV6XCuPox3SoBOG42lyaAb3RFlhWoCdIkeE,3159
5
- rootly_mcp_server/routemap_server.py,sha256=DDcuY68_B4uqg8aOuDmUp0zhrlpIxQtw0h5265ZYULk,5631
6
- rootly_mcp_server/server.py,sha256=2paE39caYRVuQG4yWEEOqZewbvpFREmf-J01lPQ5elw,19756
7
- rootly_mcp_server/test_client.py,sha256=8p1aJHrEt_Tj2NuJzTnTHw-ZeW816P99fJi5bhPidyc,5119
8
- rootly_mcp_server/data/__init__.py,sha256=fO8a0bQnRVEoRMHKvhFzj10bhoaw7VsI51czc2MsUm4,143
9
- rootly_mcp_server-2.0.5.dist-info/METADATA,sha256=H4RCI47_vv9SklpWfFlgkZOUW4j26ZuW7ZDJ5xnGazs,6141
10
- rootly_mcp_server-2.0.5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
11
- rootly_mcp_server-2.0.5.dist-info/entry_points.txt,sha256=NE33b8VgigVPGBkboyo6pvN1Vz35HZtLybxMO4Q03PI,70
12
- rootly_mcp_server-2.0.5.dist-info/licenses/LICENSE,sha256=c9w9ZZGl14r54tsP40oaq5adTVX_HMNHozPIH2ymzmw,11341
13
- rootly_mcp_server-2.0.5.dist-info/RECORD,,