From 57e0c32ec169c8cf453897f1fc29f9ec08825a11 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 21:13:33 +0000 Subject: [PATCH] proto(mcp): per-user env-var fields demo flow Throwaway UI mockup so we can show the customer the end-to-end flow for per-user MCP credentials before committing to a real implementation. Flow demoed: 1. Admin adds env vars (3 columns: name, value, scope=Global|Per-user) in "Add MCP Server"; values can be interpolated in Static Headers as ${VAR_NAME}. 2. User's dashboard cell turns red with "N user fields missing" when their per-user fields are unset. 3. "Try in Claude Code" preview shows a friendly error naming the missing fields plus a deep link back to the fill modal. 4. Fill modal saves, badge flips to green Ready, preview now shows Connected. Mock state lives entirely in localStorage keyed by (server alias, user id); no backend wiring. All new code is under components/mcp_tools/mock/ so it can be torn out once we know what the real shape should be. --- .../mcp_tools/create_mcp_server.tsx | 32 ++++ .../mcp_tools/mcp_server_columns.tsx | 20 +++ .../src/components/mcp_tools/mcp_servers.tsx | 61 +++++++- .../mcp_tools/mock/EnvVarsSection.tsx | 148 ++++++++++++++++++ .../mcp_tools/mock/FillUserFieldsModal.tsx | 142 +++++++++++++++++ .../mcp_tools/mock/MockClaudeCodeModal.tsx | 143 +++++++++++++++++ .../mcp_tools/mock/UserFieldsStatusCell.tsx | 131 ++++++++++++++++ .../mcp_tools/mock/mockMcpEnvVars.ts | 110 +++++++++++++ 8 files changed, 786 insertions(+), 1 deletion(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/mock/EnvVarsSection.tsx create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/mock/FillUserFieldsModal.tsx create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/mock/MockClaudeCodeModal.tsx create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/mock/UserFieldsStatusCell.tsx create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/mock/mockMcpEnvVars.ts 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); + }; +}