Merge pull request #21662 from BerriAI/litellm_mcp_openapi_spec
[Fix]Add mcp via openapi spec
This commit is contained in:
commit
164734fa45
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "LiteLLM_MCPServerTable" ADD COLUMN "spec_path" TEXT;
|
||||
@ -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}"
|
||||
)
|
||||
|
||||
@ -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 ""
|
||||
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user