rootly-mcp-server 1.0.0__py3-none-any.whl → 2.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.
@@ -13,7 +13,7 @@ Features:
13
13
  from .server import RootlyMCPServer
14
14
  from .client import RootlyClient
15
15
 
16
- __version__ = "0.1.0"
16
+ __version__ = "2.0.0"
17
17
  __all__ = [
18
18
  'RootlyMCPServer',
19
19
  'RootlyClient',
@@ -1,5 +1,8 @@
1
+ #!/usr/bin/env python3
1
2
  """
2
- Command-line interface for starting the Rootly MCP server.
3
+ Rootly MCP Server - Main entry point
4
+
5
+ This module provides the main entry point for the Rootly MCP Server.
3
6
  """
4
7
 
5
8
  import argparse
@@ -7,8 +10,9 @@ import logging
7
10
  import os
8
11
  import sys
9
12
  from pathlib import Path
13
+ from typing import Optional, List
10
14
 
11
- from .server import RootlyMCPServer
15
+ from .routemap_server import create_rootly_mcp_server
12
16
 
13
17
 
14
18
  def parse_args():
@@ -46,6 +50,27 @@ def parse_args():
46
50
  action="store_true",
47
51
  help="Enable debug mode (equivalent to --log-level DEBUG)",
48
52
  )
53
+ parser.add_argument(
54
+ "--base-url",
55
+ type=str,
56
+ help="Base URL for the Rootly API. Default: https://api.rootly.com",
57
+ )
58
+ parser.add_argument(
59
+ "--allowed-paths",
60
+ type=str,
61
+ help="Comma-separated list of allowed API paths to include",
62
+ )
63
+ parser.add_argument(
64
+ "--hosted",
65
+ action="store_true",
66
+ help="Enable hosted mode for remote MCP server",
67
+ )
68
+ # Backward compatibility: support deprecated --host argument
69
+ parser.add_argument(
70
+ "--host",
71
+ action="store_true",
72
+ help="(Deprecated) Use --hosted instead. Enable hosted mode for remote MCP server",
73
+ )
49
74
  return parser.parse_args()
50
75
 
51
76
 
@@ -67,11 +92,7 @@ def setup_logging(log_level, debug=False):
67
92
 
68
93
  # Set specific logger levels
69
94
  logging.getLogger("rootly_mcp_server").setLevel(numeric_level)
70
- logging.getLogger("rootly_mcp_server.server").setLevel(logging.WARNING) # Reduce server-specific logs
71
-
72
- # Always set MCP logger to ERROR level to fix Cline UI issue
73
- # This prevents INFO logs from causing problems with Cline tool display
74
- logging.getLogger("mcp").setLevel(logging.ERROR)
95
+ logging.getLogger("mcp").setLevel(numeric_level)
75
96
 
76
97
  # Log the configuration
77
98
  logger = logging.getLogger(__name__)
@@ -97,22 +118,69 @@ def check_api_token():
97
118
  logger.debug(f"Token starts with: {api_token[:5]}...")
98
119
 
99
120
 
121
+ # Create the server instance for FastMCP CLI (follows quickstart pattern)
122
+ def get_server():
123
+ """Get a configured Rootly MCP server instance."""
124
+ # Get configuration from environment variables
125
+ swagger_path = os.getenv("ROOTLY_SWAGGER_PATH")
126
+ server_name = os.getenv("ROOTLY_SERVER_NAME", "Rootly")
127
+ hosted = os.getenv("ROOTLY_HOSTED", "false").lower() in ("true", "1", "yes")
128
+ base_url = os.getenv("ROOTLY_BASE_URL")
129
+
130
+ # Parse allowed paths from environment variable
131
+ allowed_paths = None
132
+ allowed_paths_env = os.getenv("ROOTLY_ALLOWED_PATHS")
133
+ if allowed_paths_env:
134
+ allowed_paths = [path.strip() for path in allowed_paths_env.split(",")]
135
+
136
+ # Create and return the server
137
+ return create_rootly_mcp_server(
138
+ swagger_path=swagger_path,
139
+ name=server_name,
140
+ allowed_paths=allowed_paths,
141
+ hosted=hosted,
142
+ base_url=base_url,
143
+ )
144
+
145
+
146
+ # Create the server instance for FastMCP CLI (follows quickstart pattern)
147
+ mcp = get_server()
148
+
149
+
100
150
  def main():
101
- """Entry point for the Rootly MCP server."""
151
+ """Main entry point for the Rootly MCP Server."""
102
152
  args = parse_args()
103
153
  setup_logging(args.log_level, args.debug)
104
154
 
105
155
  logger = logging.getLogger(__name__)
106
156
  logger.info("Starting Rootly MCP Server")
107
157
 
158
+ # Handle backward compatibility for --host argument
159
+ hosted_mode = args.hosted
160
+ if args.host:
161
+ logger.warning("--host argument is deprecated, use --hosted instead")
162
+ hosted_mode = True
163
+
108
164
  check_api_token()
109
165
 
110
166
  try:
167
+ # Parse allowed paths from command line argument
168
+ allowed_paths = None
169
+ if args.allowed_paths:
170
+ allowed_paths = [path.strip() for path in args.allowed_paths.split(",")]
171
+
111
172
  logger.info(f"Initializing server with name: {args.name}")
112
- server = RootlyMCPServer(swagger_path=args.swagger_path, name=args.name)
173
+ server = create_rootly_mcp_server(
174
+ swagger_path=args.swagger_path,
175
+ name=args.name,
176
+ allowed_paths=allowed_paths,
177
+ hosted=hosted_mode,
178
+ base_url=args.base_url,
179
+ )
113
180
 
114
181
  logger.info(f"Running server with transport: {args.transport}...")
115
182
  server.run(transport=args.transport)
183
+
116
184
  except FileNotFoundError as e:
117
185
  logger.error(f"File not found: {e}")
118
186
  print(f"Error: {e}", file=sys.stderr)
@@ -124,4 +192,4 @@ def main():
124
192
 
125
193
 
126
194
  if __name__ == "__main__":
127
- main()
195
+ main()
@@ -11,10 +11,13 @@ from typing import Optional, Dict, Any, Union
11
11
  # Set up logger
12
12
  logger = logging.getLogger(__name__)
13
13
 
14
+
14
15
  class RootlyClient:
15
- def __init__(self, base_url: Optional[str] = None):
16
+ def __init__(self, base_url: Optional[str] = None, hosted: bool = False):
16
17
  self.base_url = base_url or "https://api.rootly.com"
17
- self._api_token = self._get_api_token()
18
+ self.hosted = hosted
19
+ if not self.hosted:
20
+ self._api_token = self._get_api_token()
18
21
  logger.debug(f"Initialized RootlyClient with base_url: {self.base_url}")
19
22
 
20
23
  def _get_api_token(self) -> str:
@@ -24,25 +27,39 @@ class RootlyClient:
24
27
  raise ValueError("ROOTLY_API_TOKEN environment variable is not set")
25
28
  return api_token
26
29
 
27
- def make_request(self, method: str, path: str, query_params: Optional[Dict[str, Any]] = None, json_data: Optional[Dict[str, Any]] = None, json_api_type: Optional[str] = None) -> str:
30
+ def make_request(
31
+ self,
32
+ method: str,
33
+ path: str,
34
+ query_params: Optional[Dict[str, Any]] = None,
35
+ json_data: Optional[Dict[str, Any]] = None,
36
+ json_api_type: Optional[str] = None,
37
+ api_token: Optional[str] = None,
38
+ ) -> str:
28
39
  """
29
40
  Make an authenticated request to the Rootly API.
30
-
41
+
31
42
  Args:
32
43
  method: The HTTP method to use.
33
44
  path: The API path.
34
45
  query_params: Query parameters for the request.
35
46
  json_data: JSON data for the request body.
36
47
  json_api_type: If set, use JSON-API format and this type value.
37
-
48
+
38
49
  Returns:
39
50
  The API response as a JSON string.
40
51
  """
52
+ if self.hosted:
53
+ if not api_token:
54
+ return json.dumps({"error": "No API token provided"})
55
+ else:
56
+ api_token = self._api_token
57
+
41
58
  # Default headers
42
59
  headers = {
43
- "Authorization": f"Bearer {self._api_token}",
60
+ "Authorization": f"Bearer {api_token}",
44
61
  "Content-Type": "application/json",
45
- "Accept": "application/json"
62
+ "Accept": "application/json",
46
63
  }
47
64
 
48
65
  # If JSON-API, update headers and wrap payload
@@ -50,30 +67,25 @@ class RootlyClient:
50
67
  headers["Content-Type"] = "application/vnd.api+json"
51
68
  headers["Accept"] = "application/vnd.api+json"
52
69
  if json_data:
53
- json_data = {
54
- "data": {
55
- "type": json_api_type,
56
- "attributes": json_data
57
- }
58
- }
70
+ json_data = {"data": {"type": json_api_type, "attributes": json_data}}
59
71
  else:
60
72
  json_data = None
61
73
 
62
74
  # Ensure path starts with a slash
63
75
  if not path.startswith("/"):
64
76
  path = f"/{path}"
65
-
77
+
66
78
  # Ensure path starts with /v1 if not already
67
79
  if not path.startswith("/v1"):
68
80
  path = f"/v1{path}"
69
-
81
+
70
82
  url = f"{self.base_url}{path}"
71
-
83
+
72
84
  logger.debug(f"Making {method} request to {url}")
73
85
  logger.debug(f"Headers: {headers}")
74
86
  logger.debug(f"Query params: {query_params}")
75
87
  logger.debug(f"JSON data: {json_data}")
76
-
88
+
77
89
  try:
78
90
  response = requests.request(
79
91
  method=method.upper(),
@@ -81,17 +93,19 @@ class RootlyClient:
81
93
  headers=headers,
82
94
  params=query_params,
83
95
  json=json_data,
84
- timeout=30 # Add a timeout to prevent hanging
96
+ timeout=30, # Add a timeout to prevent hanging
85
97
  )
86
-
98
+
87
99
  # Log the response status and headers
88
100
  logger.debug(f"Response status: {response.status_code}")
89
101
  logger.debug(f"Response headers: {response.headers}")
90
-
102
+
91
103
  # Try to parse the response as JSON
92
104
  try:
93
105
  response_json = response.json()
94
- logger.debug(f"Response parsed as JSON: {json.dumps(response_json)[:200]}...")
106
+ logger.debug(
107
+ f"Response parsed as JSON: {json.dumps(response_json)[:200]}..."
108
+ )
95
109
  response.raise_for_status()
96
110
  return json.dumps(response_json, indent=2)
97
111
  except ValueError:
@@ -99,20 +113,20 @@ class RootlyClient:
99
113
  logger.debug(f"Response is not JSON: {response.text[:200]}...")
100
114
  response.raise_for_status()
101
115
  return json.dumps({"text": response.text}, indent=2)
102
-
116
+
103
117
  except requests.exceptions.RequestException as e:
104
118
  logger.error(f"Request failed: {e}")
105
119
  error_response = {"error": str(e)}
106
-
120
+
107
121
  # Add response details if available
108
- if hasattr(e, 'response') and e.response is not None:
122
+ if hasattr(e, "response") and e.response is not None:
109
123
  try:
110
124
  error_response["status_code"] = e.response.status_code
111
125
  error_response["response_text"] = e.response.text
112
126
  except:
113
127
  pass
114
-
128
+
115
129
  return json.dumps(error_response, indent=2)
116
130
  except Exception as e:
117
131
  logger.error(f"Unexpected error: {e}")
118
- return json.dumps({"error": f"Unexpected error: {str(e)}"}, indent=2)
132
+ return json.dumps({"error": f"Unexpected error: {str(e)}"}, indent=2)
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Rootly FastMCP Server (RouteMap Version)
4
+
5
+ Alternative implementation using FastMCP's RouteMap system for filtering
6
+ instead of pre-filtering the OpenAPI spec.
7
+ """
8
+
9
+ import httpx
10
+ from fastmcp import FastMCP
11
+ from fastmcp.server.openapi import RouteMap, MCPType
12
+ import os
13
+ import logging
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Optional, List
17
+
18
+ # Import the shared OpenAPI loader
19
+ sys.path.append(str(Path(__file__).parent.parent.parent))
20
+ from rootly_openapi_loader import load_rootly_openapi_spec
21
+
22
+ # Configure logging
23
+ logging.basicConfig(level=logging.INFO)
24
+ logger = logging.getLogger(__name__)
25
+
26
+ def create_rootly_mcp_server(
27
+ swagger_path: Optional[str] = None,
28
+ name: str = "Rootly API Server (RouteMap Filtered)",
29
+ allowed_paths: Optional[List[str]] = None,
30
+ hosted: bool = False,
31
+ base_url: Optional[str] = None,
32
+ ):
33
+ """Create and configure the Rootly MCP server using RouteMap filtering."""
34
+
35
+ # Get Rootly API token from environment
36
+ ROOTLY_API_TOKEN = os.getenv("ROOTLY_API_TOKEN")
37
+ if not ROOTLY_API_TOKEN:
38
+ raise ValueError("ROOTLY_API_TOKEN environment variable is required")
39
+
40
+ logger.info("Creating authenticated HTTP client...")
41
+ # Create authenticated HTTP client
42
+ client = httpx.AsyncClient(
43
+ base_url=base_url or "https://api.rootly.com",
44
+ headers={
45
+ "Authorization": f"Bearer {ROOTLY_API_TOKEN}",
46
+ "Content-Type": "application/vnd.api+json",
47
+ "User-Agent": "Rootly-FastMCP-Server/1.0"
48
+ },
49
+ timeout=30.0
50
+ )
51
+
52
+ logger.info("Loading OpenAPI specification...")
53
+ # Load OpenAPI spec with smart fallback logic
54
+ openapi_spec = load_rootly_openapi_spec()
55
+ logger.info("✅ Successfully loaded OpenAPI specification")
56
+
57
+ logger.info("Fixing OpenAPI spec for FastMCP compatibility...")
58
+ # Fix array types for FastMCP compatibility
59
+ def fix_array_types(obj):
60
+ if isinstance(obj, dict):
61
+ keys_to_process = list(obj.keys())
62
+ for key in keys_to_process:
63
+ value = obj[key]
64
+ if key == 'type' and isinstance(value, list):
65
+ non_null_types = [t for t in value if t != 'null']
66
+ if len(non_null_types) >= 1:
67
+ obj[key] = non_null_types[0]
68
+ obj['nullable'] = True
69
+ else:
70
+ fix_array_types(value)
71
+ elif isinstance(obj, list):
72
+ for item in obj:
73
+ fix_array_types(item)
74
+
75
+ fix_array_types(openapi_spec)
76
+ logger.info("✅ Fixed OpenAPI spec compatibility issues")
77
+
78
+ logger.info("Creating FastMCP server with RouteMap filtering...")
79
+
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
+ ]
168
+
169
+ # Create MCP server with custom route maps
170
+ mcp = FastMCP.from_openapi(
171
+ openapi_spec=openapi_spec,
172
+ client=client,
173
+ name=name,
174
+ timeout=30.0,
175
+ tags={"rootly", "incident-management", "evaluation"},
176
+ route_maps=route_maps
177
+ )
178
+
179
+ logger.info(f"✅ Created MCP server with RouteMap filtering successfully")
180
+ logger.info("🚀 Selected Rootly API endpoints are now available as MCP tools")
181
+
182
+ return mcp
183
+
184
+
185
+
186
+
187
+ def main():
188
+ """Main entry point."""
189
+ try:
190
+ logger.info("🚀 Starting Rootly FastMCP Server (RouteMap Version)...")
191
+ mcp = create_rootly_mcp_server()
192
+
193
+ logger.info("🌐 Server starting on stdio transport...")
194
+ logger.info("Ready for MCP client connections!")
195
+
196
+ # Run the MCP server
197
+ mcp.run()
198
+
199
+ except KeyboardInterrupt:
200
+ logger.info("🛑 Server stopped by user")
201
+ except Exception as e:
202
+ logger.error(f"❌ Server error: {e}")
203
+ raise
204
+
205
+ if __name__ == "__main__":
206
+ main()