From 2b2e0de2af8dc6b4ff310e0abdc67debfe4e5634 Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Mon, 9 Mar 2026 15:49:52 -0700 Subject: [PATCH] feat(ui): add OpenAPI MCP server support with popular API quick-picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `openapi_registry.json` with 10 well-known APIs (GitHub, Atlassian, Figma, Google, Stripe, HubSpot, Notion, Slack, Shopify, Snowflake) — each with validated spec URLs and OAuth 2.0 endpoints - Backend endpoint `GET /v1/mcp/registry.json` to serve the registry (reads fresh from disk) - `OpenAPIQuickPicker` component: logo grid for popular APIs with letter fallback for broken images - `OpenAPIFormSection` component: encapsulates picker + spec URL input as a clean unit - When selecting a preset, spec URL and OAuth fields are pre-filled automatically - Fixed `useTestMCPConnection`: for OpenAPI transport, tools load from the spec as soon as the URL is set — no auth type or OAuth token required - Validated all spec URLs are reachable; removed Linear (GraphQL-only, no REST spec) --- .../mcp_management_endpoints.py | 38 +++++ litellm/proxy/openapi_registry.json | 134 ++++++++++++++++++ ui/litellm-dashboard/next.config.mjs | 63 ++++++++ .../mcp_tools/OpenAPIFormSection.tsx | 75 ++++++++++ .../mcp_tools/OpenAPIQuickPicker.tsx | 109 ++++++++++++++ .../mcp_tools/create_mcp_server.tsx | 25 ++-- .../src/components/networking.tsx | 33 ++++- .../src/hooks/useTestMCPConnection.tsx | 22 +-- 8 files changed, 472 insertions(+), 27 deletions(-) create mode 100644 litellm/proxy/openapi_registry.json create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/OpenAPIQuickPicker.tsx diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index f7a4cec301b..cbce5e6df6d 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -11,6 +11,7 @@ Endpoints here: - GET `/v1/mcp/tools - lists all the tools available for a key - GET `/v1/mcp/access_groups` - lists all available MCP access groups - GET `/v1/mcp/discover` - Returns curated list of well-known MCP servers for discovery UI +- GET `/v1/mcp/openapi-registry` - Returns well-known OpenAPI APIs with OAuth 2.0 metadata """ @@ -1361,3 +1362,40 @@ if MCP_AVAILABLE: "servers": servers, "categories": categories, } + + # --- OpenAPI Registry --- + + _OPENAPI_REGISTRY_PATH = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "openapi_registry.json", + ) + + def _load_openapi_registry() -> Dict[str, Any]: + try: + with open(_OPENAPI_REGISTRY_PATH, "r") as f: + data: Dict[str, Any] = json.load(f) + except Exception as e: + verbose_proxy_logger.warning( + f"Failed to load OpenAPI registry from {_OPENAPI_REGISTRY_PATH}: {e}" + ) + data = {"apis": []} + return data + + @router.get( + "/openapi-registry", + description="Returns well-known OpenAPI APIs with OAuth 2.0 metadata for the OpenAPI MCP picker", + dependencies=[Depends(user_api_key_auth)], + ) + async def get_openapi_registry( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins can access the OpenAPI registry. Your role={}".format( + user_api_key_dict.user_role + ) + }, + ) + return _load_openapi_registry() diff --git a/litellm/proxy/openapi_registry.json b/litellm/proxy/openapi_registry.json new file mode 100644 index 00000000000..a5dce3c8c8b --- /dev/null +++ b/litellm/proxy/openapi_registry.json @@ -0,0 +1,134 @@ +{ + "apis": [ + { + "name": "github", + "title": "GitHub", + "description": "Repos, issues, PRs, and workflow automation via the GitHub REST API", + "icon_url": "https://cdn.simpleicons.org/github", + "spec_url": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json", + "oauth": { + "authorization_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "pkce": false, + "docs_url": "https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app" + } + }, + { + "name": "atlassian", + "title": "Atlassian", + "description": "Jira issues, Confluence pages, and project management", + "icon_url": "https://cdn.simpleicons.org/atlassian", + "spec_url": "https://dac-static.atlassian.com/cloud/jira/platform/swagger-v3.v3.json", + "oauth": { + "authorization_url": "https://auth.atlassian.com/authorize", + "token_url": "https://auth.atlassian.com/oauth/token", + "pkce": true, + "docs_url": "https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/" + } + }, + { + "name": "figma", + "title": "Figma", + "description": "Design files, components, prototypes, and comments", + "icon_url": "https://cdn.simpleicons.org/figma", + "spec_url": "https://raw.githubusercontent.com/figma/rest-api-spec/main/openapi/openapi.yaml", + "oauth": { + "authorization_url": "https://www.figma.com/oauth", + "token_url": "https://www.figma.com/api/oauth/token", + "pkce": false, + "docs_url": "https://www.figma.com/developers/api#oauth2" + } + }, + { + "name": "google", + "title": "Google APIs", + "description": "Gmail, Calendar, Drive, and other Google services", + "icon_url": "https://cdn.simpleicons.org/google", + "spec_url": "https://www.googleapis.com/discovery/v1/apis", + "oauth": { + "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "pkce": true, + "docs_url": "https://developers.google.com/identity/protocols/oauth2/web-server" + } + }, + { + "name": "stripe", + "title": "Stripe", + "description": "Payments, customers, subscriptions, and billing", + "icon_url": "https://cdn.simpleicons.org/stripe", + "spec_url": "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", + "oauth": { + "authorization_url": "https://connect.stripe.com/oauth/authorize", + "token_url": "https://connect.stripe.com/oauth/token", + "pkce": false, + "docs_url": "https://stripe.com/docs/connect/oauth-reference" + } + }, + { + "name": "hubspot", + "title": "HubSpot", + "description": "CRM contacts, deals, companies, and marketing automation", + "icon_url": "https://cdn.simpleicons.org/hubspot", + "spec_url": "https://raw.githubusercontent.com/HubSpot/HubSpot-public-api-spec-collection/main/PublicApiSpecs/CRM/Contacts/Rollouts/424/v3/contacts.json", + "oauth": { + "authorization_url": "https://app.hubspot.com/oauth/authorize", + "token_url": "https://api.hubspot.com/oauth/v1/token", + "pkce": false, + "docs_url": "https://developers.hubspot.com/docs/api/oauth-quickstart-guide" + } + }, + { + "name": "notion", + "title": "Notion", + "description": "Pages, databases, and workspace content in Notion", + "icon_url": "https://cdn.simpleicons.org/notion", + "spec_url": "https://raw.githubusercontent.com/TakashiSasaki/notion-openapi/main/Page/notion_api_page_management_v1.yaml", + "oauth": { + "authorization_url": "https://api.notion.com/v1/oauth/authorize", + "token_url": "https://api.notion.com/v1/oauth/token", + "pkce": false, + "docs_url": "https://developers.notion.com/docs/authorization" + } + }, + { + "name": "slack", + "title": "Slack", + "description": "Channels, messages, users, and workspace management", + "icon_url": "https://raw.githubusercontent.com/simple-icons/simple-icons/develop/icons/slack.svg", + "spec_url": "https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json", + "oauth": { + "authorization_url": "https://slack.com/oauth/v2/authorize", + "token_url": "https://slack.com/api/oauth.v2.access", + "pkce": false, + "docs_url": "https://api.slack.com/authentication/oauth-v2" + } + }, + { + "name": "shopify", + "title": "Shopify", + "description": "Products, orders, customers, and store management", + "icon_url": "https://cdn.simpleicons.org/shopify", + "spec_url": "https://raw.githubusercontent.com/allengrant/shopify_openapi/master/shopify_openapi.json", + "oauth": { + "authorization_url": "https://{shop}.myshopify.com/admin/oauth/authorize", + "token_url": "https://{shop}.myshopify.com/admin/oauth/access_token", + "pkce": false, + "docs_url": "https://shopify.dev/docs/apps/auth/get-access-tokens/authorization-code-grant" + } + }, + { + "name": "snowflake", + "title": "Snowflake", + "description": "Data warehouse queries, database operations, and analytics via the Snowflake SQL API", + "icon_url": "https://cdn.simpleicons.org/snowflake", + "spec_url": "https://raw.githubusercontent.com/snowflakedb/snowflake-rest-api-specs/refs/heads/main/specifications/sqlapi.yaml", + "oauth": { + "authorization_url": "https://.snowflakecomputing.com/oauth/authorize", + "token_url": "https://.snowflakecomputing.com/oauth/token-request", + "pkce": false, + "docs_url": "https://docs.snowflake.com/en/user-guide/oauth-custom" + } + } + ] +} diff --git a/ui/litellm-dashboard/next.config.mjs b/ui/litellm-dashboard/next.config.mjs index bdf492de332..bd434a0fabb 100644 --- a/ui/litellm-dashboard/next.config.mjs +++ b/ui/litellm-dashboard/next.config.mjs @@ -5,6 +5,8 @@ import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const DEV_PROXY_TARGET = process.env.LITELLM_BACKEND_URL || "http://localhost:4000"; + const nextConfig = { output: "export", basePath: "", @@ -13,6 +15,67 @@ const nextConfig = { // Must be absolute; "." is no longer allowed root: __dirname, }, + // Dev-only: proxy API calls to the litellm backend to avoid CORS issues + async rewrites() { + return [ + { + source: "/v1/:path*", + destination: `${DEV_PROXY_TARGET}/v1/:path*`, + }, + { + source: "/v2/:path*", + destination: `${DEV_PROXY_TARGET}/v2/:path*`, + }, + { + source: "/key/:path*", + destination: `${DEV_PROXY_TARGET}/key/:path*`, + }, + { + source: "/team/:path*", + destination: `${DEV_PROXY_TARGET}/team/:path*`, + }, + { + source: "/user/:path*", + destination: `${DEV_PROXY_TARGET}/user/:path*`, + }, + { + source: "/model/:path*", + destination: `${DEV_PROXY_TARGET}/model/:path*`, + }, + { + source: "/health/:path*", + destination: `${DEV_PROXY_TARGET}/health/:path*`, + }, + { + source: "/callbacks/:path*", + destination: `${DEV_PROXY_TARGET}/callbacks/:path*`, + }, + { + source: "/config/:path*", + destination: `${DEV_PROXY_TARGET}/config/:path*`, + }, + { + source: "/mcp-rest/:path*", + destination: `${DEV_PROXY_TARGET}/mcp-rest/:path*`, + }, + { + source: "/sso/:path*", + destination: `${DEV_PROXY_TARGET}/sso/:path*`, + }, + { + source: "/.well-known/:path*", + destination: `${DEV_PROXY_TARGET}/.well-known/:path*`, + }, + { + source: "/litellm/.well-known/:path*", + destination: `${DEV_PROXY_TARGET}/litellm/.well-known/:path*`, + }, + { + source: "/in_product_nudges", + destination: `${DEV_PROXY_TARGET}/in_product_nudges`, + }, + ]; + }, }; export default nextConfig; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx new file mode 100644 index 00000000000..28a81abe4cf --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx @@ -0,0 +1,75 @@ +import React, { useState } from "react"; +import { Form, Input, Tooltip } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { FormInstance } from "antd/es/form"; +import { AUTH_TYPE } from "./types"; +import OpenAPIQuickPicker, { OpenAPIRegistryEntry } from "./OpenAPIQuickPicker"; + +interface OpenAPIFormSectionProps { + form: FormInstance; + accessToken: string | null; + /** Called when a preset is selected so the parent can sync its formValues state. */ + onValuesChange: (updates: Record) => void; +} + +/** + * Encapsulates all OpenAPI-specific form fields: + * - popular API quick-picker (logos) + * - spec URL input + * + * When a preset is selected, it pre-fills the spec URL and OAuth 2.0 fields + * directly on the Ant Design form instance, then notifies the parent so it + * can keep its own formValues state in sync (since setFieldsValue bypasses + * the Form onValuesChange callback). + */ +const OpenAPIFormSection: React.FC = ({ + form, + accessToken, + onValuesChange, +}) => { + const [selectedPreset, setSelectedPreset] = useState(null); + + const handlePresetSelect = (entry: OpenAPIRegistryEntry) => { + setSelectedPreset(entry.name); + const updates = { + spec_path: entry.spec_url, + auth_type: AUTH_TYPE.OAUTH2, + credentials: { + authorization_url: entry.oauth.authorization_url, + token_url: entry.oauth.token_url, + }, + }; + form.setFieldsValue(updates); + onValuesChange(updates); + }; + + return ( + <> + + + + OpenAPI Spec URL + + + + + } + name="spec_path" + rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]} + > + + + + ); +}; + +export default OpenAPIFormSection; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIQuickPicker.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIQuickPicker.tsx new file mode 100644 index 00000000000..71c927562a9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIQuickPicker.tsx @@ -0,0 +1,109 @@ +import React, { useEffect, useState } from "react"; +import { Spin } from "antd"; +import { fetchOpenAPIRegistry } from "../networking"; + +export interface OpenAPIRegistryEntry { + name: string; + title: string; + description: string; + icon_url: string; + spec_url: string; + oauth: { + authorization_url: string; + token_url: string; + pkce: boolean; + docs_url: string; + }; +} + +interface OpenAPIQuickPickerProps { + accessToken: string | null; + selectedName: string | null; + onSelect: (entry: OpenAPIRegistryEntry) => void; +} + +const OpenAPIQuickPicker: React.FC = ({ + accessToken, + selectedName, + onSelect, +}) => { + const [apis, setApis] = useState([]); + const [loading, setLoading] = useState(false); + const [imgErrors, setImgErrors] = useState>(new Set()); + + useEffect(() => { + if (!accessToken) return; + setLoading(true); + fetchOpenAPIRegistry(accessToken) + .then((data) => setApis(data.apis ?? [])) + .catch(() => setApis([])) + .finally(() => setLoading(false)); + }, [accessToken]); + + const handleImgError = (name: string) => { + setImgErrors((prev) => new Set(prev).add(name)); + }; + + if (loading) { + return ( +
+ Popular APIs +
+ +
+
+ ); + } + + if (apis.length === 0) return null; + + return ( +
+ Popular APIs + +
+ {apis.map((api) => { + const isSelected = selectedName === api.name; + const imgFailed = imgErrors.has(api.name); + return ( + + ); + })} +
+ +

+ Select an API to pre-fill the spec URL and OAuth 2.0 settings, or enter + your own spec URL below. +

+
+ ); +}; + +export default OpenAPIQuickPicker; 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 6ca58ffae24..582d9c2a8b5 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 @@ -10,6 +10,7 @@ import MCPConnectionStatus from "./mcp_connection_status"; import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; import MCPPermissionManagement from "./MCPPermissionManagement"; +import OpenAPIFormSection from "./OpenAPIFormSection"; import { isAdminRole } from "@/utils/roles"; import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; @@ -603,25 +604,15 @@ const CreateMCPServer: React.FC = ({ )} - {/* OpenAPI Spec URL - only show for OpenAPI transport */} + {/* OpenAPI: logo picker + spec URL input */} {transportType === TRANSPORT.OPENAPI && ( - - OpenAPI Spec URL - - - - + + setFormValues((prev) => ({ ...prev, ...updates })) } - name="spec_path" - rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]} - > - - + /> )} {/* BYOK toggle - only for OpenAPI */} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 91454d8d8b3..6b522aab1cc 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -78,7 +78,12 @@ import { jsonFields } from "./common_components/check_openapi_schema"; import NotificationsManager from "./molecules/notifications_manager"; const isLocal = process.env.NODE_ENV === "development"; -const defaultProxyBaseUrl = isLocal ? "http://localhost:4000" : null; +// In dev, if NEXT_PUBLIC_USE_REWRITES=true the Next.js dev server proxies API calls +// to the backend — use relative URLs (null) so rewrites can intercept them. +const defaultProxyBaseUrl = + isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true" + ? "http://localhost:4000" + : null; const defaultServerRootPath = "/"; export let serverRootPath = defaultServerRootPath; export let proxyBaseUrl = defaultProxyBaseUrl; @@ -98,7 +103,10 @@ const updateProxyBaseUrl = (serverRootPath: string, receivedProxyBaseUrl: string * Special function for updating the proxy base url. Should only be called by getUiConfig. */ const browserLocation = getWindowLocation(); - const resolvedDefaultProxyBaseUrl = isLocal ? "http://localhost:4000" : browserLocation?.origin ?? null; + const resolvedDefaultProxyBaseUrl = + isLocal && process.env.NEXT_PUBLIC_USE_REWRITES !== "true" + ? "http://localhost:4000" + : browserLocation?.origin ?? null; let initialProxyBaseUrl = receivedProxyBaseUrl || resolvedDefaultProxyBaseUrl; console.log("proxyBaseUrl:", proxyBaseUrl); console.log("serverRootPath:", serverRootPath); @@ -6243,6 +6251,27 @@ export const updateInternalUserSettings = async (accessToken: string, settings: } }; +export const fetchOpenAPIRegistry = async (accessToken: string) => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/openapi-registry` + : `/v1/mcp/openapi-registry`; + + const response = await fetch(url, { + method: HTTP_REQUEST.GET, + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(deriveErrorMessage(errorData)); + } + + return await response.json(); +}; + export const fetchDiscoverableMCPServers = async (accessToken: string) => { try { const url = proxyBaseUrl diff --git a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx index 32092e7f33c..eff4ff5cd60 100644 --- a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx +++ b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx @@ -58,13 +58,18 @@ export const useTestMCPConnection = ({ 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 = !!( - hasEndpoint && - formValues.transport && - formValues.auth_type && - accessToken && - (!requiresOAuthToken || oauthAccessToken) - ); + + // For OpenAPI: tools are derived from the spec itself — no auth needed to load them. + // For other transports: auth_type is required, and OAuth interactive flows need a token. + const canFetchTools = isOpenAPITransport + ? !!(hasEndpoint && accessToken) + : !!( + hasEndpoint && + formValues.transport && + formValues.auth_type && + accessToken && + (!requiresOAuthToken || oauthAccessToken) + ); const staticHeadersKey = JSON.stringify(formValues.static_headers ?? {}); const credentialsKey = JSON.stringify(formValues.credentials ?? {}); @@ -74,7 +79,8 @@ export const useTestMCPConnection = ({ return; } - if (requiresOAuthToken && !oauthAccessToken) { + // For OpenAPI transport, tools are derived from the spec — no OAuth token needed. + if (requiresOAuthToken && !oauthAccessToken && !isOpenAPITransport) { return; }