rootly-mcp-server 2.0.5__tar.gz → 2.0.6__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.
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/PKG-INFO +1 -1
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/pyproject.toml +1 -1
- rootly_mcp_server-2.0.6/rootly_fastmcp_server_routemap.py +285 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/src/rootly_mcp_server/rootly_openapi_loader.py +19 -6
- rootly_mcp_server-2.0.6/src/rootly_mcp_server/routemap_server.py +291 -0
- rootly_mcp_server-2.0.6/uv.lock +932 -0
- rootly_mcp_server-2.0.5/rootly_fastmcp_server_routemap.py +0 -210
- rootly_mcp_server-2.0.5/src/rootly_mcp_server/routemap_server.py +0 -172
- rootly_mcp_server-2.0.5/uv.lock +0 -660
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/.github/workflows/pypi-release.yml +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/.gitignore +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/.semaphore/deploy.yml +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/.semaphore/semaphore.yml +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/.semaphore/update-task-definition.sh +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/CLAUDE.md +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/Dockerfile +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/LICENSE +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/README.md +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/rootly-mcp-server-demo.gif +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/rootly_fastmcp_server.py +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/rootly_openapi_loader.py +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/src/rootly_mcp_server/__init__.py +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/src/rootly_mcp_server/__main__.py +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/src/rootly_mcp_server/client.py +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/src/rootly_mcp_server/data/__init__.py +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/src/rootly_mcp_server/server.py +0 -0
- {rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/src/rootly_mcp_server/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: rootly-mcp-server
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.6
|
|
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
|
|
@@ -0,0 +1,285 @@
|
|
|
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
|
+
from rootly_openapi_loader import load_rootly_openapi_spec
|
|
15
|
+
|
|
16
|
+
# Configure logging
|
|
17
|
+
logging.basicConfig(level=logging.INFO)
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
def create_rootly_mcp_server():
|
|
21
|
+
"""Create and configure the Rootly MCP server using RouteMap filtering."""
|
|
22
|
+
|
|
23
|
+
# Get Rootly API token from environment
|
|
24
|
+
ROOTLY_API_TOKEN = os.getenv("ROOTLY_API_TOKEN")
|
|
25
|
+
if not ROOTLY_API_TOKEN:
|
|
26
|
+
raise ValueError("ROOTLY_API_TOKEN environment variable is required")
|
|
27
|
+
|
|
28
|
+
logger.info("Creating authenticated HTTP client...")
|
|
29
|
+
# Create a custom HTTP client wrapper that ensures string responses
|
|
30
|
+
class StringifyingClient:
|
|
31
|
+
def __init__(self, base_url: str, headers: dict, timeout: float):
|
|
32
|
+
self._client = httpx.AsyncClient(
|
|
33
|
+
base_url=base_url,
|
|
34
|
+
headers=headers,
|
|
35
|
+
timeout=timeout
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
async def __aenter__(self):
|
|
39
|
+
await self._client.__aenter__()
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
43
|
+
await self._client.__aexit__(exc_type, exc_val, exc_tb)
|
|
44
|
+
|
|
45
|
+
async def request(self, method: str, url: str, **kwargs):
|
|
46
|
+
"""Override request to return responses that FastMCP can handle."""
|
|
47
|
+
response = await self._client.request(method, url, **kwargs)
|
|
48
|
+
|
|
49
|
+
# Create a response that returns the raw text instead of structured JSON
|
|
50
|
+
class TextResponse:
|
|
51
|
+
def __init__(self, original_response):
|
|
52
|
+
self.status_code = original_response.status_code
|
|
53
|
+
self.headers = original_response.headers
|
|
54
|
+
self.url = original_response.url
|
|
55
|
+
self.request = original_response.request
|
|
56
|
+
self._original = original_response
|
|
57
|
+
|
|
58
|
+
# Pre-compute the text response
|
|
59
|
+
if original_response.status_code == 200:
|
|
60
|
+
try:
|
|
61
|
+
import json
|
|
62
|
+
data = original_response.json()
|
|
63
|
+
self._text_response = json.dumps(data, indent=2)
|
|
64
|
+
except Exception:
|
|
65
|
+
self._text_response = original_response.text or "No content"
|
|
66
|
+
else:
|
|
67
|
+
self._text_response = f"Error: HTTP {original_response.status_code} - {original_response.text}"
|
|
68
|
+
|
|
69
|
+
def json(self):
|
|
70
|
+
"""Return the original JSON data for structured_content."""
|
|
71
|
+
try:
|
|
72
|
+
# Return the original JSON data so FastMCP can handle structured_content
|
|
73
|
+
return self._original.json()
|
|
74
|
+
except Exception:
|
|
75
|
+
# If JSON parsing fails, return a wrapper structure
|
|
76
|
+
return {"result": self._text_response}
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def text(self):
|
|
80
|
+
"""Return the formatted JSON as text."""
|
|
81
|
+
return self._text_response
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def content(self):
|
|
85
|
+
return self._text_response.encode('utf-8')
|
|
86
|
+
|
|
87
|
+
def raise_for_status(self):
|
|
88
|
+
"""Delegate to original response."""
|
|
89
|
+
return self._original.raise_for_status()
|
|
90
|
+
|
|
91
|
+
def __getattr__(self, name):
|
|
92
|
+
"""Delegate any missing attributes to original response."""
|
|
93
|
+
return getattr(self._original, name)
|
|
94
|
+
|
|
95
|
+
return TextResponse(response)
|
|
96
|
+
|
|
97
|
+
def __getattr__(self, name):
|
|
98
|
+
return getattr(self._client, name)
|
|
99
|
+
|
|
100
|
+
# Create authenticated HTTP client with string conversion
|
|
101
|
+
client = StringifyingClient(
|
|
102
|
+
base_url="https://api.rootly.com",
|
|
103
|
+
headers={
|
|
104
|
+
"Authorization": f"Bearer {ROOTLY_API_TOKEN}",
|
|
105
|
+
"Content-Type": "application/vnd.api+json",
|
|
106
|
+
"User-Agent": "Rootly-FastMCP-Server/1.0"
|
|
107
|
+
},
|
|
108
|
+
timeout=30.0
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
logger.info("Loading OpenAPI specification...")
|
|
112
|
+
# Load OpenAPI spec with smart fallback logic
|
|
113
|
+
openapi_spec = load_rootly_openapi_spec()
|
|
114
|
+
logger.info("✅ Successfully loaded OpenAPI specification")
|
|
115
|
+
|
|
116
|
+
logger.info("Fixing OpenAPI spec for FastMCP compatibility...")
|
|
117
|
+
# Fix array types for FastMCP compatibility
|
|
118
|
+
def fix_array_types(obj):
|
|
119
|
+
if isinstance(obj, dict):
|
|
120
|
+
keys_to_process = list(obj.keys())
|
|
121
|
+
for key in keys_to_process:
|
|
122
|
+
value = obj[key]
|
|
123
|
+
if key == 'type' and isinstance(value, list):
|
|
124
|
+
non_null_types = [t for t in value if t != 'null']
|
|
125
|
+
if len(non_null_types) >= 1:
|
|
126
|
+
obj[key] = non_null_types[0]
|
|
127
|
+
obj['nullable'] = True
|
|
128
|
+
else:
|
|
129
|
+
fix_array_types(value)
|
|
130
|
+
elif isinstance(obj, list):
|
|
131
|
+
for item in obj:
|
|
132
|
+
fix_array_types(item)
|
|
133
|
+
|
|
134
|
+
fix_array_types(openapi_spec)
|
|
135
|
+
logger.info("✅ Fixed OpenAPI spec compatibility issues")
|
|
136
|
+
|
|
137
|
+
logger.info("Creating FastMCP server with RouteMap filtering...")
|
|
138
|
+
|
|
139
|
+
# Define custom route maps for filtering specific endpoints
|
|
140
|
+
route_maps = [
|
|
141
|
+
# Core incident management - list endpoints
|
|
142
|
+
RouteMap(
|
|
143
|
+
pattern=r"^/v1/incidents$",
|
|
144
|
+
mcp_type=MCPType.TOOL,
|
|
145
|
+
mcp_tags={"incidents", "core", "list"}
|
|
146
|
+
),
|
|
147
|
+
# Incident detail endpoints
|
|
148
|
+
RouteMap(
|
|
149
|
+
pattern=r"^/v1/incidents/\{.*\}$",
|
|
150
|
+
mcp_type=MCPType.TOOL,
|
|
151
|
+
mcp_tags={"incidents", "detail"}
|
|
152
|
+
),
|
|
153
|
+
# Incident relationships
|
|
154
|
+
RouteMap(
|
|
155
|
+
pattern=r"^/v1/incidents/\{.*\}/.*$",
|
|
156
|
+
mcp_type=MCPType.TOOL,
|
|
157
|
+
mcp_tags={"incidents", "relationships"}
|
|
158
|
+
),
|
|
159
|
+
|
|
160
|
+
# Alert management
|
|
161
|
+
RouteMap(
|
|
162
|
+
pattern=r"^/v1/alerts$",
|
|
163
|
+
mcp_type=MCPType.TOOL,
|
|
164
|
+
mcp_tags={"alerts", "core", "list"}
|
|
165
|
+
),
|
|
166
|
+
RouteMap(
|
|
167
|
+
pattern=r"^/v1/alerts/\{.*\}$",
|
|
168
|
+
mcp_type=MCPType.TOOL,
|
|
169
|
+
mcp_tags={"alerts", "detail"}
|
|
170
|
+
),
|
|
171
|
+
|
|
172
|
+
# Users - both list and detail
|
|
173
|
+
RouteMap(
|
|
174
|
+
pattern=r"^/v1/users$",
|
|
175
|
+
mcp_type=MCPType.TOOL,
|
|
176
|
+
mcp_tags={"users", "list"}
|
|
177
|
+
),
|
|
178
|
+
RouteMap(
|
|
179
|
+
pattern=r"^/v1/users/me$",
|
|
180
|
+
mcp_type=MCPType.TOOL,
|
|
181
|
+
mcp_tags={"users", "current"}
|
|
182
|
+
),
|
|
183
|
+
RouteMap(
|
|
184
|
+
pattern=r"^/v1/users/\{.*\}$",
|
|
185
|
+
mcp_type=MCPType.TOOL,
|
|
186
|
+
mcp_tags={"users", "detail"}
|
|
187
|
+
),
|
|
188
|
+
|
|
189
|
+
# Teams
|
|
190
|
+
RouteMap(
|
|
191
|
+
pattern=r"^/v1/teams$",
|
|
192
|
+
mcp_type=MCPType.TOOL,
|
|
193
|
+
mcp_tags={"teams", "list"}
|
|
194
|
+
),
|
|
195
|
+
RouteMap(
|
|
196
|
+
pattern=r"^/v1/teams/\{.*\}$",
|
|
197
|
+
mcp_type=MCPType.TOOL,
|
|
198
|
+
mcp_tags={"teams", "detail"}
|
|
199
|
+
),
|
|
200
|
+
|
|
201
|
+
# Services
|
|
202
|
+
RouteMap(
|
|
203
|
+
pattern=r"^/v1/services$",
|
|
204
|
+
mcp_type=MCPType.TOOL,
|
|
205
|
+
mcp_tags={"services", "list"}
|
|
206
|
+
),
|
|
207
|
+
RouteMap(
|
|
208
|
+
pattern=r"^/v1/services/\{.*\}$",
|
|
209
|
+
mcp_type=MCPType.TOOL,
|
|
210
|
+
mcp_tags={"services", "detail"}
|
|
211
|
+
),
|
|
212
|
+
|
|
213
|
+
# Configuration entities - list patterns
|
|
214
|
+
RouteMap(
|
|
215
|
+
pattern=r"^/v1/(severities|incident_types|environments)$",
|
|
216
|
+
mcp_type=MCPType.TOOL,
|
|
217
|
+
mcp_tags={"configuration", "list"}
|
|
218
|
+
),
|
|
219
|
+
# Configuration entities - detail patterns
|
|
220
|
+
RouteMap(
|
|
221
|
+
pattern=r"^/v1/(severities|incident_types|environments)/\{.*\}$",
|
|
222
|
+
mcp_type=MCPType.TOOL,
|
|
223
|
+
mcp_tags={"configuration", "detail"}
|
|
224
|
+
),
|
|
225
|
+
|
|
226
|
+
# Exclude everything else
|
|
227
|
+
RouteMap(
|
|
228
|
+
pattern=r".*",
|
|
229
|
+
mcp_type=MCPType.EXCLUDE
|
|
230
|
+
)
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
# Custom response handler to ensure proper MCP output format
|
|
234
|
+
def ensure_mcp_response(route, component):
|
|
235
|
+
"""Ensure all responses work with FastMCP's structured content system."""
|
|
236
|
+
# Set output schema to handle structured JSON data
|
|
237
|
+
component.output_schema = {
|
|
238
|
+
"type": "object",
|
|
239
|
+
"description": "Rootly API response data",
|
|
240
|
+
"additionalProperties": True
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
# Add description
|
|
244
|
+
if hasattr(component, 'description'):
|
|
245
|
+
component.description = f"🔧 {component.description or 'Rootly API endpoint'}"
|
|
246
|
+
|
|
247
|
+
# Create MCP server with custom route maps and response handling
|
|
248
|
+
mcp = FastMCP.from_openapi(
|
|
249
|
+
openapi_spec=openapi_spec,
|
|
250
|
+
client=client,
|
|
251
|
+
name="Rootly API Server (RouteMap Filtered)",
|
|
252
|
+
timeout=30.0,
|
|
253
|
+
tags={"rootly", "incident-management", "evaluation"},
|
|
254
|
+
route_maps=route_maps,
|
|
255
|
+
mcp_component_fn=ensure_mcp_response
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
logger.info(f"✅ Created MCP server with RouteMap filtering successfully")
|
|
259
|
+
logger.info("🚀 Selected Rootly API endpoints are now available as MCP tools")
|
|
260
|
+
|
|
261
|
+
return mcp
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main():
|
|
267
|
+
"""Main entry point."""
|
|
268
|
+
try:
|
|
269
|
+
logger.info("🚀 Starting Rootly FastMCP Server (RouteMap Version)...")
|
|
270
|
+
mcp = create_rootly_mcp_server()
|
|
271
|
+
|
|
272
|
+
logger.info("🌐 Server starting on stdio transport...")
|
|
273
|
+
logger.info("Ready for MCP client connections!")
|
|
274
|
+
|
|
275
|
+
# Run the MCP server
|
|
276
|
+
mcp.run()
|
|
277
|
+
|
|
278
|
+
except KeyboardInterrupt:
|
|
279
|
+
logger.info("🛑 Server stopped by user")
|
|
280
|
+
except Exception as e:
|
|
281
|
+
logger.error(f"❌ Server error: {e}")
|
|
282
|
+
raise
|
|
283
|
+
|
|
284
|
+
if __name__ == "__main__":
|
|
285
|
+
main()
|
{rootly_mcp_server-2.0.5 → rootly_mcp_server-2.0.6}/src/rootly_mcp_server/rootly_openapi_loader.py
RENAMED
|
@@ -18,10 +18,11 @@ def load_rootly_openapi_spec() -> dict:
|
|
|
18
18
|
Load Rootly OpenAPI spec with smart fallback logic.
|
|
19
19
|
|
|
20
20
|
Loading priority:
|
|
21
|
-
1. Check
|
|
22
|
-
2. Check
|
|
23
|
-
3. Check for
|
|
24
|
-
4.
|
|
21
|
+
1. Check for Claude-compatible swagger_claude_fixed.json
|
|
22
|
+
2. Check current directory for rootly_openapi.json
|
|
23
|
+
3. Check parent directories for rootly_openapi.json
|
|
24
|
+
4. Check for swagger.json files
|
|
25
|
+
5. Only as last resort, fetch from URL and cache locally
|
|
25
26
|
|
|
26
27
|
Returns:
|
|
27
28
|
dict: The OpenAPI specification
|
|
@@ -31,7 +32,19 @@ def load_rootly_openapi_spec() -> dict:
|
|
|
31
32
|
"""
|
|
32
33
|
current_dir = Path.cwd()
|
|
33
34
|
|
|
34
|
-
# Check for
|
|
35
|
+
# Priority 1: Check for Claude-compatible swagger file first
|
|
36
|
+
for check_dir in [current_dir] + list(current_dir.parents):
|
|
37
|
+
spec_file = check_dir / "swagger_claude_fixed.json"
|
|
38
|
+
if spec_file.is_file():
|
|
39
|
+
logger.info(f"Found Claude-compatible OpenAPI spec at {spec_file}")
|
|
40
|
+
try:
|
|
41
|
+
with open(spec_file, "r") as f:
|
|
42
|
+
return json.load(f)
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.warning(f"Failed to load {spec_file}: {e}")
|
|
45
|
+
continue
|
|
46
|
+
|
|
47
|
+
# Priority 2: Check for rootly_openapi.json in current directory and parents
|
|
35
48
|
for check_dir in [current_dir] + list(current_dir.parents):
|
|
36
49
|
spec_file = check_dir / "rootly_openapi.json"
|
|
37
50
|
if spec_file.is_file():
|
|
@@ -43,7 +56,7 @@ def load_rootly_openapi_spec() -> dict:
|
|
|
43
56
|
logger.warning(f"Failed to load {spec_file}: {e}")
|
|
44
57
|
continue
|
|
45
58
|
|
|
46
|
-
# Check for swagger.json in current directory and parents
|
|
59
|
+
# Priority 3: Check for swagger.json in current directory and parents
|
|
47
60
|
for check_dir in [current_dir] + list(current_dir.parents):
|
|
48
61
|
spec_file = check_dir / "swagger.json"
|
|
49
62
|
if spec_file.is_file():
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Rootly FastMCP Server (RouteMap Version)
|
|
4
|
+
|
|
5
|
+
Working implementation using FastMCP's RouteMap system with proper response handling.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
from fastmcp import FastMCP
|
|
10
|
+
from fastmcp.server.openapi import RouteMap, MCPType
|
|
11
|
+
import os
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Optional, List
|
|
14
|
+
|
|
15
|
+
# Import the shared OpenAPI loader
|
|
16
|
+
from .rootly_openapi_loader import load_rootly_openapi_spec
|
|
17
|
+
|
|
18
|
+
# Configure logging
|
|
19
|
+
logging.basicConfig(level=logging.INFO)
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
def create_rootly_mcp_server(
|
|
23
|
+
swagger_path: Optional[str] = None,
|
|
24
|
+
name: str = "Rootly API Server (RouteMap Filtered)",
|
|
25
|
+
custom_allowed_paths: Optional[List[str]] = None,
|
|
26
|
+
hosted: bool = False,
|
|
27
|
+
base_url: Optional[str] = None,
|
|
28
|
+
):
|
|
29
|
+
"""Create and configure the Rootly MCP server using RouteMap filtering."""
|
|
30
|
+
|
|
31
|
+
# Get Rootly API token from environment
|
|
32
|
+
ROOTLY_API_TOKEN = os.getenv("ROOTLY_API_TOKEN")
|
|
33
|
+
if not ROOTLY_API_TOKEN:
|
|
34
|
+
raise ValueError("ROOTLY_API_TOKEN environment variable is required")
|
|
35
|
+
|
|
36
|
+
logger.info("Creating authenticated HTTP client...")
|
|
37
|
+
# Create a custom HTTP client wrapper that ensures string responses
|
|
38
|
+
class StringifyingClient:
|
|
39
|
+
def __init__(self, base_url: str, headers: dict, timeout: float):
|
|
40
|
+
self._client = httpx.AsyncClient(
|
|
41
|
+
base_url=base_url,
|
|
42
|
+
headers=headers,
|
|
43
|
+
timeout=timeout
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
async def __aenter__(self):
|
|
47
|
+
await self._client.__aenter__()
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
51
|
+
await self._client.__aexit__(exc_type, exc_val, exc_tb)
|
|
52
|
+
|
|
53
|
+
async def request(self, method: str, url: str, **kwargs):
|
|
54
|
+
"""Override request to return responses that FastMCP can handle."""
|
|
55
|
+
response = await self._client.request(method, url, **kwargs)
|
|
56
|
+
|
|
57
|
+
# Create a response that returns the raw text instead of structured JSON
|
|
58
|
+
class TextResponse:
|
|
59
|
+
def __init__(self, original_response):
|
|
60
|
+
self.status_code = original_response.status_code
|
|
61
|
+
self.headers = original_response.headers
|
|
62
|
+
self.url = original_response.url
|
|
63
|
+
self.request = original_response.request
|
|
64
|
+
self._original = original_response
|
|
65
|
+
|
|
66
|
+
# Pre-compute the text response
|
|
67
|
+
if original_response.status_code == 200:
|
|
68
|
+
try:
|
|
69
|
+
import json
|
|
70
|
+
data = original_response.json()
|
|
71
|
+
self._text_response = json.dumps(data, indent=2)
|
|
72
|
+
except Exception:
|
|
73
|
+
self._text_response = original_response.text or "No content"
|
|
74
|
+
else:
|
|
75
|
+
self._text_response = f"Error: HTTP {original_response.status_code} - {original_response.text}"
|
|
76
|
+
|
|
77
|
+
def json(self):
|
|
78
|
+
"""Return the original JSON data for structured_content."""
|
|
79
|
+
try:
|
|
80
|
+
# Return the original JSON data so FastMCP can handle structured_content
|
|
81
|
+
return self._original.json()
|
|
82
|
+
except Exception:
|
|
83
|
+
# If JSON parsing fails, return a wrapper structure
|
|
84
|
+
return {"result": self._text_response}
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def text(self):
|
|
88
|
+
"""Return the formatted JSON as text."""
|
|
89
|
+
return self._text_response
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def content(self):
|
|
93
|
+
return self._text_response.encode('utf-8')
|
|
94
|
+
|
|
95
|
+
def raise_for_status(self):
|
|
96
|
+
"""Delegate to original response."""
|
|
97
|
+
return self._original.raise_for_status()
|
|
98
|
+
|
|
99
|
+
def __getattr__(self, name):
|
|
100
|
+
"""Delegate any missing attributes to original response."""
|
|
101
|
+
return getattr(self._original, name)
|
|
102
|
+
|
|
103
|
+
return TextResponse(response)
|
|
104
|
+
|
|
105
|
+
def __getattr__(self, name):
|
|
106
|
+
return getattr(self._client, name)
|
|
107
|
+
|
|
108
|
+
# Create authenticated HTTP client with string conversion
|
|
109
|
+
client = StringifyingClient(
|
|
110
|
+
base_url=base_url or "https://api.rootly.com",
|
|
111
|
+
headers={
|
|
112
|
+
"Authorization": f"Bearer {ROOTLY_API_TOKEN}",
|
|
113
|
+
"Content-Type": "application/vnd.api+json",
|
|
114
|
+
"User-Agent": "Rootly-FastMCP-Server/1.0"
|
|
115
|
+
},
|
|
116
|
+
timeout=30.0
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
logger.info("Loading OpenAPI specification...")
|
|
120
|
+
# Load OpenAPI spec with smart fallback logic
|
|
121
|
+
openapi_spec = load_rootly_openapi_spec()
|
|
122
|
+
logger.info("✅ Successfully loaded OpenAPI specification")
|
|
123
|
+
|
|
124
|
+
logger.info("Fixing OpenAPI spec for FastMCP compatibility...")
|
|
125
|
+
# Fix array types for FastMCP compatibility
|
|
126
|
+
def fix_array_types(obj):
|
|
127
|
+
if isinstance(obj, dict):
|
|
128
|
+
keys_to_process = list(obj.keys())
|
|
129
|
+
for key in keys_to_process:
|
|
130
|
+
value = obj[key]
|
|
131
|
+
if key == 'type' and isinstance(value, list):
|
|
132
|
+
non_null_types = [t for t in value if t != 'null']
|
|
133
|
+
if len(non_null_types) >= 1:
|
|
134
|
+
obj[key] = non_null_types[0]
|
|
135
|
+
obj['nullable'] = True
|
|
136
|
+
else:
|
|
137
|
+
fix_array_types(value)
|
|
138
|
+
elif isinstance(obj, list):
|
|
139
|
+
for item in obj:
|
|
140
|
+
fix_array_types(item)
|
|
141
|
+
|
|
142
|
+
fix_array_types(openapi_spec)
|
|
143
|
+
logger.info("✅ Fixed OpenAPI spec compatibility issues")
|
|
144
|
+
|
|
145
|
+
logger.info("Creating FastMCP server with RouteMap filtering...")
|
|
146
|
+
|
|
147
|
+
# Define custom route maps for filtering specific endpoints
|
|
148
|
+
route_maps = [
|
|
149
|
+
# Core incident management - list endpoints
|
|
150
|
+
RouteMap(
|
|
151
|
+
pattern=r"^/v1/incidents$",
|
|
152
|
+
mcp_type=MCPType.TOOL,
|
|
153
|
+
mcp_tags={"incidents", "core", "list"}
|
|
154
|
+
),
|
|
155
|
+
# Incident detail endpoints
|
|
156
|
+
RouteMap(
|
|
157
|
+
pattern=r"^/v1/incidents/\{.*\}$",
|
|
158
|
+
mcp_type=MCPType.TOOL,
|
|
159
|
+
mcp_tags={"incidents", "detail"}
|
|
160
|
+
),
|
|
161
|
+
# Incident relationships
|
|
162
|
+
RouteMap(
|
|
163
|
+
pattern=r"^/v1/incidents/\{.*\}/.*$",
|
|
164
|
+
mcp_type=MCPType.TOOL,
|
|
165
|
+
mcp_tags={"incidents", "relationships"}
|
|
166
|
+
),
|
|
167
|
+
|
|
168
|
+
# Alert management
|
|
169
|
+
RouteMap(
|
|
170
|
+
pattern=r"^/v1/alerts$",
|
|
171
|
+
mcp_type=MCPType.TOOL,
|
|
172
|
+
mcp_tags={"alerts", "core", "list"}
|
|
173
|
+
),
|
|
174
|
+
RouteMap(
|
|
175
|
+
pattern=r"^/v1/alerts/\{.*\}$",
|
|
176
|
+
mcp_type=MCPType.TOOL,
|
|
177
|
+
mcp_tags={"alerts", "detail"}
|
|
178
|
+
),
|
|
179
|
+
|
|
180
|
+
# Users - both list and detail
|
|
181
|
+
RouteMap(
|
|
182
|
+
pattern=r"^/v1/users$",
|
|
183
|
+
mcp_type=MCPType.TOOL,
|
|
184
|
+
mcp_tags={"users", "list"}
|
|
185
|
+
),
|
|
186
|
+
RouteMap(
|
|
187
|
+
pattern=r"^/v1/users/me$",
|
|
188
|
+
mcp_type=MCPType.TOOL,
|
|
189
|
+
mcp_tags={"users", "current"}
|
|
190
|
+
),
|
|
191
|
+
RouteMap(
|
|
192
|
+
pattern=r"^/v1/users/\{.*\}$",
|
|
193
|
+
mcp_type=MCPType.TOOL,
|
|
194
|
+
mcp_tags={"users", "detail"}
|
|
195
|
+
),
|
|
196
|
+
|
|
197
|
+
# Teams
|
|
198
|
+
RouteMap(
|
|
199
|
+
pattern=r"^/v1/teams$",
|
|
200
|
+
mcp_type=MCPType.TOOL,
|
|
201
|
+
mcp_tags={"teams", "list"}
|
|
202
|
+
),
|
|
203
|
+
RouteMap(
|
|
204
|
+
pattern=r"^/v1/teams/\{.*\}$",
|
|
205
|
+
mcp_type=MCPType.TOOL,
|
|
206
|
+
mcp_tags={"teams", "detail"}
|
|
207
|
+
),
|
|
208
|
+
|
|
209
|
+
# Services
|
|
210
|
+
RouteMap(
|
|
211
|
+
pattern=r"^/v1/services$",
|
|
212
|
+
mcp_type=MCPType.TOOL,
|
|
213
|
+
mcp_tags={"services", "list"}
|
|
214
|
+
),
|
|
215
|
+
RouteMap(
|
|
216
|
+
pattern=r"^/v1/services/\{.*\}$",
|
|
217
|
+
mcp_type=MCPType.TOOL,
|
|
218
|
+
mcp_tags={"services", "detail"}
|
|
219
|
+
),
|
|
220
|
+
|
|
221
|
+
# Configuration entities - list patterns
|
|
222
|
+
RouteMap(
|
|
223
|
+
pattern=r"^/v1/(severities|incident_types|environments)$",
|
|
224
|
+
mcp_type=MCPType.TOOL,
|
|
225
|
+
mcp_tags={"configuration", "list"}
|
|
226
|
+
),
|
|
227
|
+
# Configuration entities - detail patterns
|
|
228
|
+
RouteMap(
|
|
229
|
+
pattern=r"^/v1/(severities|incident_types|environments)/\{.*\}$",
|
|
230
|
+
mcp_type=MCPType.TOOL,
|
|
231
|
+
mcp_tags={"configuration", "detail"}
|
|
232
|
+
),
|
|
233
|
+
|
|
234
|
+
# Exclude everything else
|
|
235
|
+
RouteMap(
|
|
236
|
+
pattern=r".*",
|
|
237
|
+
mcp_type=MCPType.EXCLUDE
|
|
238
|
+
)
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
# Custom response handler to ensure proper MCP output format
|
|
242
|
+
def ensure_mcp_response(route, component):
|
|
243
|
+
"""Ensure all responses work with FastMCP's structured content system."""
|
|
244
|
+
# Set output schema to handle structured JSON data
|
|
245
|
+
component.output_schema = {
|
|
246
|
+
"type": "object",
|
|
247
|
+
"description": "Rootly API response data",
|
|
248
|
+
"additionalProperties": True
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# Add description
|
|
252
|
+
if hasattr(component, 'description'):
|
|
253
|
+
component.description = f"🔧 {component.description or 'Rootly API endpoint'}"
|
|
254
|
+
|
|
255
|
+
# Create MCP server with custom route maps and response handling
|
|
256
|
+
mcp = FastMCP.from_openapi(
|
|
257
|
+
openapi_spec=openapi_spec,
|
|
258
|
+
client=client,
|
|
259
|
+
name=name,
|
|
260
|
+
timeout=30.0,
|
|
261
|
+
tags={"rootly", "incident-management", "evaluation"},
|
|
262
|
+
route_maps=route_maps,
|
|
263
|
+
mcp_component_fn=ensure_mcp_response
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
logger.info(f"✅ Created MCP server with RouteMap filtering successfully")
|
|
267
|
+
logger.info("🚀 Selected Rootly API endpoints are now available as MCP tools")
|
|
268
|
+
|
|
269
|
+
return mcp
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main():
|
|
273
|
+
"""Main entry point."""
|
|
274
|
+
try:
|
|
275
|
+
logger.info("🚀 Starting Rootly FastMCP Server (RouteMap Version)...")
|
|
276
|
+
mcp = create_rootly_mcp_server()
|
|
277
|
+
|
|
278
|
+
logger.info("🌐 Server starting on stdio transport...")
|
|
279
|
+
logger.info("Ready for MCP client connections!")
|
|
280
|
+
|
|
281
|
+
# Run the MCP server
|
|
282
|
+
mcp.run()
|
|
283
|
+
|
|
284
|
+
except KeyboardInterrupt:
|
|
285
|
+
logger.info("🛑 Server stopped by user")
|
|
286
|
+
except Exception as e:
|
|
287
|
+
logger.error(f"❌ Server error: {e}")
|
|
288
|
+
raise
|
|
289
|
+
|
|
290
|
+
if __name__ == "__main__":
|
|
291
|
+
main()
|