mcp-openapi-proxy 0.1.1741340797__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.
- mcp_openapi_proxy/__init__.py +0 -0
- mcp_openapi_proxy/__main__.py +53 -0
- mcp_openapi_proxy/server_fastmcp.py +250 -0
- mcp_openapi_proxy/server_lowlevel.py +283 -0
- mcp_openapi_proxy/utils.py +255 -0
- mcp_openapi_proxy-0.1.1741340797.dist-info/LICENSE +21 -0
- mcp_openapi_proxy-0.1.1741340797.dist-info/METADATA +225 -0
- mcp_openapi_proxy-0.1.1741340797.dist-info/RECORD +11 -0
- mcp_openapi_proxy-0.1.1741340797.dist-info/WHEEL +5 -0
- mcp_openapi_proxy-0.1.1741340797.dist-info/entry_points.txt +2 -0
- mcp_openapi_proxy-0.1.1741340797.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entry point for the mcp_openapi_proxy package.
|
|
3
|
+
|
|
4
|
+
This script determines which server to run based on the presence of
|
|
5
|
+
the OPENAPI_SIMPLE_MODE environment variable:
|
|
6
|
+
- Low-Level Server: For dynamic tool creation from OpenAPI spec.
|
|
7
|
+
- FastMCP Server: For static tool configurations defined in code.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
from mcp_openapi_proxy.utils import setup_logging
|
|
14
|
+
|
|
15
|
+
# Load environment variables from .env if present
|
|
16
|
+
load_dotenv()
|
|
17
|
+
|
|
18
|
+
def main():
|
|
19
|
+
"""
|
|
20
|
+
Main entry point for the mcp_openapi_proxy package.
|
|
21
|
+
|
|
22
|
+
Depending on the OPENAPI_SIMPLE_MODE environment variable, this function
|
|
23
|
+
launches either:
|
|
24
|
+
- Low-Level Server (dynamic tools from OpenAPI spec)
|
|
25
|
+
- FastMCP Server (static tools defined in server_fastmcp.py)
|
|
26
|
+
"""
|
|
27
|
+
# Configure logging
|
|
28
|
+
DEBUG = os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
|
|
29
|
+
logger = setup_logging(debug=DEBUG)
|
|
30
|
+
|
|
31
|
+
logger.debug("Starting mcp_openapi_proxy package entry point.")
|
|
32
|
+
|
|
33
|
+
# Default to Low-Level Mode unless SIMPLE_MODE is explicitly enabled
|
|
34
|
+
OPENAPI_SIMPLE_MODE = os.getenv("OPENAPI_SIMPLE_MODE", "false").lower() in ("true", "1", "yes") # Default to false, enable with "true" etc.
|
|
35
|
+
if OPENAPI_SIMPLE_MODE:
|
|
36
|
+
logger.debug("OPENAPI_SIMPLE_MODE is enabled. Launching FastMCP Server.")
|
|
37
|
+
from mcp_openapi_proxy.server_fastmcp import run_simple_server
|
|
38
|
+
selected_server = run_simple_server
|
|
39
|
+
else:
|
|
40
|
+
logger.debug("OPENAPI_SIMPLE_MODE is disabled. Launching Low-Level Server.")
|
|
41
|
+
from mcp_openapi_proxy.server_lowlevel import run_server
|
|
42
|
+
selected_server = run_server
|
|
43
|
+
|
|
44
|
+
# Run the selected server
|
|
45
|
+
try:
|
|
46
|
+
selected_server()
|
|
47
|
+
except Exception as e:
|
|
48
|
+
logger.critical("Unhandled exception occurred while running the server.", exc_info=True)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Provides the FastMCP server logic for mcp-openapi-proxy.
|
|
3
|
+
|
|
4
|
+
This server exposes a pre-defined set of functions based on an OpenAPI specification.
|
|
5
|
+
Configuration is controlled via environment variables:
|
|
6
|
+
|
|
7
|
+
- OPENAPI_SPEC_URL_<hash>: Unique URL per test, falls back to OPENAPI_SPEC_URL.
|
|
8
|
+
- TOOL_WHITELIST: Comma-separated list of allowed endpoint paths.
|
|
9
|
+
- SERVER_URL_OVERRIDE: (Optional) Overrides the base URL from the OpenAPI spec.
|
|
10
|
+
- API_AUTH_BEARER: (Optional) Token for endpoints requiring authentication.
|
|
11
|
+
- API_AUTH_TYPE_OVERRIDE: (Optional) 'Bearer' or 'Api-Key'.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import json
|
|
17
|
+
import requests
|
|
18
|
+
import logging
|
|
19
|
+
|
|
20
|
+
logging.basicConfig(
|
|
21
|
+
level=logging.DEBUG,
|
|
22
|
+
format='%(asctime)s %(levelname)s %(message)s',
|
|
23
|
+
datefmt='%Y-%m-%d %H:%M:%S'
|
|
24
|
+
)
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
logger.debug(f"Server CWD: {os.getcwd()}")
|
|
28
|
+
logger.debug(f"Server sys.path: {sys.path}")
|
|
29
|
+
|
|
30
|
+
from mcp.server.fastmcp import FastMCP
|
|
31
|
+
from mcp_openapi_proxy.utils import setup_logging, is_tool_whitelisted, get_auth_type
|
|
32
|
+
|
|
33
|
+
mcp = FastMCP("OpenApiProxy-Fast")
|
|
34
|
+
|
|
35
|
+
def fetch_openapi_spec(spec_url: str) -> dict:
|
|
36
|
+
logger.debug(f"Starting fetch_openapi_spec with spec_url: {spec_url}")
|
|
37
|
+
if not spec_url:
|
|
38
|
+
logger.error("spec_url is empty or None")
|
|
39
|
+
return None
|
|
40
|
+
logger.debug(f"Current CWD in fetch_openapi_spec: {os.getcwd()}")
|
|
41
|
+
try:
|
|
42
|
+
if spec_url.startswith("file://"):
|
|
43
|
+
spec_path = os.path.abspath(spec_url.replace("file://", ""))
|
|
44
|
+
logger.debug(f"Spec path after file:// strip and abspath: {spec_path}")
|
|
45
|
+
if not os.path.exists(spec_path):
|
|
46
|
+
logger.error(f"File does not exist at: {spec_path}")
|
|
47
|
+
return None
|
|
48
|
+
logger.debug(f"File exists at: {spec_path}")
|
|
49
|
+
with open(spec_path, 'r') as f:
|
|
50
|
+
spec = json.load(f)
|
|
51
|
+
logger.debug(f"Successfully read local OpenAPI spec from {spec_path}")
|
|
52
|
+
else:
|
|
53
|
+
logger.debug(f"Fetching remote spec from {spec_url}")
|
|
54
|
+
response = requests.get(spec_url)
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
spec = json.loads(response.text)
|
|
57
|
+
logger.debug(f"Successfully fetched OpenAPI spec from {spec_url}")
|
|
58
|
+
if not spec:
|
|
59
|
+
logger.error(f"Spec is empty after loading from {spec_url}")
|
|
60
|
+
return None
|
|
61
|
+
logger.debug(f"Spec keys loaded: {list(spec.keys())}")
|
|
62
|
+
logger.debug(f"Spec paths: {list(spec.get('paths', {}).keys())}")
|
|
63
|
+
return spec
|
|
64
|
+
except Exception as e:
|
|
65
|
+
logger.error(f"Failed to fetch or parse spec from {spec_url}: {e}", exc_info=True)
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
@mcp.tool()
|
|
69
|
+
def list_functions(*, env_key: str = "OPENAPI_SPEC_URL") -> str:
|
|
70
|
+
logger.debug("Executing list_functions tool.")
|
|
71
|
+
spec_url = os.environ.get(env_key, os.environ.get("OPENAPI_SPEC_URL"))
|
|
72
|
+
whitelist = os.getenv('TOOL_WHITELIST')
|
|
73
|
+
logger.debug(f"Using spec_url: {spec_url}")
|
|
74
|
+
logger.debug(f"TOOL_WHITELIST value: {whitelist}")
|
|
75
|
+
if not spec_url:
|
|
76
|
+
logger.error("No OPENAPI_SPEC_URL or custom env_key configured.")
|
|
77
|
+
return json.dumps([])
|
|
78
|
+
logger.debug(f"Calling fetch_openapi_spec with: {spec_url}")
|
|
79
|
+
spec = fetch_openapi_spec(spec_url)
|
|
80
|
+
if spec is None:
|
|
81
|
+
logger.error("Spec is None after fetch_openapi_spec")
|
|
82
|
+
return json.dumps([])
|
|
83
|
+
logger.debug(f"Raw spec loaded: {json.dumps(spec, indent=2)}")
|
|
84
|
+
logger.debug(f"Spec loaded with keys: {list(spec.keys())}")
|
|
85
|
+
paths = spec.get("paths", {})
|
|
86
|
+
logger.debug(f"Paths extracted from spec: {list(paths.keys())}")
|
|
87
|
+
if not paths:
|
|
88
|
+
logger.debug("No paths found in spec.")
|
|
89
|
+
return json.dumps([])
|
|
90
|
+
functions = []
|
|
91
|
+
for path, path_item in paths.items():
|
|
92
|
+
logger.debug(f"Processing path: {path}")
|
|
93
|
+
if not path_item:
|
|
94
|
+
logger.debug(f"Path item is empty for {path}")
|
|
95
|
+
continue
|
|
96
|
+
whitelist_check = is_tool_whitelisted(path)
|
|
97
|
+
logger.debug(f"Whitelist check for {path}: {whitelist_check}")
|
|
98
|
+
if not whitelist_check:
|
|
99
|
+
logger.debug(f"Path {path} not in whitelist - skipping.")
|
|
100
|
+
continue
|
|
101
|
+
for method, operation in path_item.items():
|
|
102
|
+
logger.debug(f"Found method: {method} for path: {path}")
|
|
103
|
+
if not method:
|
|
104
|
+
logger.debug(f"Method is empty for {path}")
|
|
105
|
+
continue
|
|
106
|
+
if method.lower() not in ["get", "post", "put", "delete", "patch"]:
|
|
107
|
+
logger.debug(f"Method {method} not supported for {path} - skipping.")
|
|
108
|
+
continue
|
|
109
|
+
function_name = f"{method.upper()} {path}"
|
|
110
|
+
function_description = operation.get("summary", operation.get("description", "No description provided."))
|
|
111
|
+
logger.debug(f"Registering function: {function_name} - {function_description}")
|
|
112
|
+
functions.append({
|
|
113
|
+
"name": function_name,
|
|
114
|
+
"description": function_description,
|
|
115
|
+
"path": path,
|
|
116
|
+
"method": method.upper(),
|
|
117
|
+
"operationId": operation.get("operationId")
|
|
118
|
+
})
|
|
119
|
+
logger.info(f"Discovered {len(functions)} functions from the OpenAPI specification.")
|
|
120
|
+
logger.debug(f"Functions list: {functions}")
|
|
121
|
+
return json.dumps(functions, indent=2)
|
|
122
|
+
|
|
123
|
+
@mcp.tool()
|
|
124
|
+
def call_function(*, function_name: str, parameters: dict = None, env_key: str = "OPENAPI_SPEC_URL") -> str:
|
|
125
|
+
logger.debug(f"call_function invoked with function_name='{function_name}' and parameters={parameters}")
|
|
126
|
+
if not function_name:
|
|
127
|
+
logger.error("function_name is empty or None")
|
|
128
|
+
return json.dumps({"error": "function_name is required"})
|
|
129
|
+
spec_url = os.environ.get(env_key, os.environ.get("OPENAPI_SPEC_URL"))
|
|
130
|
+
if not spec_url:
|
|
131
|
+
logger.error("No OPENAPI_SPEC_URL or custom env_key configured.")
|
|
132
|
+
return json.dumps({"error": "OPENAPI_SPEC_URL is not configured"})
|
|
133
|
+
logger.debug(f"Fetching spec for call_function: {spec_url}")
|
|
134
|
+
spec = fetch_openapi_spec(spec_url)
|
|
135
|
+
if spec is None:
|
|
136
|
+
logger.error("Spec is None for call_function")
|
|
137
|
+
return json.dumps({"error": "Failed to fetch or parse the OpenAPI specification"})
|
|
138
|
+
logger.debug(f"Spec keys for call_function: {list(spec.keys())}")
|
|
139
|
+
API_AUTH_TYPE = os.getenv("API_AUTH_TYPE_OVERRIDE", get_auth_type(spec))
|
|
140
|
+
logger.debug(f"API_AUTH_TYPE set to: {API_AUTH_TYPE}")
|
|
141
|
+
function_def = None
|
|
142
|
+
paths = spec.get("paths", {})
|
|
143
|
+
logger.debug(f"Paths for function lookup: {list(paths.keys())}")
|
|
144
|
+
for path, path_item in paths.items():
|
|
145
|
+
logger.debug(f"Checking path: {path}")
|
|
146
|
+
for method, operation in path_item.items():
|
|
147
|
+
logger.debug(f"Checking method: {method} for {path}")
|
|
148
|
+
if method.lower() not in ["get", "post", "put", "delete", "patch"]:
|
|
149
|
+
logger.debug(f"Skipping unsupported method: {method}")
|
|
150
|
+
continue
|
|
151
|
+
current_function_name = f"{method.upper()} {path}"
|
|
152
|
+
logger.debug(f"Comparing {current_function_name} with {function_name}")
|
|
153
|
+
if current_function_name == function_name:
|
|
154
|
+
function_def = {
|
|
155
|
+
"path": path,
|
|
156
|
+
"method": method.upper(),
|
|
157
|
+
"operation": operation
|
|
158
|
+
}
|
|
159
|
+
logger.debug(f"Matched function definition for '{function_name}': {function_def}")
|
|
160
|
+
break
|
|
161
|
+
if function_def:
|
|
162
|
+
break
|
|
163
|
+
if not function_def:
|
|
164
|
+
logger.error(f"Function '{function_name}' not found in the OpenAPI specification.")
|
|
165
|
+
return json.dumps({"error": f"Function '{function_name}' not found"})
|
|
166
|
+
logger.debug(f"Function def found: {function_def}")
|
|
167
|
+
if not is_tool_whitelisted(function_def["path"]):
|
|
168
|
+
logger.error(f"Access to function '{function_name}' is not allowed.")
|
|
169
|
+
return json.dumps({"error": f"Access to function '{function_name}' is not allowed"})
|
|
170
|
+
SERVER_URL_OVERRIDE = os.getenv("SERVER_URL_OVERRIDE")
|
|
171
|
+
if SERVER_URL_OVERRIDE:
|
|
172
|
+
base_url = SERVER_URL_OVERRIDE.strip()
|
|
173
|
+
logger.debug(f"Using SERVER_URL_OVERRIDE: {base_url}")
|
|
174
|
+
else:
|
|
175
|
+
servers = spec.get("servers", [])
|
|
176
|
+
logger.debug(f"Servers from spec: {servers}")
|
|
177
|
+
if servers:
|
|
178
|
+
base_url = servers[0].get("url", "")
|
|
179
|
+
logger.debug(f"Using base_url from OpenAPI 3.0 servers: {base_url}")
|
|
180
|
+
else:
|
|
181
|
+
schemes = spec.get("schemes", ["https"])
|
|
182
|
+
host = spec.get("host", "example.com")
|
|
183
|
+
base_url = f"{schemes[0]}://{host}"
|
|
184
|
+
logger.debug(f"Using Swagger 2.0 fallback base_url: {base_url}")
|
|
185
|
+
if not base_url:
|
|
186
|
+
logger.warning("No valid base URL found in spec or override; using empty string.")
|
|
187
|
+
base_url = ""
|
|
188
|
+
base_url = base_url.rstrip("/")
|
|
189
|
+
logger.debug(f"Normalized base_url: {base_url}")
|
|
190
|
+
base_path = spec.get("basePath", "")
|
|
191
|
+
if base_path:
|
|
192
|
+
base_path = "/" + base_path.strip("/")
|
|
193
|
+
logger.debug(f"Normalized base_path: {base_path}")
|
|
194
|
+
if base_path not in base_url:
|
|
195
|
+
base_url = base_url + base_path
|
|
196
|
+
logger.debug(f"Added base_path to URL: {base_url}")
|
|
197
|
+
path = function_def["path"]
|
|
198
|
+
path = "/" + path.lstrip("/")
|
|
199
|
+
logger.debug(f"Normalized path: {path}")
|
|
200
|
+
api_url = base_url + path
|
|
201
|
+
logger.debug(f"Final API URL: {api_url}")
|
|
202
|
+
request_params = {}
|
|
203
|
+
request_body = None
|
|
204
|
+
headers = {"Content-Type": "application/json"}
|
|
205
|
+
if parameters is None:
|
|
206
|
+
logger.debug("Parameters is None, using empty dict")
|
|
207
|
+
parameters = {}
|
|
208
|
+
logger.debug(f"Parameters received: {parameters}")
|
|
209
|
+
if parameters:
|
|
210
|
+
if function_def["method"] == "GET":
|
|
211
|
+
request_params = parameters
|
|
212
|
+
logger.debug(f"Set request_params for GET: {request_params}")
|
|
213
|
+
else:
|
|
214
|
+
request_body = parameters
|
|
215
|
+
logger.debug(f"Set request_body: {request_body}")
|
|
216
|
+
api_auth = os.getenv("API_AUTH_BEARER")
|
|
217
|
+
if api_auth:
|
|
218
|
+
headers["Authorization"] = f"{API_AUTH_TYPE} {api_auth}"
|
|
219
|
+
logger.debug(f"Added Authorization header with {API_AUTH_TYPE}")
|
|
220
|
+
logger.debug(f"Sending request - Method: {function_def['method']}, URL: {api_url}, Headers: {headers}, Params: {request_params}, Body: {request_body}")
|
|
221
|
+
try:
|
|
222
|
+
response = requests.request(
|
|
223
|
+
method=function_def["method"],
|
|
224
|
+
url=api_url,
|
|
225
|
+
headers=headers,
|
|
226
|
+
params=request_params if function_def["method"] == "GET" else None,
|
|
227
|
+
json=request_body if function_def["method"] != "GET" and request_body else None
|
|
228
|
+
)
|
|
229
|
+
response.raise_for_status()
|
|
230
|
+
logger.debug(f"API response received: {response.text}")
|
|
231
|
+
return response.text
|
|
232
|
+
except requests.exceptions.RequestException as e:
|
|
233
|
+
logger.error(f"API request failed: {e}", exc_info=True)
|
|
234
|
+
return json.dumps({"error": f"API request failed: {e}"})
|
|
235
|
+
|
|
236
|
+
def run_simple_server():
|
|
237
|
+
logger.debug("Starting run_simple_server")
|
|
238
|
+
spec_url = os.environ.get("OPENAPI_SPEC_URL")
|
|
239
|
+
if not spec_url:
|
|
240
|
+
logger.error("OPENAPI_SPEC_URL environment variable is required for FastMCP mode.")
|
|
241
|
+
sys.exit(1)
|
|
242
|
+
try:
|
|
243
|
+
logger.debug("Starting MCP server (FastMCP version)...")
|
|
244
|
+
mcp.run(transport="stdio")
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.error("Unhandled exception in MCP server (FastMCP): %s", e, exc_info=True)
|
|
247
|
+
sys.exit(1)
|
|
248
|
+
|
|
249
|
+
if __name__ == "__main__":
|
|
250
|
+
run_simple_server()
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Low-Level Server for mcp-openapi-proxy.
|
|
3
|
+
|
|
4
|
+
This server dynamically registers functions (tools) based on an OpenAPI specification,
|
|
5
|
+
directly utilizing the spec for tool definitions and invocation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import requests
|
|
13
|
+
from typing import List, Dict, Any
|
|
14
|
+
from mcp import types
|
|
15
|
+
from mcp.server.lowlevel import Server
|
|
16
|
+
from mcp.server.models import InitializationOptions
|
|
17
|
+
from mcp.server.stdio import stdio_server
|
|
18
|
+
from mcp_openapi_proxy.utils import setup_logging, fetch_openapi_spec, normalize_tool_name, is_tool_whitelisted
|
|
19
|
+
|
|
20
|
+
# Configure logging
|
|
21
|
+
DEBUG = os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
|
|
22
|
+
logger = setup_logging(debug=DEBUG)
|
|
23
|
+
|
|
24
|
+
# Global function (tool) list
|
|
25
|
+
tools: List[types.Tool] = []
|
|
26
|
+
openapi_spec_data = None # Store OpenAPI spec globally (or use caching)
|
|
27
|
+
|
|
28
|
+
# Initialize the Low-Level MCP Server
|
|
29
|
+
mcp = Server("OpenApiProxy-LowLevel")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def dispatcher_handler(request: types.CallToolRequest) -> types.ServerResult:
|
|
33
|
+
"""
|
|
34
|
+
Dispatcher handler that routes CallToolRequest to the appropriate function (tool)
|
|
35
|
+
and makes the actual API call, now handling path parameters.
|
|
36
|
+
"""
|
|
37
|
+
global openapi_spec_data
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
function_name = request.params.name
|
|
41
|
+
logger.debug(f"Dispatcher received CallToolRequest for function: {function_name}")
|
|
42
|
+
|
|
43
|
+
tool = next((tool for tool in tools if tool.name == function_name), None)
|
|
44
|
+
if not tool:
|
|
45
|
+
logger.error(f"Unknown function requested: {function_name}")
|
|
46
|
+
return types.ServerResult(
|
|
47
|
+
root=types.CallToolResult(
|
|
48
|
+
content=[types.TextContent(type="text", text="Unknown function requested")]
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
arguments = request.params.arguments
|
|
53
|
+
logger.debug(f"Function arguments: {arguments}")
|
|
54
|
+
|
|
55
|
+
operation_details = lookup_operation_details(function_name, openapi_spec_data)
|
|
56
|
+
if not operation_details:
|
|
57
|
+
return types.ServerResult(
|
|
58
|
+
root=types.CallToolResult(
|
|
59
|
+
content=[types.TextContent(type="text", text=f"Could not find OpenAPI operation for function: {function_name}")]
|
|
60
|
+
)
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
path = operation_details['path']
|
|
64
|
+
method = operation_details['method']
|
|
65
|
+
operation = operation_details['operation']
|
|
66
|
+
|
|
67
|
+
# Construct API request URL
|
|
68
|
+
base_url = openapi_spec_data.get('servers', [{}])[0].get('url', '').rstrip('/')
|
|
69
|
+
api_url = base_url + path
|
|
70
|
+
path_params = {} # Dictionary to hold path parameters
|
|
71
|
+
|
|
72
|
+
# Extract path parameters from arguments and replace in URL
|
|
73
|
+
if arguments and 'parameters' in arguments:
|
|
74
|
+
path_params_in_openapi = [
|
|
75
|
+
param['name'] for param in operation.get('parameters', []) if param['in'] == 'path'
|
|
76
|
+
]
|
|
77
|
+
for param_name in path_params_in_openapi:
|
|
78
|
+
if param_name in arguments['parameters']:
|
|
79
|
+
path_params[param_name] = arguments['parameters'].pop(param_name) # Remove from arguments as it's a path param
|
|
80
|
+
api_url = api_url.replace(f"{{{param_name}}}", str(path_params[param_name])) # Replace placeholder in URL
|
|
81
|
+
|
|
82
|
+
# Prepare remaining parameters as query parameters (after removing path params)
|
|
83
|
+
query_params = {}
|
|
84
|
+
headers = {}
|
|
85
|
+
auth_token = os.getenv("API_AUTH_BEARER")
|
|
86
|
+
if auth_token:
|
|
87
|
+
headers["Authorization"] = "Bearer " + auth_token
|
|
88
|
+
request_body = None
|
|
89
|
+
|
|
90
|
+
if arguments and 'parameters' in arguments: # 'parameters' now only contains query params (after path params removed)
|
|
91
|
+
query_params = arguments['parameters'].copy()
|
|
92
|
+
|
|
93
|
+
logger.debug(f"API Request URL: {api_url}")
|
|
94
|
+
logger.debug(f"Request Method: {method}")
|
|
95
|
+
logger.debug(f"Path Parameters: {path_params}")
|
|
96
|
+
logger.debug(f"Query Parameters: {query_params}")
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
response = requests.request(
|
|
100
|
+
method=method,
|
|
101
|
+
url=api_url,
|
|
102
|
+
params=query_params if query_params else None,
|
|
103
|
+
headers=headers,
|
|
104
|
+
json=request_body if request_body else None
|
|
105
|
+
)
|
|
106
|
+
response.raise_for_status()
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
response_data = response.json()
|
|
110
|
+
except json.JSONDecodeError:
|
|
111
|
+
response_data = response.text
|
|
112
|
+
|
|
113
|
+
return types.ServerResult(
|
|
114
|
+
root=types.CallToolResult(
|
|
115
|
+
content=[types.TextContent(type="text", text=json.dumps({
|
|
116
|
+
"status_code": response.status_code,
|
|
117
|
+
"headers": dict(response.headers),
|
|
118
|
+
"body": response_data
|
|
119
|
+
}, indent=2))]
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
except requests.exceptions.RequestException as e:
|
|
124
|
+
logger.error(f"API request failed: {e}")
|
|
125
|
+
return types.ServerResult(
|
|
126
|
+
root=types.CallToolResult(
|
|
127
|
+
content=[types.TextContent(type="text", text=json.dumps({
|
|
128
|
+
"error": f"API request failed: {e}"
|
|
129
|
+
}, indent=2))]
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
except Exception as e:
|
|
134
|
+
logger.error(f"Unhandled exception in dispatcher_handler: {e}", exc_info=True)
|
|
135
|
+
return types.ServerResult(
|
|
136
|
+
root=types.CallToolResult(
|
|
137
|
+
content=[types.TextContent(type="text", text=json.dumps({"error": "Internal server error."}, indent=2))]
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def list_tools(request: types.ListToolsRequest) -> types.ServerResult:
|
|
143
|
+
"""
|
|
144
|
+
Handler for ListToolsRequest to list all registered functions (tools).
|
|
145
|
+
"""
|
|
146
|
+
logger.debug("Handling list_tools request.")
|
|
147
|
+
return types.ServerResult(root=types.ListToolsResult(tools=tools))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def register_functions(spec: Dict) -> List[types.Tool]:
|
|
151
|
+
"""
|
|
152
|
+
Register functions (tools) dynamically based on the OpenAPI specification.
|
|
153
|
+
No longer stores metadata in tool.metadata, relies on spec lookup.
|
|
154
|
+
"""
|
|
155
|
+
global tools
|
|
156
|
+
tools = [] # Clear existing functions before re-registration
|
|
157
|
+
|
|
158
|
+
if not spec or 'paths' not in spec:
|
|
159
|
+
logger.warning("No paths found in OpenAPI spec, no functions registered.")
|
|
160
|
+
return tools
|
|
161
|
+
|
|
162
|
+
for path, path_item in spec['paths'].items():
|
|
163
|
+
if not is_tool_whitelisted(path):
|
|
164
|
+
logger.debug(f"Skipping non-whitelisted path: {path}")
|
|
165
|
+
continue
|
|
166
|
+
for method, operation in path_item.items():
|
|
167
|
+
if method.lower() not in ['get', 'post', 'put', 'delete', 'patch']:
|
|
168
|
+
continue # Skip OPTIONS, HEAD etc.
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
# Create a function name from method and path
|
|
172
|
+
function_name = normalize_tool_name(f"{method.upper()} {path}") # Normalize function name
|
|
173
|
+
|
|
174
|
+
description = operation.get('summary', operation.get('description', 'No description available'))
|
|
175
|
+
|
|
176
|
+
# Define a generic input schema - you can expand on this later
|
|
177
|
+
input_schema = {
|
|
178
|
+
"type": "object",
|
|
179
|
+
"properties": {
|
|
180
|
+
"parameters": { # Generic 'parameters' field for now
|
|
181
|
+
"type": "object",
|
|
182
|
+
"description": "Parameters for the API call",
|
|
183
|
+
"additionalProperties": True # Allow any properties for parameters
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
"additionalProperties": False # No other top-level properties allowed
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
tool = types.Tool(
|
|
190
|
+
name=function_name,
|
|
191
|
+
description=description,
|
|
192
|
+
inputSchema=input_schema,
|
|
193
|
+
)
|
|
194
|
+
tools.append(tool)
|
|
195
|
+
logger.debug(f"Registered function: {function_name} ({method.upper()} {path})")
|
|
196
|
+
|
|
197
|
+
except Exception as e:
|
|
198
|
+
logger.error(f"Error registering function for {method.upper()} {path}: {e}")
|
|
199
|
+
|
|
200
|
+
logger.info(f"Registered {len(tools)} functions from OpenAPI spec.")
|
|
201
|
+
return tools
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def lookup_operation_details(function_name: str, spec: Dict) -> Dict or None:
|
|
205
|
+
"""
|
|
206
|
+
Lookup OpenAPI operation details (path, method, operation) based on function name.
|
|
207
|
+
"""
|
|
208
|
+
if not spec or 'paths' not in spec:
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
for path, path_item in spec['paths'].items():
|
|
212
|
+
for method, operation in path_item.items():
|
|
213
|
+
if method.lower() not in ['get', 'post', 'put', 'delete', 'patch']:
|
|
214
|
+
continue
|
|
215
|
+
|
|
216
|
+
current_function_name = normalize_tool_name(f"{method.upper()} {path}")
|
|
217
|
+
if current_function_name == function_name:
|
|
218
|
+
return {
|
|
219
|
+
"path": path,
|
|
220
|
+
"method": method.upper(),
|
|
221
|
+
"operation": operation
|
|
222
|
+
}
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
async def start_server():
|
|
227
|
+
"""
|
|
228
|
+
Start the Low-Level MCP server.
|
|
229
|
+
"""
|
|
230
|
+
logger.debug("Starting Low-Level MCP server...")
|
|
231
|
+
try:
|
|
232
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
233
|
+
await mcp.run(
|
|
234
|
+
read_stream,
|
|
235
|
+
write_stream,
|
|
236
|
+
initialization_options=InitializationOptions(
|
|
237
|
+
server_name="AnyOpenAPIMCP-LowLevel",
|
|
238
|
+
server_version="0.1.0",
|
|
239
|
+
capabilities=types.ServerCapabilities(),
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
except Exception as e:
|
|
243
|
+
logger.critical(f"Unhandled exception in MCP server: {e}", e)
|
|
244
|
+
sys.exit(1)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def run_server():
|
|
248
|
+
"""
|
|
249
|
+
Run the Low-Level Any OpenAPI server.
|
|
250
|
+
Fetches OpenAPI spec, registers functions, and makes it available globally.
|
|
251
|
+
"""
|
|
252
|
+
global openapi_spec_data # To store it globally
|
|
253
|
+
|
|
254
|
+
try:
|
|
255
|
+
openapi_url = os.getenv('OPENAPI_SPEC_URL', 'https://raw.githubusercontent.com/seriousme/fastify-openapi-glue/refs/heads/master/examples/petstore/petstore-openapi.v3.json') # Example URL
|
|
256
|
+
openapi_spec_data = fetch_openapi_spec(openapi_url) # Fetch and store globally
|
|
257
|
+
if not openapi_spec_data:
|
|
258
|
+
raise ValueError("Failed to fetch or parse OpenAPI specification.")
|
|
259
|
+
logger.info("OpenAPI specification fetched successfully.")
|
|
260
|
+
|
|
261
|
+
register_functions(openapi_spec_data) # Register functions after fetching spec
|
|
262
|
+
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.critical(f"Failed to start server: {e}")
|
|
265
|
+
sys.exit(1)
|
|
266
|
+
|
|
267
|
+
mcp.request_handlers[types.ListToolsRequest] = list_tools
|
|
268
|
+
logger.debug("Registered list_tools handler.")
|
|
269
|
+
|
|
270
|
+
mcp.request_handlers[types.CallToolRequest] = dispatcher_handler
|
|
271
|
+
logger.debug("Registered dispatcher_handler for CallToolRequest.")
|
|
272
|
+
|
|
273
|
+
try:
|
|
274
|
+
asyncio.run(start_server())
|
|
275
|
+
except KeyboardInterrupt:
|
|
276
|
+
logger.debug("MCP server shutdown initiated by user.")
|
|
277
|
+
except Exception as e:
|
|
278
|
+
logger.critical("Failed to start MCP server: %s", e)
|
|
279
|
+
sys.exit(1)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
run_server()
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for mcp_openapi_proxy, including logging setup,
|
|
3
|
+
OpenAPI fetching, name normalization, and whitelist filtering for tools.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import logging
|
|
9
|
+
import requests
|
|
10
|
+
import re
|
|
11
|
+
import json
|
|
12
|
+
import yaml
|
|
13
|
+
from dotenv import load_dotenv
|
|
14
|
+
|
|
15
|
+
# Load environment variables from .env if present
|
|
16
|
+
load_dotenv()
|
|
17
|
+
|
|
18
|
+
OPENAPI_SPEC_URL = os.getenv("OPENAPI_SPEC_URL")
|
|
19
|
+
|
|
20
|
+
def setup_logging(debug: bool = False, log_dir: str = None, log_file: str = "debug-mcp-any-openapi.log") -> logging.Logger:
|
|
21
|
+
"""
|
|
22
|
+
Sets up logging for the application, including outputting CRITICAL and ERROR logs to stdout.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
debug (bool): If True, sets log level to DEBUG; otherwise, INFO.
|
|
26
|
+
log_dir (str): Directory where log files will be stored. Ignored if OPENAPI_LOGFILE_PATH is set.
|
|
27
|
+
log_file (str): Name of the log file. Ignored if OPENAPI_LOGFILE_PATH is set.
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
logging.Logger: Configured logger instance.
|
|
31
|
+
"""
|
|
32
|
+
log_path = os.getenv("OPENAPI_LOGFILE_PATH")
|
|
33
|
+
if not log_path:
|
|
34
|
+
if log_dir is None:
|
|
35
|
+
log_dir = os.path.join(os.path.expanduser("~"), "mcp_logs")
|
|
36
|
+
try:
|
|
37
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
38
|
+
log_path = os.path.join(log_dir, log_file)
|
|
39
|
+
except PermissionError as e:
|
|
40
|
+
log_path = None
|
|
41
|
+
print(f"[ERROR] Failed to create log directory: {e}", file=sys.stderr)
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
logger.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
44
|
+
logger.propagate = False
|
|
45
|
+
for handler in logger.handlers[:]:
|
|
46
|
+
logger.removeHandler(handler)
|
|
47
|
+
handlers = []
|
|
48
|
+
if log_path:
|
|
49
|
+
try:
|
|
50
|
+
file_handler = logging.FileHandler(log_path, mode="a")
|
|
51
|
+
file_handler.setLevel(logging.DEBUG if debug else logging.INFO)
|
|
52
|
+
formatter = logging.Formatter("[%(levelname)s] %(asctime)s - %(message)s")
|
|
53
|
+
file_handler.setFormatter(formatter)
|
|
54
|
+
handlers.append(file_handler)
|
|
55
|
+
except Exception as e:
|
|
56
|
+
print(f"[ERROR] Failed to create log file handler: {e}", file=sys.stderr)
|
|
57
|
+
try:
|
|
58
|
+
stdout_handler = logging.StreamHandler(sys.stdout)
|
|
59
|
+
stdout_handler.setLevel(logging.ERROR)
|
|
60
|
+
formatter = logging.Formatter("[%(levelname)s] %(message)s")
|
|
61
|
+
stdout_handler.setFormatter(formatter)
|
|
62
|
+
handlers.append(stdout_handler)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
print(f"[ERROR] Failed to create stdout log handler: {e}", file=sys.stderr)
|
|
65
|
+
for handler in handlers:
|
|
66
|
+
logger.addHandler(handler)
|
|
67
|
+
if log_path:
|
|
68
|
+
logger.debug(f"Logging initialized. Writing logs to {log_path}")
|
|
69
|
+
else:
|
|
70
|
+
logger.debug("Logging initialized. Logs will only appear in stdout.")
|
|
71
|
+
return logger
|
|
72
|
+
|
|
73
|
+
DEBUG = os.getenv("DEBUG", "").lower() in ("true", "1", "yes")
|
|
74
|
+
logger = setup_logging(debug=DEBUG)
|
|
75
|
+
|
|
76
|
+
logger.debug(f"OpenAPI Spec URL: {OPENAPI_SPEC_URL}")
|
|
77
|
+
logger.debug("utils.py initialized")
|
|
78
|
+
|
|
79
|
+
def redact_api_key(key: str) -> str:
|
|
80
|
+
"""
|
|
81
|
+
Redacts an API key for secure logging.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
key (str): The API key or secret.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
str: The redacted API key, or '<not set>' if key is invalid.
|
|
88
|
+
"""
|
|
89
|
+
if not key or len(key) <= 4:
|
|
90
|
+
return "<not set>"
|
|
91
|
+
return f"{key[:2]}{'*' * (len(key) - 4)}{key[-2:]}"
|
|
92
|
+
|
|
93
|
+
def normalize_tool_name(name: str) -> str:
|
|
94
|
+
"""
|
|
95
|
+
Normalizes tool names by converting to lowercase and replacing non-alphanumeric characters with underscores.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
name (str): The original tool name.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
str: A normalized tool name. Returns 'unknown_tool' if input is invalid.
|
|
102
|
+
"""
|
|
103
|
+
logger = logging.getLogger(__name__)
|
|
104
|
+
if not name or not isinstance(name, str):
|
|
105
|
+
logger.warning(f"Invalid tool name input: {name}. Using default 'unknown_tool'.")
|
|
106
|
+
return "unknown_tool"
|
|
107
|
+
normalized = re.sub(r"[^a-zA-Z0-9_]", "_", name).lower()
|
|
108
|
+
normalized = re.sub(r"_+", "_", normalized)
|
|
109
|
+
normalized = normalized.strip('_')
|
|
110
|
+
logger.debug(f"Normalized tool name from '{name}' to '{normalized}'")
|
|
111
|
+
return normalized or "unknown_tool"
|
|
112
|
+
|
|
113
|
+
def get_tool_prefix() -> str:
|
|
114
|
+
"""
|
|
115
|
+
Obtains the tool name prefix from the environment variable, ensuring it ends with an underscore.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
str: The tool name prefix.
|
|
119
|
+
"""
|
|
120
|
+
prefix = os.getenv("TOOL_NAME_PREFIX", "")
|
|
121
|
+
if prefix and not prefix.endswith("_"):
|
|
122
|
+
prefix += "_"
|
|
123
|
+
return prefix
|
|
124
|
+
|
|
125
|
+
def is_tool_whitelisted(endpoint: str) -> bool:
|
|
126
|
+
"""
|
|
127
|
+
Checks if an endpoint is in the TOOL_WHITELIST, supporting exact matches, prefixes, and path parameters.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
endpoint (str): The API endpoint path to check.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
bool: True if whitelisted or no whitelist set, False otherwise.
|
|
134
|
+
"""
|
|
135
|
+
whitelist = os.getenv("TOOL_WHITELIST", "")
|
|
136
|
+
logger.debug(f"Checking whitelist - endpoint: {endpoint}, TOOL_WHITELIST: {whitelist}")
|
|
137
|
+
if not whitelist:
|
|
138
|
+
logger.debug("No TOOL_WHITELIST set, allowing all endpoints.")
|
|
139
|
+
return True
|
|
140
|
+
|
|
141
|
+
whitelist_items = [item.strip() for item in whitelist.split(",") if item.strip()]
|
|
142
|
+
|
|
143
|
+
# Direct match
|
|
144
|
+
if endpoint in whitelist_items:
|
|
145
|
+
logger.debug(f"Direct match found for {endpoint} in whitelist")
|
|
146
|
+
return True
|
|
147
|
+
|
|
148
|
+
# Prefix match (e.g., /tasks matches /tasks/123)
|
|
149
|
+
for item in whitelist_items:
|
|
150
|
+
if not '{' in item and endpoint.startswith(item):
|
|
151
|
+
logger.debug(f"Prefix match found: {item} starts {endpoint}")
|
|
152
|
+
return True
|
|
153
|
+
|
|
154
|
+
# Path parameter match (e.g., /sessions/{sessionId} matches /sessions/abc123 or /sessions/abc123/items)
|
|
155
|
+
for item in whitelist_items:
|
|
156
|
+
if '{' in item and '}' in item:
|
|
157
|
+
pattern = re.escape(item)
|
|
158
|
+
pattern = pattern.replace(r"\{", "{").replace(r"\}", "}")
|
|
159
|
+
pattern = re.sub(r"\{[^}]+\}", r"[^/]+", pattern)
|
|
160
|
+
pattern = f"^{pattern}(/.*)?$" # Allow optional trailing segments
|
|
161
|
+
if re.match(pattern, endpoint):
|
|
162
|
+
logger.debug(f"Pattern match found for {endpoint} using {item}")
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
logger.debug(f"No whitelist match found for {endpoint}")
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
def fetch_openapi_spec(spec_url: str) -> dict:
|
|
169
|
+
"""
|
|
170
|
+
Fetches and parses an OpenAPI specification from a URL or local file, supporting JSON and YAML.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
spec_url (str): The URL or file path (e.g., file:///path/to/spec.json) of the OpenAPI spec.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
dict: The parsed OpenAPI specification, or None if an error occurs.
|
|
177
|
+
"""
|
|
178
|
+
logger = logging.getLogger(__name__)
|
|
179
|
+
try:
|
|
180
|
+
if spec_url.startswith("file://"):
|
|
181
|
+
spec_path = spec_url.replace("file://", "")
|
|
182
|
+
with open(spec_path, 'r') as f:
|
|
183
|
+
content = f.read()
|
|
184
|
+
logger.debug(f"Read local OpenAPI spec from {spec_path}")
|
|
185
|
+
else:
|
|
186
|
+
response = requests.get(spec_url)
|
|
187
|
+
response.raise_for_status()
|
|
188
|
+
content = response.text
|
|
189
|
+
logger.debug(f"Fetched OpenAPI spec from {spec_url}")
|
|
190
|
+
|
|
191
|
+
if spec_url.endswith(('.yaml', '.yml')):
|
|
192
|
+
spec = yaml.safe_load(content)
|
|
193
|
+
logger.debug(f"Parsed YAML OpenAPI spec from {spec_url}")
|
|
194
|
+
else:
|
|
195
|
+
spec = json.loads(content)
|
|
196
|
+
logger.debug(f"Parsed JSON OpenAPI spec from {spec_url}")
|
|
197
|
+
return spec
|
|
198
|
+
except requests.exceptions.RequestException as e:
|
|
199
|
+
logger.error(f"Error fetching OpenAPI spec from {spec_url}: {e}")
|
|
200
|
+
return None
|
|
201
|
+
except (json.JSONDecodeError, yaml.YAMLError) as e:
|
|
202
|
+
logger.error(f"Error parsing OpenAPI spec from {spec_url}: {e}")
|
|
203
|
+
return None
|
|
204
|
+
except FileNotFoundError as e:
|
|
205
|
+
logger.error(f"Local file not found for OpenAPI spec at {spec_url}: {e}")
|
|
206
|
+
return None
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.error(f"Unexpected error with OpenAPI spec from {spec_url}: {e}")
|
|
209
|
+
return None
|
|
210
|
+
|
|
211
|
+
def get_auth_type(spec: dict) -> str:
|
|
212
|
+
"""
|
|
213
|
+
Determines the authentication type from the OpenAPI spec's securityDefinitions.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
spec (dict): The OpenAPI specification.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
str: 'Api-Key' if apiKey auth is defined in Authorization header, 'Bearer' otherwise.
|
|
220
|
+
"""
|
|
221
|
+
security_defs = spec.get("securityDefinitions", {})
|
|
222
|
+
for name, definition in security_defs.items():
|
|
223
|
+
if definition.get("type") == "apiKey" and definition.get("in") == "header" and definition.get("name") == "Authorization":
|
|
224
|
+
logger.debug(f"Detected ApiKeyAuth in spec: {name}")
|
|
225
|
+
return "Api-Key"
|
|
226
|
+
logger.debug("No ApiKeyAuth found in spec, defaulting to Bearer")
|
|
227
|
+
return "Bearer"
|
|
228
|
+
|
|
229
|
+
def map_schema_to_tools(schema: dict) -> list:
|
|
230
|
+
"""
|
|
231
|
+
Maps a given schema to a list of MCP tools.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
schema (dict): The schema containing a list of classes.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
list: A list of tool objects configured from the schema.
|
|
238
|
+
"""
|
|
239
|
+
from mcp import types
|
|
240
|
+
tools = []
|
|
241
|
+
classes = schema.get("classes", [])
|
|
242
|
+
for entry in classes:
|
|
243
|
+
cls = entry.get("class", "")
|
|
244
|
+
if not cls:
|
|
245
|
+
continue
|
|
246
|
+
tool_name = normalize_tool_name(cls)
|
|
247
|
+
prefix = os.getenv("TOOL_NAME_PREFIX", "")
|
|
248
|
+
if prefix:
|
|
249
|
+
if not prefix.endswith("_"):
|
|
250
|
+
prefix += "_"
|
|
251
|
+
tool_name = prefix + tool_name
|
|
252
|
+
description = f"Tool for class {cls}: " + json.dumps(entry)
|
|
253
|
+
tool = types.Tool(name=tool_name, description=description, inputSchema={"type": "object"})
|
|
254
|
+
tools.append(tool)
|
|
255
|
+
return tools
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 mhand
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
Metadata-Version: 2.2
|
|
2
|
+
Name: mcp-openapi-proxy
|
|
3
|
+
Version: 0.1.1741340797
|
|
4
|
+
Summary: MCP server for exposing OpenAPI specifications as MCP tools.
|
|
5
|
+
Author-email: Matthew Hand <matthewhandau@gmail.com>
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: mcp[cli]>=1.2.0
|
|
9
|
+
Requires-Dist: python-dotenv>=1.0.1
|
|
10
|
+
Requires-Dist: requests>=2.25.0
|
|
11
|
+
Requires-Dist: fastapi>=0.100.0
|
|
12
|
+
Requires-Dist: pydantic>=2.0
|
|
13
|
+
Requires-Dist: prance>=23.6.21.0
|
|
14
|
+
Requires-Dist: openapi-spec-validator>=0.7.1
|
|
15
|
+
|
|
16
|
+
# mcp-openapi-proxy
|
|
17
|
+
|
|
18
|
+
**mcp-openapi-proxy** is a Python package implementing a Model Context Protocol (MCP) server that dynamically exposes REST APIs defined by OpenAPI specifications as MCP tools. This allows you to easily integrate any OpenAPI-described API into MCP-based workflows.
|
|
19
|
+
|
|
20
|
+
## Overview
|
|
21
|
+
|
|
22
|
+
The package supports two operation modes:
|
|
23
|
+
|
|
24
|
+
- **Low-Level Mode (Default):** Dynamically registers tools for all API endpoints defined in an OpenAPI specification eg. /chat/completions becomes chat_completions().
|
|
25
|
+
- **FastMCP Mode (Simple Mode):** Provides a simplified mode for exposing specific pre-configured API endpoints as tools ie. list_functions() and call_functions().
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Dynamic Tool Generation:** Automatically creates MCP tools from OpenAPI endpoint definitions.
|
|
30
|
+
- **Simple Mode Option:** Offers a static configuration option with FastMCP mode.
|
|
31
|
+
- **OpenAPI Specification Support:** Works with OpenAPI v3 specifications, and potentially v2.
|
|
32
|
+
- **Flexible Filtering:** Supports filtering endpoints by tags, paths, methods, etc. (if implemented)
|
|
33
|
+
- **MCP Integration:** Integrates seamlessly with MCP ecosystems to invoke REST APIs as tools.
|
|
34
|
+
|
|
35
|
+
## Installation
|
|
36
|
+
|
|
37
|
+
### MCP Ecosystem Integration
|
|
38
|
+
|
|
39
|
+
Add **mcp-openapi-proxy** to your MCP ecosystem by configuring your `mcpServers`. Generic example:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"mcpServers": {
|
|
44
|
+
"mcp-openapi-proxy": {
|
|
45
|
+
"command": "uvx",
|
|
46
|
+
"args": [
|
|
47
|
+
"--from",
|
|
48
|
+
"git+https://github.com/matthewhand/mcp-openapi-proxy",
|
|
49
|
+
"mcp-openapi-proxy"
|
|
50
|
+
],
|
|
51
|
+
"env": {
|
|
52
|
+
"OPENAPI_SPEC_URL": "${OPENAPI_SPEC_URL}",
|
|
53
|
+
"API_AUTH_BEARER": "",
|
|
54
|
+
"TOOL_WHITELIST": "",
|
|
55
|
+
"TOOL_NAME_PREFIX": ""
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
For examples of real world APIs like GetZep, Open-Webui, Netlify, Vercel, etc refer to [examples](./examples).
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
## Modes of Operation
|
|
66
|
+
|
|
67
|
+
### FastMCP Mode (Simple Mode)
|
|
68
|
+
|
|
69
|
+
- **Enabled by:** Setting `OPENAPI_SIMPLE_MODE=true`
|
|
70
|
+
- **Description:** Exposes a pre-defined set of tools based on specific OpenAPI endpoints defined in code.
|
|
71
|
+
- **Configuration:** Requires environment variables for tool definitions.
|
|
72
|
+
|
|
73
|
+
### Low-Level Mode (Default)
|
|
74
|
+
|
|
75
|
+
- **Description:** Dynamically registers all valid API endpoints from the provided OpenAPI specification as separate tools.
|
|
76
|
+
- **Tool Naming:** Tools are named based on normalized OpenAPI path and method.
|
|
77
|
+
- **Behavior:** Descriptions are generated from the OpenAPI operation summaries and descriptions.
|
|
78
|
+
|
|
79
|
+
## Environment Variables
|
|
80
|
+
|
|
81
|
+
- `OPENAPI_SPEC_URL`: URL to the OpenAPI specification JSON file (required).
|
|
82
|
+
- `OPENAPI_LOGFILE_PATH`: (Optional) Path for the log file.
|
|
83
|
+
- `OPENAPI_SIMPLE_MODE`: (Optional) Set to `true` for FastMCP mode.
|
|
84
|
+
- `TOOL_WHITELIST`: (Optional) A prefix filter to select only certain tools.
|
|
85
|
+
- `TOOL_NAME_PREFIX`: (Optional) A string to prepend to all tool names when mapping.
|
|
86
|
+
|
|
87
|
+
## Examples
|
|
88
|
+
|
|
89
|
+
### OpenWebUI Example
|
|
90
|
+
|
|
91
|
+
**1. Confirming the OpenAPI Endpoint**
|
|
92
|
+
|
|
93
|
+
Run the following command to retrieve the OpenAPI specification:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
curl http://localhost:3000/openapi.json
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
If the output is a valid OpenAPI JSON document, it confirms that the endpoint is working correctly.
|
|
100
|
+
|
|
101
|
+
**2. Configuring mcp-openapi-proxy**
|
|
102
|
+
|
|
103
|
+
In your MCP ecosystem configuration file, set the `OPENAPI_SPEC_URL` to the above endpoint. For example:
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"mcpServers": {
|
|
108
|
+
"mcp-openapi-proxy": {
|
|
109
|
+
"command": "uvx",
|
|
110
|
+
"args": [
|
|
111
|
+
"--from",
|
|
112
|
+
"git+https://github.com/matthewhand/mcp-openapi-proxy",
|
|
113
|
+
"mcp-openapi-proxy"
|
|
114
|
+
],
|
|
115
|
+
"env": {
|
|
116
|
+
"OPENAPI_SPEC_URL": "http://localhost:3000/openapi.json",
|
|
117
|
+
"SERVER_URL_OVERRIDE": "http://localhost:3000/v1",
|
|
118
|
+
"TOOL_WHITELIST": "/models,/chat/completion",
|
|
119
|
+
"API_AUTH_BEARER": "your_openwebui_token_here"
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
- **OPENAPI_SPEC_URL**: The URL to the OpenAPI specification.
|
|
127
|
+
- **SERVER_URL_OVERRIDE**: Should the spec not include servers, or you wish to use a different URL.
|
|
128
|
+
- **TOOL_WHITELIST**: A comma-separated list of endpoint paths to expose as tools. In this example, only the `/api/models` and `/api/chat/completions` endpoints are allowed.
|
|
129
|
+
- **API_AUTH_BEARER**: The bearer token for endpoints requiring authentication. Alternatively use ${OPENWEBUI_API_KEY} if configured as an environment variable or stored securely in a `.env` file.
|
|
130
|
+
|
|
131
|
+
**3. Resulting Tools**
|
|
132
|
+
|
|
133
|
+
With this configuration, the MCP server will dynamically generate tools for the whitelisted endpoints. For example:
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
[
|
|
137
|
+
{
|
|
138
|
+
"name": "api_models",
|
|
139
|
+
"description": "Fetches available models from /api/models"
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
"name": "api_chat_completions",
|
|
143
|
+
"description": "Generates chat completions via /api/chat/completions"
|
|
144
|
+
}
|
|
145
|
+
]
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
**4. Visual Verification**
|
|
149
|
+
|
|
150
|
+
You can verify the registration of these tools by inspecting the MCP server logs or using your MCP client.
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
## Fly.io Example
|
|
154
|
+
|
|
155
|
+
**1. Verify the OpenAPI Endpoint**
|
|
156
|
+
|
|
157
|
+
Run the following command to retrieve the Fly.io OpenAPI JSON specification:
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
curl https://raw.githubusercontent.com/abhiaagarwal/peristera/refs/heads/main/fly-machines-gen/fixed_spec.json
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Ensure the response is a valid OpenAPI JSON document containing an "openapi" field (e.g., "openapi": "3.0.0") and defined API paths.
|
|
164
|
+
|
|
165
|
+
**2. Configure mcp-openapi-proxy for Fly.io**
|
|
166
|
+
|
|
167
|
+
Update your MCP ecosystem configuration to point to the Fly.io endpoint. For example:
|
|
168
|
+
|
|
169
|
+
```json
|
|
170
|
+
{
|
|
171
|
+
"mcpServers": {
|
|
172
|
+
"mcp-openapi-proxy": {
|
|
173
|
+
"command": "uvx",
|
|
174
|
+
"args": [
|
|
175
|
+
"--from",
|
|
176
|
+
"git+https://github.com/matthewhand/mcp-openapi-proxy",
|
|
177
|
+
"mcp-openapi-proxy"
|
|
178
|
+
],
|
|
179
|
+
"env": {
|
|
180
|
+
"OPENAPI_SPEC_URL": "https://raw.githubusercontent.com/abhiaagarwal/peristera/refs/heads/main/fly-machines-gen/fixed_spec.json",
|
|
181
|
+
"SERVER_URL_OVERRIDE": "https://api.machines.dev",
|
|
182
|
+
"TOOL_WHITELIST": "/machines/list,/machines/start,/machines/status",
|
|
183
|
+
"API_AUTH_BEARER": "${FLY_API_TOKEN}"
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**3. Resulting Tools**
|
|
191
|
+
|
|
192
|
+
With this configuration, the MCP server will dynamically generate tools for the whitelisted endpoints. For example:
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
[
|
|
196
|
+
{
|
|
197
|
+
"name": "machines_list",
|
|
198
|
+
"description": "Fetches machine listing from /machines/list"
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
"name": "machines_start",
|
|
202
|
+
"description": "Initiates machine start via /machines/start"
|
|
203
|
+
},
|
|
204
|
+
{
|
|
205
|
+
"name": "machines_status",
|
|
206
|
+
"description": "Retrieves machine status from /machines/status"
|
|
207
|
+
}
|
|
208
|
+
]
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## Troubleshooting
|
|
212
|
+
|
|
213
|
+
- **Missing OPENAPI_SPEC_URL:** Verify that the `OPENAPI_SPEC_URL` environment variable is set and points to a valid OpenAPI JSON file.
|
|
214
|
+
- **Invalid OpenAPI Spec:** Ensure the JSON specification complies with the OpenAPI standard.
|
|
215
|
+
- **Filtering Issues:** Check that `TOOL_WHITELIST` is correctly defined to filter the desired classes.
|
|
216
|
+
- **Logging:** Consult the logs (as defined by `MCP_OPENAPI_LOGFILE_PATH`) for debugging information.
|
|
217
|
+
- **Verify `uvx`** Run the server directly from the GitHub repository using `uvx`:
|
|
218
|
+
|
|
219
|
+
```bash
|
|
220
|
+
uvx --from git+https://github.com/matthewhand/mcp-openapi-proxy mcp-openapi-proxy
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
mcp_openapi_proxy/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
mcp_openapi_proxy/__main__.py,sha256=VIc087EhndsDqeRU5uVMrtxZMMapxfDih1KlpouF6r8,1867
|
|
3
|
+
mcp_openapi_proxy/server_fastmcp.py,sha256=UEKz1vjX0FgkmCVl0Y9eFht4E-CfSd2MXSm004P8bVE,11400
|
|
4
|
+
mcp_openapi_proxy/server_lowlevel.py,sha256=3atPZpIPMRzul4UCny73ycZoNorEnv6j-lhhApIIwu4,10947
|
|
5
|
+
mcp_openapi_proxy/utils.py,sha256=3fgQi_IC5Cw8B57GRCDMwKqqidzDuaKawe3V5pxTonU,9495
|
|
6
|
+
mcp_openapi_proxy-0.1.1741340797.dist-info/LICENSE,sha256=PjkyMZfBImLXpO5eKb_sI__W1JMf1jL51n1O7H4a1I0,1062
|
|
7
|
+
mcp_openapi_proxy-0.1.1741340797.dist-info/METADATA,sha256=7UO-9toQSEOHNVtjMOPyr9SoxNoNs_g4CibxrZjj0CY,7976
|
|
8
|
+
mcp_openapi_proxy-0.1.1741340797.dist-info/WHEEL,sha256=jB7zZ3N9hIM9adW7qlTAyycLYW9npaWKLRzaoVcLKcM,91
|
|
9
|
+
mcp_openapi_proxy-0.1.1741340797.dist-info/entry_points.txt,sha256=d_nfFSGK0wyUwv01H4wjtOI45eo8CZ-XdKAbJOvnhFU,70
|
|
10
|
+
mcp_openapi_proxy-0.1.1741340797.dist-info/top_level.txt,sha256=oLvmu0l9_s8gAkTCT5921baWF27hlPDr1i2SIopuOc8,18
|
|
11
|
+
mcp_openapi_proxy-0.1.1741340797.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcp_openapi_proxy
|