mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
feat(mcp/): initial working list tools for openapi spec mcp tools
This commit is contained in:
parent
ff893cce42
commit
1c317a35af
6 changed files with 275 additions and 54 deletions
|
|
@ -195,6 +195,7 @@ class MCPServerManager:
|
|||
name=name_for_prefix,
|
||||
alias=alias,
|
||||
server_name=server_name,
|
||||
spec_path=server_config.get("spec_path", None),
|
||||
url=server_config.get("url", None) or "",
|
||||
command=server_config.get("command", None) or "",
|
||||
args=server_config.get("args", None) or [],
|
||||
|
|
@ -218,12 +219,170 @@ class MCPServerManager:
|
|||
access_groups=server_config.get("access_groups", None),
|
||||
)
|
||||
self.config_mcp_servers[server_id] = new_server
|
||||
|
||||
# Check if this is an OpenAPI-based server
|
||||
spec_path = server_config.get("spec_path", None)
|
||||
if spec_path:
|
||||
verbose_logger.info(
|
||||
f"Loading OpenAPI spec from {spec_path} for server {server_name}"
|
||||
)
|
||||
self._register_openapi_tools(
|
||||
spec_path=spec_path,
|
||||
server=new_server,
|
||||
base_url=server_config.get("url", ""),
|
||||
)
|
||||
|
||||
verbose_logger.debug(
|
||||
f"Loaded MCP Servers: {json.dumps(self.config_mcp_servers, indent=4, default=str)}"
|
||||
)
|
||||
|
||||
self.initialize_tool_name_to_mcp_server_name_mapping()
|
||||
|
||||
def _register_openapi_tools(self, spec_path: str, server: MCPServer, base_url: str):
|
||||
"""
|
||||
Register tools from an OpenAPI specification for a given server.
|
||||
|
||||
This creates "virtual" MCP tools from OpenAPI endpoints that are:
|
||||
1. Registered in the global tool registry with server prefix
|
||||
2. Mapped to the server for routing
|
||||
3. Executed via the local tool handler
|
||||
|
||||
Args:
|
||||
spec_path: Path to the OpenAPI specification file
|
||||
server: The MCPServer instance to register tools for
|
||||
base_url: Base URL for API calls
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
|
||||
build_input_schema,
|
||||
create_tool_function,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
|
||||
get_base_url as get_openapi_base_url,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
|
||||
load_openapi_spec,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.tool_registry import (
|
||||
global_mcp_tool_registry,
|
||||
)
|
||||
|
||||
try:
|
||||
# Load OpenAPI spec
|
||||
spec = load_openapi_spec(spec_path)
|
||||
|
||||
# Use base_url from config if provided, otherwise extract from spec
|
||||
if not base_url:
|
||||
base_url = get_openapi_base_url(spec)
|
||||
|
||||
verbose_logger.info(
|
||||
f"Registering OpenAPI tools for server {server.name} with base URL: {base_url}"
|
||||
)
|
||||
|
||||
# Get server prefix for tool naming
|
||||
server_prefix = get_server_prefix(server)
|
||||
|
||||
# Build headers from server configuration
|
||||
headers = {}
|
||||
|
||||
# Add authentication headers if configured
|
||||
if server.authentication_token:
|
||||
from litellm.types.mcp import MCPAuth
|
||||
|
||||
if server.auth_type == MCPAuth.bearer_token:
|
||||
headers["Authorization"] = f"Bearer {server.authentication_token}"
|
||||
elif server.auth_type == MCPAuth.api_key:
|
||||
headers["Authorization"] = f"ApiKey {server.authentication_token}"
|
||||
elif server.auth_type == MCPAuth.basic:
|
||||
headers["Authorization"] = f"Basic {server.authentication_token}"
|
||||
|
||||
# Add any extra headers from server config
|
||||
# Note: extra_headers is a List[str] of header names to forward, not a dict
|
||||
# For OpenAPI tools, we'll just use the authentication headers
|
||||
# If extra_headers were needed, they would be processed separately
|
||||
|
||||
verbose_logger.debug(
|
||||
f"Using headers for OpenAPI tools (excluding sensitive values): "
|
||||
f"{list(headers.keys())}"
|
||||
)
|
||||
|
||||
# Extract and register tools from OpenAPI paths
|
||||
paths = spec.get("paths", {})
|
||||
registered_count = 0
|
||||
|
||||
verbose_logger.debug(f"Processing {len(paths)} paths from OpenAPI spec")
|
||||
|
||||
for path, path_item in paths.items():
|
||||
for method in ["get", "post", "put", "delete", "patch"]:
|
||||
if method not in path_item:
|
||||
continue
|
||||
|
||||
operation = path_item[method]
|
||||
|
||||
# Generate tool name (without prefix initially)
|
||||
operation_id = operation.get(
|
||||
"operationId", f"{method}_{path.replace('/', '_')}"
|
||||
)
|
||||
base_tool_name = operation_id.replace(" ", "_").lower()
|
||||
|
||||
# Check if tool is allowed for this server
|
||||
if not self.check_allowed_or_banned_tools(base_tool_name, server):
|
||||
verbose_logger.debug(
|
||||
f"Skipping tool {base_tool_name} - not in allowed_tools for server {server.name}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Add server prefix to tool name
|
||||
prefixed_tool_name = add_server_prefix_to_tool_name(
|
||||
base_tool_name, server_prefix
|
||||
)
|
||||
|
||||
# Get description
|
||||
description = operation.get(
|
||||
"summary",
|
||||
operation.get("description", f"{method.upper()} {path}"),
|
||||
)
|
||||
|
||||
# Build input schema using imported function
|
||||
input_schema = build_input_schema(operation)
|
||||
|
||||
# Create tool function with headers using imported function
|
||||
tool_func = create_tool_function(
|
||||
path, method, operation, base_url, headers=headers
|
||||
)
|
||||
tool_func.__name__ = prefixed_tool_name
|
||||
tool_func.__doc__ = description
|
||||
|
||||
# Register tool with prefixed name in global registry
|
||||
global_mcp_tool_registry.register_tool(
|
||||
name=prefixed_tool_name,
|
||||
description=description,
|
||||
input_schema=input_schema,
|
||||
handler=tool_func,
|
||||
)
|
||||
|
||||
# Update tool name to server name mapping (for both prefixed and base names)
|
||||
self.tool_name_to_mcp_server_name_mapping[base_tool_name] = (
|
||||
server_prefix
|
||||
)
|
||||
self.tool_name_to_mcp_server_name_mapping[prefixed_tool_name] = (
|
||||
server_prefix
|
||||
)
|
||||
|
||||
registered_count += 1
|
||||
verbose_logger.debug(
|
||||
f"Registered OpenAPI tool: {prefixed_tool_name} for server {server.name}"
|
||||
)
|
||||
|
||||
verbose_logger.info(
|
||||
f"Successfully registered {registered_count} OpenAPI tools for server {server.name}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
verbose_logger.error(
|
||||
f"Failed to register OpenAPI tools for server {server.name}: {str(e)}"
|
||||
)
|
||||
raise e
|
||||
|
||||
def remove_server(self, mcp_server: LiteLLM_MCPServerTable):
|
||||
"""
|
||||
Remove a server from the registry
|
||||
|
|
@ -469,6 +628,10 @@ class MCPServerManager:
|
|||
Returns:
|
||||
List[MCPTool]: List of tools available on the server with prefixed names
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.tool_registry import (
|
||||
global_mcp_tool_registry,
|
||||
)
|
||||
|
||||
verbose_logger.debug(f"Connecting to url: {server.url}")
|
||||
verbose_logger.info(f"_get_tools_from_server for {server.name}...")
|
||||
|
||||
|
|
@ -481,7 +644,14 @@ class MCPServerManager:
|
|||
extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
tools = await self._fetch_tools_with_timeout(client, server.name)
|
||||
## HANDLE OPENAPI TOOLS
|
||||
if server.spec_path:
|
||||
_tools = global_mcp_tool_registry.list_tools(tool_prefix=server.name)
|
||||
tools = global_mcp_tool_registry.convert_tools_to_mcp_sdk_tool_type(
|
||||
_tools
|
||||
)
|
||||
else:
|
||||
tools = await self._fetch_tools_with_timeout(client, server.name)
|
||||
|
||||
prefixed_or_original_tools = self._create_prefixed_tools(
|
||||
tools, server, add_prefix=add_prefix
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ This module is used to generate MCP tools from OpenAPI specs.
|
|||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -104,9 +104,23 @@ def build_input_schema(operation: Dict[str, Any]) -> Dict[str, Any]:
|
|||
|
||||
|
||||
def create_tool_function(
|
||||
path: str, method: str, operation: Dict[str, Any], base_url: str
|
||||
path: str,
|
||||
method: str,
|
||||
operation: Dict[str, Any],
|
||||
base_url: str,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
"""Create a tool function for an OpenAPI operation."""
|
||||
"""Create a tool function for an OpenAPI operation.
|
||||
|
||||
Args:
|
||||
path: API endpoint path
|
||||
method: HTTP method (get, post, put, delete, patch)
|
||||
operation: OpenAPI operation object
|
||||
base_url: Base URL for the API
|
||||
headers: Optional headers to include in requests (e.g., authentication)
|
||||
"""
|
||||
if headers is None:
|
||||
headers = {}
|
||||
|
||||
path_params, query_params, body_params = extract_parameters(operation)
|
||||
all_params = path_params + query_params + body_params
|
||||
|
|
@ -156,15 +170,15 @@ async def tool_function({params_str}) -> str:
|
|||
# Make HTTP request
|
||||
async with httpx.AsyncClient() as client:
|
||||
if "{method.lower()}" == "get":
|
||||
response = await client.get(url, params=params, headers=HEADERS)
|
||||
response = await client.get(url, params=params, headers=headers)
|
||||
elif "{method.lower()}" == "post":
|
||||
response = await client.post(url, params=params, json=json_body, headers=HEADERS)
|
||||
response = await client.post(url, params=params, json=json_body, headers=headers)
|
||||
elif "{method.lower()}" == "put":
|
||||
response = await client.put(url, params=params, json=json_body, headers=HEADERS)
|
||||
response = await client.put(url, params=params, json=json_body, headers=headers)
|
||||
elif "{method.lower()}" == "delete":
|
||||
response = await client.delete(url, params=params, headers=HEADERS)
|
||||
response = await client.delete(url, params=params, headers=headers)
|
||||
elif "{method.lower()}" == "patch":
|
||||
response = await client.patch(url, params=params, json=json_body, headers=HEADERS)
|
||||
response = await client.patch(url, params=params, json=json_body, headers=headers)
|
||||
else:
|
||||
return "Unsupported HTTP method: {method}"
|
||||
|
||||
|
|
@ -174,7 +188,7 @@ async def tool_function({params_str}) -> str:
|
|||
# Execute the function code to create the actual function
|
||||
local_vars = {
|
||||
"httpx": httpx,
|
||||
"HEADERS": HEADERS,
|
||||
"headers": headers,
|
||||
"base_url": base_url,
|
||||
"path": path,
|
||||
"method": method,
|
||||
|
|
|
|||
|
|
@ -364,25 +364,25 @@ if MCP_AVAILABLE:
|
|||
def _tool_name_matches(tool_name: str, filter_list: List[str]) -> bool:
|
||||
"""
|
||||
Check if a tool name matches any name in the filter list.
|
||||
|
||||
|
||||
Checks both the full tool name and unprefixed version (without server prefix).
|
||||
This allows users to configure simple tool names regardless of prefixing.
|
||||
|
||||
|
||||
Args:
|
||||
tool_name: The tool name to check (may be prefixed like "server-tool_name")
|
||||
filter_list: List of tool names to match against
|
||||
|
||||
|
||||
Returns:
|
||||
True if the tool name (prefixed or unprefixed) is in the filter list
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.utils import (
|
||||
get_server_name_prefix_tool_mcp,
|
||||
)
|
||||
|
||||
|
||||
# Check if the full name is in the list
|
||||
if tool_name in filter_list:
|
||||
return True
|
||||
|
||||
|
||||
# Check if the unprefixed name is in the list
|
||||
unprefixed_name, _ = get_server_name_prefix_tool_mcp(tool_name)
|
||||
return unprefixed_name in filter_list
|
||||
|
|
@ -393,34 +393,36 @@ if MCP_AVAILABLE:
|
|||
) -> List[MCPTool]:
|
||||
"""
|
||||
Filter tools by allowed/disallowed tools configuration.
|
||||
|
||||
|
||||
If allowed_tools is set, only tools in that list are returned.
|
||||
If disallowed_tools is set, tools in that list are excluded.
|
||||
Tool names are matched with and without server prefixes for flexibility.
|
||||
|
||||
|
||||
Args:
|
||||
tools: List of tools to filter
|
||||
mcp_server: Server configuration with allowed_tools/disallowed_tools
|
||||
|
||||
|
||||
Returns:
|
||||
Filtered list of tools
|
||||
"""
|
||||
tools_to_return = tools
|
||||
|
||||
|
||||
# Filter by allowed_tools (whitelist)
|
||||
if mcp_server.allowed_tools:
|
||||
tools_to_return = [
|
||||
tool for tool in tools
|
||||
tool
|
||||
for tool in tools
|
||||
if _tool_name_matches(tool.name, mcp_server.allowed_tools)
|
||||
]
|
||||
|
||||
|
||||
# Filter by disallowed_tools (blacklist)
|
||||
if mcp_server.disallowed_tools:
|
||||
tools_to_return = [
|
||||
tool for tool in tools_to_return
|
||||
tool
|
||||
for tool in tools_to_return
|
||||
if not _tool_name_matches(tool.name, mcp_server.disallowed_tools)
|
||||
]
|
||||
|
||||
|
||||
return tools_to_return
|
||||
|
||||
async def _get_tools_from_mcp_servers(
|
||||
|
|
@ -497,7 +499,7 @@ if MCP_AVAILABLE:
|
|||
extra_headers=extra_headers,
|
||||
add_prefix=add_prefix,
|
||||
)
|
||||
|
||||
|
||||
filtered_tools = filter_tools_by_allowed_tools(tools, server)
|
||||
|
||||
filtered_tools = await filter_tools_by_key_team_permissions(
|
||||
|
|
@ -507,7 +509,7 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
|
||||
all_tools.extend(filtered_tools)
|
||||
|
||||
|
||||
verbose_logger.debug(
|
||||
f"Successfully fetched {len(tools)} tools from server {server.name}, {len(filtered_tools)} after filtering"
|
||||
)
|
||||
|
|
@ -680,33 +682,42 @@ if MCP_AVAILABLE:
|
|||
standard_logging_mcp_tool_call
|
||||
)
|
||||
litellm_logging_obj.model = f"MCP: {name}"
|
||||
# Try managed server tool first (pass the full prefixed name)
|
||||
# Primary and recommended way to use MCP servers
|
||||
# Check if tool exists in local registry first (for OpenAPI-based tools)
|
||||
# These tools are registered with their prefixed names
|
||||
#########################################################
|
||||
mcp_server: Optional[MCPServer] = (
|
||||
global_mcp_server_manager._get_mcp_server_from_tool_name(name)
|
||||
)
|
||||
if mcp_server:
|
||||
standard_logging_mcp_tool_call["mcp_server_cost_info"] = (
|
||||
mcp_server.mcp_info or {}
|
||||
).get("mcp_server_cost_info")
|
||||
response = await _handle_managed_mcp_tool(
|
||||
name=name, # Pass the full name (potentially prefixed)
|
||||
arguments=arguments,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
)
|
||||
local_tool = global_mcp_tool_registry.get_tool(name)
|
||||
if local_tool:
|
||||
verbose_logger.debug(f"Executing local registry tool: {name}")
|
||||
response = await _handle_local_mcp_tool(name, arguments)
|
||||
|
||||
# Fall back to local tool registry (use original name)
|
||||
#########################################################
|
||||
# Deprecated: Local MCP Server Tool
|
||||
# Try managed MCP server tool (pass the full prefixed name)
|
||||
# Primary and recommended way to use external MCP servers
|
||||
#########################################################
|
||||
else:
|
||||
response = await _handle_local_mcp_tool(original_tool_name, arguments)
|
||||
mcp_server: Optional[MCPServer] = (
|
||||
global_mcp_server_manager._get_mcp_server_from_tool_name(name)
|
||||
)
|
||||
if mcp_server:
|
||||
standard_logging_mcp_tool_call["mcp_server_cost_info"] = (
|
||||
mcp_server.mcp_info or {}
|
||||
).get("mcp_server_cost_info")
|
||||
response = await _handle_managed_mcp_tool(
|
||||
name=name, # Pass the full name (potentially prefixed)
|
||||
arguments=arguments,
|
||||
user_api_key_auth=user_api_key_auth,
|
||||
mcp_auth_header=mcp_auth_header,
|
||||
mcp_server_auth_headers=mcp_server_auth_headers,
|
||||
oauth2_headers=oauth2_headers,
|
||||
raw_headers=raw_headers,
|
||||
litellm_logging_obj=litellm_logging_obj,
|
||||
)
|
||||
|
||||
# Fall back to local tool registry with original name (legacy support)
|
||||
#########################################################
|
||||
# Deprecated: Local MCP Server Tool
|
||||
#########################################################
|
||||
else:
|
||||
response = await _handle_local_mcp_tool(original_tool_name, arguments)
|
||||
|
||||
#########################################################
|
||||
# Post MCP Tool Call Hook
|
||||
|
|
@ -778,14 +789,21 @@ if MCP_AVAILABLE:
|
|||
Handle tool execution for local registry tools
|
||||
Note: Local tools don't use prefixes, so we use the original name
|
||||
"""
|
||||
import inspect
|
||||
|
||||
tool = global_mcp_tool_registry.get_tool(name)
|
||||
if not tool:
|
||||
raise HTTPException(status_code=404, detail=f"Tool '{name}' not found")
|
||||
|
||||
try:
|
||||
result = tool.handler(**arguments)
|
||||
# Check if handler is async or sync
|
||||
if inspect.iscoroutinefunction(tool.handler):
|
||||
result = await tool.handler(**arguments)
|
||||
else:
|
||||
result = tool.handler(**arguments)
|
||||
return [TextContent(text=str(result), type="text")]
|
||||
except Exception as e:
|
||||
verbose_logger.exception(f"Error executing local tool {name}: {str(e)}")
|
||||
return [TextContent(text=f"Error: {str(e)}", type="text")]
|
||||
|
||||
def _get_mcp_servers_in_path(path: str) -> Optional[List[str]]:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import json
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from mcp.types import Tool as MCPToolSDKTool
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.proxy.types_utils.utils import get_instance_fn
|
||||
from litellm.types.mcp_server.tool_registry import MCPTool
|
||||
|
|
@ -39,12 +41,30 @@ class MCPToolRegistry:
|
|||
"""
|
||||
return self.tools.get(name)
|
||||
|
||||
def list_tools(self) -> List[MCPTool]:
|
||||
def list_tools(self, tool_prefix: Optional[str] = None) -> List[MCPTool]:
|
||||
"""
|
||||
List all registered tools
|
||||
"""
|
||||
if tool_prefix:
|
||||
return [
|
||||
tool
|
||||
for tool in self.tools.values()
|
||||
if tool.name.startswith(tool_prefix)
|
||||
]
|
||||
return list(self.tools.values())
|
||||
|
||||
def convert_tools_to_mcp_sdk_tool_type(
|
||||
self, tools: List[MCPTool]
|
||||
) -> List[MCPToolSDKTool]:
|
||||
return [
|
||||
MCPToolSDKTool(
|
||||
name=tool.name,
|
||||
description=tool.description,
|
||||
inputSchema=tool.input_schema,
|
||||
)
|
||||
for tool in tools
|
||||
]
|
||||
|
||||
def load_tools_from_config(
|
||||
self, mcp_tools_config: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -18,11 +18,9 @@ model_list:
|
|||
|
||||
mcp_servers:
|
||||
my_api_mcp:
|
||||
url: "http://0.0.0.0:8000"
|
||||
spec_path: "/Users/krrishdholakia/Documents/temp_py_folder/openapi.json"
|
||||
url: "http://0.0.0.0:8090"
|
||||
spec_path: "/Users/krrishdholakia/Documents/temp_py_folder/example_openapi.json"
|
||||
auth_type: none
|
||||
allowed_tools: ["get_users", "create_user"]
|
||||
access_groups: ["dev_group", "api_team"]
|
||||
|
||||
|
||||
litellm_settings:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class MCPServer(BaseModel):
|
|||
url: Optional[str] = None
|
||||
spec_path: Optional[str] = None
|
||||
transport: MCPTransportType
|
||||
spec_path: Optional[str] = None
|
||||
auth_type: Optional[MCPAuthType] = None
|
||||
authentication_token: Optional[str] = None
|
||||
mcp_info: Optional[MCPInfo] = None
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue