diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260220124742_add_spec_path_to_mcp_servers/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260220124742_add_spec_path_to_mcp_servers/migration.sql new file mode 100644 index 0000000000..4f4e72a879 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260220124742_add_spec_path_to_mcp_servers/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "spec_path" TEXT; diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index e5a2119bc2..5c72bfbc13 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -390,8 +390,7 @@ class MCPServerManager: # Use base_url from config if provided, otherwise extract from spec if not base_url: - base_url = get_openapi_base_url(spec) - + base_url = get_openapi_base_url(spec, spec_path) verbose_logger.info( f"Registering OpenAPI tools for server {server.name} with base URL: {base_url}" ) 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 deb0b4f954..21d39c97d7 100644 --- a/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py +++ b/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py @@ -2,8 +2,8 @@ This module is used to generate MCP tools from OpenAPI specs. """ -import json import asyncio +import json import os from pathlib import PurePosixPath from typing import Any, Dict, Optional @@ -80,7 +80,7 @@ async def load_openapi_spec_async(filepath: str) -> Dict[str, Any]: return json.load(f) -def get_base_url(spec: Dict[str, Any]) -> str: +def get_base_url(spec: Dict[str, Any], spec_path: Optional[str] = None) -> str: """Extract base URL from OpenAPI spec.""" # OpenAPI 3.x if "servers" in spec and spec["servers"]: @@ -90,6 +90,20 @@ def get_base_url(spec: Dict[str, Any]) -> str: scheme = spec.get("schemes", ["https"])[0] base_path = spec.get("basePath", "") return f"{scheme}://{spec['host']}{base_path}" + + # Fallback: derive base URL from spec_path if it's a URL + if spec_path and (spec_path.startswith("http://") or spec_path.startswith("https://")): + for suffix in ["/openapi.json", "/openapi.yaml", "/swagger.json", "/swagger.yaml"]: + if spec_path.endswith(suffix): + base_url = spec_path[:-len(suffix)] + verbose_logger.info(f"No server info in OpenAPI spec. Using derived base URL: {base_url}") + return base_url + + if spec_path.split("/")[-1].endswith((".json", ".yaml", ".yml")): + base_url = "/".join(spec_path.split("/")[:-1]) + verbose_logger.info(f"No server info in OpenAPI spec. Using derived base URL: {base_url}") + return base_url + return "" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py index 573e095606..bc93d54830 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py @@ -9,16 +9,17 @@ This test suite ensures that: 5. Path parameters are properly URL encoded """ -import pytest from types import SimpleNamespace from unittest.mock import AsyncMock, patch -from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( - create_tool_function, - build_input_schema, - extract_parameters, -) +import pytest +from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import ( + build_input_schema, + create_tool_function, + extract_parameters, + get_base_url, +) GET_ASYNC_CLIENT_TARGET = ( "litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator.get_async_httpx_client" @@ -496,3 +497,169 @@ class TestPathSecurity: call_args = async_client.get.call_args url = call_args[0][0] assert url == "https://example.com/files/report%202024.json" + + +class TestGetBaseUrl: + """Test base URL extraction and fallback logic.""" + + def test_openapi_3x_with_servers(self): + """Test extraction from OpenAPI 3.x servers field.""" + spec = { + "openapi": "3.0.0", + "servers": [ + {"url": "https://api.example.com/v1"}, + {"url": "https://api-staging.example.com/v1"} + ], + "paths": {} + } + + base_url = get_base_url(spec) + assert base_url == "https://api.example.com/v1" + + def test_openapi_2x_with_host(self): + """Test extraction from OpenAPI 2.x (Swagger) host field.""" + spec = { + "swagger": "2.0", + "host": "api.example.com", + "basePath": "/v1", + "schemes": ["https"], + "paths": {} + } + + base_url = get_base_url(spec) + assert base_url == "https://api.example.com/v1" + + def test_openapi_2x_without_basepath(self): + """Test extraction from OpenAPI 2.x without basePath.""" + spec = { + "swagger": "2.0", + "host": "api.example.com", + "schemes": ["https"], + "paths": {} + } + + base_url = get_base_url(spec) + assert base_url == "https://api.example.com" + + def test_openapi_2x_default_scheme(self): + """Test that https is used as default scheme when not specified.""" + spec = { + "swagger": "2.0", + "host": "api.example.com", + "paths": {} + } + + base_url = get_base_url(spec) + assert base_url == "https://api.example.com" + + def test_fallback_with_openapi_json_suffix(self): + """Test fallback: derive base URL from spec_path with /openapi.json suffix.""" + spec = { + "openapi": "3.0.0", + "paths": {} + # No servers field + } + spec_path = "http://localhost:8001/openapi.json" + + base_url = get_base_url(spec, spec_path) + assert base_url == "http://localhost:8001" + + def test_fallback_with_swagger_json_suffix(self): + """Test fallback: derive base URL from spec_path with /swagger.json suffix.""" + spec = { + "swagger": "2.0", + "paths": {} + # No host field + } + spec_path = "https://api.example.com/api/swagger.json" + + base_url = get_base_url(spec, spec_path) + assert base_url == "https://api.example.com/api" + + def test_fallback_with_openapi_yaml_suffix(self): + """Test fallback: derive base URL from spec_path with .yaml suffix.""" + spec = { + "openapi": "3.0.0", + "paths": {} + } + spec_path = "http://localhost:3000/docs/openapi.yaml" + + base_url = get_base_url(spec, spec_path) + assert base_url == "http://localhost:3000/docs" + + def test_fallback_with_generic_json_file(self): + """Test fallback: remove last segment if it's a JSON file.""" + spec = { + "openapi": "3.0.0", + "paths": {} + } + spec_path = "https://example.com/v1/api-spec.json" + + base_url = get_base_url(spec, spec_path) + assert base_url == "https://example.com/v1" + + def test_fallback_with_generic_yaml_file(self): + """Test fallback: remove last segment if it's a YAML file.""" + spec = { + "openapi": "3.0.0", + "paths": {} + } + spec_path = "https://example.com/docs/api.yml" + + base_url = get_base_url(spec, spec_path) + assert base_url == "https://example.com/docs" + + def test_no_fallback_without_spec_path(self): + """Test that empty string is returned when no server info and no spec_path.""" + spec = { + "openapi": "3.0.0", + "paths": {} + } + + base_url = get_base_url(spec) + assert base_url == "" + + def test_no_fallback_with_local_file_path(self): + """Test that fallback doesn't apply to local file paths.""" + spec = { + "openapi": "3.0.0", + "paths": {} + } + spec_path = "/Users/test/openapi.json" + + base_url = get_base_url(spec, spec_path) + assert base_url == "" + + def test_priority_servers_over_fallback(self): + """Test that servers field takes priority over spec_path fallback.""" + spec = { + "openapi": "3.0.0", + "servers": [{"url": "https://production.example.com"}], + "paths": {} + } + spec_path = "http://localhost:8001/openapi.json" + + base_url = get_base_url(spec, spec_path) + assert base_url == "https://production.example.com" + + def test_fallback_with_port_number(self): + """Test fallback handles URLs with port numbers correctly.""" + spec = { + "openapi": "3.0.0", + "paths": {} + } + spec_path = "http://localhost:8001/openapi.json" + + base_url = get_base_url(spec, spec_path) + assert base_url == "http://localhost:8001" + + def test_fallback_with_nested_path(self): + """Test fallback with deeply nested spec path.""" + spec = { + "openapi": "3.0.0", + "paths": {} + } + spec_path = "https://api.example.com/v2/docs/api/openapi.json" + + base_url = get_base_url(spec, spec_path) + assert base_url == "https://api.example.com/v2/docs/api" diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx index 8cdc67030c..84967f7a83 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { Card, Title, Text } from "@tremor/react"; -import { ToolOutlined, CheckCircleOutlined } from "@ant-design/icons"; -import { Badge, Spin, Checkbox } from "antd"; +import { ToolOutlined, CheckCircleOutlined, SearchOutlined } from "@ant-design/icons"; +import { Badge, Spin, Checkbox, Input } from "antd"; import { useTestMCPConnection } from "../../hooks/useTestMCPConnection"; interface MCPToolConfigurationProps { @@ -22,6 +22,7 @@ const MCPToolConfiguration: React.FC = ({ onAllowedToolsChange, }) => { const previousToolsLengthRef = useRef(0); + const [toolSearchTerm, setToolSearchTerm] = useState(""); const { tools, isLoadingTools, toolsError, canFetchTools } = useTestMCPConnection({ accessToken, @@ -30,6 +31,15 @@ const MCPToolConfiguration: React.FC = ({ enabled: true, }); + // Filter tools based on search term + const filteredTools = tools.filter((tool) => { + const searchLower = toolSearchTerm.toLowerCase(); + return ( + tool.name.toLowerCase().includes(searchLower) || + (tool.description && tool.description.toLowerCase().includes(searchLower)) + ); + }); + // Auto-select tools when tools are first loaded useEffect(() => { // Only auto-select if: @@ -168,9 +178,26 @@ const MCPToolConfiguration: React.FC = ({ + {/* Search bar */} + } + value={toolSearchTerm} + onChange={(e) => setToolSearchTerm(e.target.value)} + allowClear + className="rounded-lg" + size="large" + /> + {/* Tool list with checkboxes */}
- {tools.map((tool, index) => ( + {filteredTools.length === 0 ? ( +
+ + No tools found matching "{toolSearchTerm}" +
+ ) : ( + filteredTools.map((tool, index) => (
= ({
- ))} + )) + )} )} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx index 7ee1e64a22..1cc505ec02 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx @@ -5,7 +5,8 @@ import { MCPTool, MCPToolsViewerProps, MCPContent, CallMCPToolResponse } from ". import { listMCPTools, callMCPTool } from "../networking"; import { Card, Title, Text } from "@tremor/react"; -import { RobotOutlined, ToolOutlined } from "@ant-design/icons"; +import { RobotOutlined, ToolOutlined, SearchOutlined } from "@ant-design/icons"; +import { Input } from "antd"; const MCPToolsViewer = ({ serverId, @@ -18,6 +19,7 @@ const MCPToolsViewer = ({ const [selectedTool, setSelectedTool] = useState(null); const [toolResult, setToolResult] = useState(null); const [toolError, setToolError] = useState(null); + const [toolSearchTerm, setToolSearchTerm] = useState(""); // Query to fetch MCP tools const { @@ -58,6 +60,16 @@ const MCPToolsViewer = ({ const toolsData = mcpToolsResponse?.tools || []; + // Filter tools based on search term + const filteredTools = toolsData.filter((tool: MCPTool) => { + const searchLower = toolSearchTerm.toLowerCase(); + return ( + tool.name.toLowerCase().includes(searchLower) || + (tool.description && tool.description.toLowerCase().includes(searchLower)) || + (tool.mcp_info.server_name && tool.mcp_info.server_name.toLowerCase().includes(searchLower)) + ); + }); + return (
@@ -78,6 +90,21 @@ const MCPToolsViewer = ({ )} + {/* Search Bar */} + {toolsData.length > 0 && ( +
+ } + value={toolSearchTerm} + onChange={(e) => setToolSearchTerm(e.target.value)} + allowClear + className="rounded-lg" + size="middle" + /> +
+ )} + {/* Loading State */} {isLoadingTools && (
@@ -116,15 +143,23 @@ const MCPToolsViewer = ({ {/* Tools List */} {!isLoadingTools && !mcpToolsResponse?.error && toolsData.length > 0 && ( -
- {toolsData.map((tool: MCPTool) => ( + <> + {filteredTools.length === 0 ? ( +
+ +

No tools found

+

No tools match "{toolSearchTerm}"

+
+ ) : ( +
+ {filteredTools.map((tool: MCPTool) => (
)}
- ))} -
+ ))} +
+ )} + )}