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:
Claude 2026-05-19 02:13:57 +00:00
parent 761c280a6e
commit 677809ad18
No known key found for this signature in database
6 changed files with 583 additions and 2 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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

View file

@ -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",

View file

@ -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&apos;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>
);
};

View 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;
}