Merge pull request #21662 from BerriAI/litellm_mcp_openapi_spec

[Fix]Add mcp via openapi spec
This commit is contained in:
Sameer Kankute 2026-02-20 18:16:30 +05:30 committed by GitHub
commit 164734fa45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 274 additions and 27 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "spec_path" TEXT;

View File

@ -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}"
)

View File

@ -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 ""

View File

@ -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"

View File

@ -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<MCPToolConfigurationProps> = ({
onAllowedToolsChange,
}) => {
const previousToolsLengthRef = useRef(0);
const [toolSearchTerm, setToolSearchTerm] = useState("");
const { tools, isLoadingTools, toolsError, canFetchTools } = useTestMCPConnection({
accessToken,
@ -30,6 +31,15 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
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<MCPToolConfigurationProps> = ({
</div>
</div>
{/* Search bar */}
<Input
placeholder="Search tools by name or description..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="large"
/>
{/* Tool list with checkboxes */}
<div className="space-y-2">
{tools.map((tool, index) => (
{filteredTools.length === 0 ? (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<SearchOutlined className="text-2xl mb-2" />
<Text>No tools found matching "{toolSearchTerm}"</Text>
</div>
) : (
filteredTools.map((tool, index) => (
<div
key={index}
className={`p-4 rounded-lg border transition-colors cursor-pointer ${
@ -202,7 +229,8 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
</div>
</div>
</div>
))}
))
)}
</div>
</div>
)}

View File

@ -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<MCPTool | null>(null);
const [toolResult, setToolResult] = useState<MCPContent[] | null>(null);
const [toolError, setToolError] = useState<Error | null>(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 (
<div className="w-full h-screen p-4 bg-white">
<Card className="w-full rounded-xl shadow-md overflow-hidden">
@ -78,6 +90,21 @@ const MCPToolsViewer = ({
)}
</Text>
{/* Search Bar */}
{toolsData.length > 0 && (
<div className="mb-3">
<Input
placeholder="Search tools..."
prefix={<SearchOutlined className="text-gray-400" />}
value={toolSearchTerm}
onChange={(e) => setToolSearchTerm(e.target.value)}
allowClear
className="rounded-lg"
size="middle"
/>
</div>
)}
{/* Loading State */}
{isLoadingTools && (
<div className="flex flex-col items-center justify-center py-8 bg-white border border-gray-200 rounded-lg">
@ -116,15 +143,23 @@ const MCPToolsViewer = ({
{/* Tools List */}
{!isLoadingTools && !mcpToolsResponse?.error && toolsData.length > 0 && (
<div
className="space-y-2 flex-1 overflow-y-auto min-h-0 mcp-tools-scrollable"
style={{
maxHeight: "400px",
scrollbarWidth: "auto",
scrollbarColor: "#cbd5e0 #f7fafc",
}}
>
{toolsData.map((tool: MCPTool) => (
<>
{filteredTools.length === 0 ? (
<div className="p-4 text-center bg-white border border-gray-200 rounded-lg">
<SearchOutlined className="text-2xl text-gray-400 mb-2" />
<p className="text-xs font-medium text-gray-700 mb-1">No tools found</p>
<p className="text-xs text-gray-500">No tools match "{toolSearchTerm}"</p>
</div>
) : (
<div
className="space-y-2 flex-1 overflow-y-auto min-h-0 mcp-tools-scrollable"
style={{
maxHeight: "400px",
scrollbarWidth: "auto",
scrollbarColor: "#cbd5e0 #f7fafc",
}}
>
{filteredTools.map((tool: MCPTool) => (
<div
key={tool.name}
className={`border rounded-lg p-3 cursor-pointer transition-all hover:shadow-sm ${selectedTool?.name === tool.name
@ -168,8 +203,10 @@ const MCPToolsViewer = ({
</div>
)}
</div>
))}
</div>
))}
</div>
)}
</>
)}
</div>
</div>