diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index b53e1e14d7..83729a16eb 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -8023,6 +8023,80 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "black_forest_labs/flux-kontext-pro": { + "litellm_provider": "black_forest_labs", + "mode": "image_edit", + "output_cost_per_image": 0.04, + "source": "https://bfl.ai/pricing", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ] + }, + "black_forest_labs/flux-kontext-max": { + "litellm_provider": "black_forest_labs", + "mode": "image_edit", + "output_cost_per_image": 0.08, + "source": "https://bfl.ai/pricing", + "supported_endpoints": [ + "/v1/images/edits", + "/v1/images/generations" + ] + }, + "black_forest_labs/flux-pro-1.0-fill": { + "litellm_provider": "black_forest_labs", + "mode": "image_edit", + "output_cost_per_image": 0.05, + "source": "https://bfl.ai/pricing", + "supported_endpoints": [ + "/v1/images/edits" + ] + }, + "black_forest_labs/flux-pro-1.0-expand": { + "litellm_provider": "black_forest_labs", + "mode": "image_edit", + "output_cost_per_image": 0.05, + "source": "https://bfl.ai/pricing", + "supported_endpoints": [ + "/v1/images/edits" + ] + }, + "black_forest_labs/flux-pro-1.1": { + "litellm_provider": "black_forest_labs", + "mode": "image_generation", + "output_cost_per_image": 0.04, + "source": "https://bfl.ai/pricing", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "black_forest_labs/flux-pro-1.1-ultra": { + "litellm_provider": "black_forest_labs", + "mode": "image_generation", + "output_cost_per_image": 0.06, + "source": "https://bfl.ai/pricing", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "black_forest_labs/flux-dev": { + "litellm_provider": "black_forest_labs", + "mode": "image_generation", + "output_cost_per_image": 0.025, + "source": "https://bfl.ai/pricing", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, + "black_forest_labs/flux-pro": { + "litellm_provider": "black_forest_labs", + "mode": "image_generation", + "output_cost_per_image": 0.05, + "source": "https://bfl.ai/pricing", + "supported_endpoints": [ + "/v1/images/generations" + ] + }, "cerebras/llama-3.3-70b": { "input_cost_per_token": 8.5e-07, "litellm_provider": "cerebras", diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 6082e9bd60..0c62f5d5da 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -69,6 +69,72 @@ if MCP_AVAILABLE: return server_auth return mcp_auth_header + async def _get_user_oauth_extra_headers( + server, + user_api_key_dict: UserAPIKeyAuth, + ) -> Optional[Dict[str, str]]: + """ + For OAuth2 servers, look up the user's stored access token and return it + as extra_headers {"Authorization": "Bearer "} so that it reaches + the MCP server the same way the admin "Add MCP / Authorize and Fetch" flow does. + Returns None for non-OAuth2 servers or when no credential is stored. + """ + from litellm.types.mcp import MCPAuth + + if getattr(server, "auth_type", None) != MCPAuth.oauth2: + return None + user_id = getattr(user_api_key_dict, "user_id", None) + server_id = getattr(server, "server_id", None) + if not user_id or not server_id: + return None + try: + from litellm.proxy._experimental.mcp_server.db import ( + get_user_oauth_credential, + ) + from litellm.proxy.utils import get_prisma_client_or_throw + + prisma_client = get_prisma_client_or_throw( + "Database not connected. Connect a database to use OAuth2 MCP tools." + ) + cred = await get_user_oauth_credential(prisma_client, user_id, server_id) + if cred and cred.get("access_token"): + return {"Authorization": f"Bearer {cred['access_token']}"} + except Exception: + verbose_logger.debug("Failed to fetch OAuth credential", exc_info=True) + return None + + async def _get_bulk_user_oauth_headers( + user_api_key_dict: UserAPIKeyAuth, + ) -> Dict[str, Dict[str, str]]: + """ + Fetch ALL OAuth2 credentials for the current user in a single DB query and + return a mapping of server_id → {"Authorization": "Bearer "}. + + This is the batch alternative to calling _get_user_oauth_extra_headers + per-server inside a loop (N+1 DB queries). + """ + user_id = getattr(user_api_key_dict, "user_id", None) + if not user_id: + return {} + try: + from litellm.proxy._experimental.mcp_server.db import ( + list_user_oauth_credentials, + ) + from litellm.proxy.utils import get_prisma_client_or_throw + + prisma_client = get_prisma_client_or_throw( + "Database not connected. Connect a database to use OAuth2 MCP tools." + ) + creds = await list_user_oauth_credentials(prisma_client, user_id) + return { + c["server_id"]: {"Authorization": f"Bearer {c['access_token']}"} + for c in creds + if c.get("access_token") and c.get("server_id") + } + except Exception: + verbose_logger.debug("Failed to bulk-fetch OAuth credentials", exc_info=True) + return {} + def _create_tool_response_objects(tools, server_mcp_info): """Helper function to create tool response objects.""" return [ @@ -162,11 +228,13 @@ if MCP_AVAILABLE: server_auth_header, raw_headers: Optional[Dict[str, str]] = None, user_api_key_auth: Optional[UserAPIKeyAuth] = None, + extra_headers: Optional[Dict[str, str]] = None, ): """Helper function to get tools for a single server.""" tools = await global_mcp_server_manager._get_tools_from_server( server=server, mcp_auth_header=server_auth_header, + extra_headers=extra_headers, add_prefix=False, raw_headers=raw_headers, ) @@ -294,6 +362,13 @@ if MCP_AVAILABLE: # If server_id is specified, only query that specific server if server_id: + # Resolve a server name to its UUID if needed (MCPConnectPicker passes + # server_name strings, but allowed_server_ids_set contains UUIDs). + if server_id not in allowed_server_ids: + _resolved = global_mcp_server_manager.get_mcp_server_by_name(server_id) + if _resolved is not None and _resolved.server_id in set(allowed_server_ids): + server_id = _resolved.server_id + if server_id not in allowed_server_ids: _server = global_mcp_server_manager.get_mcp_server_by_id(server_id) if ( @@ -333,6 +408,8 @@ if MCP_AVAILABLE: server_auth_header = _get_server_auth_header( server, mcp_server_auth_headers, mcp_auth_header ) + # Single-server request: targeted lookup is more efficient than a bulk fetch. + user_oauth_extra_headers = await _get_user_oauth_extra_headers(server, user_api_key_dict) try: list_tools_result = await _get_tools_for_single_server( @@ -340,6 +417,7 @@ if MCP_AVAILABLE: server_auth_header, raw_headers_from_request, user_api_key_dict, + extra_headers=user_oauth_extra_headers, ) except Exception as e: verbose_logger.exception( @@ -373,7 +451,10 @@ if MCP_AVAILABLE: }, ) - # Query all servers the user has access to + # Query all servers the user has access to. + # Bulk-fetch OAuth creds once so each per-server call below can + # do an O(1) dict lookup instead of N individual DB queries. + bulk_oauth_headers = await _get_bulk_user_oauth_headers(user_api_key_dict) errors = [] for allowed_server_id in allowed_server_ids: server = global_mcp_server_manager.get_mcp_server_by_id( @@ -385,6 +466,7 @@ if MCP_AVAILABLE: server_auth_header = _get_server_auth_header( server, mcp_server_auth_headers, mcp_auth_header ) + user_oauth_extra_headers = bulk_oauth_headers.get(server.server_id) try: tools_result = await _get_tools_for_single_server( @@ -392,6 +474,7 @@ if MCP_AVAILABLE: server_auth_header, raw_headers_from_request, user_api_key_dict, + extra_headers=user_oauth_extra_headers, ) list_tools_result.extend(tools_result) except Exception as e: @@ -505,6 +588,16 @@ if MCP_AVAILABLE: request, user_api_key_dict, server_id ) + # Look up per-user OAuth headers for this server (mirrors list_tool_rest_api). + user_oauth_extra_headers: Optional[Dict[str, str]] = None + target_server = next( + (s for s in allowed_mcp_servers if s.server_id == server_id), None + ) + if target_server is not None: + user_oauth_extra_headers = await _get_user_oauth_extra_headers( + target_server, user_api_key_dict + ) + # Call execute_mcp_tool directly (permission checks already done) result = await execute_mcp_tool( name=tool_name, @@ -514,7 +607,7 @@ if MCP_AVAILABLE: user_api_key_auth=data.get("user_api_key_auth"), mcp_auth_header=data.get("mcp_auth_header"), mcp_server_auth_headers=data.get("mcp_server_auth_headers"), - oauth2_headers=data.get("oauth2_headers"), + oauth2_headers=user_oauth_extra_headers or data.get("oauth2_headers"), raw_headers=data.get("raw_headers"), litellm_logging_obj=data.get("litellm_logging_obj"), ) diff --git a/litellm/proxy/mcp_registry.json b/litellm/proxy/mcp_registry.json index 2e1e8f64ea..7c5b21dc39 100644 --- a/litellm/proxy/mcp_registry.json +++ b/litellm/proxy/mcp_registry.json @@ -33,8 +33,8 @@ "icon_url": "https://cdn.simpleicons.org/atlassian", "category": "Developer Tools", "registry_url": "https://registry.modelcontextprotocol.io/servers/com.atlassian%2Fatlassian-mcp-server", - "transport": "sse", - "url": "https://mcp.atlassian.com/v1/sse", + "transport": "http", + "url": "https://mcp.atlassian.com/v1/mcp", "env_vars": [] }, { diff --git a/ui/litellm-dashboard/src/components/chat/ChatPage.tsx b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx index 8a33a2487f..ccf39d2147 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatPage.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx @@ -144,7 +144,10 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user const [inputText, setInputText] = useState(""); const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - const [sidebarView, setSidebarView] = useState<"chats" | "apps" | "credentials">("chats"); + const _oauthReturn = searchParams?.get("mcpOauthReturn"); + const [sidebarView, setSidebarView] = useState<"chats" | "apps" | "credentials">( + _oauthReturn === "apps" ? "apps" : "chats" + ); const [storageBannerDismissed, setStorageBannerDismissed] = useState(false); // Comparison mode state (active when selectedModels.length > 1) @@ -172,6 +175,15 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user renameConversation, } = useChatHistory(activeConversationId); + // Clean up the OAuth return param after it's been consumed + useEffect(() => { + if (_oauthReturn && typeof window !== "undefined") { + const url = new URL(window.location.href); + url.searchParams.delete("mcpOauthReturn"); + window.history.replaceState({}, "", url.toString()); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + // Load models useEffect(() => { if (!accessToken) return; diff --git a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx index a80468edfb..32d208261e 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useCallback, useEffect, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { Spin, Input, Button, Skeleton } from "antd"; import { SearchOutlined, ArrowLeftOutlined, RightOutlined, ToolOutlined, CheckCircleOutlined } from "@ant-design/icons"; import { deleteMCPOAuthUserCredential, fetchMCPServers, getMCPOAuthUserCredentialStatus, listMCPTools } from "../networking"; @@ -99,6 +99,16 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange // OAuth2 connect state — tracks which server_ids have a stored user credential const [oauthConnected, setOauthConnected] = useState>(new Set()); + // Refs keep the latest values for the auto-enable effect so it always reads + // the current servers/selectedServers/onChange without needing them as + // dependencies (which would cause the effect to fire on every render). + const serversRef = useRef([]); + useEffect(() => { serversRef.current = servers; }, [servers]); + const selectedServersRef = useRef(selectedServers); + useEffect(() => { selectedServersRef.current = selectedServers; }, [selectedServers]); + const onChangeRef = useRef(onChange); + useEffect(() => { onChangeRef.current = onChange; }, [onChange]); + const nameOf = (s: MCPServer) => s.server_name ?? s.alias ?? s.server_id; useEffect(() => { @@ -155,9 +165,31 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange return () => { cancelled = true; }; }, [accessToken]); + // Auto-enable oauth2 servers for the current chat session when a valid + // credential is detected (either on mount or after a fresh OAuth sign-in). + // Uses refs for servers/selectedServers/onChange to avoid stale closures + // without adding them as dependencies (which would re-fire on every render). + useEffect(() => { + if (oauthConnected.size === 0) return; + const namesToAdd = serversRef.current + .filter((s) => oauthConnected.has(s.server_id) && !selectedServersRef.current.includes(nameOf(s))) + .map(nameOf); + if (namesToAdd.length > 0) { + onChangeRef.current([...selectedServersRef.current, ...namesToAdd]); + } + }, [oauthConnected]); + const handleToggle = async (serverName: string, checked: boolean, serverId?: string) => { if (!checked) { onChange(selectedServers.filter((s) => s !== serverName)); + // Also clear from oauthConnected so the auto-enable effect doesn't re-add it. + if (serverId) { + setOauthConnected((prev) => { + const next = new Set(prev); + next.delete(serverId); + return next; + }); + } return; } setTogglingOn((prev) => new Set(prev).add(serverName)); @@ -169,7 +201,11 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange message.warning(`Could not load tools for ${serverName}`); return; } - onChange([...selectedServers, serverName]); + // Use the ref so we read the most up-to-date list; guard against duplicates + // that the oauthConnected effect may have already added while we awaited. + if (!selectedServersRef.current.includes(serverName)) { + onChange([...selectedServersRef.current, serverName]); + } } catch { message.warning(`Could not load tools for ${serverName}`); } finally { @@ -283,6 +319,7 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange // Ignore — credential may already be gone; update UI regardless. } setOauthConnected((prev) => { const n = new Set(prev); n.delete(detailServer.server_id); return n; }); + onChange(selectedServers.filter((s) => s !== name)); }} style={{ borderRadius: 8, fontWeight: 600, height: 38, minWidth: 110 }} > @@ -292,7 +329,10 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange setOauthConnected((prev) => new Set(prev).add(id))} + onConnect={(id) => { + setOauthConnected((prev) => new Set(prev).add(id)); + handleToggle(name, true, detailServer.server_id); + }} variant="button" /> ) @@ -517,7 +557,10 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange setOauthConnected((prev) => new Set(prev).add(id))} + onConnect={(id) => { + setOauthConnected((prev) => new Set(prev).add(id)); + handleToggle(nameOf(server), true, server.server_id); + }} variant="badge" /> ) diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx index 4f884d3303..5e16a5c4b8 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx @@ -1,10 +1,11 @@ -import React, { useEffect, useState, useMemo } from "react"; +import React, { useEffect, useRef, useState, useMemo } from "react"; import { listMCPTools } from "../networking"; import { MCPTool, MCPServer } from "../mcp_tools/types"; import { Text } from "@tremor/react"; -import { Spin, Checkbox } from "antd"; -import { XIcon } from "lucide-react"; +import { Spin, Radio } from "antd"; import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers"; +import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel"; +import { classifyToolOp } from "../../utils/mcpToolCrudClassification"; interface MCPToolPermissionsProps { accessToken: string; @@ -25,6 +26,15 @@ const MCPToolPermissions: React.FC = ({ const [serverTools, setServerTools] = useState>({}); const [loadingTools, setLoadingTools] = useState>({}); const [toolErrors, setToolErrors] = useState>({}); + const [viewModes, setViewModes] = useState>({}); + + // Keep a ref to the latest toolPermissions so async fetch callbacks always + // read the current value and do not overwrite sibling servers' results when + // multiple fetches complete out-of-order (stale-closure race condition). + const toolPermissionsRef = useRef(toolPermissions); + useEffect(() => { + toolPermissionsRef.current = toolPermissions; + }, [toolPermissions]); // Filter servers based on selectedServers const servers = useMemo(() => { @@ -32,19 +42,31 @@ const MCPToolPermissions: React.FC = ({ return allServers.filter((server: MCPServer) => selectedServers.includes(server.server_id)); }, [allServers, selectedServers]); - // Fetch tools for a specific server - const fetchToolsForServer = async (serverId: string) => { + // Fetch tools for a specific server; applies delete-blocked-by-default for new servers. + // `token` is passed explicitly so the closure never captures a stale accessToken. + const fetchToolsForServer = async (serverId: string, token: string) => { setLoadingTools((prev) => ({ ...prev, [serverId]: true })); setToolErrors((prev) => ({ ...prev, [serverId]: "" })); try { - const response = await listMCPTools(accessToken, serverId); + const response = await listMCPTools(token, serverId); if (response.error) { setToolErrors((prev) => ({ ...prev, [serverId]: response.message || "Failed to fetch tools" })); setServerTools((prev) => ({ ...prev, [serverId]: [] })); } else { - setServerTools((prev) => ({ ...prev, [serverId]: response.tools || [] })); + const fetchedTools: MCPTool[] = response.tools || []; + setServerTools((prev) => ({ ...prev, [serverId]: fetchedTools })); + + // For servers that have no permissions stored yet, block delete tools by default. + // Read latest permissions from the ref to avoid clobbering concurrent results. + const latestPermissions = toolPermissionsRef.current; + if (!latestPermissions[serverId] && fetchedTools.length > 0) { + const nonDeleteTools = fetchedTools + .filter((t) => classifyToolOp(t.name, t.description || "") !== "delete") + .map((t) => t.name); + onChange({ ...latestPermissions, [serverId]: nonDeleteTools }); + } } } catch (err) { console.error(`Error fetching tools for server ${serverId}:`, err); @@ -55,44 +77,29 @@ const MCPToolPermissions: React.FC = ({ } }; - // Auto-fetch tools when servers change + // Auto-fetch tools when servers or accessToken change useEffect(() => { servers.forEach((server) => { if (!serverTools[server.server_id] && !loadingTools[server.server_id]) { - fetchToolsForServer(server.server_id); + fetchToolsForServer(server.server_id, accessToken); } }); - }, [servers]); + // fetchToolsForServer is defined in this render scope but receives `accessToken` + // as an explicit argument, so it is safe to omit from deps here. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [servers, accessToken]); - // Handle tool selection - const handleToolToggle = (serverId: string, toolName: string) => { - const currentTools = toolPermissions[serverId] || []; - const newTools = currentTools.includes(toolName) - ? currentTools.filter((name) => name !== toolName) - : [...currentTools, toolName]; - - const updatedPermissions = { - ...toolPermissions, - [serverId]: newTools, - }; - onChange(updatedPermissions); + const handleCrudPanelChange = (serverId: string, allowed: string[]) => { + onChange({ ...toolPermissions, [serverId]: allowed }); }; const handleSelectAll = (serverId: string) => { const tools = serverTools[serverId] || []; - const newPermissions = { - ...toolPermissions, - [serverId]: tools.map((t) => t.name), - }; - onChange(newPermissions); + onChange({ ...toolPermissions, [serverId]: tools.map((t) => t.name) }); }; const handleDeselectAll = (serverId: string) => { - const newPermissions = { - ...toolPermissions, - [serverId]: [], - }; - onChange(newPermissions); + onChange({ ...toolPermissions, [serverId]: [] }); }; if (selectedServers.length === 0) { @@ -107,6 +114,7 @@ const MCPToolPermissions: React.FC = ({ const selectedTools = toolPermissions[server.server_id] || []; const isLoading = loadingTools[server.server_id]; const error = toolErrors[server.server_id]; + const viewMode = viewModes[server.server_id] ?? "crud"; return (
@@ -117,38 +125,46 @@ const MCPToolPermissions: React.FC = ({ {server.description && {server.description}}
- - - + {!disabled && tools.length > 0 && ( + + setViewModes((prev) => ({ ...prev, [server.server_id]: e.target.value })) + } + size="small" + optionType="button" + buttonStyle="solid" + options={[ + { label: "Risk Groups", value: "crud" }, + { label: "Flat List", value: "flat" }, + ]} + /> + )} + {!disabled && ( + <> + + + + )}
{/* Tools */}
- Available Tools - {/* Loading */} {isLoading && (
@@ -165,23 +181,42 @@ const MCPToolPermissions: React.FC = ({
)} - {/* Tool List - Compact */} - {!isLoading && !error && tools.length > 0 && ( + {/* CRUD grouped view */} + {!isLoading && !error && tools.length > 0 && viewMode === "crud" && ( + handleCrudPanelChange(server.server_id, allowed)} + readOnly={disabled} + /> + )} + + {/* Flat list view */} + {!isLoading && !error && tools.length > 0 && viewMode === "flat" && (
{tools.map((tool) => { const isSelected = selectedTools.includes(tool.name); - return (
- handleToolToggle(server.server_id, tool.name)} + onChange={() => { + if (disabled) return; + const next = isSelected + ? selectedTools.filter((n) => n !== tool.name) + : [...selectedTools, tool.name]; + handleCrudPanelChange(server.server_id, next); + }} disabled={disabled} + className="mt-0.5" />
{tool.name} - - {tool.description || "No description"} + + - {tool.description || "No description"} +
diff --git a/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx b/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx new file mode 100644 index 0000000000..248385fe00 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/McpCrudPermissionPanel.tsx @@ -0,0 +1,270 @@ +/** + * McpCrudPermissionPanel + * + * Displays MCP tools grouped by CRUD operation risk category. + * Lets admins toggle an entire category (Read / Create / Update / Delete) + * or individual tools within a category. + * + * The component is a drop-in replacement for a flat tool checkbox list. + * Output is the same `string[]` of allowed tool names that the backend accepts. + */ + +import React, { useMemo, useState } from "react"; +import { Checkbox } from "antd"; +import { Text } from "@tremor/react"; +import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; +import { + CrudOp, + MCPToolEntry, + CRUD_GROUP_META, + groupToolsByCrud, +} from "../../utils/mcpToolCrudClassification"; + +interface McpCrudPermissionPanelProps { + /** List of tools available on this MCP server. */ + tools: MCPToolEntry[]; + /** + * Currently allowed tool names. + * `undefined` means "allow all" (no restriction stored yet). + * An empty array means "allow none". + */ + value: string[] | undefined; + /** Called whenever the allowed set changes. Always emits a concrete string[]. */ + onChange: (allowed: string[]) => void; + readOnly?: boolean; + /** + * Optional search filter string. When set, only tools whose name or description + * contain this string (case-insensitive) are shown. Group-level toggles still + * operate on the complete group — not just the visible (filtered) subset. + */ + searchFilter?: string; +} + +const CRUD_ORDER: CrudOp[] = ["read", "create", "update", "delete", "unknown"]; + +const RISK_BADGE: Record = { + low: "bg-green-100 text-green-800", + medium: "bg-yellow-100 text-yellow-800", + high: "bg-red-100 text-red-800 font-semibold", + unknown: "bg-gray-100 text-gray-700", +}; + +const GROUP_BORDER: Record = { + read: "border-green-200", + create: "border-blue-200", + update: "border-yellow-200", + delete: "border-red-300", + unknown: "border-gray-200", +}; + +const GROUP_HEADER_BG: Record = { + read: "bg-green-50", + create: "bg-blue-50", + update: "bg-yellow-50", + delete: "bg-red-50", + unknown: "bg-gray-50", +}; + +// --------------------------------------------------------------------------- + +const McpCrudPermissionPanel: React.FC = ({ + tools, + value, + onChange, + readOnly = false, + searchFilter = "", +}) => { + const [collapsed, setCollapsed] = useState>({ + read: false, + create: false, + update: false, + delete: false, + unknown: true, + }); + + const grouped = useMemo(() => groupToolsByCrud(tools), [tools]); + + /** + * Derive the effective allowed set: + * - `undefined` → all tools allowed + * - We materialise it to a Set for fast lookups. + */ + const effectiveAllowed: Set = useMemo(() => { + if (value === undefined) { + return new Set(tools.map((t) => t.name)); + } + return new Set(value); + }, [value, tools]); + + const isToolAllowed = (name: string) => effectiveAllowed.has(name); + + const isGroupFullyAllowed = (op: CrudOp) => { + const group = grouped[op]; + return group.length > 0 && group.every((t) => effectiveAllowed.has(t.name)); + }; + + const isGroupPartiallyAllowed = (op: CrudOp) => { + const group = grouped[op]; + if (group.length === 0) return false; + const allowedCount = group.filter((t) => effectiveAllowed.has(t.name)).length; + return allowedCount > 0 && allowedCount < group.length; + }; + + const toggleTool = (toolName: string) => { + if (readOnly) return; + const next = new Set(effectiveAllowed); + if (next.has(toolName)) { + next.delete(toolName); + } else { + next.add(toolName); + } + onChange(Array.from(next)); + }; + + const toggleGroup = (op: CrudOp, enable: boolean) => { + if (readOnly) return; + const next = new Set(effectiveAllowed); + for (const tool of grouped[op]) { + if (enable) { + next.add(tool.name); + } else { + next.delete(tool.name); + } + } + onChange(Array.from(next)); + }; + + const toggleCollapse = (op: CrudOp) => { + setCollapsed((prev) => ({ ...prev, [op]: !prev[op] })); + }; + + if (tools.length === 0) return null; + + return ( +
+ {CRUD_ORDER.map((op) => { + const group = grouped[op]; + if (group.length === 0) return null; + + // If a search filter is active and no tools in this group match, hide the + // entire group — including its header — to avoid empty visual blocks. + if (searchFilter) { + const lf = searchFilter.toLowerCase(); + const hasMatch = group.some( + (t) => + t.name.toLowerCase().includes(lf) || + (t.description ?? "").toLowerCase().includes(lf) + ); + if (!hasMatch) return null; + } + + const meta = CRUD_GROUP_META[op]; + const fullyAllowed = isGroupFullyAllowed(op); + const partial = isGroupPartiallyAllowed(op); + const isCollapsed = collapsed[op]; + + return ( +
+ {/* Group header */} +
+ + + {!readOnly && ( +
+ + {fullyAllowed ? "All on" : partial ? "Partial" : "All off"} + + {/* Checkbox supports `indeterminate`; Switch does not. */} + toggleGroup(op, e.target.checked)} + onClick={(e) => e.stopPropagation()} + /> +
+ )} +
+ + {/* Description row */} + {!isCollapsed && ( +
+ {meta.description} +
+ )} + + {/* Tool list — searchFilter narrows display only; group toggles still cover all tools */} + {!isCollapsed && ( +
+ {group + .filter((t) => + !searchFilter || + t.name.toLowerCase().includes(searchFilter.toLowerCase()) || + (t.description ?? "").toLowerCase().includes(searchFilter.toLowerCase()) + ) + .map((tool) => { + const allowed = isToolAllowed(tool.name); + return ( +
toggleTool(tool.name)} + > + toggleTool(tool.name)} + disabled={readOnly} + onClick={(e) => e.stopPropagation()} + /> +
+ {tool.name} + {tool.description && ( + + {tool.description} + + )} +
+ + {allowed ? "on" : "off"} + +
+ ); + })} +
+ )} +
+ ); + })} +
+ ); +}; + +export default McpCrudPermissionPanel; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index 2b9683311c..34eeb6b1e8 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -128,7 +128,13 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) server.mcp_access_groups?.some((g: any) => (typeof g === "string" ? g === group : g && g.name === group)), ); } - setFilteredServers(filtered); + const sorted = [...filtered].sort((a, b) => { + if (!a.created_at && !b.created_at) return 0; + if (!a.created_at) return 1; + if (!b.created_at) return -1; + return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); + }); + setFilteredServers(sorted); }, [serversWithHealth]); // Handle team filter change diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx index 360bb893b5..af6890a6d8 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tool_configuration.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { Card, Title, Text } from "@tremor/react"; import { ToolOutlined, CheckCircleOutlined, SearchOutlined, EditOutlined } from "@ant-design/icons"; -import { Badge, Spin, Checkbox, Input } from "antd"; +import { Badge, Spin, Checkbox, Input, Radio } from "antd"; import { useTestMCPConnection } from "../../hooks/useTestMCPConnection"; +import McpCrudPermissionPanel from "./McpCrudPermissionPanel"; interface KeyTool { name: string; @@ -160,6 +161,7 @@ const MCPToolConfiguration: React.FC = ({ }) => { const previousToolsRef = useRef([]); const [toolSearchTerm, setToolSearchTerm] = useState(""); + const [viewMode, setViewMode] = useState<"crud" | "flat">("crud"); const hasInitializedRef = useRef(false); const previousSuggestedToolNamesRef = useRef(""); const [expandedTools, setExpandedTools] = useState>(new Set()); @@ -367,6 +369,19 @@ const MCPToolConfiguration: React.FC = ({ /> )}
+ {tools.length > 0 && ( + setViewMode(e.target.value)} + size="small" + optionType="button" + buttonStyle="solid" + options={[ + { label: "Risk Groups", value: "crud" }, + { label: "Flat List", value: "flat" }, + ]} + /> + )}
{/* Description */} @@ -436,7 +451,7 @@ const MCPToolConfiguration: React.FC = ({ - {/* Search bar */} + {/* Search box shared by both views */} } @@ -447,14 +462,26 @@ const MCPToolConfiguration: React.FC = ({ size="large" /> - {/* Tool list with checkboxes */} - {filteredTools.length === 0 ? ( -
- - No tools found matching "{toolSearchTerm}" -
- ) : ( -
+ {/* CRUD grouped view */} + {viewMode === "crud" && ( + onAllowedToolsChange(allowed)} + /> + )} + + {/* Flat list view */} + {viewMode === "flat" && ( + <> + {filteredTools.length === 0 ? ( +
+ + No tools found matching "{toolSearchTerm}" +
+ ) : ( +
{pinnedFiltered.length > 0 && ( <>
@@ -533,7 +560,9 @@ const MCPToolConfiguration: React.FC = ({ ))} )} -
+
+ )} + )}
)} diff --git a/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx index 26aab97b34..f8c0db2689 100644 --- a/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useUserMcpOAuthFlow.tsx @@ -179,7 +179,9 @@ export const useUserMcpOAuthFlow = ({ }; setStorage(FLOW_STATE_KEY, JSON.stringify(flowState)); - setStorage(RETURN_URL_KEY, window.location.href); + const returnUrl = new URL(window.location.href); + returnUrl.searchParams.set("mcpOauthReturn", "apps"); + setStorage(RETURN_URL_KEY, returnUrl.toString()); window.location.href = authorizeUrl; } catch (err) { diff --git a/ui/litellm-dashboard/src/utils/mcpToolCrudClassification.ts b/ui/litellm-dashboard/src/utils/mcpToolCrudClassification.ts new file mode 100644 index 0000000000..523995cce4 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/mcpToolCrudClassification.ts @@ -0,0 +1,86 @@ +export type CrudOp = "read" | "create" | "update" | "delete" | "unknown"; + +const DELETE_RE = /\b(delete|remove|destroy|purge|drop|erase|unlink)\b/i; +const CREATE_RE = /\b(create|add|insert|new|post|submit|register|make|generate|write|upload)\b/i; +const UPDATE_RE = /\b(update|edit|modify|change|patch|put|set|rename|move|transform)\b/i; +const READ_RE = /\b(get|read|list|fetch|search|find|query|retrieve|show|view|check|describe|info)\b/i; + +export interface MCPToolEntry { + name: string; + description?: string; +} + +/** + * Classifies a tool by its name first; falls back to description only when + * the name alone yields no match. This prevents incidental phrasing in + * free-form descriptions (e.g. "removes noise from…") from promoting a safe + * tool into a high-risk bucket. + * + * READ is checked before DELETE/UPDATE so that tools like `get_removed_entries` + * or `list_deleted_items` — where the primary verb is a read operation — are + * not silently blocked by the delete-by-default policy for new servers. + */ +export function classifyToolOp(name: string, description = ""): CrudOp { + const nameLower = name.toLowerCase(); + if (READ_RE.test(nameLower)) return "read"; + if (DELETE_RE.test(nameLower)) return "delete"; + if (UPDATE_RE.test(nameLower)) return "update"; + if (CREATE_RE.test(nameLower)) return "create"; + + // Only consult description when the name is unrecognised. + if (description) { + const descLower = description.toLowerCase(); + if (READ_RE.test(descLower)) return "read"; + if (DELETE_RE.test(descLower)) return "delete"; + if (UPDATE_RE.test(descLower)) return "update"; + if (CREATE_RE.test(descLower)) return "create"; + } + + return "unknown"; +} + +export function groupToolsByCrud(tools: MCPToolEntry[]): Record { + const groups: Record = { + read: [], + create: [], + update: [], + delete: [], + unknown: [], + }; + for (const tool of tools) { + const op = classifyToolOp(tool.name, tool.description); + groups[op].push(tool); + } + return groups; +} + +export const CRUD_GROUP_META: Record< + CrudOp, + { label: string; description: string; risk: "low" | "medium" | "high" | "unknown" } +> = { + read: { + label: "Read", + description: "Safe operations — fetch, list, search. No side effects.", + risk: "low", + }, + create: { + label: "Create", + description: "Add new resources — insert, upload, register.", + risk: "medium", + }, + update: { + label: "Update", + description: "Modify existing resources — edit, patch, rename.", + risk: "medium", + }, + delete: { + label: "Delete", + description: "Destructive operations — remove, purge, destroy.", + risk: "high", + }, + unknown: { + label: "Other", + description: "Operations that could not be automatically classified.", + risk: "unknown", + }, +};