feat(ui): add OpenAPI MCP server support with popular API quick-picker

- 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)
This commit is contained in:
Ishaan Jaffer 2026-03-09 15:49:52 -07:00
parent 90b6cc2a4c
commit 2b2e0de2af
8 changed files with 472 additions and 27 deletions

View file

@ -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()

View file

@ -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://<account>.snowflakecomputing.com/oauth/authorize",
"token_url": "https://<account>.snowflakecomputing.com/oauth/token-request",
"pkce": false,
"docs_url": "https://docs.snowflake.com/en/user-guide/oauth-custom"
}
}
]
}

View file

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

View file

@ -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<string, any>) => 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<OpenAPIFormSectionProps> = ({
form,
accessToken,
onValuesChange,
}) => {
const [selectedPreset, setSelectedPreset] = useState<string | null>(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 (
<>
<OpenAPIQuickPicker
accessToken={accessToken}
selectedName={selectedPreset}
onSelect={handlePresetSelect}
/>
<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>
</>
);
};
export default OpenAPIFormSection;

View file

@ -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<OpenAPIQuickPickerProps> = ({
accessToken,
selectedName,
onSelect,
}) => {
const [apis, setApis] = useState<OpenAPIRegistryEntry[]>([]);
const [loading, setLoading] = useState(false);
const [imgErrors, setImgErrors] = useState<Set<string>>(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 (
<div className="mb-4">
<span className="text-sm font-medium text-gray-700">Popular APIs</span>
<div className="flex justify-center py-6">
<Spin size="small" />
</div>
</div>
);
}
if (apis.length === 0) return null;
return (
<div className="mb-4">
<span className="text-sm font-medium text-gray-700 block mb-2">Popular APIs</span>
<div className="grid grid-cols-5 gap-2">
{apis.map((api) => {
const isSelected = selectedName === api.name;
const imgFailed = imgErrors.has(api.name);
return (
<button
key={api.name}
type="button"
title={api.description}
onClick={() => onSelect(api)}
className={`flex flex-col items-center gap-1.5 p-3 rounded-lg border transition-all cursor-pointer
${
isSelected
? "border-blue-500 bg-blue-50 shadow-sm"
: "border-gray-200 hover:border-blue-300 hover:bg-gray-50"
}`}
>
{imgFailed ? (
<span className="w-7 h-7 rounded-full bg-gray-200 flex items-center justify-center text-sm font-bold text-gray-600">
{api.title.charAt(0)}
</span>
) : (
<img
src={api.icon_url}
alt={api.title}
className="w-7 h-7 object-contain"
onError={() => handleImgError(api.name)}
/>
)}
<span className="text-xs text-gray-600 text-center leading-tight font-medium">
{api.title}
</span>
</button>
);
})}
</div>
<p className="text-xs text-gray-400 mt-2">
Select an API to pre-fill the spec URL and OAuth 2.0 settings, or enter
your own spec URL below.
</p>
</div>
);
};
export default OpenAPIQuickPicker;

View file

@ -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<CreateMCPServerProps> = ({
</Form.Item>
)}
{/* OpenAPI Spec URL - only show for OpenAPI transport */}
{/* OpenAPI: logo picker + spec URL input */}
{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>
<OpenAPIFormSection
form={form}
accessToken={accessToken}
onValuesChange={(updates) =>
setFormValues((prev) => ({ ...prev, ...updates }))
}
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>
/>
)}
{/* BYOK toggle - only for OpenAPI */}

View file

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

View file

@ -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;
}