mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
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:
parent
accecaf65f
commit
28883ea80f
8 changed files with 787 additions and 223 deletions
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
413
ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx
Normal file
413
ui/litellm-dashboard/src/components/mcp_tools/MCPServerCard.tsx
Normal 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;
|
||||
|
|
@ -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'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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue