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..fd366b7e8f1 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 @@ -12,6 +12,12 @@ import StdioConfiguration from "./StdioConfiguration"; import MCPPermissionManagement from "./MCPPermissionManagement"; import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection"; import MCPLogoSelector from "./MCPLogoSelector"; +import EnvVarsSection from "./mock/EnvVarsSection"; +import { + setEnvVarDefinitions, + notifyEnvVarsChanged, + EnvVarDefinition, +} from "./mock/mockMcpEnvVars"; import { isAdminRole } from "@/utils/roles"; import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; @@ -286,9 +292,30 @@ const CreateMCPServer: React.FC = ({ available_on_public_internet: availableOnPublicInternetRaw, delegate_auth_to_upstream: delegateAuthToUpstreamRaw, token_validation_json: rawTokenValidationJson, + mock_env_vars: mockEnvVarsRaw, ...restValues } = values; + // PROTOTYPE: persist the env-var definitions to localStorage keyed by + // the server alias. Replaced once the backend stores these properly. + const cleanedEnvVars: EnvVarDefinition[] = Array.isArray(mockEnvVarsRaw) + ? mockEnvVarsRaw + .filter((row: any) => row && row.name && String(row.name).trim() !== "") + .map((row: any) => ({ + name: String(row.name).trim(), + value: row.scope === "per_user" ? "" : (row.value ?? ""), + scope: row.scope === "per_user" ? "per_user" : "global", + })) + : []; + const aliasForEnvVars = + (restValues.alias && String(restValues.alias).trim()) || + (restValues.server_name && String(restValues.server_name).trim()) || + ""; + if (aliasForEnvVars && cleanedEnvVars.length > 0) { + setEnvVarDefinitions(aliasForEnvVars, cleanedEnvVars); + notifyEnvVarsChanged(); + } + // Transform access groups into objects with name property const accessGroups = restValues.mcp_access_groups; @@ -989,6 +1016,11 @@ const CreateMCPServer: React.FC = ({ + {/* PROTOTYPE: Environment variables (global vs per-user) */} +
+ +
+ {/* Permission Management / Access Control Section */}
void, onRecheckHealth?: (serverId: string) => void, recheckingServerIds?: Set, + // PROTOTYPE: hooks for the env-var user-fields demo + userIdForMockFields?: string, + onOpenFillFields?: (server: MCPServer) => void, + onOpenMockDemo?: (server: MCPServer) => void, ): ColumnDef[] => [ { accessorKey: "server_id", @@ -296,6 +301,21 @@ export const mcpServerColumns = ( ) : null; }, }, + { + id: "mock_user_fields", + header: "My Credentials", + cell: ({ row }) => { + const alias = row.original.alias || row.original.server_name || ""; + return ( + onOpenFillFields?.(row.original)} + onOpenDemo={() => onOpenMockDemo?.(row.original)} + /> + ); + }, + }, { id: "actions", header: "Actions", 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..eb5181c6c15 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,8 @@ import MCPNetworkSettings from "./MCPNetworkSettings"; import MCPDiscovery from "./mcp_discovery"; import { ByokCredentialModal } from "./ByokCredentialModal"; import { getSecureItem } from "@/utils/secureStorage"; +import FillUserFieldsModal from "./mock/FillUserFieldsModal"; +import MockClaudeCodeModal from "./mock/MockClaudeCodeModal"; const { Text: AntdText, Title: AntdTitle } = Typography; const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state"; @@ -64,8 +66,35 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) const [prefillData, setPrefillData] = useState(null); const [isDeletingServer, setIsDeletingServer] = useState(false); const [byokModalServer, setByokModalServer] = useState(null); + // PROTOTYPE: state for the per-user fields demo + const [fillFieldsServer, setFillFieldsServer] = useState(null); + const [mockDemoServer, setMockDemoServer] = useState(null); const isInternalUser = userRole === "Internal User"; + // PROTOTYPE: deep-link via ?fill_fields= (used by the mock Claude + // Code error message). Looks the alias up in the loaded server list and + // opens the fill modal. + useEffect(() => { + if (typeof window === "undefined") return; + if (!serversWithHealth || serversWithHealth.length === 0) return; + const params = new URLSearchParams(window.location.search); + const fillAlias = params.get("fill_fields"); + if (!fillAlias) return; + const match = serversWithHealth.find( + (s) => (s.alias || s.server_name) === fillAlias, + ); + if (match) { + setFillFieldsServer(match); + params.delete("fill_fields"); + const newSearch = params.toString(); + const newUrl = + window.location.pathname + + (newSearch ? `?${newSearch}` : "") + + window.location.hash; + window.history.replaceState({}, "", newUrl); + } + }, [serversWithHealth]); + useEffect(() => { if (typeof window === "undefined") { return; @@ -173,8 +202,11 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) (server: MCPServer) => setByokModalServer(server), recheckServerHealth, recheckingServerIds, + userID ?? "", + (server: MCPServer) => setFillFieldsServer(server), + (server: MCPServer) => setMockDemoServer(server), ), - [userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds], + [userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds, userID], ); function handleDelete(server_id: string) { @@ -461,6 +493,33 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) accessToken={accessToken || ""} /> )} + + {/* PROTOTYPE: per-user env-var fill modal */} + {fillFieldsServer && ( + setFillFieldsServer(null)} + /> + )} + + {/* PROTOTYPE: simulated Claude Code error/success preview */} + {mockDemoServer && ( + setMockDemoServer(null)} + onOpenFillModal={() => setFillFieldsServer(mockDemoServer)} + /> + )}
); }; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mock/EnvVarsSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mock/EnvVarsSection.tsx new file mode 100644 index 00000000000..f12cd12e80c --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mock/EnvVarsSection.tsx @@ -0,0 +1,148 @@ +// PROTOTYPE: 3-column env-vars editor (name, value, scope) for the create-MCP +// modal. Mounted as a plain Form.List under field name "mock_env_vars" so the +// caller can persist the values to localStorage on submit. + +import React from "react"; +import { Form, Input, Select, Space, Button, Tooltip, Tag, Typography } from "antd"; +import { + InfoCircleOutlined, + MinusCircleOutlined, + PlusOutlined, +} from "@ant-design/icons"; + +const { Text } = Typography; + +const EnvVarsSection: React.FC = () => { + return ( +
+
+ + Prototype + + + Environment Variables + + + Define variables you can interpolate in Static Headers using{" "} + {"${VAR_NAME}"}.
+ Global: admin-defined value used for every user. +
+ Per-user: each user supplies their own value (e.g. personal + credentials). + + } + > + +
+
+ + Reference these in Static Headers as {"${VAR_NAME}"}. For + example:{" "} + + {"${DB_PROTOCOL}://${CORP_USERNAME}:${CORP_PASSWORD}@${DB_HOSTNAME}"} + + + + + {(fields, { add, remove }) => ( +
+ {fields.length > 0 && ( +
+
Variable Name
+
Value
+
Scope
+
+
+ )} + {fields.map(({ key, name, ...restField }) => ( + + + + + + + + + onChange?.(e.target.value)} + placeholder={ + isPerUser ? "set by each user" : "e.g. postgresql" + } + disabled={isPerUser} + className="rounded-md font-mono" + /> + ); +}; + +export default EnvVarsSection; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mock/FillUserFieldsModal.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mock/FillUserFieldsModal.tsx new file mode 100644 index 00000000000..bd4a224b533 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mock/FillUserFieldsModal.tsx @@ -0,0 +1,142 @@ +// PROTOTYPE: modal where an end-user fills in their per-user fields for an +// MCP server (mock — values stored in localStorage keyed by user + server alias). + +import React, { useEffect, useMemo, useState } from "react"; +import { Modal, Input, Form, Typography, Tag, Alert } from "antd"; +import { Button } from "@tremor/react"; +import { + EnvVarDefinition, + getEnvVarDefinitions, + getPerUserValues, + setPerUserValues, + notifyEnvVarsChanged, +} from "./mockMcpEnvVars"; + +const { Text, Title } = Typography; + +interface FillUserFieldsModalProps { + open: boolean; + serverAlias: string; + serverName?: string | null; + userId: string; + onClose: () => void; + onSaved?: () => void; +} + +const FillUserFieldsModal: React.FC = ({ + open, + serverAlias, + serverName, + userId, + onClose, + onSaved, +}) => { + const [defs, setDefs] = useState([]); + const [values, setValues] = useState>({}); + const [saving, setSaving] = useState(false); + + useEffect(() => { + if (!open || !serverAlias) return; + const loadedDefs = getEnvVarDefinitions(serverAlias); + setDefs(loadedDefs); + setValues(getPerUserValues(serverAlias, userId)); + }, [open, serverAlias, userId]); + + const perUserDefs = useMemo( + () => defs.filter((d) => d.scope === "per_user"), + [defs], + ); + + const handleSave = () => { + setSaving(true); + // Simulate brief latency so the demo feels real. + setTimeout(() => { + setPerUserValues(serverAlias, userId, values); + notifyEnvVarsChanged(); + setSaving(false); + onSaved?.(); + onClose(); + }, 250); + }; + + return ( + +
+ + Set your credentials + + Prototype +
+ + {serverName || serverAlias} + +
+ } + width={520} + > +
+ {perUserDefs.length === 0 ? ( + + ) : ( + <> + + These values are private to you. Your admin configured this MCP + server to require per-user credentials. + +
+ {perUserDefs.map((d) => ( + + {d.name} + + } + required + > + + setValues((prev) => ({ + ...prev, + [d.name]: e.target.value, + })) + } + placeholder={`Enter your ${d.name}`} + visibilityToggle + /> + + ))} +
+
+ + +
+ + )} +
+ + ); +}; + +export default FillUserFieldsModal; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mock/MockClaudeCodeModal.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mock/MockClaudeCodeModal.tsx new file mode 100644 index 00000000000..1ccb7429fca --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mock/MockClaudeCodeModal.tsx @@ -0,0 +1,143 @@ +// PROTOTYPE: simulates what a user would see in Claude Code when they try to +// use the MCP server. Shows a friendly error when per-user fields are missing +// (with a deep link to the fill-credentials modal), and a success state once +// the credentials are saved. + +import React, { useMemo } from "react"; +import { Modal, Typography, Tag } from "antd"; +import { + CheckCircleFilled, + CloseCircleFilled, + LinkOutlined, +} from "@ant-design/icons"; +import { Button } from "@tremor/react"; +import { getMissingUserFields } from "./mockMcpEnvVars"; + +const { Text, Title, Paragraph } = Typography; + +interface MockClaudeCodeModalProps { + open: boolean; + serverAlias: string; + serverName?: string | null; + userId: string; + onClose: () => void; + onOpenFillModal: () => void; +} + +const MockClaudeCodeModal: React.FC = ({ + open, + serverAlias, + serverName, + userId, + onClose, + onOpenFillModal, +}) => { + // Recomputed on each render — fine for a modal that only re-mounts on open. + const missing = useMemo( + () => (open ? getMissingUserFields(serverAlias, userId) : []), + [open, serverAlias, userId], + ); + + const hasMissing = missing.length > 0; + + const fillUrl = useMemo(() => { + if (typeof window === "undefined") return ""; + const url = new URL(window.location.href); + url.searchParams.set("fill_fields", serverAlias); + return url.toString(); + }, [serverAlias]); + + return ( + + + Simulated Claude Code session + + Prototype +
+ } + > +
+
+ $ claude +
+ > Using MCP server {serverName || serverAlias}... +
+ + {hasMissing ? ( +
+
+ +
+
+ Cannot connect to MCP server "{serverName || serverAlias}" +
+
+ Your administrator configured this server to require per-user + credentials, but you haven't set the following yet: +
+
    + {missing.map((name) => ( +
  • + • {name} +
  • + ))} +
+
+ Set your credentials here: +
+ + {fillUrl} + +
+
+
+
+ ) : ( +
+ +
+
+ Connected to {serverName || serverAlias} +
+
+ MCP server tools are now available. Try asking Claude to use + them. +
+
+
+ )} +
+ + + This panel mocks what a user would see in their MCP client (Claude Code, + Cursor, etc). In a real implementation, the error would arrive over the + MCP protocol with the same deep link. + + +
+ + {hasMissing && ( + + )} +
+ + ); +}; + +export default MockClaudeCodeModal; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mock/UserFieldsStatusCell.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mock/UserFieldsStatusCell.tsx new file mode 100644 index 00000000000..86e7817863e --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mock/UserFieldsStatusCell.tsx @@ -0,0 +1,131 @@ +// PROTOTYPE: cell rendered in the MCP servers table. Shows a red "N user +// fields missing" pill plus quick-action buttons when the current user hasn't +// filled in their per-user fields, otherwise shows a green "Ready" pill. + +import React, { useEffect, useState } from "react"; +import { Tooltip } from "antd"; +import { + ExclamationCircleFilled, + CheckCircleFilled, + PlayCircleOutlined, +} from "@ant-design/icons"; +import { + getEnvVarDefinitions, + getMissingUserFields, + subscribeEnvVarsChanged, +} from "./mockMcpEnvVars"; + +interface UserFieldsStatusCellProps { + serverAlias: string; + userId: string; + onOpenFill: () => void; + onOpenDemo: () => void; +} + +const UserFieldsStatusCell: React.FC = ({ + serverAlias, + userId, + onOpenFill, + onOpenDemo, +}) => { + const [tick, setTick] = useState(0); + useEffect(() => subscribeEnvVarsChanged(() => setTick((t) => t + 1)), []); + + const defs = serverAlias ? getEnvVarDefinitions(serverAlias) : []; + const perUserCount = defs.filter((d) => d.scope === "per_user").length; + const missing = serverAlias ? getMissingUserFields(serverAlias, userId) : []; + + // Server has no per-user fields at all → no badge to show. + if (perUserCount === 0) { + return ( + + ); + } + + if (missing.length > 0) { + return ( +
+ +
Missing user fields:
+
    + {missing.map((m) => ( +
  • • {m}
  • + ))} +
+
+ } + > + + + {missing.length} user field{missing.length === 1 ? "" : "s"} missing + + + + +
+ ); + } + + return ( +
+ + + Ready + + + + +
+ ); +}; + +export default UserFieldsStatusCell; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mock/mockMcpEnvVars.ts b/ui/litellm-dashboard/src/components/mcp_tools/mock/mockMcpEnvVars.ts new file mode 100644 index 00000000000..b99f02176b1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/mock/mockMcpEnvVars.ts @@ -0,0 +1,110 @@ +// PROTOTYPE: mocked env-var storage for the "MCP per-user fields" demo. +// All state lives in localStorage; no backend wiring. Throwaway code — once +// the customer agrees on the flow, this gets rebuilt against the real DB. + +export type EnvVarScope = "global" | "per_user"; + +export interface EnvVarDefinition { + name: string; + value: string; + scope: EnvVarScope; +} + +const defsKey = (alias: string) => `mock-mcp-env-defs::${alias}`; +const userKey = (alias: string, userId: string) => + `mock-mcp-env-user::${alias}::${userId}`; + +export function getEnvVarDefinitions(serverAlias: string): EnvVarDefinition[] { + if (!serverAlias || typeof window === "undefined") return []; + try { + const raw = window.localStorage.getItem(defsKey(serverAlias)); + if (!raw) return []; + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +export function setEnvVarDefinitions( + serverAlias: string, + defs: EnvVarDefinition[], +): void { + if (!serverAlias || typeof window === "undefined") return; + try { + window.localStorage.setItem(defsKey(serverAlias), JSON.stringify(defs)); + } catch (err) { + console.warn("[mock-mcp-env-vars] failed to save defs", err); + } +} + +export function getPerUserValues( + serverAlias: string, + userId: string, +): Record { + if (!serverAlias || !userId || typeof window === "undefined") return {}; + try { + const raw = window.localStorage.getItem(userKey(serverAlias, userId)); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + return {}; + } +} + +export function setPerUserValues( + serverAlias: string, + userId: string, + values: Record, +): void { + if (!serverAlias || !userId || typeof window === "undefined") return; + try { + window.localStorage.setItem( + userKey(serverAlias, userId), + JSON.stringify(values), + ); + } catch (err) { + console.warn("[mock-mcp-env-vars] failed to save user values", err); + } +} + +export function getMissingUserFields( + serverAlias: string, + userId: string, +): string[] { + const defs = getEnvVarDefinitions(serverAlias); + const values = getPerUserValues(serverAlias, userId); + return defs + .filter((d) => d.scope === "per_user") + .map((d) => d.name) + .filter((name) => !values[name] || values[name].trim() === ""); +} + +export function getPerUserFieldNames(serverAlias: string): string[] { + return getEnvVarDefinitions(serverAlias) + .filter((d) => d.scope === "per_user") + .map((d) => d.name); +} + +// Bump on each save so list views can re-read localStorage without polling. +const CHANGE_EVENT = "mock-mcp-env-vars-changed"; + +export function notifyEnvVarsChanged(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new CustomEvent(CHANGE_EVENT)); +} + +export function subscribeEnvVarsChanged(handler: () => void): () => void { + if (typeof window === "undefined") return () => undefined; + window.addEventListener(CHANGE_EVENT, handler); + // also react to storage events from other tabs + const storageHandler = (e: StorageEvent) => { + if (e.key && e.key.startsWith("mock-mcp-env-")) handler(); + }; + window.addEventListener("storage", storageHandler); + return () => { + window.removeEventListener(CHANGE_EVENT, handler); + window.removeEventListener("storage", storageHandler); + }; +}