diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index fd1c3974c85..f7cd7a0a02a 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -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 diff --git a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py index 74e149a06e9..72288f8e673 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -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, diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index d7ebfb805f3..f38e44e99d9 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -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]]: diff --git a/litellm/proxy/_experimental/mcp_server/tool_registry.py b/litellm/proxy/_experimental/mcp_server/tool_registry.py index c08b7979683..bc69095fc43 100644 --- a/litellm/proxy/_experimental/mcp_server/tool_registry.py +++ b/litellm/proxy/_experimental/mcp_server/tool_registry.py @@ -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: diff --git a/litellm/proxy/_new_secret_config.yaml b/litellm/proxy/_new_secret_config.yaml index b62a6258dbb..462d329420c 100644 --- a/litellm/proxy/_new_secret_config.yaml +++ b/litellm/proxy/_new_secret_config.yaml @@ -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: diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 73088329a6b..254c5355c63 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -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