rootly-mcp-server 0.0.3__tar.gz → 0.0.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rootly-mcp-server
3
- Version: 0.0.3
3
+ Version: 0.0.4
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rootly-mcp-server"
3
- version = "0.0.3"
3
+ version = "0.0.4"
4
4
  description = "A Model Context Protocol server for Rootly APIs using OpenAPI spec"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -30,51 +30,68 @@ SWAGGER_URL = "https://rootly-heroku.s3.amazonaws.com/swagger/v1/swagger.json"
30
30
  class RootlyMCPServer(FastMCP):
31
31
  """
32
32
  A Model Context Protocol server for Rootly API integration.
33
-
33
+
34
34
  This server dynamically generates MCP tools based on the Rootly API's
35
35
  OpenAPI (Swagger) specification.
36
36
  """
37
-
38
- def __init__(self, swagger_path: Optional[str] = None, name: str = "Rootly", default_page_size: int = 10):
37
+
38
+ def __init__(self,
39
+ swagger_path: Optional[str] = None,
40
+ name: str = "Rootly",
41
+ default_page_size: int = 10,
42
+ allowed_paths: Optional[List[str]] = None):
39
43
  """
40
44
  Initialize the Rootly MCP Server.
41
-
45
+
42
46
  Args:
43
47
  swagger_path: Path to the Swagger JSON file. If None, will look for
44
48
  swagger.json in the current directory and parent directories.
45
49
  name: Name of the MCP server.
46
50
  default_page_size: Default number of items to return per page for paginated endpoints.
47
- This helps prevent context window overflow.
51
+ allowed_paths: List of API paths to load. If None, all paths will be loaded.
52
+ Paths should be specified without the /v1 prefix.
53
+ Example: ["/incidents", "/incidents/{incident_id}/alerts"]
48
54
  """
49
- logger.info(f"Initializing RootlyMCPServer with name: {name}")
55
+ # Set default allowed paths if none provided
56
+ self.allowed_paths = allowed_paths or [
57
+ "/incidents",
58
+ "/incidents/{incident_id}/alerts"
59
+ ]
60
+ # Add /v1 prefix to paths if not present
61
+ self.allowed_paths = [
62
+ f"/v1{path}" if not path.startswith("/v1") else path
63
+ for path in self.allowed_paths
64
+ ]
65
+
66
+ logger.info(f"Initializing RootlyMCPServer with allowed paths: {self.allowed_paths}")
50
67
  super().__init__(name)
51
-
68
+
52
69
  # Initialize the Rootly API client
53
70
  self.client = RootlyClient()
54
-
71
+
55
72
  # Store default page size
56
73
  self.default_page_size = default_page_size
57
74
  logger.info(f"Using default page size: {default_page_size}")
58
-
75
+
59
76
  # Load the Swagger specification
60
77
  logger.info("Loading Swagger specification")
61
78
  self.swagger_spec = self._load_swagger_spec(swagger_path)
62
- logger.info(f"Loaded Swagger spec with {len(self.swagger_spec.get('paths', {}))} paths")
63
-
79
+ logger.info(f"Loaded Swagger spec with {len(self.swagger_spec.get('paths', {}))} total paths")
80
+
64
81
  # Register tools based on the Swagger spec
65
82
  logger.info("Registering tools based on Swagger spec")
66
83
  self._register_tools()
67
-
84
+
68
85
  def _fetch_swagger_from_url(self, url: str = SWAGGER_URL) -> Dict[str, Any]:
69
86
  """
70
87
  Fetch the Swagger specification from the specified URL.
71
-
88
+
72
89
  Args:
73
90
  url: URL of the Swagger JSON file.
74
-
91
+
75
92
  Returns:
76
93
  The Swagger specification as a dictionary.
77
-
94
+
78
95
  Raises:
79
96
  Exception: If the request fails or the response is not valid JSON.
80
97
  """
@@ -89,18 +106,18 @@ class RootlyMCPServer(FastMCP):
89
106
  except json.JSONDecodeError as e:
90
107
  logger.error(f"Failed to parse Swagger spec: {e}")
91
108
  raise Exception(f"Failed to parse Swagger specification: {e}")
92
-
109
+
93
110
  def _load_swagger_spec(self, swagger_path: Optional[str] = None) -> Dict[str, Any]:
94
111
  """
95
112
  Load the Swagger specification from a file.
96
-
113
+
97
114
  Args:
98
115
  swagger_path: Path to the Swagger JSON file. If None, will look for
99
116
  swagger.json in the following locations (in order):
100
117
  1. package data directory
101
118
  2. current directory and parent directories
102
119
  3. download from the URL
103
-
120
+
104
121
  Returns:
105
122
  The Swagger specification as a dictionary.
106
123
  """
@@ -121,18 +138,18 @@ class RootlyMCPServer(FastMCP):
121
138
  return json.load(f)
122
139
  except Exception as e:
123
140
  logger.debug(f"Could not load Swagger file from package data: {e}")
124
-
141
+
125
142
  # Then, look for swagger.json in the current directory and parent directories
126
143
  logger.info("Looking for swagger.json in current directory and parent directories")
127
144
  current_dir = Path.cwd()
128
-
145
+
129
146
  # Check current directory first
130
147
  swagger_path = current_dir / "swagger.json"
131
148
  if swagger_path.is_file():
132
149
  logger.info(f"Found Swagger file at {swagger_path}")
133
150
  with open(swagger_path, "r") as f:
134
151
  return json.load(f)
135
-
152
+
136
153
  # Check parent directories
137
154
  for parent in current_dir.parents:
138
155
  swagger_path = parent / "swagger.json"
@@ -140,11 +157,11 @@ class RootlyMCPServer(FastMCP):
140
157
  logger.info(f"Found Swagger file at {swagger_path}")
141
158
  with open(swagger_path, "r") as f:
142
159
  return json.load(f)
143
-
160
+
144
161
  # If the file wasn't found, fetch it from the URL and save it
145
162
  logger.info("Swagger file not found locally, fetching from URL")
146
163
  swagger_spec = self._fetch_swagger_from_url()
147
-
164
+
148
165
  # Save the fetched spec to the current directory
149
166
  swagger_path = current_dir / "swagger.json"
150
167
  logger.info(f"Saving Swagger file to {swagger_path}")
@@ -154,32 +171,38 @@ class RootlyMCPServer(FastMCP):
154
171
  logger.info(f"Saved Swagger file to {swagger_path}")
155
172
  except Exception as e:
156
173
  logger.warning(f"Failed to save Swagger file: {e}")
157
-
174
+
158
175
  return swagger_spec
159
-
176
+
160
177
  def _register_tools(self) -> None:
161
178
  """
162
179
  Register MCP tools based on the Swagger specification.
163
-
164
- This method iterates through the paths and operations in the Swagger spec
165
- and creates corresponding MCP tools.
180
+ Only registers tools for paths specified in allowed_paths.
166
181
  """
167
182
  paths = self.swagger_spec.get("paths", {})
168
- logger.info(f"Found {len(paths)} paths in Swagger spec")
169
-
183
+
184
+ # Filter paths based on allowed_paths
185
+ filtered_paths = {
186
+ path: path_item
187
+ for path, path_item in paths.items()
188
+ if path in self.allowed_paths
189
+ }
190
+
191
+ logger.info(f"Registering {len(filtered_paths)} paths out of {len(paths)} total paths")
192
+
170
193
  # Register the list_endpoints tool
171
194
  @self.tool()
172
195
  def list_endpoints() -> str:
173
196
  """List all available Rootly API endpoints."""
174
197
  endpoints = []
175
- for path, path_item in paths.items():
198
+ for path, path_item in filtered_paths.items():
176
199
  for method, operation in path_item.items():
177
200
  if method.lower() not in ["get", "post", "put", "delete", "patch"]:
178
201
  continue
179
-
202
+
180
203
  summary = operation.get("summary", "")
181
204
  description = operation.get("description", "")
182
-
205
+
183
206
  endpoints.append({
184
207
  "path": path,
185
208
  "method": method.upper(),
@@ -187,92 +210,89 @@ class RootlyMCPServer(FastMCP):
187
210
  "description": description,
188
211
  "tool_name": self._create_tool_name(path, method)
189
212
  })
190
-
213
+
191
214
  return json.dumps(endpoints, indent=2)
192
-
215
+
193
216
  # Register a tool for each endpoint
194
217
  tool_count = 0
195
-
196
- for path, path_item in paths.items():
218
+
219
+ for path, path_item in filtered_paths.items():
197
220
  # Skip path parameters
198
221
  if "parameters" in path_item:
199
222
  path_item = {k: v for k, v in path_item.items() if k != "parameters"}
200
-
223
+
201
224
  for method, operation in path_item.items():
202
225
  if method.lower() not in ["get", "post", "put", "delete", "patch"]:
203
226
  continue
204
-
227
+
205
228
  # Create a tool name based on the path and method
206
229
  tool_name = self._create_tool_name(path, method)
207
-
230
+
208
231
  # Create a tool description
209
232
  description = operation.get("summary", "") or operation.get("description", "")
210
233
  if not description:
211
234
  description = f"{method.upper()} {path}"
212
-
213
- # Create the input schema
214
- input_schema = self._create_input_schema(path, operation)
215
-
235
+
216
236
  # Register the tool using the direct method
217
237
  try:
218
238
  # Define the tool function
219
239
  def create_tool_fn(p=path, m=method, op=operation):
220
240
  def tool_fn(**kwargs):
221
241
  return self._handle_api_request(p, m, op, **kwargs)
222
-
242
+
223
243
  # Set the function name and docstring
224
244
  tool_fn.__name__ = tool_name
225
245
  tool_fn.__doc__ = description
226
246
  return tool_fn
227
-
247
+
228
248
  # Create the tool function
229
249
  tool_fn = create_tool_fn()
230
-
250
+
231
251
  # Register the tool with FastMCP
232
252
  self.add_tool(
233
253
  name=tool_name,
234
254
  description=description,
235
255
  fn=tool_fn
236
256
  )
237
-
257
+
238
258
  tool_count += 1
239
259
  logger.info(f"Registered tool: {tool_name}")
240
260
  except Exception as e:
241
261
  logger.error(f"Error registering tool {tool_name}: {e}")
242
-
243
- logger.info(f"Registered {tool_count} tools in total")
244
-
262
+
263
+ logger.info(f"Registered {tool_count} tools in total. Modify allowed_paths to register more paths from the Rootly API.")
264
+
245
265
  def _create_tool_name(self, path: str, method: str) -> str:
246
266
  """
247
267
  Create a tool name based on the path and method.
248
-
268
+
249
269
  Args:
250
270
  path: The API path.
251
271
  method: The HTTP method.
252
-
272
+
253
273
  Returns:
254
274
  A tool name string.
255
275
  """
256
276
  # Remove the /v1 prefix if present
257
277
  if path.startswith("/v1"):
258
278
  path = path[3:]
259
-
279
+
260
280
  # Replace path parameters with "by_id"
261
281
  path = re.sub(r"\{([^}]+)\}", r"by_\1", path)
262
-
282
+
263
283
  # Replace slashes with underscores and remove leading/trailing underscores
264
284
  path = path.replace("/", "_").strip("_")
265
-
285
+
266
286
  return f"{path}_{method.lower()}"
267
-
287
+
268
288
  def _create_input_schema(self, path: str, operation: Dict[str, Any]) -> Dict[str, Any]:
269
289
  """
270
290
  Create an input schema for the tool.
271
-
291
+
272
292
  Args:
273
293
  path: The API path.
274
294
  operation: The Swagger operation object.
275
-
295
+
276
296
  Returns:
277
297
  An input schema dictionary.
278
298
  """
@@ -283,7 +303,7 @@ class RootlyMCPServer(FastMCP):
283
303
  "required": [],
284
304
  "additionalProperties": False
285
305
  }
286
-
306
+
287
307
  # Extract path parameters
288
308
  path_params = re.findall(r"\{([^}]+)\}", path)
289
309
  for param in path_params:
@@ -292,80 +312,80 @@ class RootlyMCPServer(FastMCP):
292
312
  "description": f"Path parameter: {param}"
293
313
  }
294
314
  schema["required"].append(param)
295
-
315
+
296
316
  # Add operation parameters
297
317
  for param in operation.get("parameters", []):
298
318
  param_name = param.get("name")
299
319
  param_in = param.get("in")
300
-
320
+
301
321
  if param_in in ["query", "header"]:
302
322
  param_schema = param.get("schema", {})
303
323
  param_type = param_schema.get("type", "string")
304
-
324
+
305
325
  schema["properties"][param_name] = {
306
326
  "type": param_type,
307
327
  "description": param.get("description", f"{param_in} parameter: {param_name}")
308
328
  }
309
-
329
+
310
330
  if param.get("required", False):
311
331
  schema["required"].append(param_name)
312
-
332
+
313
333
  # Add request body for POST, PUT, PATCH methods
314
334
  if "requestBody" in operation:
315
335
  content = operation["requestBody"].get("content", {})
316
336
  if "application/json" in content:
317
337
  body_schema = content["application/json"].get("schema", {})
318
-
338
+
319
339
  if "properties" in body_schema:
320
340
  for prop_name, prop_schema in body_schema["properties"].items():
321
341
  schema["properties"][prop_name] = {
322
342
  "type": prop_schema.get("type", "string"),
323
343
  "description": prop_schema.get("description", f"Body parameter: {prop_name}")
324
344
  }
325
-
345
+
326
346
  if "required" in body_schema:
327
347
  schema["required"].extend(body_schema["required"])
328
-
348
+
329
349
  return schema
330
-
350
+
331
351
  def _handle_api_request(self, path: str, method: str, operation: Dict[str, Any], **kwargs) -> str:
332
352
  """
333
353
  Handle an API request to the Rootly API.
334
-
354
+
335
355
  Args:
336
356
  path: The API path.
337
357
  method: The HTTP method.
338
358
  operation: The Swagger operation object.
339
359
  **kwargs: The parameters for the request.
340
-
360
+
341
361
  Returns:
342
362
  The API response as a JSON string.
343
363
  """
344
364
  logger.debug(f"Handling API request: {method} {path}")
345
365
  logger.debug(f"Request parameters: {kwargs}")
346
-
366
+
347
367
  # Extract path parameters
348
368
  path_params = re.findall(r"\{([^}]+)\}", path)
349
369
  actual_path = path
350
-
370
+
351
371
  # Replace path parameters in the URL
352
372
  for param in path_params:
353
373
  if param in kwargs:
354
374
  actual_path = actual_path.replace(f"{{{param}}}", str(kwargs.pop(param)))
355
-
375
+
356
376
  # Separate query parameters and body parameters
357
377
  query_params = {}
358
378
  body_params = {}
359
-
379
+
360
380
  if method.lower() == "get":
361
381
  # For GET requests, all remaining parameters are query parameters
362
382
  query_params = kwargs
363
-
383
+
364
384
  # Add default pagination for incident-related endpoints
365
385
  if "incidents" in path and method.lower() == "get":
366
386
  # Check if pagination parameters are already specified
367
387
  has_pagination = any(param.startswith("page[") for param in query_params.keys())
368
-
388
+
369
389
  # If no pagination parameters are specified, add default page size
370
390
  if not has_pagination:
371
391
  query_params["page[size]"] = self.default_page_size
@@ -375,13 +395,13 @@ class RootlyMCPServer(FastMCP):
375
395
  for param in operation.get("parameters", []):
376
396
  param_name = param.get("name")
377
397
  param_in = param.get("in")
378
-
398
+
379
399
  if param_in == "query" and param_name in kwargs:
380
400
  query_params[param_name] = kwargs.pop(param_name)
381
-
401
+
382
402
  # All remaining parameters go in the request body
383
403
  body_params = kwargs
384
-
404
+
385
405
  # Make the API request
386
406
  try:
387
407
  response = self.client.make_request(
@@ -393,4 +413,4 @@ class RootlyMCPServer(FastMCP):
393
413
  return response
394
414
  except Exception as e:
395
415
  logger.error(f"Error calling Rootly API: {e}")
396
- return json.dumps({"error": str(e)})
416
+ return json.dumps({"error": str(e)})
@@ -1,4 +1,5 @@
1
1
  version = 1
2
+ revision = 1
2
3
  requires-python = ">=3.12"
3
4
 
4
5
  [[package]]
@@ -379,7 +380,7 @@ wheels = [
379
380
 
380
381
  [[package]]
381
382
  name = "rootly-mcp-server"
382
- version = "0.0.1"
383
+ version = "0.0.4"
383
384
  source = { editable = "." }
384
385
  dependencies = [
385
386
  { name = "mcp" },
@@ -408,6 +409,7 @@ requires-dist = [
408
409
  { name = "pydantic", specifier = ">=2.0.0" },
409
410
  { name = "requests", specifier = ">=2.28.0" },
410
411
  ]
412
+ provides-extras = ["dev"]
411
413
 
412
414
  [package.metadata.requires-dev]
413
415
  dev = [