mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
prototype(ui): per-user fields for MCP servers (mock/throwaway)
UI-only mockup of an admin-defined per-user-fields feature for MCP servers, to demo the flow to a customer: 1. Admin adds a new MCP server and defines arbitrary per-user fields (e.g. each user supplies their own Gmail bearer token). 2. On the user's MCP dashboard, the server shows a pulsing red "N fields missing" badge and a top-of-page red alert summarizing every server that needs configuration. 3. Clicking the badge opens a modal where the user fills the fields. The modal also has a "Preview Claude Code Error" tab that shows what the friendly CLI error (with a deep link to this modal) would look like. 4. After saving, the row flips to green "All set" and the alert disappears. Deep-link support: visiting the dashboard with `?openUserFields=<server_id>` auto-opens the configure modal — this is the URL we render in the mocked Claude Code error. This is intentionally throwaway: defs and values are persisted in localStorage (not the backend), no API changes, no enforcement at request time. Real impl will store defs server-side and enforce on the MCP request path.
This commit is contained in:
parent
761c280a6e
commit
677809ad18
6 changed files with 583 additions and 2 deletions
|
|
@ -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<UserFieldsAdminSectionProps> = ({ value, onChange }) => {
|
||||
const fields = value || [];
|
||||
|
||||
const update = (idx: number, patch: Partial<UserField>) => {
|
||||
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 (
|
||||
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<Title level={5} style={{ margin: 0 }}>
|
||||
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>
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ margin: 0, fontSize: 12 }}>
|
||||
Each user will be prompted to fill these in on their dashboard. The MCP server will refuse
|
||||
connections until they do.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={add}
|
||||
className="text-sm bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded-md font-medium transition-colors flex items-center gap-1"
|
||||
>
|
||||
<PlusOutlined /> Add Field
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{fields.length === 0 ? (
|
||||
<div className="text-center py-6 text-gray-400 text-sm bg-white rounded border border-dashed border-gray-300">
|
||||
No per-user fields. Click <strong>Add Field</strong> to require users to provide their own
|
||||
values (e.g. a personal bearer token).
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{fields.map((f, idx) => (
|
||||
<div key={idx} className="bg-white rounded border border-gray-200 p-3">
|
||||
<div className="grid grid-cols-12 gap-2 items-start">
|
||||
<div className="col-span-3">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>Field Name (key)</Text>
|
||||
<Input
|
||||
placeholder="bearer_token"
|
||||
value={f.name}
|
||||
onChange={(e) => update(idx, { name: e.target.value })}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-3">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>Label</Text>
|
||||
<Input
|
||||
placeholder="Personal Gmail Token"
|
||||
value={f.label}
|
||||
onChange={(e) => update(idx, { label: e.target.value })}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-4">
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>Description (shown to user)</Text>
|
||||
<Input
|
||||
placeholder="Generate one at https://..."
|
||||
value={f.description}
|
||||
onChange={(e) => update(idx, { description: e.target.value })}
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-1 pt-4">
|
||||
<Tooltip title="Treat as secret (masked input)">
|
||||
<Switch
|
||||
checked={!!f.secret}
|
||||
onChange={(checked) => update(idx, { secret: checked })}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="col-span-1 pt-3 text-right">
|
||||
<Tooltip title="Remove field">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => remove(idx)}
|
||||
className="p-1 text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserFieldsAdminSection;
|
||||
|
|
@ -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<UserFieldsModalProps> = ({
|
||||
server,
|
||||
userId,
|
||||
open,
|
||||
onClose,
|
||||
onSaved,
|
||||
}) => {
|
||||
const [defs, setDefs] = useState<UserField[]>([]);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [activeTab, setActiveTab] = useState<string>("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}` : `<dashboard-url>?openUserFields=${server.server_id}`}
|
||||
|
||||
Once configured, retry your request.`;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
width={680}
|
||||
footer={null}
|
||||
title={
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-gray-100">
|
||||
{allFilled ? (
|
||||
<CheckCircleFilled style={{ color: "#10b981", fontSize: 22 }} />
|
||||
) : (
|
||||
<ExclamationCircleFilled style={{ color: "#ef4444", fontSize: 22 }} />
|
||||
)}
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{allFilled ? "Configured: " : "Configure your fields for "}
|
||||
{serverDisplayName}
|
||||
</Title>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
items={[
|
||||
{
|
||||
key: "configure",
|
||||
label: "Your Fields",
|
||||
children: (
|
||||
<div className="pt-2">
|
||||
<Paragraph type="secondary">
|
||||
This MCP server requires per-user configuration. Fill in the fields below to start
|
||||
using it.
|
||||
</Paragraph>
|
||||
|
||||
{defs.length === 0 ? (
|
||||
<div className="py-6 text-center text-gray-400">
|
||||
No per-user fields are defined for this server.
|
||||
</div>
|
||||
) : (
|
||||
<Form layout="vertical">
|
||||
{defs.map((f) => {
|
||||
const isMissing = !values[f.name] || values[f.name].trim() === "";
|
||||
return (
|
||||
<Form.Item
|
||||
key={f.name}
|
||||
label={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{f.label || f.name}</span>
|
||||
{isMissing ? (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-red-50 text-red-700 border border-red-200">
|
||||
required
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200">
|
||||
<CheckCircleFilled style={{ fontSize: 10 }} /> set
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
help={f.description}
|
||||
>
|
||||
{f.secret ? (
|
||||
<Input.Password
|
||||
placeholder={`Enter your ${f.label || f.name}`}
|
||||
value={values[f.name] || ""}
|
||||
onChange={(e) =>
|
||||
setValues({ ...values, [f.name]: e.target.value })
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={`Enter your ${f.label || f.name}`}
|
||||
value={values[f.name] || ""}
|
||||
onChange={(e) =>
|
||||
setValues({ ...values, [f.name]: e.target.value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
);
|
||||
})}
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-3 border-t border-gray-100 mt-3">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={defs.length === 0}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "preview",
|
||||
label: "Preview Claude Code Error",
|
||||
children: (
|
||||
<div className="pt-2">
|
||||
<Paragraph type="secondary">
|
||||
This is what your users will see in their terminal when they try to use{" "}
|
||||
<Text code>{serverDisplayName}</Text> via Claude Code without configuring these
|
||||
fields:
|
||||
</Paragraph>
|
||||
<div
|
||||
className="rounded-lg bg-gray-900 text-gray-100 font-mono text-xs p-4 whitespace-pre-wrap relative"
|
||||
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (typeof navigator !== "undefined" && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(errorPreview);
|
||||
NotificationsManager.success("Copied error preview");
|
||||
}
|
||||
}}
|
||||
className="absolute top-2 right-2 text-gray-400 hover:text-white p-1"
|
||||
>
|
||||
<CopyOutlined />
|
||||
</button>
|
||||
{errorPreview}
|
||||
</div>
|
||||
<Paragraph type="secondary" style={{ marginTop: 12, fontSize: 12 }}>
|
||||
The link in the error opens this same dialog directly so users can fix and retry in
|
||||
one click.
|
||||
</Paragraph>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserFieldsModal;
|
||||
|
|
@ -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<CreateMCPServerProps> = ({
|
|||
const [oauthAccessToken, setOauthAccessToken] = useState<string | null>(null);
|
||||
const [logoUrl, setLogoUrl] = useState<string | undefined>(undefined);
|
||||
const [oauthDocsUrl, setOauthDocsUrl] = useState<string | null>(null);
|
||||
const [userFields, setUserFields] = useState<UserField[]>([]);
|
||||
|
||||
// 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<CreateMCPServerProps> = ({
|
|||
? 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<CreateMCPServerProps> = ({
|
|||
setAllowedTools([]);
|
||||
setAliasManuallyEdited(false);
|
||||
setLogoUrl(undefined);
|
||||
setUserFields([]);
|
||||
setModalVisible(false);
|
||||
onCreateSuccess(response);
|
||||
}
|
||||
|
|
@ -441,6 +454,7 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
setAllowedTools([]);
|
||||
setAliasManuallyEdited(false);
|
||||
setLogoUrl(undefined);
|
||||
setUserFields([]);
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
|
|
@ -989,6 +1003,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
|
|||
<StdioConfiguration isVisible={transportType === "stdio"} />
|
||||
</div>
|
||||
|
||||
{/* Per-User Fields Section (admin defines fields each end-user must fill in) */}
|
||||
<div className="mt-8">
|
||||
<UserFieldsAdminSection value={userFields} onChange={setUserFields} />
|
||||
</div>
|
||||
|
||||
{/* Permission Management / Access Control Section */}
|
||||
<div className="mt-8">
|
||||
<MCPPermissionManagement
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { Icon } from "@tremor/react";
|
|||
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
|
||||
import { getMaskedAndFullUrl } from "./utils";
|
||||
import { Tooltip } from "antd";
|
||||
import { CheckOutlined } from "@ant-design/icons";
|
||||
import { CheckOutlined, ExclamationCircleFilled } from "@ant-design/icons";
|
||||
import { getUserFieldDefs, getMissingUserFields } from "./userFields";
|
||||
|
||||
const HealthStatusBadge: React.FC<{
|
||||
server: MCPServer;
|
||||
|
|
@ -92,6 +93,9 @@ export const mcpServerColumns = (
|
|||
onByokConnect?: (server: MCPServer) => void,
|
||||
onRecheckHealth?: (serverId: string) => void,
|
||||
recheckingServerIds?: Set<string>,
|
||||
userId?: string | null,
|
||||
onConfigureUserFields?: (server: MCPServer) => void,
|
||||
userFieldsRefreshKey?: number,
|
||||
): ColumnDef<MCPServer>[] => [
|
||||
{
|
||||
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 <span className="text-gray-300 text-xs">—</span>;
|
||||
}
|
||||
if (!userId) {
|
||||
return <span className="text-gray-400 text-xs">{defs.length} field{defs.length === 1 ? "" : "s"}</span>;
|
||||
}
|
||||
const missing = getMissingUserFields(server.server_id, userId);
|
||||
|
||||
if (missing.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-green-50 text-green-700 border border-green-200">
|
||||
<CheckOutlined style={{ fontSize: 10 }} /> All set
|
||||
</span>
|
||||
{onConfigureUserFields && (
|
||||
<button
|
||||
className="text-xs text-gray-400 hover:text-blue-600 transition-colors"
|
||||
onClick={() => onConfigureUserFields(server)}
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// MISSING — the visual centerpiece of the prototype
|
||||
return (
|
||||
<button
|
||||
onClick={() => onConfigureUserFields && onConfigureUserFields(server)}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-semibold px-2.5 py-1 rounded-md bg-red-50 hover:bg-red-100 text-red-700 border border-red-300 ring-1 ring-red-200 animate-pulse cursor-pointer transition-colors"
|
||||
title="Click to configure"
|
||||
>
|
||||
<ExclamationCircleFilled style={{ fontSize: 12 }} />
|
||||
{missing.length} field{missing.length === 1 ? "" : "s"} missing
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "byok_credential",
|
||||
header: "Credential",
|
||||
|
|
|
|||
|
|
@ -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<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
const [prefillData, setPrefillData] = useState<DiscoverableMCPServer | null>(null);
|
||||
const [isDeletingServer, setIsDeletingServer] = useState(false);
|
||||
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(null);
|
||||
const [userFieldsServer, setUserFieldsServer] = useState<MCPServer | null>(null);
|
||||
const [userFieldsRefreshKey, setUserFieldsRefreshKey] = useState(0);
|
||||
const isInternalUser = userRole === "Internal User";
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -85,6 +90,43 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
}
|
||||
}, []);
|
||||
|
||||
// PROTOTYPE: auto-open the user-fields modal when navigated to with
|
||||
// ?openUserFields=<server_id> (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<MCPServerProps> = ({ 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<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
{serversNeedingUserFields.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border-2 border-red-300 bg-red-50 px-4 py-3 flex items-start gap-3">
|
||||
<ExclamationCircleFilled style={{ color: "#dc2626", fontSize: 22, marginTop: 2 }} />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-red-800">
|
||||
{serversNeedingUserFields.length} MCP server
|
||||
{serversNeedingUserFields.length === 1 ? " needs" : "s need"} your configuration
|
||||
</div>
|
||||
<div className="text-sm text-red-700 mt-0.5">
|
||||
These servers won't work in Claude Code (or anywhere else) until you fill in
|
||||
your per-user fields:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{serversNeedingUserFields.map(({ server, missingCount }) => (
|
||||
<button
|
||||
key={server.server_id}
|
||||
onClick={() => setUserFieldsServer(server)}
|
||||
className="inline-flex items-center gap-1.5 bg-white hover:bg-red-100 border border-red-300 rounded-md px-2.5 py-1 text-xs font-medium text-red-700 transition-colors"
|
||||
>
|
||||
{server.mcp_info?.logo_url && (
|
||||
<img
|
||||
src={server.mcp_info.logo_url}
|
||||
alt=""
|
||||
className="h-4 w-4 object-contain"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span>{server.server_name || server.alias || server.server_id.slice(0, 7)}</span>
|
||||
<span className="inline-flex items-center justify-center bg-red-600 text-white rounded-full text-[10px] font-bold h-4 min-w-4 px-1">
|
||||
{missingCount}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MCPDiscovery
|
||||
isVisible={isDiscoveryVisible}
|
||||
onClose={() => setDiscoveryVisible(false)}
|
||||
|
|
@ -461,6 +545,14 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
|
|||
accessToken={accessToken || ""}
|
||||
/>
|
||||
)}
|
||||
|
||||
<UserFieldsModal
|
||||
server={userFieldsServer}
|
||||
userId={userID || ""}
|
||||
open={!!userFieldsServer}
|
||||
onClose={() => setUserFieldsServer(null)}
|
||||
onSaved={() => setUserFieldsRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
85
ui/litellm-dashboard/src/components/mcp_tools/userFields.ts
Normal file
85
ui/litellm-dashboard/src/components/mcp_tools/userFields.ts
Normal file
|
|
@ -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<T>(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<UserField[]>(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<string, string> {
|
||||
if (typeof window === "undefined") return {};
|
||||
return safeParse<Record<string, string>>(
|
||||
window.localStorage.getItem(VALUES_KEY(serverId, userId)),
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
export function setUserFieldValues(
|
||||
serverId: string,
|
||||
userId: string,
|
||||
values: Record<string, string>,
|
||||
): 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;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue