diff --git a/ui/litellm-dashboard/src/components/mcp_tools/UserFieldsAdminSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/UserFieldsAdminSection.tsx new file mode 100644 index 00000000000..ec782c6aa35 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/UserFieldsAdminSection.tsx @@ -0,0 +1,125 @@ +import React from "react"; +import { Input, Switch, Typography, Tooltip } from "antd"; +import { InfoCircleOutlined, PlusOutlined, DeleteOutlined } from "@ant-design/icons"; +import { UserField } from "./userFields"; + +const { Title, Paragraph, Text } = Typography; + +interface UserFieldsAdminSectionProps { + value: UserField[]; + onChange: (next: UserField[]) => void; +} + +const blankField = (): UserField => ({ + name: "", + label: "", + description: "", + secret: true, +}); + +const UserFieldsAdminSection: React.FC = ({ value, onChange }) => { + const fields = value || []; + + const update = (idx: number, patch: Partial) => { + onChange(fields.map((f, i) => (i === idx ? { ...f, ...patch } : f))); + }; + + const remove = (idx: number) => { + onChange(fields.filter((_, i) => i !== idx)); + }; + + const add = () => { + onChange([...fields, blankField()]); + }; + + return ( +
+
+
+ + Per-User Fields + <Tooltip title="Fields each end-user must fill in themselves before using this MCP server. Use for per-user secrets like personal bearer tokens, account IDs, etc."> + <InfoCircleOutlined className="ml-2 text-blue-400" /> + </Tooltip> + + + Each user will be prompted to fill these in on their dashboard. The MCP server will refuse + connections until they do. + +
+ +
+ + {fields.length === 0 ? ( +
+ No per-user fields. Click Add Field to require users to provide their own + values (e.g. a personal bearer token). +
+ ) : ( +
+ {fields.map((f, idx) => ( +
+
+
+ Field Name (key) + update(idx, { name: e.target.value })} + size="middle" + /> +
+
+ Label + update(idx, { label: e.target.value })} + size="middle" + /> +
+
+ Description (shown to user) + update(idx, { description: e.target.value })} + size="middle" + /> +
+
+ + update(idx, { secret: checked })} + size="small" + /> + +
+
+ + + +
+
+
+ ))} +
+ )} +
+ ); +}; + +export default UserFieldsAdminSection; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/UserFieldsModal.tsx b/ui/litellm-dashboard/src/components/mcp_tools/UserFieldsModal.tsx new file mode 100644 index 00000000000..98228dc34bc --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/UserFieldsModal.tsx @@ -0,0 +1,209 @@ +import React, { useEffect, useState } from "react"; +import { Modal, Input, Typography, Tabs, Form } from "antd"; +import { CheckCircleFilled, ExclamationCircleFilled, CopyOutlined } from "@ant-design/icons"; +import { Button } from "@tremor/react"; +import { + UserField, + getUserFieldDefs, + getUserFieldValues, + setUserFieldValues, +} from "./userFields"; +import { MCPServer } from "./types"; +import NotificationsManager from "../molecules/notifications_manager"; + +const { Title, Paragraph, Text } = Typography; + +interface UserFieldsModalProps { + server: MCPServer | null; + userId: string; + open: boolean; + onClose: () => void; + onSaved: () => void; +} + +const UserFieldsModal: React.FC = ({ + server, + userId, + open, + onClose, + onSaved, +}) => { + const [defs, setDefs] = useState([]); + const [values, setValues] = useState>({}); + const [activeTab, setActiveTab] = useState("configure"); + + useEffect(() => { + if (!open || !server) return; + const loadedDefs = getUserFieldDefs(server.server_id); + setDefs(loadedDefs); + setValues(getUserFieldValues(server.server_id, userId)); + setActiveTab("configure"); + }, [open, server, userId]); + + if (!server) return null; + + const missing = defs.filter( + (f) => !values[f.name] || values[f.name].trim() === "", + ); + const allFilled = defs.length > 0 && missing.length === 0; + + const handleSave = () => { + setUserFieldValues(server.server_id, userId, values); + NotificationsManager.success( + allFilled + ? `Saved. ${server.server_name || "MCP server"} is ready to use.` + : `Saved ${defs.length - missing.length} of ${defs.length} fields.`, + ); + onSaved(); + if (allFilled) { + onClose(); + } + }; + + const serverDisplayName = server.server_name || server.alias || server.server_id; + const errorPreview = `Error: MCP server "${serverDisplayName}" requires user configuration before use. + +${missing.length > 0 ? `Missing field${missing.length === 1 ? "" : "s"}:` : "All fields configured."} +${missing.map((f) => ` • ${f.label || f.name}${f.description ? ` — ${f.description}` : ""}`).join("\n")} + +Please configure your fields at: +${typeof window !== "undefined" ? `${window.location.origin}${window.location.pathname}?openUserFields=${server.server_id}` : `?openUserFields=${server.server_id}`} + +Once configured, retry your request.`; + + return ( + + {allFilled ? ( + + ) : ( + + )} + + {allFilled ? "Configured: " : "Configure your fields for "} + {serverDisplayName} + + + } + > + + + This MCP server requires per-user configuration. Fill in the fields below to start + using it. + + + {defs.length === 0 ? ( +
+ No per-user fields are defined for this server. +
+ ) : ( +
+ {defs.map((f) => { + const isMissing = !values[f.name] || values[f.name].trim() === ""; + return ( + + {f.label || f.name} + {isMissing ? ( + + required + + ) : ( + + set + + )} + + } + help={f.description} + > + {f.secret ? ( + + setValues({ ...values, [f.name]: e.target.value }) + } + /> + ) : ( + + setValues({ ...values, [f.name]: e.target.value }) + } + /> + )} + + ); + })} +
+ )} + +
+ + +
+ + ), + }, + { + key: "preview", + label: "Preview Claude Code Error", + children: ( +
+ + This is what your users will see in their terminal when they try to use{" "} + {serverDisplayName} via Claude Code without configuring these + fields: + +
+ + {errorPreview} +
+ + The link in the error opens this same dialog directly so users can fix and retry in + one click. + +
+ ), + }, + ]} + /> +
+ ); +}; + +export default UserFieldsModal; 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 f8b0141b25d..7ad46d09b4a 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 @@ -18,6 +18,8 @@ import NotificationsManager from "../molecules/notifications_manager"; import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow"; import { useTestMCPConnection } from "@/hooks/useTestMCPConnection"; import { getSecureItem, setSecureItem } from "@/utils/secureStorage"; +import UserFieldsAdminSection from "./UserFieldsAdminSection"; +import { UserField, setUserFieldDefs } from "./userFields"; const asset_logos_folder = "../ui/assets/logos/"; export const mcpLogoImg = `${asset_logos_folder}mcp_logo.png`; @@ -74,6 +76,7 @@ const CreateMCPServer: React.FC = ({ const [oauthAccessToken, setOauthAccessToken] = useState(null); const [logoUrl, setLogoUrl] = useState(undefined); const [oauthDocsUrl, setOauthDocsUrl] = useState(null); + const [userFields, setUserFields] = useState([]); // Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests. const { tools, isLoadingTools, toolsError, toolsErrorStackTrace, canFetchTools, fetchTools, clearTools } = useTestMCPConnection({ @@ -409,6 +412,15 @@ const CreateMCPServer: React.FC = ({ ? await createMCPServer(accessToken, payload) : await registerMCPServer(accessToken, payload); + // PROTOTYPE: persist per-user-field defs in localStorage keyed by the + // newly-created server's id. Real impl would save these server-side. + const validUserFields = userFields.filter( + (f) => f && f.name && f.name.trim() !== "", + ); + if (response?.server_id && validUserFields.length > 0) { + setUserFieldDefs(response.server_id, validUserFields); + } + NotificationsManager.success( isAdmin ? "MCP Server created successfully" @@ -420,6 +432,7 @@ const CreateMCPServer: React.FC = ({ setAllowedTools([]); setAliasManuallyEdited(false); setLogoUrl(undefined); + setUserFields([]); setModalVisible(false); onCreateSuccess(response); } @@ -441,6 +454,7 @@ const CreateMCPServer: React.FC = ({ setAllowedTools([]); setAliasManuallyEdited(false); setLogoUrl(undefined); + setUserFields([]); setModalVisible(false); }; @@ -989,6 +1003,11 @@ const CreateMCPServer: React.FC = ({ + {/* Per-User Fields Section (admin defines fields each end-user must fill in) */} +
+ +
+ {/* Permission Management / Access Control Section */}
void, onRecheckHealth?: (serverId: string) => void, recheckingServerIds?: Set, + userId?: string | null, + onConfigureUserFields?: (server: MCPServer) => void, + userFieldsRefreshKey?: number, ): ColumnDef[] => [ { accessorKey: "server_id", @@ -261,6 +265,53 @@ export const mcpServerColumns = ( ); }, }, + { + id: "user_fields_status", + header: "Your Fields", + cell: ({ row }) => { + // reference refresh key so the cell re-evaluates when values change + void userFieldsRefreshKey; + const server = row.original; + const defs = getUserFieldDefs(server.server_id); + if (defs.length === 0) { + return —; + } + if (!userId) { + return {defs.length} field{defs.length === 1 ? "" : "s"}; + } + const missing = getMissingUserFields(server.server_id, userId); + + if (missing.length === 0) { + return ( +
+ + All set + + {onConfigureUserFields && ( + + )} +
+ ); + } + + // MISSING — the visual centerpiece of the prototype + return ( + + ); + }, + }, { id: "byok_credential", header: "Credential", 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 72d5e4b5aa8..2279d03b6af 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -21,6 +21,9 @@ import MCPNetworkSettings from "./MCPNetworkSettings"; import MCPDiscovery from "./mcp_discovery"; import { ByokCredentialModal } from "./ByokCredentialModal"; import { getSecureItem } from "@/utils/secureStorage"; +import UserFieldsModal from "./UserFieldsModal"; +import { getMissingUserFields, getUserFieldDefs } from "./userFields"; +import { ExclamationCircleFilled } from "@ant-design/icons"; const { Text: AntdText, Title: AntdTitle } = Typography; const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state"; @@ -64,6 +67,8 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) const [prefillData, setPrefillData] = useState(null); const [isDeletingServer, setIsDeletingServer] = useState(false); const [byokModalServer, setByokModalServer] = useState(null); + const [userFieldsServer, setUserFieldsServer] = useState(null); + const [userFieldsRefreshKey, setUserFieldsRefreshKey] = useState(0); const isInternalUser = userRole === "Internal User"; useEffect(() => { @@ -85,6 +90,43 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) } }, []); + // PROTOTYPE: auto-open the user-fields modal when navigated to with + // ?openUserFields= (this is the deep-link target shown in the + // mocked Claude Code error message). + useEffect(() => { + if (typeof window === "undefined" || !serversWithHealth || serversWithHealth.length === 0) { + return; + } + const params = new URLSearchParams(window.location.search); + const targetId = params.get("openUserFields"); + if (!targetId) return; + const target = serversWithHealth.find((s) => s.server_id === targetId); + if (target) { + setUserFieldsServer(target); + } + params.delete("openUserFields"); + const remaining = params.toString(); + const newUrl = + window.location.pathname + (remaining ? `?${remaining}` : "") + window.location.hash; + window.history.replaceState({}, "", newUrl); + }, [serversWithHealth]); + + // Servers with one or more missing user fields for the current user (prototype) + const serversNeedingUserFields = React.useMemo(() => { + if (!serversWithHealth || !userID) return []; + // userFieldsRefreshKey participates so this recomputes after a save. + void userFieldsRefreshKey; + return serversWithHealth + .map((server) => { + const defs = getUserFieldDefs(server.server_id); + if (defs.length === 0) return null; + const missing = getMissingUserFields(server.server_id, userID); + if (missing.length === 0) return null; + return { server, missingCount: missing.length }; + }) + .filter((x): x is { server: MCPServer; missingCount: number } => x !== null); + }, [serversWithHealth, userID, userFieldsRefreshKey]); + // Get unique teams from all servers const uniqueTeams = React.useMemo(() => { if (!serversWithHealth) return []; @@ -173,8 +215,11 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) (server: MCPServer) => setByokModalServer(server), recheckServerHealth, recheckingServerIds, + userID, + (server: MCPServer) => setUserFieldsServer(server), + userFieldsRefreshKey, ), - [userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds], + [userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds, userID, userFieldsRefreshKey], ); function handleDelete(server_id: string) { @@ -331,6 +376,45 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) )}
+ {serversNeedingUserFields.length > 0 && ( +
+ +
+
+ {serversNeedingUserFields.length} MCP server + {serversNeedingUserFields.length === 1 ? " needs" : "s need"} your configuration +
+
+ These servers won't work in Claude Code (or anywhere else) until you fill in + your per-user fields: +
+
+ {serversNeedingUserFields.map(({ server, missingCount }) => ( + + ))} +
+
+
+ )} setDiscoveryVisible(false)} @@ -461,6 +545,14 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) accessToken={accessToken || ""} /> )} + + setUserFieldsServer(null)} + onSaved={() => setUserFieldsRefreshKey((k) => k + 1)} + /> ); }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/userFields.ts b/ui/litellm-dashboard/src/components/mcp_tools/userFields.ts new file mode 100644 index 00000000000..05556df02d4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/userFields.ts @@ -0,0 +1,85 @@ +/** + * PROTOTYPE-ONLY mock storage for the MCP "user fields" feature. + * + * Real implementation will store defs server-side (per MCP server) and + * encrypted per-user values in the DB / vault. This file fakes both using + * localStorage so we can demo the flow without backend changes. + * + * Do NOT model real auth/credential storage after this file. + */ + +export interface UserField { + name: string; + label: string; + description?: string; + secret?: boolean; +} + +const DEFS_KEY = (serverId: string) => `mcp_user_fields_defs_${serverId}`; +const VALUES_KEY = (serverId: string, userId: string) => + `mcp_user_fields_values_${serverId}_${userId}`; + +function safeParse(raw: string | null, fallback: T): T { + if (!raw) return fallback; + try { + return JSON.parse(raw) as T; + } catch { + return fallback; + } +} + +export function getUserFieldDefs(serverId: string): UserField[] { + if (typeof window === "undefined") return []; + return safeParse(window.localStorage.getItem(DEFS_KEY(serverId)), []); +} + +export function setUserFieldDefs(serverId: string, fields: UserField[]): void { + if (typeof window === "undefined") return; + if (!fields || fields.length === 0) { + window.localStorage.removeItem(DEFS_KEY(serverId)); + return; + } + window.localStorage.setItem(DEFS_KEY(serverId), JSON.stringify(fields)); +} + +export function getUserFieldValues( + serverId: string, + userId: string, +): Record { + if (typeof window === "undefined") return {}; + return safeParse>( + window.localStorage.getItem(VALUES_KEY(serverId, userId)), + {}, + ); +} + +export function setUserFieldValues( + serverId: string, + userId: string, + values: Record, +): void { + if (typeof window === "undefined") return; + window.localStorage.setItem( + VALUES_KEY(serverId, userId), + JSON.stringify(values), + ); +} + +export function getMissingUserFields( + serverId: string, + userId: string, +): UserField[] { + const defs = getUserFieldDefs(serverId); + if (defs.length === 0) return []; + const values = getUserFieldValues(serverId, userId); + return defs.filter( + (f) => + !values[f.name] || + typeof values[f.name] !== "string" || + values[f.name].trim() === "", + ); +} + +export function hasAnyUserFields(serverId: string): boolean { + return getUserFieldDefs(serverId).length > 0; +}