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.
- rootly_mcp_server/__init__.py +1 -1
- rootly_mcp_server/__main__.py +6 -6
- rootly_mcp_server/client.py +2 -2
- rootly_mcp_server/routemap_server.py +97 -63
- rootly_mcp_server/server.py +254 -175
- rootly_mcp_server/test_client.py +11 -9
- rootly_mcp_server/utils.py +105 -0
- {rootly_mcp_server-2.0.5.dist-info → rootly_mcp_server-2.0.8.dist-info}/METADATA +27 -26
- rootly_mcp_server-2.0.8.dist-info/RECORD +13 -0
- rootly_mcp_server/rootly_openapi_loader.py +0 -96
- rootly_mcp_server-2.0.5.dist-info/RECORD +0 -13
- {rootly_mcp_server-2.0.5.dist-info → rootly_mcp_server-2.0.8.dist-info}/WHEEL +0 -0
- {rootly_mcp_server-2.0.5.dist-info → rootly_mcp_server-2.0.8.dist-info}/entry_points.txt +0 -0
- {rootly_mcp_server-2.0.5.dist-info → rootly_mcp_server-2.0.8.dist-info}/licenses/LICENSE +0 -0
rootly_mcp_server/__init__.py
CHANGED
rootly_mcp_server/__main__.py
CHANGED
|
@@ -10,9 +10,7 @@ import logging
|
|
|
10
10
|
import os
|
|
11
11
|
import sys
|
|
12
12
|
from pathlib import Path
|
|
13
|
-
from
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
+
allowed_paths=allowed_paths,
|
|
177
177
|
hosted=hosted_mode,
|
|
178
178
|
base_url=args.base_url,
|
|
179
179
|
)
|
rootly_mcp_server/client.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
78
|
+
logger.info("Creating FastMCP server with RouteMap filtering...")
|
|
77
79
|
|
|
78
|
-
# Define
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
"
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
"
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
"
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
|
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(
|
|
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
|
rootly_mcp_server/server.py
CHANGED
|
@@ -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,
|
|
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
|
|
21
|
-
from starlette.requests import Request
|
|
22
|
-
from pydantic import BaseModel, Field
|
|
19
|
+
from pydantic import Field
|
|
23
20
|
|
|
24
|
-
from .
|
|
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
|
|
65
|
-
|
|
143
|
+
return self
|
|
144
|
+
|
|
66
145
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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() ->
|
|
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
|
|
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
|
|
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:
|
|
203
|
-
page_number: Annotated[int, Field(description="Page number to retrieve", ge=
|
|
204
|
-
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
300
|
+
current_page = 1
|
|
301
|
+
effective_page_size = min(page_size, 10)
|
|
302
|
+
|
|
241
303
|
try:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
#
|
|
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
|
rootly_mcp_server/test_client.py
CHANGED
|
@@ -33,7 +33,7 @@ async def test_server():
|
|
|
33
33
|
hosted=False # Use local API token
|
|
34
34
|
)
|
|
35
35
|
|
|
36
|
-
print(
|
|
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(
|
|
81
|
+
print("\n⚠️ No tools found")
|
|
82
82
|
|
|
83
83
|
# Test accessing a specific tool
|
|
84
84
|
if tool_count > 0:
|
|
85
|
-
print(
|
|
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
|
|
96
|
+
# Try to get tools and find the specific tool
|
|
97
97
|
try:
|
|
98
|
-
|
|
99
|
-
if
|
|
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
|
|
104
|
+
print(f" ❌ Could not find tool by name: {first_tool_name}")
|
|
103
105
|
except Exception as e:
|
|
104
|
-
print(f" ❌ Error retrieving
|
|
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(
|
|
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.
|
|
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
|
|
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
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|