rootly-mcp-server 0.0.1__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.
@@ -0,0 +1,20 @@
1
+ """
2
+ Rootly MCP Server - A Model Context Protocol server for Rootly API integration.
3
+
4
+ This package provides a Model Context Protocol (MCP) server for Rootly API integration.
5
+ It dynamically generates MCP tools based on the Rootly API's OpenAPI (Swagger) specification.
6
+
7
+ Features:
8
+ - Automatic tool generation from Swagger spec
9
+ - Authentication via ROOTLY_API_TOKEN environment variable
10
+ - Default pagination (10 items) for incidents endpoints to prevent context window overflow
11
+ """
12
+
13
+ from .server import RootlyMCPServer
14
+ from .client import RootlyClient
15
+
16
+ __version__ = "0.1.0"
17
+ __all__ = [
18
+ 'RootlyMCPServer',
19
+ 'RootlyClient',
20
+ ]
@@ -0,0 +1,123 @@
1
+ """
2
+ Command-line interface for starting the Rootly MCP server.
3
+ """
4
+
5
+ import argparse
6
+ import logging
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from .server import RootlyMCPServer
12
+
13
+
14
+ def parse_args():
15
+ """Parse command-line arguments."""
16
+ parser = argparse.ArgumentParser(
17
+ description="Start the Rootly MCP server for API integration."
18
+ )
19
+ parser.add_argument(
20
+ "--swagger-path",
21
+ type=str,
22
+ help="Path to the Swagger JSON file. If not provided, will look for swagger.json in the current directory and parent directories.",
23
+ )
24
+ parser.add_argument(
25
+ "--log-level",
26
+ type=str,
27
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
28
+ default="INFO",
29
+ help="Set the logging level. Default: INFO",
30
+ )
31
+ parser.add_argument(
32
+ "--name",
33
+ type=str,
34
+ default="Rootly",
35
+ help="Name of the MCP server. Default: Rootly",
36
+ )
37
+ parser.add_argument(
38
+ "--transport",
39
+ type=str,
40
+ choices=["stdio", "sse"],
41
+ default="stdio",
42
+ help="Transport protocol to use. Default: stdio",
43
+ )
44
+ parser.add_argument(
45
+ "--debug",
46
+ action="store_true",
47
+ help="Enable debug mode (equivalent to --log-level DEBUG)",
48
+ )
49
+ return parser.parse_args()
50
+
51
+
52
+ def setup_logging(log_level, debug=False):
53
+ """Set up logging configuration."""
54
+ if debug or os.getenv("DEBUG", "").lower() in ("true", "1", "yes"):
55
+ log_level = "DEBUG"
56
+
57
+ numeric_level = getattr(logging, log_level.upper(), None)
58
+ if not isinstance(numeric_level, int):
59
+ raise ValueError(f"Invalid log level: {log_level}")
60
+
61
+ # Configure root logger
62
+ logging.basicConfig(
63
+ level=numeric_level,
64
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
65
+ handlers=[logging.StreamHandler(sys.stderr)], # Log to stderr for stdio transport
66
+ )
67
+
68
+ # Set specific logger levels
69
+ logging.getLogger("rootly_mcp_server").setLevel(numeric_level)
70
+ logging.getLogger("mcp").setLevel(numeric_level)
71
+
72
+ # Log the configuration
73
+ logger = logging.getLogger(__name__)
74
+ logger.info(f"Logging configured with level: {log_level}")
75
+ logger.debug(f"Python version: {sys.version}")
76
+ logger.debug(f"Current directory: {Path.cwd()}")
77
+ logger.debug(f"Environment variables: {', '.join([f'{k}={v[:3]}...' if k.endswith('TOKEN') else f'{k}={v}' for k, v in os.environ.items() if k.startswith('ROOTLY_') or k in ['DEBUG']])}")
78
+
79
+
80
+ def check_api_token():
81
+ """Check if the Rootly API token is set."""
82
+ logger = logging.getLogger(__name__)
83
+
84
+ api_token = os.environ.get("ROOTLY_API_TOKEN")
85
+ if not api_token:
86
+ logger.error("ROOTLY_API_TOKEN environment variable is not set.")
87
+ print("Error: ROOTLY_API_TOKEN environment variable is not set.", file=sys.stderr)
88
+ print("Please set it with: export ROOTLY_API_TOKEN='your-api-token-here'", file=sys.stderr)
89
+ sys.exit(1)
90
+ else:
91
+ logger.info("ROOTLY_API_TOKEN is set")
92
+ # Log the first few characters of the token for debugging
93
+ logger.debug(f"Token starts with: {api_token[:5]}...")
94
+
95
+
96
+ def main():
97
+ """Entry point for the Rootly MCP server."""
98
+ args = parse_args()
99
+ setup_logging(args.log_level, args.debug)
100
+
101
+ logger = logging.getLogger(__name__)
102
+ logger.info("Starting Rootly MCP Server")
103
+
104
+ check_api_token()
105
+
106
+ try:
107
+ logger.info(f"Initializing server with name: {args.name}")
108
+ server = RootlyMCPServer(swagger_path=args.swagger_path, name=args.name)
109
+
110
+ logger.info(f"Running server with transport: {args.transport}...")
111
+ server.run(transport=args.transport)
112
+ except FileNotFoundError as e:
113
+ logger.error(f"File not found: {e}")
114
+ print(f"Error: {e}", file=sys.stderr)
115
+ sys.exit(1)
116
+ except Exception as e:
117
+ logger.error(f"Failed to start server: {e}", exc_info=True)
118
+ print(f"Error: {e}", file=sys.stderr)
119
+ sys.exit(1)
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main()
@@ -0,0 +1,102 @@
1
+ """
2
+ Rootly API client for making authenticated requests to the Rootly API.
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import logging
8
+ import requests
9
+ from typing import Optional, Dict, Any, Union
10
+
11
+ # Set up logger
12
+ logger = logging.getLogger(__name__)
13
+
14
+ class RootlyClient:
15
+ def __init__(self, base_url: Optional[str] = None):
16
+ self.base_url = base_url or "https://api.rootly.com"
17
+ self._api_token = self._get_api_token()
18
+ logger.debug(f"Initialized RootlyClient with base_url: {self.base_url}")
19
+
20
+ def _get_api_token(self) -> str:
21
+ """Get the API token from environment variables."""
22
+ api_token = os.getenv("ROOTLY_API_TOKEN")
23
+ if not api_token:
24
+ raise ValueError("ROOTLY_API_TOKEN environment variable is not set")
25
+ return api_token
26
+
27
+ def make_request(self, method: str, path: str, query_params: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None) -> str:
28
+ """
29
+ Make an authenticated request to the Rootly API.
30
+
31
+ Args:
32
+ method: The HTTP method to use.
33
+ path: The API path.
34
+ query_params: Query parameters for the request.
35
+ json_data: JSON data for the request body.
36
+
37
+ Returns:
38
+ The API response as a JSON string.
39
+ """
40
+ headers = {
41
+ "Authorization": f"Bearer {self._api_token}",
42
+ "Content-Type": "application/json",
43
+ "Accept": "application/json"
44
+ }
45
+
46
+ # Ensure path starts with a slash
47
+ if not path.startswith("/"):
48
+ path = f"/{path}"
49
+
50
+ # Ensure path starts with /v1 if not already
51
+ if not path.startswith("/v1"):
52
+ path = f"/v1{path}"
53
+
54
+ url = f"{self.base_url}{path}"
55
+
56
+ logger.debug(f"Making {method} request to {url}")
57
+ logger.debug(f"Headers: {headers}")
58
+ logger.debug(f"Query params: {query_params}")
59
+ logger.debug(f"JSON data: {json_data}")
60
+
61
+ try:
62
+ response = requests.request(
63
+ method=method.upper(),
64
+ url=url,
65
+ headers=headers,
66
+ params=query_params,
67
+ json=json_data,
68
+ timeout=30 # Add a timeout to prevent hanging
69
+ )
70
+
71
+ # Log the response status and headers
72
+ logger.debug(f"Response status: {response.status_code}")
73
+ logger.debug(f"Response headers: {response.headers}")
74
+
75
+ # Try to parse the response as JSON
76
+ try:
77
+ response_json = response.json()
78
+ logger.debug(f"Response parsed as JSON: {json.dumps(response_json)[:200]}...")
79
+ response.raise_for_status()
80
+ return json.dumps(response_json, indent=2)
81
+ except ValueError:
82
+ # If the response is not JSON, return the text
83
+ logger.debug(f"Response is not JSON: {response.text[:200]}...")
84
+ response.raise_for_status()
85
+ return json.dumps({"text": response.text}, indent=2)
86
+
87
+ except requests.exceptions.RequestException as e:
88
+ logger.error(f"Request failed: {e}")
89
+ error_response = {"error": str(e)}
90
+
91
+ # Add response details if available
92
+ if hasattr(e, 'response') and e.response is not None:
93
+ try:
94
+ error_response["status_code"] = e.response.status_code
95
+ error_response["response_text"] = e.response.text
96
+ except:
97
+ pass
98
+
99
+ return json.dumps(error_response, indent=2)
100
+ except Exception as e:
101
+ logger.error(f"Unexpected error: {e}")
102
+ return json.dumps({"error": f"Unexpected error: {str(e)}"}, indent=2)
@@ -0,0 +1,340 @@
1
+ """
2
+ Rootly MCP Server - A Model Context Protocol server for Rootly API integration.
3
+
4
+ This module implements a server that dynamically generates MCP tools based on
5
+ the Rootly API's OpenAPI (Swagger) specification.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import re
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Tuple, Union, Callable
14
+
15
+ import mcp
16
+ from mcp.server.fastmcp import FastMCP
17
+ from pydantic import BaseModel, Field
18
+
19
+ from .client import RootlyClient
20
+
21
+ # Set up logger
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class RootlyMCPServer(FastMCP):
26
+ """
27
+ A Model Context Protocol server for Rootly API integration.
28
+
29
+ This server dynamically generates MCP tools based on the Rootly API's
30
+ OpenAPI (Swagger) specification.
31
+ """
32
+
33
+ def __init__(self, swagger_path: Optional[str] = None, name: str = "Rootly", default_page_size: int = 10):
34
+ """
35
+ Initialize the Rootly MCP Server.
36
+
37
+ Args:
38
+ swagger_path: Path to the Swagger JSON file. If None, will look for
39
+ swagger.json in the current directory and parent directories.
40
+ name: Name of the MCP server.
41
+ default_page_size: Default number of items to return per page for paginated endpoints.
42
+ This helps prevent context window overflow.
43
+ """
44
+ logger.info(f"Initializing RootlyMCPServer with name: {name}")
45
+ super().__init__(name)
46
+
47
+ # Initialize the Rootly API client
48
+ self.client = RootlyClient()
49
+
50
+ # Store default page size
51
+ self.default_page_size = default_page_size
52
+ logger.info(f"Using default page size: {default_page_size}")
53
+
54
+ # Load the Swagger specification
55
+ logger.info("Loading Swagger specification")
56
+ self.swagger_spec = self._load_swagger_spec(swagger_path)
57
+ logger.info(f"Loaded Swagger spec with {len(self.swagger_spec.get('paths', {}))} paths")
58
+
59
+ # Register tools based on the Swagger spec
60
+ logger.info("Registering tools based on Swagger spec")
61
+ self._register_tools()
62
+
63
+ def _load_swagger_spec(self, swagger_path: Optional[str] = None) -> Dict[str, Any]:
64
+ """
65
+ Load the Swagger specification from a file.
66
+
67
+ Args:
68
+ swagger_path: Path to the Swagger JSON file. If None, will look for
69
+ swagger.json in the current directory and parent directories.
70
+
71
+ Returns:
72
+ The Swagger specification as a dictionary.
73
+ """
74
+ if swagger_path:
75
+ # Use the provided path
76
+ logger.info(f"Using provided Swagger path: {swagger_path}")
77
+ if not os.path.isfile(swagger_path):
78
+ raise FileNotFoundError(f"Swagger file not found at {swagger_path}")
79
+ with open(swagger_path, "r") as f:
80
+ return json.load(f)
81
+ else:
82
+ # Look for swagger.json in the current directory and parent directories
83
+ logger.info("Looking for swagger.json in current directory and parent directories")
84
+ current_dir = Path.cwd()
85
+
86
+ # Check current directory first
87
+ swagger_path = current_dir / "swagger.json"
88
+ if swagger_path.is_file():
89
+ logger.info(f"Found Swagger file at {swagger_path}")
90
+ with open(swagger_path, "r") as f:
91
+ return json.load(f)
92
+
93
+ # Check parent directories
94
+ for parent in current_dir.parents:
95
+ swagger_path = parent / "swagger.json"
96
+ if swagger_path.is_file():
97
+ logger.info(f"Found Swagger file at {swagger_path}")
98
+ with open(swagger_path, "r") as f:
99
+ return json.load(f)
100
+
101
+ # If we get here, we didn't find the file
102
+ raise FileNotFoundError("Could not find swagger.json in current directory or parent directories")
103
+
104
+ def _register_tools(self) -> None:
105
+ """
106
+ Register MCP tools based on the Swagger specification.
107
+
108
+ This method iterates through the paths and operations in the Swagger spec
109
+ and creates corresponding MCP tools.
110
+ """
111
+ paths = self.swagger_spec.get("paths", {})
112
+ logger.info(f"Found {len(paths)} paths in Swagger spec")
113
+
114
+ # Register the list_endpoints tool
115
+ @self.tool()
116
+ def list_endpoints() -> str:
117
+ """List all available Rootly API endpoints."""
118
+ endpoints = []
119
+ for path, path_item in paths.items():
120
+ for method, operation in path_item.items():
121
+ if method.lower() not in ["get", "post", "put", "delete", "patch"]:
122
+ continue
123
+
124
+ summary = operation.get("summary", "")
125
+ description = operation.get("description", "")
126
+
127
+ endpoints.append({
128
+ "path": path,
129
+ "method": method.upper(),
130
+ "summary": summary,
131
+ "description": description,
132
+ "tool_name": self._create_tool_name(path, method)
133
+ })
134
+
135
+ return json.dumps(endpoints, indent=2)
136
+
137
+ # Register a tool for each endpoint
138
+ tool_count = 0
139
+
140
+ for path, path_item in paths.items():
141
+ # Skip path parameters
142
+ if "parameters" in path_item:
143
+ path_item = {k: v for k, v in path_item.items() if k != "parameters"}
144
+
145
+ for method, operation in path_item.items():
146
+ if method.lower() not in ["get", "post", "put", "delete", "patch"]:
147
+ continue
148
+
149
+ # Create a tool name based on the path and method
150
+ tool_name = self._create_tool_name(path, method)
151
+
152
+ # Create a tool description
153
+ description = operation.get("summary", "") or operation.get("description", "")
154
+ if not description:
155
+ description = f"{method.upper()} {path}"
156
+
157
+ # Create the input schema
158
+ input_schema = self._create_input_schema(path, operation)
159
+
160
+ # Register the tool using the direct method
161
+ try:
162
+ # Define the tool function
163
+ def create_tool_fn(p=path, m=method, op=operation):
164
+ def tool_fn(**kwargs):
165
+ return self._handle_api_request(p, m, op, **kwargs)
166
+
167
+ # Set the function name and docstring
168
+ tool_fn.__name__ = tool_name
169
+ tool_fn.__doc__ = description
170
+ return tool_fn
171
+
172
+ # Create the tool function
173
+ tool_fn = create_tool_fn()
174
+
175
+ # Register the tool with FastMCP
176
+ self.add_tool(
177
+ name=tool_name,
178
+ description=description,
179
+ fn=tool_fn
180
+ )
181
+
182
+ tool_count += 1
183
+ logger.info(f"Registered tool: {tool_name}")
184
+ except Exception as e:
185
+ logger.error(f"Error registering tool {tool_name}: {e}")
186
+
187
+ logger.info(f"Registered {tool_count} tools in total")
188
+
189
+ def _create_tool_name(self, path: str, method: str) -> str:
190
+ """
191
+ Create a tool name based on the path and method.
192
+
193
+ Args:
194
+ path: The API path.
195
+ method: The HTTP method.
196
+
197
+ Returns:
198
+ A tool name string.
199
+ """
200
+ # Remove the /v1 prefix if present
201
+ if path.startswith("/v1"):
202
+ path = path[3:]
203
+
204
+ # Replace path parameters with "by_id"
205
+ path = re.sub(r"\{([^}]+)\}", r"by_\1", path)
206
+
207
+ # Replace slashes with underscores and remove leading/trailing underscores
208
+ path = path.replace("/", "_").strip("_")
209
+
210
+ return f"{path}_{method.lower()}"
211
+
212
+ def _create_input_schema(self, path: str, operation: Dict[str, Any]) -> Dict[str, Any]:
213
+ """
214
+ Create an input schema for the tool.
215
+
216
+ Args:
217
+ path: The API path.
218
+ operation: The Swagger operation object.
219
+
220
+ Returns:
221
+ An input schema dictionary.
222
+ """
223
+ # Create a basic schema
224
+ schema = {
225
+ "type": "object",
226
+ "properties": {},
227
+ "required": [],
228
+ "additionalProperties": False
229
+ }
230
+
231
+ # Extract path parameters
232
+ path_params = re.findall(r"\{([^}]+)\}", path)
233
+ for param in path_params:
234
+ schema["properties"][param] = {
235
+ "type": "string",
236
+ "description": f"Path parameter: {param}"
237
+ }
238
+ schema["required"].append(param)
239
+
240
+ # Add operation parameters
241
+ for param in operation.get("parameters", []):
242
+ param_name = param.get("name")
243
+ param_in = param.get("in")
244
+
245
+ if param_in in ["query", "header"]:
246
+ param_schema = param.get("schema", {})
247
+ param_type = param_schema.get("type", "string")
248
+
249
+ schema["properties"][param_name] = {
250
+ "type": param_type,
251
+ "description": param.get("description", f"{param_in} parameter: {param_name}")
252
+ }
253
+
254
+ if param.get("required", False):
255
+ schema["required"].append(param_name)
256
+
257
+ # Add request body for POST, PUT, PATCH methods
258
+ if "requestBody" in operation:
259
+ content = operation["requestBody"].get("content", {})
260
+ if "application/json" in content:
261
+ body_schema = content["application/json"].get("schema", {})
262
+
263
+ if "properties" in body_schema:
264
+ for prop_name, prop_schema in body_schema["properties"].items():
265
+ schema["properties"][prop_name] = {
266
+ "type": prop_schema.get("type", "string"),
267
+ "description": prop_schema.get("description", f"Body parameter: {prop_name}")
268
+ }
269
+
270
+ if "required" in body_schema:
271
+ schema["required"].extend(body_schema["required"])
272
+
273
+ return schema
274
+
275
+ def _handle_api_request(self, path: str, method: str, operation: Dict[str, Any], **kwargs) -> str:
276
+ """
277
+ Handle an API request to the Rootly API.
278
+
279
+ Args:
280
+ path: The API path.
281
+ method: The HTTP method.
282
+ operation: The Swagger operation object.
283
+ **kwargs: The parameters for the request.
284
+
285
+ Returns:
286
+ The API response as a JSON string.
287
+ """
288
+ logger.debug(f"Handling API request: {method} {path}")
289
+ logger.debug(f"Request parameters: {kwargs}")
290
+
291
+ # Extract path parameters
292
+ path_params = re.findall(r"\{([^}]+)\}", path)
293
+ actual_path = path
294
+
295
+ # Replace path parameters in the URL
296
+ for param in path_params:
297
+ if param in kwargs:
298
+ actual_path = actual_path.replace(f"{{{param}}}", str(kwargs.pop(param)))
299
+
300
+ # Separate query parameters and body parameters
301
+ query_params = {}
302
+ body_params = {}
303
+
304
+ if method.lower() == "get":
305
+ # For GET requests, all remaining parameters are query parameters
306
+ query_params = kwargs
307
+
308
+ # Add default pagination for incident-related endpoints
309
+ if "incidents" in path and method.lower() == "get":
310
+ # Check if pagination parameters are already specified
311
+ has_pagination = any(param.startswith("page[") for param in query_params.keys())
312
+
313
+ # If no pagination parameters are specified, add default page size
314
+ if not has_pagination:
315
+ query_params["page[size]"] = self.default_page_size
316
+ logger.info(f"Added default pagination (page[size]={self.default_page_size}) for incidents endpoint: {path}")
317
+ else:
318
+ # For other methods, check which parameters are query parameters
319
+ for param in operation.get("parameters", []):
320
+ param_name = param.get("name")
321
+ param_in = param.get("in")
322
+
323
+ if param_in == "query" and param_name in kwargs:
324
+ query_params[param_name] = kwargs.pop(param_name)
325
+
326
+ # All remaining parameters go in the request body
327
+ body_params = kwargs
328
+
329
+ # Make the API request
330
+ try:
331
+ response = self.client.make_request(
332
+ method=method.upper(),
333
+ path=actual_path,
334
+ query_params=query_params if query_params else None,
335
+ json_data=body_params if body_params else None
336
+ )
337
+ return response
338
+ except Exception as e:
339
+ logger.error(f"Error calling Rootly API: {e}")
340
+ return json.dumps({"error": str(e)})
@@ -0,0 +1,67 @@
1
+ """
2
+ Test script for Rootly MCP Server.
3
+
4
+ This script tests the default pagination for incidents endpoints.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import os
10
+ import sys
11
+
12
+ # Configure logging
13
+ logging.basicConfig(
14
+ level=logging.INFO,
15
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
16
+ )
17
+
18
+ # Add the parent directory to the path so we can import the package
19
+ sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../..')))
20
+
21
+ from rootly_mcp_server import RootlyMCPServer
22
+
23
+ def test_incidents_pagination():
24
+ """Test that incidents endpoints have default pagination."""
25
+
26
+ # Create a server instance
27
+ server = RootlyMCPServer(default_page_size=5) # Use a smaller page size for testing
28
+
29
+ # Find an incidents endpoint tool
30
+ incidents_tool = None
31
+ for tool_name in server.list_tools():
32
+ if "incidents" in tool_name and tool_name.endswith("_get"):
33
+ incidents_tool = tool_name
34
+ break
35
+
36
+ if not incidents_tool:
37
+ logging.error("No incidents GET endpoint found")
38
+ return
39
+
40
+ logging.info(f"Testing pagination with tool: {incidents_tool}")
41
+
42
+ # Call the tool
43
+ try:
44
+ result = server.invoke_tool(incidents_tool, {})
45
+ result_json = json.loads(result)
46
+
47
+ # Check if the result has pagination info
48
+ if "meta" in result_json and "pagination" in result_json["meta"]:
49
+ pagination = result_json["meta"]["pagination"]
50
+ logging.info(f"Pagination info: {pagination}")
51
+
52
+ if pagination.get("per_page") == 5:
53
+ logging.info("✅ Default pagination applied successfully!")
54
+ else:
55
+ logging.warning(f"❌ Default pagination not applied. Per page: {pagination.get('per_page')}")
56
+ else:
57
+ logging.warning("❌ No pagination info found in response")
58
+
59
+ # Log the number of items returned
60
+ if "data" in result_json:
61
+ logging.info(f"Number of items returned: {len(result_json['data'])}")
62
+
63
+ except Exception as e:
64
+ logging.error(f"Error testing pagination: {e}")
65
+
66
+ if __name__ == "__main__":
67
+ test_incidents_pagination()