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.
This commit is contained in:
Claude 2026-05-20 21:13:33 +00:00
parent f82ff7ee2a
commit 57e0c32ec1
No known key found for this signature in database
8 changed files with 786 additions and 1 deletions

View file

@ -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<CreateMCPServerProps> = ({
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<CreateMCPServerProps> = ({
<StdioConfiguration isVisible={transportType === "stdio"} />
</div>
{/* PROTOTYPE: Environment variables (global vs per-user) */}
<div className="mt-8">
<EnvVarsSection />
</div>
{/* Permission Management / Access Control Section */}
<div className="mt-8">
<MCPPermissionManagement

View file

@ -6,6 +6,7 @@ import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import { getMaskedAndFullUrl } from "./utils";
import { Tooltip } from "antd";
import { CheckOutlined } from "@ant-design/icons";
import UserFieldsStatusCell from "./mock/UserFieldsStatusCell";
const HealthStatusBadge: React.FC<{
server: MCPServer;
@ -92,6 +93,10 @@ export const mcpServerColumns = (
onByokConnect?: (server: MCPServer) => void,
onRecheckHealth?: (serverId: string) => void,
recheckingServerIds?: Set<string>,
// PROTOTYPE: hooks for the env-var user-fields demo
userIdForMockFields?: string,
onOpenFillFields?: (server: MCPServer) => void,
onOpenMockDemo?: (server: MCPServer) => void,
): ColumnDef<MCPServer>[] => [
{
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 (
<UserFieldsStatusCell
serverAlias={alias}
userId={userIdForMockFields || ""}
onOpenFill={() => onOpenFillFields?.(row.original)}
onOpenDemo={() => onOpenMockDemo?.(row.original)}
/>
);
},
},
{
id: "actions",
header: "Actions",

View file

@ -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<MCPServerProps> = ({ accessToken, userRole, userID })
const [prefillData, setPrefillData] = useState<DiscoverableMCPServer | null>(null);
const [isDeletingServer, setIsDeletingServer] = useState(false);
const [byokModalServer, setByokModalServer] = useState<MCPServer | null>(null);
// PROTOTYPE: state for the per-user fields demo
const [fillFieldsServer, setFillFieldsServer] = useState<MCPServer | null>(null);
const [mockDemoServer, setMockDemoServer] = useState<MCPServer | null>(null);
const isInternalUser = userRole === "Internal User";
// PROTOTYPE: deep-link via ?fill_fields=<alias> (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<MCPServerProps> = ({ 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<MCPServerProps> = ({ accessToken, userRole, userID })
accessToken={accessToken || ""}
/>
)}
{/* PROTOTYPE: per-user env-var fill modal */}
{fillFieldsServer && (
<FillUserFieldsModal
open={!!fillFieldsServer}
serverAlias={
fillFieldsServer.alias || fillFieldsServer.server_name || ""
}
serverName={fillFieldsServer.server_name}
userId={userID || ""}
onClose={() => setFillFieldsServer(null)}
/>
)}
{/* PROTOTYPE: simulated Claude Code error/success preview */}
{mockDemoServer && (
<MockClaudeCodeModal
open={!!mockDemoServer}
serverAlias={
mockDemoServer.alias || mockDemoServer.server_name || ""
}
serverName={mockDemoServer.server_name}
userId={userID || ""}
onClose={() => setMockDemoServer(null)}
onOpenFillModal={() => setFillFieldsServer(mockDemoServer)}
/>
)}
</div>
);
};

View file

@ -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 (
<div className="rounded-lg border border-dashed border-purple-300 bg-purple-50 p-4">
<div className="flex items-center gap-2 mb-1">
<Tag color="purple" style={{ marginRight: 0 }}>
Prototype
</Tag>
<Text strong className="text-sm">
Environment Variables
</Text>
<Tooltip
title={
<>
Define variables you can interpolate in Static Headers using{" "}
<code>{"${VAR_NAME}"}</code>. <br />
<b>Global</b>: admin-defined value used for every user.
<br />
<b>Per-user</b>: each user supplies their own value (e.g. personal
credentials).
</>
}
>
<InfoCircleOutlined className="text-purple-500" />
</Tooltip>
</div>
<Text className="text-xs text-gray-600 block mb-3">
Reference these in Static Headers as <code>{"${VAR_NAME}"}</code>. For
example:{" "}
<code className="bg-white px-1 rounded border border-gray-200">
{"${DB_PROTOCOL}://${CORP_USERNAME}:${CORP_PASSWORD}@${DB_HOSTNAME}"}
</code>
</Text>
<Form.List name="mock_env_vars">
{(fields, { add, remove }) => (
<div className="space-y-2">
{fields.length > 0 && (
<div className="flex gap-3 px-1 text-xs font-medium text-gray-500 uppercase tracking-wide">
<div style={{ flex: 1 }}>Variable Name</div>
<div style={{ flex: 1 }}>Value</div>
<div style={{ width: 160 }}>Scope</div>
<div style={{ width: 24 }} />
</div>
)}
{fields.map(({ key, name, ...restField }) => (
<Space
key={key}
className="flex w-full"
align="baseline"
size="middle"
>
<Form.Item
{...restField}
name={[name, "name"]}
className="flex-1 mb-0"
rules={[
{
pattern: /^[A-Z_][A-Z0-9_]*$/,
message: "Use UPPER_SNAKE_CASE",
warningOnly: true,
},
]}
>
<Input
placeholder="e.g. DB_PROTOCOL"
className="rounded-md font-mono"
/>
</Form.Item>
<Form.Item
{...restField}
name={[name, "value"]}
className="flex-1 mb-0"
shouldUpdate
>
<ValueField fieldName={name} />
</Form.Item>
<Form.Item
{...restField}
name={[name, "scope"]}
className="mb-0"
initialValue="global"
style={{ width: 160 }}
>
<Select
options={[
{ value: "global", label: "Global" },
{ value: "per_user", label: "Per-user field" },
]}
/>
</Form.Item>
<MinusCircleOutlined
onClick={() => remove(name)}
className="text-gray-500 hover:text-red-500 cursor-pointer"
/>
</Space>
))}
<Button
type="dashed"
onClick={() => add({ scope: "global" })}
icon={<PlusOutlined />}
block
>
Add Environment Variable
</Button>
</div>
)}
</Form.List>
</div>
);
};
// Disables the value field when scope=per_user (those values come from each
// user later), keeping the column visible so the row layout stays consistent.
const ValueField: React.FC<{
fieldName: number;
value?: string;
onChange?: (v: string) => void;
}> = ({ fieldName, value, onChange }) => {
const scope = Form.useWatch(["mock_env_vars", fieldName, "scope"]);
const isPerUser = scope === "per_user";
return (
<Input
value={value ?? ""}
onChange={(e) => onChange?.(e.target.value)}
placeholder={
isPerUser ? "set by each user" : "e.g. postgresql"
}
disabled={isPerUser}
className="rounded-md font-mono"
/>
);
};
export default EnvVarsSection;

View file

@ -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<FillUserFieldsModalProps> = ({
open,
serverAlias,
serverName,
userId,
onClose,
onSaved,
}) => {
const [defs, setDefs] = useState<EnvVarDefinition[]>([]);
const [values, setValues] = useState<Record<string, string>>({});
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 (
<Modal
open={open}
onCancel={onClose}
footer={null}
title={
<div>
<div className="flex items-center gap-2">
<Title level={5} style={{ margin: 0 }}>
Set your credentials
</Title>
<Tag color="purple">Prototype</Tag>
</div>
<Text type="secondary" className="text-xs">
{serverName || serverAlias}
</Text>
</div>
}
width={520}
>
<div className="space-y-4 mt-2">
{perUserDefs.length === 0 ? (
<Alert
type="info"
showIcon
message="No per-user fields configured for this server."
/>
) : (
<>
<Text className="text-sm text-gray-600 block">
These values are private to you. Your admin configured this MCP
server to require per-user credentials.
</Text>
<Form layout="vertical">
{perUserDefs.map((d) => (
<Form.Item
key={d.name}
label={
<span className="font-mono text-sm font-semibold">
{d.name}
</span>
}
required
>
<Input.Password
value={values[d.name] ?? ""}
onChange={(e) =>
setValues((prev) => ({
...prev,
[d.name]: e.target.value,
}))
}
placeholder={`Enter your ${d.name}`}
visibilityToggle
/>
</Form.Item>
))}
</Form>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-100">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
onClick={handleSave}
loading={saving}
disabled={perUserDefs.some(
(d) => !values[d.name] || values[d.name].trim() === "",
)}
>
Save Credentials
</Button>
</div>
</>
)}
</div>
</Modal>
);
};
export default FillUserFieldsModal;

View file

@ -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<MockClaudeCodeModalProps> = ({
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 (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={620}
title={
<div className="flex items-center gap-2">
<Title level={5} style={{ margin: 0 }}>
Simulated Claude Code session
</Title>
<Tag color="purple">Prototype</Tag>
</div>
}
>
<div className="mt-2 rounded-lg bg-[#1e1e1e] text-gray-100 font-mono text-sm p-4 leading-relaxed">
<div className="text-gray-400">
$ claude
<br />
&gt; Using MCP server <span className="text-cyan-300">{serverName || serverAlias}</span>...
</div>
{hasMissing ? (
<div className="mt-4">
<div className="flex items-start gap-2">
<CloseCircleFilled className="text-red-400 mt-1" />
<div>
<div className="text-red-300 font-semibold">
Cannot connect to MCP server &quot;{serverName || serverAlias}&quot;
</div>
<div className="text-gray-300 mt-2">
Your administrator configured this server to require per-user
credentials, but you haven&apos;t set the following yet:
</div>
<ul className="mt-2 ml-4 text-yellow-200">
{missing.map((name) => (
<li key={name}>
• <span className="font-bold">{name}</span>
</li>
))}
</ul>
<div className="mt-3 text-gray-300">
Set your credentials here:
<br />
<span className="text-blue-300 underline break-all">
<LinkOutlined /> {fillUrl}
</span>
</div>
</div>
</div>
</div>
) : (
<div className="mt-4 flex items-start gap-2">
<CheckCircleFilled className="text-green-400 mt-1" />
<div>
<div className="text-green-300 font-semibold">
Connected to {serverName || serverAlias}
</div>
<div className="text-gray-300 mt-2">
MCP server tools are now available. Try asking Claude to use
them.
</div>
</div>
</div>
)}
</div>
<Paragraph type="secondary" className="text-xs mt-3 mb-0">
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.
</Paragraph>
<div className="flex items-center justify-end gap-2 pt-4 mt-3 border-t border-gray-100">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
{hasMissing && (
<Button
variant="primary"
onClick={() => {
onClose();
onOpenFillModal();
}}
>
Set Credentials
</Button>
)}
</div>
</Modal>
);
};
export default MockClaudeCodeModal;

View file

@ -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<UserFieldsStatusCellProps> = ({
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 (
<button
onClick={(e) => {
e.stopPropagation();
onOpenDemo();
}}
className="inline-flex items-center gap-1 text-xs text-gray-500 hover:text-blue-600 transition-colors"
title="Simulate using this MCP server in Claude Code"
>
<PlayCircleOutlined /> Try in Claude Code
</button>
);
}
if (missing.length > 0) {
return (
<div
className="inline-flex items-center gap-2 px-2 py-1 rounded-md border-2 border-red-300 bg-red-50"
// PROTOTYPE: cell-level highlight stands in for full-row highlight
// since DataTable doesn't expose a row-class hook today.
>
<Tooltip
title={
<div>
<div className="font-semibold mb-1">Missing user fields:</div>
<ul className="ml-3">
{missing.map((m) => (
<li key={m}>• {m}</li>
))}
</ul>
</div>
}
>
<span className="inline-flex items-center gap-1 text-xs font-semibold text-red-700">
<ExclamationCircleFilled />
{missing.length} user field{missing.length === 1 ? "" : "s"} missing
</span>
</Tooltip>
<button
onClick={(e) => {
e.stopPropagation();
onOpenFill();
}}
className="text-xs bg-red-600 hover:bg-red-700 text-white px-2 py-0.5 rounded font-medium transition-colors"
>
Set
</button>
<button
onClick={(e) => {
e.stopPropagation();
onOpenDemo();
}}
className="text-xs text-gray-600 hover:text-blue-600 transition-colors"
title="Simulate using this MCP server in Claude Code"
>
<PlayCircleOutlined />
</button>
</div>
);
}
return (
<div className="inline-flex items-center gap-2">
<Tooltip title="All per-user fields are set for your account.">
<span className="inline-flex items-center gap-1 text-xs font-medium text-green-700 bg-green-50 border border-green-200 px-2 py-0.5 rounded-full">
<CheckCircleFilled /> Ready
</span>
</Tooltip>
<button
onClick={(e) => {
e.stopPropagation();
onOpenFill();
}}
className="text-xs text-gray-400 hover:text-blue-600 transition-colors"
>
Update
</button>
<button
onClick={(e) => {
e.stopPropagation();
onOpenDemo();
}}
className="text-xs text-gray-600 hover:text-blue-600 transition-colors"
title="Simulate using this MCP server in Claude Code"
>
<PlayCircleOutlined />
</button>
</div>
);
};
export default UserFieldsStatusCell;

View file

@ -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<string, string> {
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<string, string>,
): 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);
};
}