From 0695aa1894658086278c79e70381e148b0c537dd Mon Sep 17 00:00:00 2001 From: Ishaan Jaffer Date: Fri, 6 Mar 2026 18:28:48 -0800 Subject: [PATCH] feat(mcp): popular REST API gallery for OpenAPI MCPs + OAuth2 user connect in ChatUI Admin: when adding an OpenAPI MCP, shows a gallery of 12 well-known APIs (GitHub, Figma, Jira, Confluence, Slack, Stripe, Notion, Linear, HubSpot, Salesforce, Zendesk, Snowflake) with logos. Clicking one pre-fills the OpenAPI spec URL and OAuth2 URLs/scopes so the admin doesn't need to look them up. User (ChatUI Apps panel): OAuth2 MCP servers now show a "Sign In" button instead of a toggle. After OAuth flow completes the panel shows a "Connected" badge. Backend: - Add 12 REST API entries to mcp_registry.json (category "REST APIs") - Add GET /v1/mcp/user/credential/{server_id} endpoint to check per-user creds - Extend has_user_credential annotation to cover oauth2 servers, not just BYOK - Fix _get_tools_from_server to check spec_path before creating MCP client, fixing GitHub/Figma tool loading (client creation attempted m2m auth which fails for auth-code-only providers) - Propagate is_byok/byok_description/byok_api_key_help_url from YAML config into MCPServer when loading from proxy_config.yaml --- .../mcp_server/mcp_server_manager.py | 23 +- .../mcp_management_endpoints.py | 36 ++- litellm/proxy/mcp_registry.json | 156 +++++++++ .../src/components/chat/MCPAppsPanel.tsx | 245 ++++++++++---- .../mcp_tools/create_mcp_server.tsx | 301 ++++++++++++++++-- .../src/components/mcp_tools/types.tsx | 6 + .../src/components/networking.tsx | 30 ++ 7 files changed, 706 insertions(+), 91 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 7c17da36bb7..4dd7a87dca1 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -315,6 +315,10 @@ class MCPServerManager: command=server_config.get("command", None) or "", args=server_config.get("args", None) or [], env=server_config.get("env", None) or {}, + # byok fields + is_byok=bool(server_config.get("is_byok", False)), + byok_description=server_config.get("byok_description", []) or [], + byok_api_key_help_url=server_config.get("byok_api_key_help_url", None), # oauth specific fields client_id=server_config.get("client_id", None), client_secret=server_config.get("client_secret", None), @@ -1008,22 +1012,21 @@ class MCPServerManager: extra_headers = {} extra_headers.update(server.static_headers) - stdio_env = self._build_stdio_env(server, raw_headers) - - client = await self._create_mcp_client( - server=server, - mcp_auth_header=mcp_auth_header, - extra_headers=extra_headers, - stdio_env=stdio_env, - ) - - ## HANDLE OPENAPI TOOLS + ## HANDLE OPENAPI TOOLS — skip MCP client creation entirely if server.spec_path: _tools = global_mcp_tool_registry.list_tools(tool_prefix=server.name) tools = global_mcp_tool_registry.convert_tools_to_mcp_sdk_tool_type( _tools ) else: + stdio_env = self._build_stdio_env(server, raw_headers) + + client = await self._create_mcp_client( + server=server, + mcp_auth_header=mcp_auth_header, + extra_headers=extra_headers, + stdio_env=stdio_env, + ) tools = await self._fetch_tools_with_timeout(client, server.name) prefixed_or_original_tools = self._create_prefixed_tools( diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index b48db72a536..f06b6cd6adb 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -82,6 +82,7 @@ if MCP_AVAILABLE: get_all_mcp_servers_for_user, get_mcp_server, get_user_credential, + has_user_credential, store_user_credential, update_mcp_server, ) @@ -604,23 +605,24 @@ if MCP_AVAILABLE: server.mcp_info = {} server.mcp_info["is_public"] = True - # Annotate has_user_credential for BYOK servers (single batched query) + # Annotate has_user_credential for BYOK and OAuth2 user-token servers (single batched query) from litellm.proxy.proxy_server import prisma_client as _byok_prisma_client user_id = user_api_key_dict.user_id or "" if user_id and _byok_prisma_client is not None: - byok_server_ids = [ + user_cred_server_ids = [ s.server_id for s in redacted_mcp_servers if getattr(s, "is_byok", False) + or getattr(s, "auth_type", None) == "oauth2" ] - if byok_server_ids: + if user_cred_server_ids: cred_rows = await _byok_prisma_client.db.litellm_mcpusercredentials.find_many( - where={"user_id": user_id, "server_id": {"in": byok_server_ids}} + where={"user_id": user_id, "server_id": {"in": user_cred_server_ids}} ) cred_set = {r.server_id for r in cred_rows} for server in redacted_mcp_servers: - if getattr(server, "is_byok", False): + if getattr(server, "is_byok", False) or getattr(server, "auth_type", None) == "oauth2": server.has_user_credential = server.server_id in cred_set # Virtual keys only get a sanitized discovery view. @@ -1060,6 +1062,30 @@ if MCP_AVAILABLE: return Response(status_code=status.HTTP_202_ACCEPTED) + @router.get( + "/user/credential/{server_id}", + description="Check whether the calling user has a stored credential for an MCP server", + dependencies=[Depends(user_api_key_auth)], + response_model=MCPUserCredentialResponse, + ) + @management_endpoint_wrapper + async def get_mcp_user_credential( + server_id: str, + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + ): + """Check if the calling user has a credential stored for this MCP server.""" + prisma_client = get_prisma_client_or_throw( + "Database not connected. Connect a database to use BYOK MCP servers" + ) + user_id = user_api_key_dict.user_id + if not user_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "User ID not found in token"}, + ) + has_cred = await has_user_credential(prisma_client, user_id, server_id) + return MCPUserCredentialResponse(server_id=server_id, has_credential=has_cred) + @router.post( "/server/{server_id}/user-credential", description="Store or update the calling user's API key for a BYOK MCP server", diff --git a/litellm/proxy/mcp_registry.json b/litellm/proxy/mcp_registry.json index 2e1e8f64eae..5f7209aae0a 100644 --- a/litellm/proxy/mcp_registry.json +++ b/litellm/proxy/mcp_registry.json @@ -421,6 +421,162 @@ {"name": "SUPABASE_URL", "description": "Supabase Project URL", "secret": false}, {"name": "SUPABASE_SERVICE_ROLE_KEY", "description": "Supabase Service Role Key", "secret": true} ] + }, + { + "name": "github_openapi", + "title": "GitHub", + "description": "Repos, issues, PRs, and code search — imported from GitHub's OpenAPI spec", + "icon_url": "https://cdn.simpleicons.org/github", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions-next/api.github.com/api.github.com.json", + "auth_type": "oauth2", + "authorization_url": "https://github.com/login/oauth/authorize", + "token_url": "https://github.com/login/oauth/access_token", + "default_scopes": ["repo", "user"] + }, + { + "name": "figma_openapi", + "title": "Figma", + "description": "Read and write Figma files, comments, and components via Figma REST API", + "icon_url": "https://cdn.simpleicons.org/figma", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://raw.githubusercontent.com/figma/rest-api-spec/main/openapi/openapi.yaml", + "auth_type": "oauth2", + "authorization_url": "https://www.figma.com/oauth", + "token_url": "https://api.figma.com/v1/oauth/token", + "default_scopes": ["file_read"] + }, + { + "name": "jira_openapi", + "title": "Jira", + "description": "Issues, sprints, projects, and boards via Atlassian Jira REST API", + "icon_url": "https://cdn.simpleicons.org/jira", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json", + "auth_type": "oauth2", + "authorization_url": "https://auth.atlassian.com/authorize", + "token_url": "https://auth.atlassian.com/oauth/token", + "default_scopes": ["read:jira-work", "write:jira-work", "offline_access"] + }, + { + "name": "confluence_openapi", + "title": "Confluence", + "description": "Pages, spaces, and search via Atlassian Confluence REST API", + "icon_url": "https://cdn.simpleicons.org/confluence", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://developer.atlassian.com/cloud/confluence/swagger.v3.json", + "auth_type": "oauth2", + "authorization_url": "https://auth.atlassian.com/authorize", + "token_url": "https://auth.atlassian.com/oauth/token", + "default_scopes": ["read:confluence-content.all", "write:confluence-content", "offline_access"] + }, + { + "name": "snowflake_openapi", + "title": "Snowflake", + "description": "Query and manage Snowflake data warehouses via Snowflake REST API", + "icon_url": "https://cdn.simpleicons.org/snowflake", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://docs.snowflake.com/en/_downloads/openapi.json", + "auth_type": "oauth2", + "authorization_url": "https://.snowflakecomputing.com/oauth/authorize", + "token_url": "https://.snowflakecomputing.com/oauth/token-request", + "default_scopes": ["session:role:PUBLIC"] + }, + { + "name": "slack_openapi", + "title": "Slack", + "description": "Messages, channels, users, and files via Slack Web API", + "icon_url": "https://cdn.simpleicons.org/slack", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json", + "auth_type": "oauth2", + "authorization_url": "https://slack.com/oauth/v2/authorize", + "token_url": "https://slack.com/api/oauth.v2.access", + "default_scopes": ["channels:read", "chat:write", "files:read", "users:read"] + }, + { + "name": "hubspot_openapi", + "title": "HubSpot", + "description": "Contacts, deals, companies, and marketing workflows via HubSpot CRM API", + "icon_url": "https://cdn.simpleicons.org/hubspot", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://api.hubspot.com/api-catalog-public/v1/apis", + "auth_type": "oauth2", + "authorization_url": "https://app.hubspot.com/oauth/authorize", + "token_url": "https://api.hubspot.com/oauth/v1/token", + "default_scopes": ["crm.objects.contacts.read", "crm.objects.deals.read", "crm.objects.companies.read"] + }, + { + "name": "salesforce_openapi", + "title": "Salesforce", + "description": "Leads, opportunities, accounts, and custom objects via Salesforce REST API", + "icon_url": "https://cdn.simpleicons.org/salesforce", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://developer.salesforce.com/docs/platform/rest/guide/intro_rest_resources.htm", + "auth_type": "oauth2", + "authorization_url": "https://login.salesforce.com/services/oauth2/authorize", + "token_url": "https://login.salesforce.com/services/oauth2/token", + "default_scopes": ["api", "refresh_token"] + }, + { + "name": "zendesk_openapi", + "title": "Zendesk", + "description": "Tickets, users, and organizations via Zendesk Support REST API", + "icon_url": "https://cdn.simpleicons.org/zendesk", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://developer.zendesk.com/api-reference/ticketing/introduction/", + "auth_type": "oauth2", + "authorization_url": "https://{subdomain}.zendesk.com/oauth/authorizations/new", + "token_url": "https://{subdomain}.zendesk.com/oauth/tokens", + "default_scopes": ["read", "write"] + }, + { + "name": "notion_openapi", + "title": "Notion", + "description": "Pages, databases, and blocks via Notion REST API", + "icon_url": "https://cdn.simpleicons.org/notion", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://raw.githubusercontent.com/notion-community/notion-openapi/main/notion-api.yaml", + "auth_type": "oauth2", + "authorization_url": "https://api.notion.com/v1/oauth/authorize", + "token_url": "https://api.notion.com/v1/oauth/token", + "default_scopes": [] + }, + { + "name": "linear_openapi", + "title": "Linear", + "description": "Issues, projects, cycles, and teams via Linear REST API", + "icon_url": "https://cdn.simpleicons.org/linear", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://raw.githubusercontent.com/linear/linear/master/packages/sdk/src/_generated_documents.ts", + "auth_type": "oauth2", + "authorization_url": "https://linear.app/oauth/authorize", + "token_url": "https://api.linear.app/oauth/token", + "default_scopes": ["read", "write"] + }, + { + "name": "stripe_openapi", + "title": "Stripe", + "description": "Payments, subscriptions, customers, and invoices via Stripe REST API", + "icon_url": "https://cdn.simpleicons.org/stripe", + "category": "REST APIs", + "transport": "openapi", + "openapi_spec_url": "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", + "auth_type": "bearer_token", + "authorization_url": null, + "token_url": null, + "default_scopes": [] } ] } diff --git a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx index 926d6697bca..73d63bc9827 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx @@ -1,9 +1,9 @@ "use client"; -import React, { useEffect, useState } from "react"; -import { Switch, Spin, Input, Button } from "antd"; -import { SearchOutlined, ArrowLeftOutlined, RightOutlined } from "@ant-design/icons"; -import { fetchMCPServers, listMCPTools } from "../networking"; +import React, { useEffect, useState, useCallback } from "react"; +import { Spin, Input, Button, Tag } from "antd"; +import { SearchOutlined, ArrowLeftOutlined, RightOutlined, CheckCircleFilled, LinkOutlined } from "@ant-design/icons"; +import { fetchMCPServers, listMCPTools, checkMCPUserCredential, deleteMCPUserCredential, proxyBaseUrl } from "../networking"; import { MCPServer } from "../mcp_tools/types"; import { message } from "antd"; @@ -24,6 +24,37 @@ function getAvatarColor(name: string): string { return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length]; } +function ServerAvatar({ server, size = 38 }: { server: MCPServer; size?: number }) { + const name = server.server_name ?? server.alias ?? server.server_id; + const logoUrl = server.mcp_info?.logo_url; + const color = getAvatarColor(name); + const radius = size === 64 ? 16 : 10; + const fontSize = size === 64 ? 28 : 16; + + const [imgError, setImgError] = useState(false); + + if (logoUrl && !imgError) { + return ( + {name} setImgError(true)} + style={{ width: size, height: size, borderRadius: radius, objectFit: "contain", flexShrink: 0, background: "#f9fafb", border: "1px solid #e5e7eb" }} + /> + ); + } + + return ( +
+ {name.charAt(0).toUpperCase()} +
+ ); +} + type TabKey = "all" | "connected"; const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange }) => { @@ -33,8 +64,12 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange const [activeTab, setActiveTab] = useState("all"); const [togglingOn, setTogglingOn] = useState>(new Set()); const [detailServer, setDetailServer] = useState(null); + // credential state for the detail view (OAuth2 / BYOK servers) + const [hasCredential, setHasCredential] = useState(null); + const [credLoading, setCredLoading] = useState(false); + const [disconnecting, setDisconnecting] = useState(false); - useEffect(() => { + const loadServers = useCallback(() => { let cancelled = false; setLoading(true); fetchMCPServers(accessToken) @@ -43,15 +78,52 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange const list: MCPServer[] = Array.isArray(data) ? data : (data?.data ?? []); setServers(list); }) - .catch(() => { - if (!cancelled) setServers([]); - }) - .finally(() => { - if (!cancelled) setLoading(false); - }); + .catch(() => { if (!cancelled) setServers([]); }) + .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [accessToken]); + useEffect(() => { + const cancel = loadServers(); + return cancel; + }, [loadServers]); + + // Handle return from OAuth — URL param ?mcp_oauth_complete= + useEffect(() => { + if (typeof window === "undefined") return; + const params = new URLSearchParams(window.location.search); + const completedServerId = params.get("mcp_oauth_complete"); + if (!completedServerId) return; + // Remove the param from URL without reload + const newUrl = new URL(window.location.href); + newUrl.searchParams.delete("mcp_oauth_complete"); + window.history.replaceState({}, "", newUrl.toString()); + // Reload servers and open the detail for the completed server + loadServers(); + }, [loadServers]); + + const isOAuth2Server = (s: MCPServer) => (s as MCPServer & { auth_type?: string }).auth_type === "oauth2"; + + // When the detail server changes, check credential status for OAuth2 / BYOK servers + useEffect(() => { + if (!detailServer) { setHasCredential(null); return; } + const needsCheck = isOAuth2Server(detailServer) || (detailServer as MCPServer & { is_byok?: boolean }).is_byok; + if (!needsCheck) { setHasCredential(null); return; } + + // Try from the server list first (has_user_credential is annotated server-side) + const annotated = (detailServer as MCPServer & { has_user_credential?: boolean }).has_user_credential; + if (annotated !== undefined) { + setHasCredential(annotated); + return; + } + // Fall back to individual API call + setCredLoading(true); + checkMCPUserCredential(accessToken, detailServer.server_id) + .then((res) => setHasCredential(res.has_credential)) + .catch(() => setHasCredential(false)) + .finally(() => setCredLoading(false)); + }, [detailServer, accessToken]); + const handleToggle = async (serverName: string, checked: boolean) => { if (!checked) { onChange(selectedServers.filter((s) => s !== serverName)); @@ -69,13 +141,38 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange message.warning(`Could not load tools for ${serverName}`); } finally { setTogglingOn((prev) => { - const next = new Set(prev); - next.delete(serverName); - return next; + const next = new Set(prev); next.delete(serverName); return next; }); } }; + const handleOAuthSignIn = (server: MCPServer) => { + const base = proxyBaseUrl ?? ""; + const redirectUri = window.location.href.split("?")[0] + `?mcp_oauth_complete=${server.server_id}`; + const authorizeUrl = `${base}/v1/mcp/oauth/authorize?server_id=${encodeURIComponent(server.server_id)}&redirect_uri=${encodeURIComponent(redirectUri)}`; + window.location.href = authorizeUrl; + }; + + const handleDisconnect = async (server: MCPServer) => { + const name = server.server_name ?? server.alias ?? server.server_id; + setDisconnecting(true); + try { + await deleteMCPUserCredential(accessToken, server.server_id); + setHasCredential(false); + onChange(selectedServers.filter((s) => s !== name)); + // Update the server in the list + setServers((prev) => prev.map((s) => + s.server_id === server.server_id + ? { ...s, has_user_credential: false } as MCPServer + : s + )); + } catch { + message.error("Failed to disconnect"); + } finally { + setDisconnecting(false); + } + }; + const nameOf = (s: MCPServer) => s.server_name ?? s.alias ?? s.server_id; const filtered = servers.filter((s) => { @@ -83,18 +180,26 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange const matchesQuery = !query.trim() || name.toLowerCase().includes(query.toLowerCase()) || (s.description ?? "").toLowerCase().includes(query.toLowerCase()); - const matchesTab = activeTab === "all" || selectedServers.includes(name); + const matchesTab = activeTab === "all" || selectedServers.includes(name) || + (isOAuth2Server(s) && (s as MCPServer & { has_user_credential?: boolean }).has_user_credential); return matchesQuery && matchesTab; }); - const connectedCount = servers.filter((s) => selectedServers.includes(nameOf(s))).length; + const connectedCount = servers.filter((s) => { + const name = nameOf(s); + if (selectedServers.includes(name)) return true; + if (isOAuth2Server(s) && (s as MCPServer & { has_user_credential?: boolean }).has_user_credential) return true; + return false; + }).length; // ── Detail view ── if (detailServer) { const name = nameOf(detailServer); const isConnected = selectedServers.includes(name); const isTogglingOn = togglingOn.has(name); - const color = getAvatarColor(name); + const isOAuth2 = isOAuth2Server(detailServer); + const isByok = (detailServer as MCPServer & { is_byok?: boolean }).is_byok; + const needsUserAuth = isOAuth2 || isByok; return (
@@ -113,41 +218,67 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange {/* Avatar + name + connect */}
-
- {name.charAt(0).toUpperCase()} -
+

{name}

{detailServer.description ?? "MCP server"}

- + + {/* Connect button — OAuth2 path */} + {needsUserAuth && ( + credLoading ? ( + + ) : hasCredential ? ( +
+ } color="success" style={{ margin: 0, fontSize: 12, padding: "4px 10px" }}> + Connected + + +
+ ) : ( + + ) + )} + + {/* Connect button — standard (non-OAuth2) path */} + {!needsUserAuth && ( + + )}
{/* Info table */}

Information

- {[ + {([ ["Server ID", detailServer.server_id], - ["Transport", (detailServer as MCPServer & { mcp_info?: { server_url?: string } }).mcp_info?.server_url ? "HTTP" : "stdio"], - ["Status", isConnected ? "Connected" : "Not connected"], - ].filter(([, v]) => v).map(([label, value], i, arr) => ( + ["Auth", isOAuth2 ? "OAuth 2.0 (sign in with your account)" : isByok ? "API Key (BYOK)" : "None"], + ["Status", (needsUserAuth ? hasCredential : isConnected) ? "Connected" : "Not connected"], + ] as [string, string][]).filter(([, v]) => v).map(([label, value], i, arr) => (
{label} {value} @@ -166,7 +297,7 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange
-

MCP Servers

+

Apps

= ({ accessToken, selectedServers, onChange }}>Beta

- Connect tools to your chat. + Connect apps to your chat.

} - placeholder="Search servers..." + placeholder="Search apps..." value={query} onChange={(e) => setQuery(e.target.value)} allowClear @@ -217,15 +348,17 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange ) : filtered.length === 0 ? (
{servers.length === 0 - ? "No MCP servers configured. Add servers in Tools → MCP Servers." - : activeTab === "connected" ? "No servers connected yet." : "No servers match your search."} + ? "No apps configured. Add servers in Tools → MCP Servers." + : activeTab === "connected" ? "No apps connected yet." : "No apps match your search."}
) : (
{filtered.map((server, idx) => { const name = nameOf(server); const isConnected = selectedServers.includes(name); - const color = getAvatarColor(name); + const isOAuth2 = isOAuth2Server(server); + const hasCred = (server as MCPServer & { has_user_credential?: boolean }).has_user_credential; + const isLinked = isConnected || (isOAuth2 && hasCred); const isLeftCol = idx % 2 === 0; return ( @@ -237,19 +370,12 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange padding: "14px 16px", background: "#fff", borderRight: isLeftCol ? "1px solid #f3f4f6" : "none", borderBottom: Math.floor(idx / 2) < Math.floor((filtered.length - 1) / 2) ? "1px solid #f3f4f6" : "none", - cursor: "pointer", minWidth: 0, - transition: "background 0.1s", + cursor: "pointer", minWidth: 0, transition: "background 0.1s", }} onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fafafa"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fff"; }} > -
- {name.charAt(0).toUpperCase()} -
+
{name} @@ -258,8 +384,11 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange {server.description ?? "MCP server"}
- {isConnected && ( - + {isLinked && ( + + )} + {isOAuth2 && !hasCred && ( + Sign In )}
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..3da3cb867ea 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 @@ -4,6 +4,162 @@ import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { createMCPServer } from "../networking"; import { AUTH_TYPE, DiscoverableMCPServer, OAUTH_FLOW, MCPServer, MCPServerCostInfo, TRANSPORT } from "./types"; + +const WELL_KNOWN_OPENAPI_SERVERS: DiscoverableMCPServer[] = [ + { + name: "github_openapi", + title: "GitHub", + description: "Repos, issues, PRs, and code search", + icon_url: "https://cdn.simpleicons.org/github/black", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions-next/api.github.com/api.github.com.json", + auth_type: "oauth2", + authorization_url: "https://github.com/login/oauth/authorize", + token_url: "https://github.com/login/oauth/access_token", + default_scopes: ["repo", "user"], + }, + { + name: "figma_openapi", + title: "Figma", + description: "Files, comments, and components", + icon_url: "https://cdn.simpleicons.org/figma", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://raw.githubusercontent.com/figma/rest-api-spec/main/openapi/openapi.yaml", + auth_type: "oauth2", + authorization_url: "https://www.figma.com/oauth", + token_url: "https://api.figma.com/v1/oauth/token", + default_scopes: ["file_read"], + }, + { + name: "jira_openapi", + title: "Jira", + description: "Issues, sprints, and projects", + icon_url: "https://cdn.simpleicons.org/jira", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json", + auth_type: "oauth2", + authorization_url: "https://auth.atlassian.com/authorize", + token_url: "https://auth.atlassian.com/oauth/token", + default_scopes: ["read:jira-work", "write:jira-work"], + }, + { + name: "confluence_openapi", + title: "Confluence", + description: "Pages, spaces, and search", + icon_url: "https://cdn.simpleicons.org/confluence", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://developer.atlassian.com/cloud/confluence/swagger.v3.json", + auth_type: "oauth2", + authorization_url: "https://auth.atlassian.com/authorize", + token_url: "https://auth.atlassian.com/oauth/token", + default_scopes: ["read:confluence-content.all"], + }, + { + name: "slack_openapi", + title: "Slack", + description: "Messages, channels, and files", + icon_url: "https://cdn.simpleicons.org/slack", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json", + auth_type: "oauth2", + authorization_url: "https://slack.com/oauth/v2/authorize", + token_url: "https://slack.com/api/oauth.v2.access", + default_scopes: ["channels:read", "chat:write"], + }, + { + name: "stripe_openapi", + title: "Stripe", + description: "Payments, subscriptions, and invoices", + icon_url: "https://cdn.simpleicons.org/stripe", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", + auth_type: "bearer_token", + }, + { + name: "notion_openapi", + title: "Notion", + description: "Pages, databases, and search", + icon_url: "https://cdn.simpleicons.org/notion/black", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://developers.notion.com", + auth_type: "oauth2", + authorization_url: "https://api.notion.com/v1/oauth/authorize", + token_url: "https://api.notion.com/v1/oauth/token", + default_scopes: [], + }, + { + name: "linear_openapi", + title: "Linear", + description: "Issues, projects, and teams", + icon_url: "https://cdn.simpleicons.org/linear", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://api.linear.app/graphql", + auth_type: "oauth2", + authorization_url: "https://linear.app/oauth/authorize", + token_url: "https://api.linear.app/oauth/token", + default_scopes: ["read", "write"], + }, + { + name: "hubspot_openapi", + title: "HubSpot", + description: "Contacts, deals, and marketing", + icon_url: "https://cdn.simpleicons.org/hubspot", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://api.hubspot.com/api-catalog-public/v1/apis", + auth_type: "oauth2", + authorization_url: "https://app.hubspot.com/oauth/authorize", + token_url: "https://api.hubspot.com/oauth/v1/token", + default_scopes: ["crm.objects.contacts.read"], + }, + { + name: "salesforce_openapi", + title: "Salesforce", + description: "Leads, opportunities, and accounts", + icon_url: "https://cdn.simpleicons.org/salesforce", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://developer.salesforce.com/docs/platform/rest/guide/intro_rest_resources.htm", + auth_type: "oauth2", + authorization_url: "https://login.salesforce.com/services/oauth2/authorize", + token_url: "https://login.salesforce.com/services/oauth2/token", + default_scopes: ["api"], + }, + { + name: "zendesk_openapi", + title: "Zendesk", + description: "Tickets, users, and support workflows", + icon_url: "https://cdn.simpleicons.org/zendesk", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://developer.zendesk.com/api-reference/ticketing/introduction/", + auth_type: "oauth2", + authorization_url: "https://{subdomain}.zendesk.com/oauth/authorizations/new", + token_url: "https://{subdomain}.zendesk.com/oauth/tokens", + default_scopes: ["read", "write"], + }, + { + name: "snowflake_openapi", + title: "Snowflake", + description: "Queries and data warehouses", + icon_url: "https://cdn.simpleicons.org/snowflake", + category: "REST APIs", + transport: TRANSPORT.OPENAPI, + openapi_spec_url: "https://docs.snowflake.com/en/_downloads/openapi.json", + auth_type: "oauth2", + authorization_url: "https://.snowflakecomputing.com/oauth/authorize", + token_url: "https://.snowflakecomputing.com/oauth/token-request", + default_scopes: ["session:role:SYSADMIN"], + }, +]; import OAuthFormFields from "./OAuthFormFields"; import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPConnectionStatus from "./mcp_connection_status"; @@ -59,6 +215,9 @@ const CreateMCPServer: React.FC = ({ const [transportType, setTransportType] = useState(""); const [searchValue, setSearchValue] = useState(""); const [oauthAccessToken, setOauthAccessToken] = useState(null); + const [openApiServers] = useState(WELL_KNOWN_OPENAPI_SERVERS); + const [selectedOpenApiServer, setSelectedOpenApiServer] = useState(null); + const [showCustomUrl, setShowCustomUrl] = useState(false); const authType = formValues.auth_type as string | undefined; const shouldShowAuthValueField = authType ? AUTH_TYPES_REQUIRING_AUTH_VALUE.includes(authType) : false; const isOAuthAuthType = authType === AUTH_TYPE.OAUTH2; @@ -242,6 +401,14 @@ const CreateMCPServer: React.FC = ({ if (Object.keys(stdioObj).length > 0) { prefillValues.stdio_config = JSON.stringify(stdioObj, null, 2); } + } else if (transport === TRANSPORT.OPENAPI) { + if (prefillData.openapi_spec_url) prefillValues.spec_path = prefillData.openapi_spec_url; + if (prefillData.auth_type) prefillValues.auth_type = prefillData.auth_type; + if (prefillData.authorization_url) prefillValues.authorization_url = prefillData.authorization_url; + if (prefillData.token_url) prefillValues.token_url = prefillData.token_url; + if (prefillData.default_scopes && prefillData.default_scopes.length > 0) { + prefillValues.credentials = { scopes: prefillData.default_scopes }; + } } else if (prefillData.url) { prefillValues.url = prefillData.url; } @@ -403,6 +570,8 @@ const CreateMCPServer: React.FC = ({ const handleTransportChange = (value: string) => { setTransportType(value); + setSelectedOpenApiServer(null); + setShowCustomUrl(false); // Clear fields that are not relevant for the selected transport if (value === "stdio") { form.setFieldsValue({ url: undefined, spec_path: undefined, auth_type: undefined, credentials: undefined }); @@ -458,9 +627,35 @@ const CreateMCPServer: React.FC = ({ React.useEffect(() => { if (!isModalVisible) { setFormValues({}); + setSelectedOpenApiServer(null); + setShowCustomUrl(false); } }, [isModalVisible]); + const handleSelectOpenApiServer = (server: DiscoverableMCPServer) => { + setSelectedOpenApiServer(server); + setShowCustomUrl(false); + const sanitizedName = (server.name || "") + .replace(/[^a-zA-Z0-9_]/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); + const updates: Record = { + server_name: sanitizedName, + alias: sanitizedName, + description: server.description || "", + }; + if (server.openapi_spec_url) updates.spec_path = server.openapi_spec_url; + if (server.auth_type) updates.auth_type = server.auth_type; + if (server.authorization_url) updates.authorization_url = server.authorization_url; + if (server.token_url) updates.token_url = server.token_url; + if (server.default_scopes && server.default_scopes.length > 0) { + updates.credentials = { scopes: server.default_scopes }; + } + form.setFieldsValue(updates); + setFormValues((prev) => ({ ...prev, ...updates })); + setAliasManuallyEdited(false); + }; + // rendering if (!isAdminRole(userRole)) { return null; @@ -603,25 +798,95 @@ const CreateMCPServer: React.FC = ({ )} - {/* OpenAPI Spec URL - only show for OpenAPI transport */} + {/* OpenAPI: gallery of well-known APIs + custom URL */} {transportType === TRANSPORT.OPENAPI && ( - - OpenAPI Spec URL - - - - - } - name="spec_path" - rules={[{ required: true, message: "Please enter an OpenAPI spec URL" }]} - > - - + <> + {openApiServers.length > 0 && ( +
+
Popular APIs
+
+ {openApiServers.map((server) => { + const isSelected = selectedOpenApiServer?.name === server.name; + return ( + + ); + })} +
+ + {/* Divider */} +
+
+ or +
+
+
+ )} + + {/* Custom URL toggle */} + {!showCustomUrl && !selectedOpenApiServer && ( + + )} + + {/* Spec URL field: shown when custom or after selecting a server */} + {(showCustomUrl || selectedOpenApiServer) && ( + + OpenAPI Spec URL + + + + + } + 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/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 6ba25012197..fe868be70bf 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -205,6 +205,12 @@ export interface DiscoverableMCPServer { command?: string | null; args?: string[] | null; env_vars?: Array<{ name: string; description?: string; secret?: boolean }> | null; + // OpenAPI-specific fields + openapi_spec_url?: string | null; + auth_type?: string | null; + authorization_url?: string | null; + token_url?: string | null; + default_scopes?: string[] | null; } export interface DiscoverMCPServersResponse { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 30c1d3c5b81..ae97f94b74e 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -6810,6 +6810,36 @@ export const callMCPTool = async ( } }; +export const checkMCPUserCredential = async ( + accessToken: string, + serverId: string, +): Promise<{ has_credential: boolean }> => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/user/credential/${serverId}` + : `/v1/mcp/user/credential/${serverId}`; + const response = await fetch(url, { + method: "GET", + headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + return { has_credential: false }; + } + return response.json(); +}; + +export const deleteMCPUserCredential = async ( + accessToken: string, + serverId: string, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/user-credential` + : `/v1/mcp/server/${serverId}/user-credential`; + await fetch(url, { + method: "DELETE", + headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` }, + }); +}; + export const tagCreateCall = async (accessToken: string, formValues: TagNewRequest): Promise => { try { let url = proxyBaseUrl ? `${proxyBaseUrl}/tag/new` : `/tag/new`;