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.
- rootly_mcp_server/__init__.py +20 -0
- rootly_mcp_server/__main__.py +123 -0
- rootly_mcp_server/client.py +102 -0
- rootly_mcp_server/server.py +340 -0
- rootly_mcp_server/test_client.py +67 -0
- rootly_mcp_server-0.0.1.dist-info/METADATA +108 -0
- rootly_mcp_server-0.0.1.dist-info/RECORD +10 -0
- rootly_mcp_server-0.0.1.dist-info/WHEEL +4 -0
- rootly_mcp_server-0.0.1.dist-info/entry_points.txt +2 -0
- rootly_mcp_server-0.0.1.dist-info/licenses/LICENSE +674 -0
|
@@ -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()
|