feat(mcp/ui): match env-vars UI to card-grid design from #28399

Adopts the desired UI from the prototype PR while keeping the working
backend (bulk /user-env-vars/status, scope global/user):

- Replace the MCP servers table with a card grid (MCPServerCard) plus
  search and sort. Per-user status renders as a red "N user fields
  missing / Set" footer on each card, driven by the bulk status endpoint
  (no per-card N+1 fetch).
- Restyle EnvVarsSection as a purple 3-column editor (name / value /
  scope) with scope labeled Instance / Per-user; value disabled for
  per-user rows. Surface it as a top-level section in the create and
  edit forms instead of inside the collapsed Permission panel.
- Restyle UserEnvVarsModal to match the prototype fill modal
  (Per-user tag, masked inputs, "Save Credentials").
- Revert the now-unused env-var chip in mcp_server_columns to baseline.

https://claude.ai/code/session_01X5YQzqswkwcVLtsBbk7Qyh
This commit is contained in:
Claude 2026-05-24 02:41:04 +00:00
parent accecaf65f
commit 28883ea80f
No known key found for this signature in database
8 changed files with 787 additions and 223 deletions

View file

@ -1,11 +1,15 @@
import React from "react";
import { Form, Input, Select, Space, Button, Tooltip, Typography } from "antd";
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { Form, Input, Select, Button, Tooltip, Typography } from "antd";
import {
InfoCircleOutlined,
MinusCircleOutlined,
PlusOutlined,
} from "@ant-design/icons";
const { Text } = Typography;
const SCOPE_OPTIONS = [
{ value: "global", label: "Global" },
{ value: "global", label: "Instance" },
{ value: "user", label: "Per-user" },
];
@ -13,56 +17,60 @@ const SCOPE_OPTIONS = [
* Form section for admin-configured MCP environment variables.
*
* Each row has: name | value | scope. Variables can be interpolated into
* Static Headers via ${NAME}. ``scope=global`` values are used as-is.
* ``scope=user`` values are filled in by each user the admin-entered
* value is just a placeholder/description.
* Static Headers via ${NAME}. ``scope=global`` (shown as "Instance") values
* are used as-is. ``scope=user`` (shown as "Per-user") values are filled in
* by each user via the MCP Gateway dashboard.
*
* The parent form must render this inside a ``<Form>`` and read the
* ``env_vars`` field from the form values.
* The parent form reads the ``env_vars`` field from the form values.
*/
const EnvVarsSection: React.FC = () => {
return (
<Form.Item
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
<div className="rounded-lg border border-dashed border-purple-300 bg-purple-50 p-4">
<div className="flex items-center gap-2 mb-1">
<Text strong className="text-sm">
Environment Variables
<Tooltip
title={
<div>
<div>
Define variables that get interpolated into Static Headers via{" "}
<code>{"${NAME}"}</code> syntax.
</div>
<div className="mt-2">
<b>Global</b>: value is used for every user.
</div>
<div>
<b>Per-user</b>: each user fills in their own value via the
MCP Gateway dashboard. The value you enter here is shown to
the user as a placeholder/description.
</div>
</div>
}
>
<InfoCircleOutlined className="ml-2 text-blue-400 hover:text-blue-600 cursor-help" />
</Tooltip>
</span>
}
required={false}
>
<Text className="block text-xs text-gray-500 mb-2">
Reference these in Static Headers like{" "}
<code className="bg-gray-100 px-1 rounded">{"${DB_PROTOCOL}://${CORP_USERNAME}:${CORP_PASSWORD}@..."}</code>
</Text>
<Tooltip
title={
<>
Define variables you can interpolate in Static Headers or
Authentication using <code>{"${VAR_NAME}"}</code>. <br />
<b>Instance</b>: admin-defined value used for every user.
<br />
<b>Per-user</b>: each user supplies their own value (e.g. personal
credentials) via the MCP Gateway dashboard.
</>
}
>
<InfoCircleOutlined className="text-purple-500" />
</Tooltip>
</div>
<Text className="text-xs text-gray-600 block mb-3">
Reference these in Static Headers or Authentication 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="env_vars">
{(fields, { add, remove }) => (
<div className="space-y-3">
<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">
<div key={key} className="flex gap-3 items-baseline">
<Form.Item
{...restField}
name={[name, "name"]}
className="flex-1"
className="mb-0"
style={{ flex: 1 }}
rules={[
{ required: true, message: "Variable name is required" },
{
@ -70,39 +78,44 @@ const EnvVarsSection: React.FC = () => {
message: "Use letters, digits, underscores; cannot start with a digit.",
},
]}
>
<Input size="large" allowClear className="rounded-lg" placeholder="DB_PROTOCOL" />
</Form.Item>
<Form.Item
{...restField}
name={[name, "value"]}
className="flex-1"
>
<Input
size="large"
allowClear
className="rounded-lg"
placeholder="postgres / placeholder for user-scoped"
placeholder="e.g. DB_PROTOCOL"
className="rounded-md font-mono"
/>
</Form.Item>
<Form.Item
{...restField}
name={[name, "scope"]}
className="w-36"
initialValue="global"
rules={[{ required: true, message: "Scope required" }]}
name={[name, "value"]}
className="mb-0"
style={{ flex: 1 }}
shouldUpdate
>
<Select size="large" options={SCOPE_OPTIONS} />
<ValueField fieldName={name} />
</Form.Item>
<MinusCircleOutlined
onClick={() => remove(name)}
className="text-gray-500 hover:text-red-500 cursor-pointer"
/>
</Space>
<Form.Item
{...restField}
name={[name, "scope"]}
className="mb-0"
initialValue="global"
style={{ width: 160 }}
>
<Select options={SCOPE_OPTIONS} />
</Form.Item>
<div
style={{ width: 24 }}
className="flex items-center justify-center"
>
<MinusCircleOutlined
onClick={() => remove(name)}
className="text-gray-500 hover:text-red-500 cursor-pointer"
/>
</div>
</div>
))}
<Button
type="dashed"
onClick={() => add({ name: "", value: "", scope: "global" })}
onClick={() => add({ scope: "global" })}
icon={<PlusOutlined />}
block
>
@ -111,7 +124,27 @@ const EnvVarsSection: React.FC = () => {
</div>
)}
</Form.List>
</Form.Item>
</div>
);
};
// Disables the value field when scope=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(["env_vars", fieldName, "scope"]);
const isPerUser = scope === "user";
return (
<Input
value={value ?? ""}
onChange={(e) => onChange?.(e.target.value)}
placeholder={isPerUser ? "Defined per user" : "e.g. postgresql"}
disabled={isPerUser}
className="rounded-md font-mono"
/>
);
};

View file

@ -2,7 +2,6 @@ import React, { useEffect } from "react";
import { Alert, Form, Select, Tooltip, Collapse, Input, Space, Button, Switch } from "antd";
import { InfoCircleOutlined, MinusCircleOutlined, PlusOutlined } from "@ant-design/icons";
import { MCPServer, AUTH_TYPE } from "./types";
import EnvVarsSection from "./EnvVarsSection";
const { Panel } = Collapse;
interface MCPPermissionManagementProps {
@ -283,8 +282,6 @@ const MCPPermissionManagement: React.FC<MCPPermissionManagementProps> = ({
)}
</Form.List>
</Form.Item>
<EnvVarsSection />
</div>
</Panel>
</Collapse>

View file

@ -0,0 +1,413 @@
import { useState, type FC, type KeyboardEvent, type MouseEvent } from "react";
import { Dropdown, Tooltip, Typography, Tag } from "antd";
import type { MenuProps } from "antd";
import {
CheckOutlined,
DeleteOutlined,
ExclamationCircleFilled,
MoreOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import type { MCPServer } from "./types";
import { getMaskedAndFullUrl } from "./utils";
const { Text } = Typography;
interface MCPServerCardProps {
server: MCPServer;
// Per-user env-var fields this user still needs to fill in for this server.
// Computed by the parent from the bulk /user-env-vars/status response, so
// the card never issues a per-row request (no N+1).
missingUserFields?: string[];
isLoadingHealth?: boolean;
isRechecking?: boolean;
onClick: () => void;
onRecheckHealth?: () => void;
onByokConnect?: () => void;
onOpenFillFields?: () => void;
onDelete?: () => void;
}
const HEALTH_TONE: Record<string, { dot: string }> = {
healthy: { dot: "bg-green-500" },
unhealthy: { dot: "bg-red-500" },
unknown: { dot: "bg-gray-300" },
};
// Stop card-level click handler from firing when an interactive child is used.
const stop = (e: MouseEvent | KeyboardEvent) => e.stopPropagation();
const MCPServerCard: FC<MCPServerCardProps> = ({
server,
missingUserFields,
isLoadingHealth,
isRechecking,
onClick,
onRecheckHealth,
onByokConnect,
onOpenFillFields,
onDelete,
}) => {
const alias = server.alias || server.server_name || "";
const name = server.server_name || alias || server.server_id;
// Logo is sourced exclusively from the admin-set `mcp_info.logo_url`.
const candidateLogo = server.mcp_info?.logo_url ?? undefined;
const [failedLogoUrl, setFailedLogoUrl] = useState<string | null>(null);
const logoUrl =
candidateLogo && failedLogoUrl !== candidateLogo ? candidateLogo : undefined;
const transport = server.transport || "http";
const displayTransport =
server.spec_path && transport !== "stdio" ? "openapi" : transport;
const authType = server.auth_type || "none";
const status = server.status || "unknown";
const healthTone = HEALTH_TONE[status] ?? HEALTH_TONE.unknown;
const isPublic = server.available_on_public_internet;
const accessGroups = (server.mcp_access_groups ?? []).filter(
(g): g is string => typeof g === "string",
);
const missing = missingUserFields ?? [];
const needsAttention = missing.length > 0;
const cardClass = needsAttention
? "border-2 border-red-300 bg-red-50/40 hover:border-red-400 hover:shadow-md"
: "border border-gray-200 bg-white hover:border-gray-300 hover:shadow-md";
const url = server.url || "";
const { maskedUrl } = url ? getMaskedAndFullUrl(url) : { maskedUrl: "" };
// Transport-adapted identifier shown under the title. Every transport has
// something useful here, which keeps the tag row vertically aligned across
// cards in the grid (stdio cards no longer "snap up" because they lack a URL).
let subtitle = "";
let subtitleTooltip = "";
if (transport === "stdio") {
const parts = [server.command, ...(server.args ?? [])]
.filter((p): p is string => typeof p === "string" && p.length > 0);
subtitle = parts.join(" ");
subtitleTooltip = subtitle;
} else if (server.spec_path) {
subtitle = server.spec_path;
subtitleTooltip = server.spec_path;
} else if (url) {
subtitle = maskedUrl;
subtitleTooltip = url;
}
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick();
}
};
const menuItems: MenuProps["items"] = [];
if (onRecheckHealth) {
menuItems.push({
key: "test-connection",
label: "Test Connection",
icon: <ThunderboltOutlined />,
disabled: isRechecking,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
onRecheckHealth();
},
});
}
if (onDelete) {
if (menuItems.length > 0) {
menuItems.push({ key: "divider", type: "divider" });
}
menuItems.push({
key: "delete",
label: "Delete",
icon: <DeleteOutlined />,
danger: true,
onClick: ({ domEvent }) => {
domEvent.stopPropagation();
onDelete();
},
});
}
// Card uses role="button" + nested <button> children (Set, BYOK Connect, the
// recheck-health Tag), so a real <button> wrapper would produce invalid
// nested-interactive HTML. The role + tabIndex + Enter/Space handler keeps
// the whole card clickable and keyboard-accessible.
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={handleKeyDown}
className={`group relative flex h-full cursor-pointer flex-col gap-3 rounded-lg p-4 transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 ${cardClass}`}
>
<div className="flex items-start gap-3">
{logoUrl ? (
<img
src={logoUrl}
alt={`${name} logo`}
className="h-10 w-10 flex-shrink-0 rounded object-contain"
onError={() => setFailedLogoUrl(logoUrl)}
/>
) : (
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded bg-gray-100 font-semibold text-gray-500">
{(name || "?").slice(0, 2).toUpperCase()}
</div>
)}
<div className="min-w-0 flex-1">
<div
className="block w-full truncate text-left font-semibold text-gray-900"
title={name}
>
{name}
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-gray-500">
{alias && <span className="truncate">{alias}</span>}
{alias && <span className="text-gray-300">·</span>}
<Tooltip title={server.server_id}>
<span className="font-mono text-blue-600">
{server.server_id.slice(0, 7)}
</span>
</Tooltip>
</div>
</div>
{menuItems.length > 0 && (
<Dropdown
menu={{ items: menuItems }}
trigger={["click"]}
placement="bottomRight"
>
<button
type="button"
onClick={stop}
onKeyDown={stop}
aria-label="Server actions"
className="-mr-1 -mt-1 inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 hover:text-blue-600"
>
<MoreOutlined style={{ fontSize: 20 }} />
</button>
</Dropdown>
)}
</div>
{subtitle ? (
<Tooltip title={subtitleTooltip}>
<Text
className="truncate font-mono text-xs text-gray-500"
ellipsis
>
{subtitle}
</Text>
</Tooltip>
) : (
// Defensive placeholder: keep the row even when no identifier is
// available so the tag row stays vertically aligned across the grid.
<div className="h-[18px]" aria-hidden />
)}
<div className="flex flex-wrap items-center gap-1.5">
<HealthChip
status={status}
isLoadingHealth={isLoadingHealth}
isRechecking={isRechecking}
onRecheck={onRecheckHealth}
lastCheck={server.last_health_check}
error={server.health_check_error}
dotClass={healthTone.dot}
/>
<Tag className="m-0">{displayTransport.toUpperCase()}</Tag>
<Tag className="m-0">{authType}</Tag>
<Tag color={isPublic ? "green" : "orange"} className="m-0">
<span className="inline-flex items-center gap-1">
<span
className={`h-1.5 w-1.5 rounded-full ${
isPublic ? "bg-green-500" : "bg-orange-500"
}`}
/>
{isPublic ? "Public" : "Internal"}
</span>
</Tag>
{accessGroups.slice(0, 2).map((g) => (
<Tooltip key={g} title={g}>
<Tag className="m-0 max-w-[120px] truncate">{g}</Tag>
</Tooltip>
))}
{accessGroups.length > 2 && (
<Tooltip title={accessGroups.slice(2).join(", ")}>
<Tag className="m-0">+{accessGroups.length - 2}</Tag>
</Tooltip>
)}
</div>
{(server.is_byok || needsAttention) && (
<div className="mt-auto flex flex-col gap-2">
{server.is_byok && (
<ByokRow
connected={!!server.has_user_credential}
onConnect={onByokConnect}
/>
)}
{needsAttention && (
<div className="flex items-center justify-between gap-2 text-xs">
<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 font-semibold text-red-700">
<ExclamationCircleFilled />
{missing.length} user field
{missing.length === 1 ? "" : "s"} missing
</span>
</Tooltip>
{onOpenFillFields && (
<button
type="button"
onClick={(e) => {
stop(e);
onOpenFillFields();
}}
className="rounded-md bg-red-600 px-3 py-1 text-xs font-medium text-white shadow-sm transition-colors hover:bg-red-700"
>
Set
</button>
)}
</div>
)}
</div>
)}
</div>
);
};
interface HealthChipProps {
status: string;
isLoadingHealth?: boolean;
isRechecking?: boolean;
onRecheck?: () => void;
lastCheck?: string | null;
error?: string | null;
dotClass: string;
}
const HealthChip: FC<HealthChipProps> = ({
status,
isLoadingHealth,
isRechecking,
onRecheck,
lastCheck,
error,
dotClass,
}) => {
if (isLoadingHealth || isRechecking) {
return (
<Tag className="m-0">
<span className="inline-flex items-center gap-1.5 text-xs text-gray-500">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-gray-300" />
Checking
</span>
</Tag>
);
}
const tooltip = (
<div className="max-w-xs">
<div className="font-semibold mb-1">Health: {status}</div>
{lastCheck && (
<div className="text-xs mb-1">
Last check: {new Date(lastCheck).toLocaleString()}
</div>
)}
{error && (
<div className="text-xs">
<div className="font-medium text-red-300 mb-1">Error</div>
<div className="break-words">{error}</div>
</div>
)}
{!lastCheck && !error && (
<div className="text-xs text-gray-400">No health data</div>
)}
{onRecheck && <div className="mt-1 text-xs text-gray-300">Click to recheck</div>}
</div>
);
return (
<Tooltip title={tooltip} placement="top">
<Tag
className={`m-0 ${onRecheck ? "cursor-pointer hover:opacity-80" : "cursor-default"}`}
onClick={
onRecheck
? (e) => {
e.stopPropagation();
onRecheck();
}
: undefined
}
>
<span className="inline-flex items-center gap-1.5">
<span className={`h-1.5 w-1.5 rounded-full ${dotClass}`} />
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
</Tag>
</Tooltip>
);
};
interface ByokRowProps {
connected: boolean;
onConnect?: () => void;
}
const ByokRow: FC<ByokRowProps> = ({ connected, onConnect }) => {
if (connected) {
return (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-gray-500">BYOK credential</span>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1 rounded-full border border-green-200 bg-green-50 px-2 py-0.5 font-medium text-green-700">
<CheckOutlined style={{ fontSize: 10 }} /> Connected
</span>
{onConnect && (
<button
type="button"
onClick={(e) => {
stop(e);
onConnect();
}}
className="text-xs text-gray-400 transition-colors hover:text-blue-600"
>
Update
</button>
)}
</div>
</div>
);
}
return (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-gray-500">BYOK credential</span>
{onConnect ? (
<button
type="button"
onClick={(e) => {
stop(e);
onConnect();
}}
className="rounded-md bg-blue-600 px-3 py-1 text-xs font-medium text-white shadow-sm transition-colors hover:bg-blue-700"
>
Connect
</button>
) : (
<span className="text-gray-400"></span>
)}
</div>
);
};
export default MCPServerCard;

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react";
import { Modal, Form, Input, Button, Alert, Typography } from "antd";
import { Modal, Form, Input, Button, Alert, Spin, Tag, Typography } from "antd";
import { MCPServer, MCPUserEnvVarsStatus } from "./types";
import {
getMCPUserEnvVars,
@ -7,7 +7,7 @@ import {
} from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
const { Text, Title, Paragraph } = Typography;
const { Text, Title } = Typography;
interface UserEnvVarsModalProps {
server: MCPServer | null;
@ -77,7 +77,7 @@ const UserEnvVarsModal: React.FC<UserEnvVarsModalProps> = ({
}
const saved = await storeMCPUserEnvVars(accessToken, server.server_id, trimmed);
setStatus(saved);
NotificationsManager.success("Environment variables saved");
NotificationsManager.success("Credentials saved");
if (onSaved) onSaved(saved);
onClose();
} catch (err) {
@ -89,86 +89,83 @@ const UserEnvVarsModal: React.FC<UserEnvVarsModalProps> = ({
}
};
const displayName = server?.alias || server?.server_name || server?.server_id || "MCP Server";
const displayName = server?.server_name || server?.alias || server?.server_id || "MCP Server";
const required = status?.required ?? [];
return (
<Modal
title={
<div>
<Title level={4} className="!mb-0">
Set your credentials for {displayName}
</Title>
<Text type="secondary" className="text-sm">
These values are stored only for you and are injected into the MCP server&apos;s
request headers when you use it.
</Text>
</div>
}
open={open}
onCancel={onClose}
footer={null}
width={580}
width={520}
destroyOnHidden
>
{status && status.required.length === 0 ? (
<Alert
type="info"
showIcon
message="This MCP server doesn't require any per-user values."
/>
) : (
<Form
form={form}
layout="vertical"
onFinish={handleSave}
disabled={isLoading || isSaving}
>
{status?.missing_count ? (
<Alert
type="warning"
showIcon
className="mb-4"
message={`${status.missing_count} required field${status.missing_count === 1 ? "" : "s"} missing`}
/>
) : null}
{(status?.required ?? []).map((spec) => (
<Form.Item
key={spec.name}
name={spec.name}
label={<span className="font-mono text-sm">{spec.name}</span>}
extra={spec.description || undefined}
rules={[{ required: true, message: `${spec.name} is required` }]}
>
<Input
size="large"
placeholder={spec.description || `Enter value for ${spec.name}`}
allowClear
/>
</Form.Item>
))}
{(status?.required ?? []).length === 0 && !isLoading && (
<Paragraph type="secondary">
No per-user variables required for this server.
</Paragraph>
)}
<div className="flex justify-end gap-2 mt-4">
<Button onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
type="primary"
htmlType="submit"
loading={isSaving}
disabled={(status?.required ?? []).length === 0}
>
Save
</Button>
title={
<div>
<div className="flex items-center gap-2">
<Title level={5} style={{ margin: 0 }}>
Set your credentials
</Title>
<Tag color="blue">Per-user</Tag>
</div>
</Form>
)}
<Text type="secondary" className="text-xs">
{displayName}
</Text>
</div>
}
>
<div className="space-y-4 mt-2">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Spin />
</div>
) : required.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 these per-user credentials:
</Text>
<Form
form={form}
layout="vertical"
onFinish={handleSave}
disabled={isSaving}
>
{required.map((spec) => (
<Form.Item
key={spec.name}
name={spec.name}
label={
<span className="font-mono text-sm font-semibold">
{spec.name}
</span>
}
extra={spec.description || undefined}
rules={[{ required: true, message: `${spec.name} is required` }]}
>
<Input.Password
placeholder={spec.description || `Enter your ${spec.name}`}
visibilityToggle
/>
</Form.Item>
))}
<div className="flex items-center justify-end gap-2 pt-2 border-t border-gray-100">
<Button onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button type="primary" htmlType="submit" loading={isSaving}>
Save Credentials
</Button>
</div>
</Form>
</>
)}
</div>
</Modal>
);
};

View file

@ -12,6 +12,7 @@ import StdioConfiguration from "./StdioConfiguration";
import MCPPermissionManagement from "./MCPPermissionManagement";
import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection";
import MCPLogoSelector from "./MCPLogoSelector";
import EnvVarsSection from "./EnvVarsSection";
import { isAdminRole } from "@/utils/roles";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
@ -1018,6 +1019,11 @@ const CreateMCPServer: React.FC<CreateMCPServerProps> = ({
<StdioConfiguration isVisible={transportType === "stdio"} />
</div>
{/* Environment Variables Section */}
<div className="mt-8">
<EnvVarsSection />
</div>
{/* Permission Management / Access Control Section */}
<div className="mt-8">
<MCPPermissionManagement

View file

@ -1,11 +1,11 @@
import { useState } from "react";
import { ColumnDef } from "@tanstack/react-table";
import { MCPServer, MCPUserEnvVarsStatus } from "./types";
import { MCPServer } from "./types";
import { Icon } from "@tremor/react";
import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline";
import { getMaskedAndFullUrl } from "./utils";
import { Tooltip } from "antd";
import { CheckOutlined, ExclamationCircleFilled } from "@ant-design/icons";
import { CheckOutlined } from "@ant-design/icons";
const HealthStatusBadge: React.FC<{
server: MCPServer;
@ -92,8 +92,6 @@ export const mcpServerColumns = (
onByokConnect?: (server: MCPServer) => void,
onRecheckHealth?: (serverId: string) => void,
recheckingServerIds?: Set<string>,
envVarStatusByServer?: Record<string, MCPUserEnvVarsStatus>,
onSetEnvVars?: (server: MCPServer) => void,
): ColumnDef<MCPServer>[] => [
{
accessorKey: "server_id",
@ -115,15 +113,8 @@ export const mcpServerColumns = (
cell: ({ row }) => {
const logoUrl = row.original.mcp_info?.logo_url;
const name = row.original.server_name;
const status = envVarStatusByServer?.[row.original.server_id];
const missing = status?.missing_count ?? 0;
const showWarning = missing > 0;
return (
<div
className={`flex items-center gap-2 ${
showWarning ? "border border-red-300 bg-red-50 px-2 py-1 rounded-md" : ""
}`}
>
<div className="flex items-center gap-2">
{logoUrl ? (
<img
src={logoUrl}
@ -132,23 +123,7 @@ export const mcpServerColumns = (
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
) : null}
<span className={showWarning ? "text-red-700 font-medium" : ""}>{name}</span>
{showWarning && (
<Tooltip
title={`Set ${missing} required user field${missing === 1 ? "" : "s"} before using this MCP server`}
>
<button
onClick={(e) => {
e.stopPropagation();
if (onSetEnvVars) onSetEnvVars(row.original);
}}
className="inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full bg-red-100 text-red-700 border border-red-300 hover:bg-red-200 cursor-pointer"
>
<ExclamationCircleFilled />
{missing} missing field{missing === 1 ? "" : "s"}
</button>
</Tooltip>
)}
<span>{name}</span>
</div>
);
},

View file

@ -9,6 +9,7 @@ import MCPPermissionManagement from "./MCPPermissionManagement";
import MCPToolConfiguration from "./mcp_tool_configuration";
import StdioConfiguration from "./StdioConfiguration";
import MCPLogoSelector from "./MCPLogoSelector";
import EnvVarsSection from "./EnvVarsSection";
import { validateMCPServerUrl, validateMCPServerName } from "./utils";
import NotificationsManager from "../molecules/notifications_manager";
import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow";
@ -1112,6 +1113,11 @@ const MCPServerEdit: React.FC<MCPServerEditProps> = ({
</>
)}
{/* Environment Variables Section */}
<div className="mt-6">
<EnvVarsSection />
</div>
{/* Permission Management / Access Control Section */}
<div className="mt-6">
<MCPPermissionManagement

View file

@ -1,8 +1,8 @@
import { isAdminRole } from "@/utils/roles";
import { QuestionCircleOutlined } from "@ant-design/icons";
import { QuestionCircleOutlined, SearchOutlined } from "@ant-design/icons";
import { Button, Tab, TabGroup, TabList, TabPanel, TabPanels, Text, Title } from "@tremor/react";
import NewBadge from "../common_components/NewBadge";
import { Descriptions, Modal, Select, Tooltip, Typography } from "antd";
import { Descriptions, Empty, Input, Modal, Select, Spin, Tooltip, Typography } from "antd";
import React, { useEffect, useState, useMemo, useCallback } from "react";
import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers";
import { useMCPServerHealth } from "../../app/(dashboard)/hooks/mcpServers/useMCPServerHealth";
@ -10,19 +10,66 @@ import NotificationsManager from "../molecules/notifications_manager";
import { deleteMCPServer } from "../networking";
import { MCPSubmissionsTab } from "./MCPSubmissionsTab";
import { MCPToolsetsTab } from "./MCPToolsetsTab";
import { DataTable } from "../view_logs/table";
import CreateMCPServer from "./create_mcp_server";
import MCPConnect from "./mcp_connect";
import { mcpServerColumns } from "./mcp_server_columns";
import MCPServerCard from "./MCPServerCard";
import { MCPServerView } from "./mcp_server_view";
import { DiscoverableMCPServer, MCPServer, MCPServerProps, MCPUserEnvVarsStatus, Team } from "./types";
import type { DiscoverableMCPServer, MCPServer, MCPServerProps, MCPUserEnvVarsStatus, Team } from "./types";
import MCPSemanticFilterSettings from "../Settings/AdminSettings/MCPSemanticFilterSettings/MCPSemanticFilterSettings";
import MCPNetworkSettings from "./MCPNetworkSettings";
import MCPDiscovery from "./mcp_discovery";
import { ByokCredentialModal } from "./ByokCredentialModal";
import { getSecureItem } from "@/utils/secureStorage";
import UserEnvVarsModal from "./UserEnvVarsModal";
import { listMCPUserEnvVarStatus } from "../networking";
import { getSecureItem } from "@/utils/secureStorage";
type SortKey = "created_desc" | "updated_desc" | "name_asc" | "health";
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
{ value: "created_desc", label: "Recently created" },
{ value: "updated_desc", label: "Recently updated" },
{ value: "name_asc", label: "Name (A→Z)" },
{ value: "health", label: "Health (unhealthy first)" },
];
const HEALTH_RANK: Record<string, number> = {
unhealthy: 0,
unknown: 1,
healthy: 2,
};
const compareServers = (
a: MCPServer,
b: MCPServer,
sort: SortKey,
): number => {
switch (sort) {
case "name_asc": {
const nameA = (a.server_name || a.alias || a.server_id).toLowerCase();
const nameB = (b.server_name || b.alias || b.server_id).toLowerCase();
return nameA.localeCompare(nameB);
}
case "updated_desc": {
const ta = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const tb = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return tb - ta;
}
case "health": {
const ra = HEALTH_RANK[a.status ?? "unknown"] ?? 1;
const rb = HEALTH_RANK[b.status ?? "unknown"] ?? 1;
if (ra !== rb) return ra - rb;
const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
return tb - ta;
}
case "created_desc":
default: {
const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
return tb - ta;
}
}
};
const { Text: AntdText, Title: AntdTitle } = Typography;
const EDIT_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-edit-state";
@ -66,10 +113,15 @@ 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);
// Per-user env-var fill modal target + bulk status across accessible servers.
const [envVarsModalServer, setEnvVarsModalServer] = useState<MCPServer | null>(null);
const [envVarStatusByServer, setEnvVarStatusByServer] = useState<Record<string, MCPUserEnvVarsStatus>>({});
const [searchQuery, setSearchQuery] = useState<string>("");
const [sortKey, setSortKey] = useState<SortKey>("created_desc");
const isInternalUser = userRole === "Internal User";
// Single bulk fetch of this user's per-server env-var status. Drives the
// red "N user fields missing" footer on each card with no per-row request.
const refetchEnvVarStatus = useCallback(async () => {
if (!accessToken) {
setEnvVarStatusByServer({});
@ -91,26 +143,38 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
refetchEnvVarStatus();
}, [refetchEnvVarStatus, mcpServers]);
// Deep-link support: open the modal automatically when the URL contains
// ?fill_env_vars=<server_id>. This is the link users follow from the
// friendly error returned by the proxy when a per-user var is missing.
useEffect(() => {
if (typeof window === "undefined" || !mcpServers) {
return;
// Per-server list of per-user fields this user still needs to fill in.
const missingFieldsByServer = useMemo(() => {
const map: Record<string, string[]> = {};
for (const [serverId, status] of Object.entries(envVarStatusByServer)) {
map[serverId] = (status.required ?? [])
.filter((spec) => !spec.is_set)
.map((spec) => spec.name);
}
return map;
}, [envVarStatusByServer]);
// Deep-link via ?fill_env_vars=<server_id> — the link users follow from the
// friendly error the proxy returns when a per-user var is missing. Opens the
// fill modal for the matching server, then strips the param.
useEffect(() => {
if (typeof window === "undefined") return;
if (!serversWithHealth || serversWithHealth.length === 0) return;
const params = new URLSearchParams(window.location.search);
const targetId = params.get("fill_env_vars");
if (!targetId) return;
const target = mcpServers.find((s) => s.server_id === targetId);
if (target) {
setEnvVarsModalServer(target);
// Strip the query param so the modal doesn't re-open on every render.
const match = serversWithHealth.find((s) => s.server_id === targetId);
if (match) {
setEnvVarsModalServer(match);
params.delete("fill_env_vars");
const cleaned = params.toString();
const newUrl = `${window.location.pathname}${cleaned ? `?${cleaned}` : ""}${window.location.hash}`;
window.history.replaceState(null, "", newUrl);
const newSearch = params.toString();
const newUrl =
window.location.pathname +
(newSearch ? `?${newSearch}` : "") +
window.location.hash;
window.history.replaceState({}, "", newUrl);
}
}, [mcpServers]);
}, [serversWithHealth]);
useEffect(() => {
if (typeof window === "undefined") {
@ -202,28 +266,25 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
filterServers(selectedTeam, selectedMcpAccessGroup);
}, [serversWithHealth, selectedTeam, selectedMcpAccessGroup, filterServers]);
const columns = React.useMemo(
() =>
mcpServerColumns(
userRole ?? "",
(serverId: string) => {
setSelectedServerId(serverId);
setEditServer(false);
},
(serverId: string) => {
setSelectedServerId(serverId);
setEditServer(true);
},
handleDelete,
isLoadingHealth,
(server: MCPServer) => setByokModalServer(server),
recheckServerHealth,
recheckingServerIds,
envVarStatusByServer,
(server: MCPServer) => setEnvVarsModalServer(server),
),
[userRole, isLoadingHealth, recheckServerHealth, recheckingServerIds, envVarStatusByServer],
);
// Search + sort layer applied on top of the team/access-group filters.
const displayedServers = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
const matches = q
? filteredServers.filter((s) => {
const name = (s.server_name || "").toLowerCase();
const alias = (s.alias || "").toLowerCase();
const url = (s.url || "").toLowerCase();
const id = s.server_id.toLowerCase();
return (
name.includes(q) ||
alias.includes(q) ||
url.includes(q) ||
id.includes(q)
);
})
: filteredServers;
return [...matches].sort((a, b) => compareServers(a, b, sortKey));
}, [filteredServers, searchQuery, sortKey]);
function handleDelete(server_id: string) {
setServerToDelete(server_id);
@ -238,6 +299,14 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
setIsDeletingServer(true);
await deleteMCPServer(accessToken, serverIdToDelete);
NotificationsManager.success("Deleted MCP Server successfully");
// If the user is currently viewing the detail page of the server they
// just deleted, return them to the All Servers list. Otherwise the
// detail view would stay mounted, fall back to an empty stub server,
// and show a phantom "Unnamed Server" page.
if (selectedServerId === serverIdToDelete) {
setEditServer(false);
setSelectedServerId(null);
}
refetch();
} catch (error) {
console.error("Error deleting the mcp server:", error);
@ -462,17 +531,82 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
</div>
</div>
</div>
<div className="w-full mt-6">
<DataTable
data={filteredServers}
columns={columns}
renderSubComponent={() => <div></div>}
getRowCanExpand={() => false}
isLoading={isLoadingServers}
noDataMessage="No MCP servers configured. Click '+ Add New MCP Server' to get started."
loadingMessage="Loading MCP servers..."
enableSorting={true}
<div className="mt-4 flex flex-wrap items-center gap-3">
<Input
allowClear
prefix={<SearchOutlined className="text-gray-400" />}
placeholder="Search by name, alias, URL, or ID"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ maxWidth: 320 }}
/>
<div className="flex items-center gap-2">
<Text className="whitespace-nowrap text-sm font-medium text-gray-600">
Sort
</Text>
<Select
value={sortKey}
onChange={(v: SortKey) => setSortKey(v)}
style={{ width: 220 }}
size="middle"
>
{SORT_OPTIONS.map((opt) => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
</div>
<div className="ml-auto text-xs text-gray-500">
{displayedServers.length} of {filteredServers.length} servers
</div>
</div>
<div className="mt-4 w-full">
{isLoadingServers ? (
<div className="flex items-center justify-center rounded-lg border border-dashed border-gray-200 bg-white p-12">
<Spin tip="Loading MCP servers..." />
</div>
) : displayedServers.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-200 bg-white p-12">
<Empty
description={
filteredServers.length === 0
? "No MCP servers configured. Click '+ Add New MCP Server' to get started."
: "No servers match the current filters or search."
}
/>
</div>
) : (
<div className="grid auto-rows-fr grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{displayedServers.map((server) => (
<MCPServerCard
key={server.server_id}
server={server}
missingUserFields={missingFieldsByServer[server.server_id]}
isLoadingHealth={isLoadingHealth}
isRechecking={recheckingServerIds?.has(server.server_id)}
onClick={() => {
setSelectedServerId(server.server_id);
setEditServer(true);
}}
onRecheckHealth={
recheckServerHealth
? () => recheckServerHealth(server.server_id)
: undefined
}
onByokConnect={
server.is_byok ? () => setByokModalServer(server) : undefined
}
onOpenFillFields={() => setEnvVarsModalServer(server)}
onDelete={
isAdminRole(userRole)
? () => handleDelete(server.server_id)
: undefined
}
/>
))}
</div>
)}
</div>
</div>
)}
@ -510,12 +644,15 @@ const MCPServers: React.FC<MCPServerProps> = ({ accessToken, userRole, userID })
/>
)}
{/* Per-user env-var fill modal — backed by /v1/mcp/server/{id}/user-env-vars */}
<UserEnvVarsModal
server={envVarsModalServer}
open={!!envVarsModalServer}
accessToken={accessToken}
onClose={() => setEnvVarsModalServer(null)}
onSaved={() => {
// Refresh the bulk status so the red "N user fields missing" footer
// on each card clears once the user has filled in their values.
refetchEnvVarStatus();
}}
/>