Add OpenAPI-to-MCP support via API and UI (#21575)

* add spec_path column to LiteLLM_MCPServerTable schema

* add spec_path to MCP request types and table model

* wire spec_path through build_mcp_server_from_table

* add openapi transport type constant

* add OpenAPI Spec as first-class transport option in create form

* add OpenAPI transport support to edit form with auto-detection

* support spec_path in connection status component

* support spec_path in tool configuration component

* support OpenAPI transport in test connection hook

* register OpenAPI tools on server add/update/reload

* preview OpenAPI tools in test/tools/list endpoint
This commit is contained in:
Ishaan Jaff 2026-02-19 12:23:24 -08:00 • committed by GitHub
parent e9a07347dc
commit cd95e54c10
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 186 additions and 32 deletions

View file

@ -71,7 +71,9 @@ try:
from mcp.shared.tool_name_validation import (
validate_tool_name, # pyright: ignore[reportAssignmentType]
)
from mcp.shared.tool_name_validation import SEP_986_URL
from mcp.shared.tool_name_validation import (
SEP_986_URL,
)
except ImportError:
from pydantic import BaseModel
@ -608,6 +610,7 @@ class MCPServerManager:
alias=getattr(mcp_server, "alias", None),
server_name=getattr(mcp_server, "server_name", None),
url=mcp_server.url,
spec_path=getattr(mcp_server, "spec_path", None),
transport=cast(MCPTransportType, mcp_server.transport),
auth_type=auth_type,
authentication_token=auth_value,
@ -638,11 +641,25 @@ class MCPServerManager:
)
return new_server
async def _maybe_register_openapi_tools(self, server: MCPServer):
"""Register OpenAPI tools if the server has a spec_path configured."""
if server.spec_path:
verbose_logger.info(
f"Loading OpenAPI spec from {server.spec_path} for server {server.name}"
)
await self._register_openapi_tools(
spec_path=server.spec_path,
server=server,
base_url=server.url or "",
)
self.initialize_tool_name_to_mcp_server_name_mapping()
async def add_server(self, mcp_server: LiteLLM_MCPServerTable):
try:
if mcp_server.server_id not in self.registry:
new_server = await self.build_mcp_server_from_table(mcp_server)
self.registry[mcp_server.server_id] = new_server
await self._maybe_register_openapi_tools(new_server)
verbose_logger.debug(f"Added MCP Server: {new_server.name}")
except Exception as e:
@ -654,6 +671,7 @@ class MCPServerManager:
if mcp_server.server_id in self.registry:
new_server = await self.build_mcp_server_from_table(mcp_server)
self.registry[mcp_server.server_id] = new_server
await self._maybe_register_openapi_tools(new_server)
verbose_logger.debug(f"Updated MCP Server: {new_server.name}")
except Exception as e:
@ -2242,9 +2260,9 @@ class MCPServerManager:
verbose_logger.debug(
f"Building server from DB: {server.server_id} ({server.server_name})"
)
new_registry[server.server_id] = await self.build_mcp_server_from_table(
server
)
new_server = await self.build_mcp_server_from_table(server)
new_registry[server.server_id] = new_server
await self._maybe_register_openapi_tools(new_server)
self.registry = new_registry

View file

@ -625,6 +625,46 @@ if MCP_AVAILABLE:
"message": "Failed to connect to MCP server. Check proxy logs for details.",
}
async def _preview_openapi_tools(spec_path: str) -> dict:
"""Generate tool previews from an OpenAPI spec without creating a server."""
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
build_input_schema,
load_openapi_spec_async,
)
try:
spec = await load_openapi_spec_async(spec_path)
paths = spec.get("paths", {})
tools: List[dict] = []
for path, path_item in paths.items():
for method in ("get", "post", "put", "patch", "delete"):
operation = path_item.get(method)
if operation is None:
continue
op_id = operation.get("operationId", f"{method}_{path}")
summary = operation.get("summary", "")
description = operation.get("description", summary)
input_schema = build_input_schema(operation)
tools.append(
{
"name": op_id,
"description": description or summary or f"{method.upper()} {path}",
"inputSchema": input_schema,
}
)
return {
"tools": tools,
"error": None,
"message": f"Found {len(tools)} tools from OpenAPI spec",
}
except Exception as e:
verbose_logger.error("Error previewing OpenAPI tools: %s", e, exc_info=True)
return {
"tools": [],
"error": True,
"message": f"Failed to load OpenAPI spec: {e}",
}
@router.post("/test/connection", dependencies=[Depends(user_api_key_auth)])
async def test_connection(
request: Request,
@ -657,6 +697,10 @@ if MCP_AVAILABLE:
"""
Preview tools available from MCP server before adding it
"""
# For OpenAPI spec servers, generate tools from the spec directly
if new_mcp_server_request.spec_path:
return await _preview_openapi_tools(new_mcp_server_request.spec_path)
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
)

View file

@ -1077,6 +1077,7 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
auth_type: Optional[MCPAuthType] = None
credentials: Optional[MCPCredentials] = None
url: Optional[str] = None
spec_path: Optional[str] = None
mcp_info: Optional[MCPInfo] = None
mcp_access_groups: List[str] = Field(default_factory=list)
allowed_tools: Optional[List[str]] = None
@ -1103,8 +1104,8 @@ class NewMCPServerRequest(LiteLLMPydanticObjectBase):
if not values.get("args"):
raise ValueError("args is required for stdio transport")
elif transport in [MCPTransport.http, MCPTransport.sse]:
if not values.get("url"):
raise ValueError("url is required for HTTP/SSE transport")
if not values.get("url") and not values.get("spec_path"):
raise ValueError("url or spec_path is required for HTTP/SSE transport")
return values
@model_validator(mode="before")
@ -1139,6 +1140,7 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
auth_type: Optional[MCPAuthType] = None
credentials: Optional[MCPCredentials] = None
url: Optional[str] = None
spec_path: Optional[str] = None
mcp_info: Optional[MCPInfo] = None
mcp_access_groups: List[str] = Field(default_factory=list)
allowed_tools: Optional[List[str]] = None
@ -1165,8 +1167,8 @@ class UpdateMCPServerRequest(LiteLLMPydanticObjectBase):
if not values.get("args"):
raise ValueError("args is required for stdio transport")
elif transport in [MCPTransport.http, MCPTransport.sse]:
if not values.get("url"):
raise ValueError("url is required for HTTP/SSE transport")
if not values.get("url") and not values.get("spec_path"):
raise ValueError("url or spec_path is required for HTTP/SSE transport")
return values
@ -1178,6 +1180,7 @@ class LiteLLM_MCPServerTable(LiteLLMPydanticObjectBase):
alias: Optional[str] = None
description: Optional[str] = None
url: Optional[str] = None
spec_path: Optional[str] = None
transport: MCPTransportType
auth_type: Optional[MCPAuthType] = None
credentials: Optional[MCPCredentials] = None

View file

@ -273,6 +273,7 @@ model LiteLLM_MCPServerTable {
alias String?
description String?
url String?
spec_path String?
transport String @default("sse")
auth_type String?
credentials Json? @default("{}")

View file

@ -3,7 +3,7 @@ import { Modal, Tooltip, Form, Select, Input } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TextInput } from "@tremor/react";
import { createMCPServer } from "../networking";
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo } from "./types";
import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
import OAuthFormFields from "./OAuthFormFields";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPConnectionStatus from "./mcp_connection_status";
@ -316,6 +316,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
}
}
// Map "openapi" transport to "http" for the backend
if (restValues.transport === TRANSPORT.OPENAPI) {
restValues.transport = "http";
}
// Prepare the payload with cost configuration and allowed tools
const payload: Record<string, any> = {
...restValues,
@ -377,9 +382,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
setTransportType(value);
// Clear fields that are not relevant for the selected transport
if (value === "stdio") {
form.setFieldsValue({ url: undefined, auth_type: undefined, credentials: undefined });
form.setFieldsValue({ url: undefined, spec_path: undefined, auth_type: undefined, credentials: undefined });
} else if (value === TRANSPORT.OPENAPI) {
form.setFieldsValue({ url: undefined, command: undefined, args: undefined, env: undefined });
} else {
form.setFieldsValue({ command: undefined, args: undefined, env: undefined });
form.setFieldsValue({ spec_path: undefined, command: undefined, args: undefined, env: undefined });
}
};
@ -555,11 +562,12 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
<Select.Option value="http">Streamable HTTP (Recommended)</Select.Option>
<Select.Option value="sse">Server-Sent Events (SSE)</Select.Option>
<Select.Option value="stdio">Standard Input/Output (stdio)</Select.Option>
<Select.Option value={TRANSPORT.OPENAPI}>OpenAPI Spec</Select.Option>
</Select>
</Form.Item>
{/* URL field - only show for HTTP and SSE */}
{transportType !== "stdio" && (
{(transportType === "http" || transportType === "sse") && (
<Form.Item
label={<span className="text-sm font-medium text-gray-700">MCP Server URL</span>}
name="url"
@ -575,8 +583,29 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
</Form.Item>
)}
{/* Authentication - only show for HTTP and SSE */}
{transportType !== "stdio" && (
{/* OpenAPI Spec URL - only show for OpenAPI transport */}
{transportType === TRANSPORT.OPENAPI && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OpenAPI Spec URL
<Tooltip title="URL to an OpenAPI specification (JSON or YAML). MCP tools will be automatically generated from the API endpoints defined in the spec.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="spec_path"
rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]}
>
<Input
placeholder="https://petstore3.swagger.io/api/v3/openapi.json"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
)}
{/* Authentication - show for HTTP, SSE, and OpenAPI */}
{transportType !== "stdio" && transportType !== "" && (
<Form.Item
label={<span className="text-sm font-medium text-gray-700">Authentication</span>}
name="auth_type"
@ -592,7 +621,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
</Form.Item>
)}
{transportType !== "stdio" && shouldShowAuthValueField && (
{transportType !== "stdio" && transportType !== "" && shouldShowAuthValueField && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
@ -613,7 +642,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
</Form.Item>
)}
{transportType !== "stdio" && isOAuthAuthType && (
{transportType !== "stdio" && transportType !== "" && isOAuthAuthType && (
<OAuthFormFields
isM2M={isM2MFlow}
initialFlowType={OAUTH_FLOW.INTERACTIVE}

View file

@ -25,7 +25,7 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({ accessToken,
}, [tools, onToolsLoaded]);
// Don't show anything if required fields aren't filled
if (!canFetchTools && !formValues.url) {
if (!canFetchTools && !formValues.url && !formValues.spec_path) {
return null;
}
@ -37,7 +37,7 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({ accessToken,
<Title>Connection Status</Title>
</div>
{!canFetchTools && formValues.url && (
{!canFetchTools && (formValues.url || formValues.spec_path) && (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2" />
<Text>Complete required fields to test connection</Text>
@ -60,7 +60,7 @@ const MCPConnectionStatus: React.FC<MCPConnectionStatusProps> = ({ accessToken,
: "Ready to test connection"}
</Text>
<br />
<Text className="text-gray-500 text-sm">Server: {formValues.url}</Text>
<Text className="text-gray-500 text-sm">Server: {formValues.url || formValues.spec_path}</Text>
</div>
{isLoadingTools && (

View file

@ -2,7 +2,7 @@ import React, { useState, useEffect } from "react";
import { Form, Select, Button as AntdButton, Tooltip, Input } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo } from "./types";
import { AUTH_TYPE, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types";
import { updateMCPServer, testMCPToolsListRequest } from "../networking";
import MCPServerCostConfig from "./mcp_server_cost_config";
import MCPPermissionManagement from "./MCPPermissionManagement";
@ -42,6 +42,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
const authType = Form.useWatch("auth_type", form) as string | undefined;
const transportType = Form.useWatch("transport", form) as string | undefined;
const isStdioTransport = transportType === "stdio";
const isOpenAPITransport = transportType === TRANSPORT.OPENAPI;
const isMCPTransport = !isStdioTransport && !isOpenAPITransport;
const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false;
const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2;
const oauthFlowTypeValue = Form.useWatch("oauth_flow_type", form) as string | undefined;
@ -142,13 +144,22 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
}, [mcpServer.env]);
// If server has spec_path and no url, show it as "openapi" transport in the UI
const effectiveTransport = React.useMemo(() => {
if (mcpServer.spec_path && !mcpServer.url && mcpServer.transport !== "stdio") {
return TRANSPORT.OPENAPI;
}
return mcpServer.transport;
}, [mcpServer]);
const initialValues = React.useMemo(
() => ({
...mcpServer,
transport: effectiveTransport,
static_headers: initialStaticHeaders,
oauth_flow_type: mcpServer.token_url ? OAUTH_FLOW.M2M : OAUTH_FLOW.INTERACTIVE,
}),
[mcpServer, initialStaticHeaders, initialEnvJson],
[mcpServer, effectiveTransport, initialStaticHeaders, initialEnvJson],
);
// Initialize cost config from existing server data
@ -231,8 +242,8 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
const fetchTools = async () => {
if (!accessToken) return;
// HTTP/SSE requires a URL; stdio does not.
if (mcpServer.transport !== "stdio" && !mcpServer.url) return;
// HTTP/SSE requires a URL (unless spec_path is set); stdio does not.
if (mcpServer.transport !== "stdio" && !mcpServer.url && !mcpServer.spec_path) return;
const isM2M = mcpServer.auth_type === AUTH_TYPE.OAUTH2 && !!mcpServer.token_url;
if (mcpServer.auth_type === AUTH_TYPE.OAUTH2 && !isM2M && !oauthAccessToken) {
@ -311,14 +322,24 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
if (value === "stdio") {
form.setFieldsValue({
url: undefined,
spec_path: undefined,
auth_type: undefined,
credentials: undefined,
authorization_url: undefined,
token_url: undefined,
registration_url: undefined,
});
} else if (value === TRANSPORT.OPENAPI) {
form.setFieldsValue({
url: undefined,
command: undefined,
args: undefined,
env_json: undefined,
stdio_config: undefined,
});
} else {
form.setFieldsValue({
spec_path: undefined,
command: undefined,
args: undefined,
env_json: undefined,
@ -457,6 +478,11 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
}
}
// Map "openapi" transport to "http" for the backend
if (restValues.transport === TRANSPORT.OPENAPI) {
restValues.transport = "http";
}
// Prepare the payload with cost configuration and permission fields
const mcpInfoServerName =
restValues.server_name ||
@ -543,14 +569,15 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
</Form.Item>
<Form.Item label="Transport Type" name="transport" rules={[{ required: true }]}>
<Select onChange={handleTransportChange}>
<Select.Option value="sse">Server-Sent Events (SSE)</Select.Option>
<Select.Option value="http">Streamable HTTP (Recommended)</Select.Option>
<Select.Option value="sse">Server-Sent Events (SSE)</Select.Option>
<Select.Option value="stdio">Standard Input/Output (stdio)</Select.Option>
<Select.Option value={TRANSPORT.OPENAPI}>OpenAPI Spec</Select.Option>
</Select>
</Form.Item>
{/* URL/Auth fields are only applicable for HTTP/SSE */}
{!isStdioTransport && (
{/* URL field - only for HTTP/SSE */}
{isMCPTransport && (
<Form.Item
label="MCP Server URL"
name="url"
@ -566,6 +593,28 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
</Form.Item>
)}
{/* OpenAPI Spec URL - only for OpenAPI transport */}
{isOpenAPITransport && (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
OpenAPI Spec URL
<Tooltip title="URL to an OpenAPI specification (JSON or YAML). MCP tools will be automatically generated from the API endpoints defined in the spec.">
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
name="spec_path"
rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]}
>
<Input
placeholder="https://petstore3.swagger.io/api/v3/openapi.json"
className="rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"
/>
</Form.Item>
)}
{/* Authentication - for HTTP, SSE, and OpenAPI */}
{!isStdioTransport && (
<Form.Item label="Authentication" name="auth_type" rules={[{ required: true }]}>
<Select>

View file

@ -71,7 +71,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
};
// Don't show anything if required fields aren't filled
if (!canFetchTools && !formValues.url) {
if (!canFetchTools && !formValues.url && !formValues.spec_path) {
return null;
}
@ -130,7 +130,7 @@ const MCPToolConfiguration: React.FC<MCPToolConfigurationProps> = ({
)}
{/* Incomplete form state */}
{!canFetchTools && formValues.url && (
{!canFetchTools && (formValues.url || formValues.spec_path) && (
<div className="text-center py-6 text-gray-400 border rounded-lg border-dashed">
<ToolOutlined className="text-2xl mb-2" />
<Text>Complete required fields to configure tools</Text>

View file

@ -22,6 +22,7 @@ export const TRANSPORT = {
SSE: "sse",
HTTP: "http",
STDIO: "stdio",
OPENAPI: "openapi",
};
export const handleTransport = (transport?: string | null): string => {
@ -143,6 +144,7 @@ export interface MCPServer {
* For `stdio`, the backend can return null/undefined.
*/
url?: string | null;
spec_path?: string | null;
transport?: string | null;
auth_type?: string | null;
authorization_url?: string | null;

View file

@ -1,11 +1,12 @@
import { useState, useEffect } from "react";
import { testMCPToolsListRequest } from "../components/networking";
import { AUTH_TYPE, OAUTH_FLOW } from "@/components/mcp_tools/types";
import { AUTH_TYPE, OAUTH_FLOW, TRANSPORT } from "@/components/mcp_tools/types";
interface MCPServerConfig {
server_id?: string;
server_name?: string;
url?: string;
spec_path?: string;
transport?: string;
auth_type?: string;
authorization_url?: string;
@ -55,8 +56,10 @@ export const useTestMCPConnection = ({
const isM2MOAuth = formValues.auth_type === AUTH_TYPE.OAUTH2
&& formValues.oauth_flow_type === OAUTH_FLOW.M2M;
const requiresOAuthToken = formValues.auth_type === AUTH_TYPE.OAUTH2 && !isM2MOAuth;
const isOpenAPITransport = formValues.transport === TRANSPORT.OPENAPI;
const hasEndpoint = isOpenAPITransport ? !!formValues.spec_path : !!formValues.url;
const canFetchTools = !!(
formValues.url &&
hasEndpoint &&
formValues.transport &&
formValues.auth_type &&
accessToken &&
@ -67,7 +70,7 @@ export const useTestMCPConnection = ({
const credentialsKey = JSON.stringify(formValues.credentials ?? {});
const fetchTools = async () => {
if (!accessToken || !formValues.url) {
if (!accessToken || (!formValues.url && !formValues.spec_path)) {
return;
}
@ -125,11 +128,15 @@ export const useTestMCPConnection = ({
)
: undefined;
// For OpenAPI transport, map to "http" for backend compatibility
const effectiveTransport = formValues.transport === TRANSPORT.OPENAPI ? "http" : formValues.transport;
const mcpServerConfig: MCPServerConfig = {
server_id: formValues.server_id || "",
server_name: formValues.server_name || "",
url: formValues.url,
transport: formValues.transport,
spec_path: formValues.spec_path,
transport: effectiveTransport,
auth_type: formValues.auth_type,
authorization_url: formValues.authorization_url,
token_url: formValues.token_url,
@ -190,6 +197,7 @@ export const useTestMCPConnection = ({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
formValues.url,
formValues.spec_path,
formValues.transport,
formValues.auth_type,
accessToken,