From 19b7b4019c3d9087c2c426682e78f329bd394fb7 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 20 Feb 2026 13:46:59 +0530 Subject: [PATCH 1/4] Fix: server url extraction from spec_path --- .../mcp_server/mcp_server_manager.py | 3 +-- .../mcp_server/openapi_to_mcp_generator.py | 18 ++++++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) 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 "" From ba74ee5a312e94c8869d249550980594fa07a9fe Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 20 Feb 2026 13:47:44 +0530 Subject: [PATCH 2/4] Add test for base url extraction and migration --- .../migration.sql | 2 + .../test_openapi_to_mcp_generator.py | 179 +++++++++++++++++- 2 files changed, 175 insertions(+), 6 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260220124742_add_spec_path_to_mcp_servers/migration.sql 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/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" From f99ea619da713d557f5cc04e31e0cf3e249b2b0d Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 20 Feb 2026 13:57:58 +0530 Subject: [PATCH 3/4] Add search bar for enabling and calling tool --- .../mcp_tools/mcp_tool_configuration.tsx | 38 ++++++++++-- .../src/components/mcp_tools/mcp_tools.tsx | 61 +++++++++++++++---- ui/litellm-dashboard/tsconfig.json | 2 +- 3 files changed, 83 insertions(+), 18 deletions(-) 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) => (
)}
- ))} -
+ ))} +
+ )} + )}
diff --git a/ui/litellm-dashboard/tsconfig.json b/ui/litellm-dashboard/tsconfig.json index d24bdd340f..5b0352feb9 100644 --- a/ui/litellm-dashboard/tsconfig.json +++ b/ui/litellm-dashboard/tsconfig.json @@ -14,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "react-jsx", + "jsx": "preserve", "incremental": true, "plugins": [ { From 3cd907253987fd7451c61c771e6815f67e49eb49 Mon Sep 17 00:00:00 2001 From: Sameer Kankute Date: Fri, 20 Feb 2026 14:08:38 +0530 Subject: [PATCH 4/4] Update ui/litellm-dashboard/tsconfig.json Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- ui/litellm-dashboard/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/tsconfig.json b/ui/litellm-dashboard/tsconfig.json index 5b0352feb9..d24bdd340f 100644 --- a/ui/litellm-dashboard/tsconfig.json +++ b/ui/litellm-dashboard/tsconfig.json @@ -14,7 +14,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ {