From cd95e54c10c15137f8d1795c88df886e5b0a2ea9 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Thu, 19 Feb 2026 12:23:24 -0800 Subject: [PATCH] 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 --- .../mcp_server/mcp_server_manager.py | 26 ++++++-- .../mcp_server/rest_endpoints.py | 44 +++++++++++++ litellm/proxy/_types.py | 11 ++-- litellm/proxy/schema.prisma | 1 + .../mcp_tools/create_mcp_server.tsx | 45 ++++++++++--- .../mcp_tools/mcp_connection_status.tsx | 6 +- .../components/mcp_tools/mcp_server_edit.tsx | 63 ++++++++++++++++--- .../mcp_tools/mcp_tool_configuration.tsx | 4 +- .../src/components/mcp_tools/types.tsx | 2 + .../src/hooks/useTestMCPConnection.tsx | 16 +++-- 10 files changed, 186 insertions(+), 32 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 49c4a0ce681..e5a2119bc2c 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -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 diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index aed81afd254..581671598c6 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -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, ) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 231b9fdd1f6..0e4fab9c79d 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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 diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index d483e92e528..6eaeabe8916 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -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("{}") diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 61cad4c437b..fb9efffb9b5 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -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 = ({ } } + // 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 = { ...restValues, @@ -377,9 +382,11 @@ const CreateMCPServer: React.FC = ({ 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 = ({ Streamable HTTP (Recommended) Server-Sent Events (SSE) Standard Input/Output (stdio) + OpenAPI Spec {/* URL field - only show for HTTP and SSE */} - {transportType !== "stdio" && ( + {(transportType === "http" || transportType === "sse") && ( MCP Server URL} name="url" @@ -575,8 +583,29 @@ const CreateMCPServer: React.FC = ({ )} - {/* Authentication - only show for HTTP and SSE */} - {transportType !== "stdio" && ( + {/* OpenAPI Spec URL - only show for OpenAPI transport */} + {transportType === TRANSPORT.OPENAPI && ( + + OpenAPI Spec URL + + + + + } + name="spec_path" + rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]} + > + + + )} + + {/* Authentication - show for HTTP, SSE, and OpenAPI */} + {transportType !== "stdio" && transportType !== "" && ( Authentication} name="auth_type" @@ -592,7 +621,7 @@ const CreateMCPServer: React.FC = ({ )} - {transportType !== "stdio" && shouldShowAuthValueField && ( + {transportType !== "stdio" && transportType !== "" && shouldShowAuthValueField && ( @@ -613,7 +642,7 @@ const CreateMCPServer: React.FC = ({ )} - {transportType !== "stdio" && isOAuthAuthType && ( + {transportType !== "stdio" && transportType !== "" && isOAuthAuthType && ( = ({ 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 = ({ accessToken, Connection Status - {!canFetchTools && formValues.url && ( + {!canFetchTools && (formValues.url || formValues.spec_path) && (
Complete required fields to test connection @@ -60,7 +60,7 @@ const MCPConnectionStatus: React.FC = ({ accessToken, : "Ready to test connection"}
- Server: {formValues.url} + Server: {formValues.url || formValues.spec_path}
{isLoadingTools && ( diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 6f8aef2b4ff..88f8a737af2 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -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 = ({ 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 = ({ }, [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 = ({ 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 = ({ 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 = ({ } } + // 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 = ({ - {/* URL/Auth fields are only applicable for HTTP/SSE */} - {!isStdioTransport && ( + {/* URL field - only for HTTP/SSE */} + {isMCPTransport && ( = ({ )} + {/* OpenAPI Spec URL - only for OpenAPI transport */} + {isOpenAPITransport && ( + + OpenAPI Spec URL + + + + + } + name="spec_path" + rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]} + > + + + )} + + {/* Authentication - for HTTP, SSE, and OpenAPI */} {!isStdioTransport && (