Skip to content

Commit d921df1

Browse files
Merge pull request #15343 from BerriAI/litellm_dev_10_08_2025_p1
MCP - support converting OpenAPI specs to MCP servers
2 parents 8cbc855 + cd13e53 commit d921df1

File tree

9 files changed

+826
-162
lines changed

9 files changed

+826
-162
lines changed

litellm/proxy/_experimental/mcp_server/mcp_server_manager.py

Lines changed: 329 additions & 86 deletions
Large diffs are not rendered by default.
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
"""
2+
This module is used to generate MCP tools from OpenAPI specs.
3+
"""
4+
5+
import json
6+
from typing import Any, Dict, Optional
7+
8+
import httpx
9+
10+
from litellm._logging import verbose_logger
11+
from litellm.proxy._experimental.mcp_server.tool_registry import (
12+
global_mcp_tool_registry,
13+
)
14+
15+
# Store the base URL and headers globally
16+
BASE_URL = ""
17+
HEADERS: Dict[str, str] = {}
18+
19+
20+
def load_openapi_spec(filepath: str) -> Dict[str, Any]:
21+
"""Load OpenAPI specification from JSON file."""
22+
with open(filepath, "r") as f:
23+
return json.load(f)
24+
25+
26+
def get_base_url(spec: Dict[str, Any]) -> str:
27+
"""Extract base URL from OpenAPI spec."""
28+
# OpenAPI 3.x
29+
if "servers" in spec and spec["servers"]:
30+
return spec["servers"][0]["url"]
31+
# OpenAPI 2.x (Swagger)
32+
elif "host" in spec:
33+
scheme = spec.get("schemes", ["https"])[0]
34+
base_path = spec.get("basePath", "")
35+
return f"{scheme}://{spec['host']}{base_path}"
36+
return ""
37+
38+
39+
def extract_parameters(operation: Dict[str, Any]) -> tuple:
40+
"""Extract parameter names from OpenAPI operation."""
41+
path_params = []
42+
query_params = []
43+
body_params = []
44+
45+
# OpenAPI 3.x and 2.x parameters
46+
if "parameters" in operation:
47+
for param in operation["parameters"]:
48+
param_name = param["name"]
49+
if param.get("in") == "path":
50+
path_params.append(param_name)
51+
elif param.get("in") == "query":
52+
query_params.append(param_name)
53+
elif param.get("in") == "body":
54+
body_params.append(param_name)
55+
56+
# OpenAPI 3.x requestBody
57+
if "requestBody" in operation:
58+
body_params.append("body")
59+
60+
return path_params, query_params, body_params
61+
62+
63+
def build_input_schema(operation: Dict[str, Any]) -> Dict[str, Any]:
64+
"""Build MCP input schema from OpenAPI operation."""
65+
properties = {}
66+
required = []
67+
68+
# Process parameters
69+
if "parameters" in operation:
70+
for param in operation["parameters"]:
71+
param_name = param["name"]
72+
param_schema = param.get("schema", {})
73+
param_type = param_schema.get("type", "string")
74+
75+
properties[param_name] = {
76+
"type": param_type,
77+
"description": param.get("description", ""),
78+
}
79+
80+
if param.get("required", False):
81+
required.append(param_name)
82+
83+
# Process requestBody (OpenAPI 3.x)
84+
if "requestBody" in operation:
85+
request_body = operation["requestBody"]
86+
content = request_body.get("content", {})
87+
88+
# Try to get JSON schema
89+
if "application/json" in content:
90+
schema = content["application/json"].get("schema", {})
91+
properties["body"] = {
92+
"type": "object",
93+
"description": request_body.get("description", "Request body"),
94+
"properties": schema.get("properties", {}),
95+
}
96+
if request_body.get("required", False):
97+
required.append("body")
98+
99+
return {
100+
"type": "object",
101+
"properties": properties,
102+
"required": required if required else [],
103+
}
104+
105+
106+
def create_tool_function(
107+
path: str,
108+
method: str,
109+
operation: Dict[str, Any],
110+
base_url: str,
111+
headers: Optional[Dict[str, str]] = None,
112+
):
113+
"""Create a tool function for an OpenAPI operation.
114+
115+
Args:
116+
path: API endpoint path
117+
method: HTTP method (get, post, put, delete, patch)
118+
operation: OpenAPI operation object
119+
base_url: Base URL for the API
120+
headers: Optional headers to include in requests (e.g., authentication)
121+
"""
122+
if headers is None:
123+
headers = {}
124+
125+
path_params, query_params, body_params = extract_parameters(operation)
126+
all_params = path_params + query_params + body_params
127+
128+
# Build function signature dynamically
129+
if all_params:
130+
params_str = ", ".join(f"{p}: str = ''" for p in all_params)
131+
else:
132+
params_str = ""
133+
134+
# Create the function code as a string
135+
func_code = f'''
136+
async def tool_function({params_str}) -> str:
137+
"""Dynamically generated tool function."""
138+
url = base_url + path
139+
140+
# Replace path parameters
141+
path_param_names = {path_params}
142+
for param_name in path_param_names:
143+
param_value = locals().get(param_name, "")
144+
if param_value:
145+
url = url.replace("{{" + param_name + "}}", str(param_value))
146+
147+
# Build query params
148+
query_param_names = {query_params}
149+
params = {{}}
150+
for param_name in query_param_names:
151+
param_value = locals().get(param_name, "")
152+
if param_value:
153+
params[param_name] = param_value
154+
155+
# Build request body
156+
body_param_names = {body_params}
157+
json_body = None
158+
if body_param_names:
159+
body_value = locals().get("body", {{}})
160+
if isinstance(body_value, dict):
161+
json_body = body_value
162+
elif body_value:
163+
# If it's a string, try to parse as JSON
164+
import json as json_module
165+
try:
166+
json_body = json_module.loads(body_value) if isinstance(body_value, str) else {{"data": body_value}}
167+
except:
168+
json_body = {{"data": body_value}}
169+
170+
# Make HTTP request
171+
async with httpx.AsyncClient() as client:
172+
if "{method.lower()}" == "get":
173+
response = await client.get(url, params=params, headers=headers)
174+
elif "{method.lower()}" == "post":
175+
response = await client.post(url, params=params, json=json_body, headers=headers)
176+
elif "{method.lower()}" == "put":
177+
response = await client.put(url, params=params, json=json_body, headers=headers)
178+
elif "{method.lower()}" == "delete":
179+
response = await client.delete(url, params=params, headers=headers)
180+
elif "{method.lower()}" == "patch":
181+
response = await client.patch(url, params=params, json=json_body, headers=headers)
182+
else:
183+
return "Unsupported HTTP method: {method}"
184+
185+
return response.text
186+
'''
187+
188+
# Execute the function code to create the actual function
189+
local_vars = {
190+
"httpx": httpx,
191+
"headers": headers,
192+
"base_url": base_url,
193+
"path": path,
194+
"method": method,
195+
}
196+
exec(func_code, local_vars)
197+
198+
return local_vars["tool_function"]
199+
200+
201+
def register_tools_from_openapi(spec: Dict[str, Any], base_url: str):
202+
"""Register MCP tools from OpenAPI specification."""
203+
paths = spec.get("paths", {})
204+
205+
for path, path_item in paths.items():
206+
for method in ["get", "post", "put", "delete", "patch"]:
207+
if method in path_item:
208+
operation = path_item[method]
209+
210+
# Generate tool name
211+
operation_id = operation.get(
212+
"operationId", f"{method}_{path.replace('/', '_')}"
213+
)
214+
tool_name = operation_id.replace(" ", "_").lower()
215+
216+
# Get description
217+
description = operation.get(
218+
"summary", operation.get("description", f"{method.upper()} {path}")
219+
)
220+
221+
# Build input schema
222+
input_schema = build_input_schema(operation)
223+
224+
# Create tool function
225+
tool_func = create_tool_function(path, method, operation, base_url)
226+
tool_func.__name__ = tool_name
227+
tool_func.__doc__ = description
228+
229+
# Register tool with local registry
230+
global_mcp_tool_registry.register_tool(
231+
name=tool_name,
232+
description=description,
233+
input_schema=input_schema,
234+
handler=tool_func,
235+
)
236+
verbose_logger.debug(f"Registered tool: {tool_name}")

0 commit comments

Comments
 (0)