better-chatbot / src /components /mcp-card.tsx
Bot
Initial commit for HF Spaces
05c5ed5
Raw
History Blame Contribute Delete
12.6 kB
"use client";
import {
ChevronRight,
FlaskConical,
ShieldAlertIcon,
Loader,
RotateCw,
Settings,
Settings2,
Wrench,
} from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "ui/alert";
import { Button } from "ui/button";
import { Card, CardContent, CardHeader } from "ui/card";
import JsonView from "ui/json-view";
import { Tooltip, TooltipContent, TooltipTrigger } from "ui/tooltip";
import { memo, useCallback, useMemo, useState } from "react";
import Link from "next/link";
import { useSWRConfig } from "swr";
import { safe } from "ts-safe";
import { handleErrorWithToast } from "ui/shared-toast";
import {
refreshMcpClientAction,
removeMcpClientAction,
shareMcpServerAction,
} from "@/app/api/mcp/actions";
import { ShareableActions, type Visibility } from "./shareable-actions";
import type { MCPServerInfo, MCPToolInfo } from "app-types/mcp";
import { ToolDetailPopup } from "./tool-detail-popup";
import { useTranslations } from "next-intl";
import { Separator } from "ui/separator";
import { Avatar, AvatarFallback, AvatarImage } from "ui/avatar";
import { appStore } from "@/app/store";
import { isString } from "lib/utils";
import { redriectMcpOauth } from "lib/ai/mcp/oauth-redirect";
import { BasicUser } from "app-types/user";
import { canChangeVisibilityMCP } from "lib/auth/client-permissions";
// Main MCPCard component
export const MCPCard = memo(function MCPCard({
id,
config,
error,
status,
name,
toolInfo,
visibility,
enabled,
userId,
user,
userName,
userAvatar,
}: MCPServerInfo & { user: BasicUser }) {
const [isProcessing, setIsProcessing] = useState(false);
const [visibilityChangeLoading, setVisibilityChangeLoading] = useState(false);
const t = useTranslations("MCP");
const appStoreMutate = appStore((state) => state.mutate);
const { mutate } = useSWRConfig();
const isOwner = userId === user?.id;
const canChangeVisibility = useMemo(
() => canChangeVisibilityMCP(user?.role),
[user?.role],
);
const isLoading = useMemo(() => {
return isProcessing || status === "loading";
}, [isProcessing, status]);
const needsAuthorization = status === "authorizing";
const isDisabled = isLoading || needsAuthorization;
// Check permissions (kept for potential future use)
const errorMessage = useMemo(() => {
if (error) {
return isString(error) ? error : JSON.stringify(error);
}
return null;
}, [error]);
const pipeProcessing = useCallback(
async (fn: () => Promise<any>) =>
safe(() => setIsProcessing(true))
.ifOk(fn)
.ifOk(() => mutate("/api/mcp/list"))
.ifFail(handleErrorWithToast)
.watch(() => setIsProcessing(false)),
[],
);
const handleRefresh = useCallback(
() => pipeProcessing(() => refreshMcpClientAction(id)),
[id],
);
const handleDelete = useCallback(async () => {
await pipeProcessing(() => removeMcpClientAction(id));
}, [id]);
const handleAuthorize = useCallback(
() => pipeProcessing(() => redriectMcpOauth(id)),
[id],
);
const handleVisibilityChange = useCallback(
async (newVisibility: Visibility) => {
// Map visibility for MCP (public becomes featured)
const mcpVisibility = newVisibility === "public" ? "public" : "private";
safe(() => setVisibilityChangeLoading(true))
.map(async () => shareMcpServerAction(id, mcpVisibility))
.ifOk(() => {
mutate("/api/mcp/list");
})
.ifFail((e) => {
handleErrorWithToast(e);
})
.watch(() => setVisibilityChangeLoading(false));
},
[id],
);
return (
<Card
key={`mcp-card-${id}-${status}`}
className="relative hover:border-foreground/20 transition-colors bg-secondary/40"
data-testid="mcp-server-card"
data-featured={visibility === "public"}
>
{isLoading && (
<div className="animate-pulse z-10 absolute inset-0 bg-background/50 flex items-center justify-center w-full h-full" />
)}
<CardHeader
key={`header-${status}-${needsAuthorization}`}
className="flex items-center gap-1 mb-2"
>
{isLoading && <Loader className="size-4 z-20 animate-spin mr-1" />}
<h4 className="font-bold text-xs sm:text-lg flex items-center gap-1">
{name}
</h4>
<div className="flex-1" />
{needsAuthorization && (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleAuthorize}
disabled={isProcessing}
>
<ShieldAlertIcon className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Authorize</p>
</TooltipContent>
</Tooltip>
<div className="h-4">
<Separator orientation="vertical" />
</div>
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
disabled={isDisabled}
onClick={() =>
appStoreMutate({
mcpCustomizationPopup: {
id,
name,
config,
status,
toolInfo,
error,
visibility,
enabled,
userId,
},
})
}
>
<Settings2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("mcpServerCustomization")}</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
{isDisabled ? (
<div className="cursor-pointer hidden sm:block">
<Button variant="ghost" size="icon" disabled>
<FlaskConical className="size-3.5" />
</Button>
</div>
) : (
<Link
href={`/mcp/test/${encodeURIComponent(id)}`}
className="cursor-pointer hidden sm:block"
>
<Button variant="ghost" size="icon">
<FlaskConical className="size-3.5" />
</Button>
</Link>
)}
</TooltipTrigger>
<TooltipContent>
<p>{t("toolsTest")}</p>
</TooltipContent>
</Tooltip>
<div className="h-4">
<Separator orientation="vertical" />
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={isLoading}
>
<RotateCw className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{t("refresh")}</p>
</TooltipContent>
</Tooltip>
{/* Add sharing actions for owners or visibility indicator for featured servers */}
<ShareableActions
type="mcp"
visibility={visibility === "public" ? "public" : "private"}
isOwner={isOwner}
canChangeVisibility={canChangeVisibility}
editHref={
isOwner ? `/mcp/modify/${encodeURIComponent(id)}` : undefined
}
onVisibilityChange={
canChangeVisibility ? handleVisibilityChange : undefined
}
onDelete={isOwner ? handleDelete : undefined}
isVisibilityChangeLoading={visibilityChangeLoading}
isDeleteLoading={isProcessing}
disabled={isLoading}
renderActions={() => null}
/>
{/* Show user info for featured servers */}
{!isOwner && userName && (
<>
<div className="h-4">
<Separator orientation="vertical" />
</div>
<div className="flex items-center gap-1.5 ml-2">
<Avatar className="size-4 ring shrink-0 rounded-full">
<AvatarImage src={userAvatar || undefined} />
<AvatarFallback className="text-xs">
{userName[0]?.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="text-xs text-muted-foreground font-medium">
{userName}
</span>
</div>
</>
)}
</CardHeader>
{errorMessage && <ErrorAlert error={errorMessage} />}
{needsAuthorization && (
<div className="px-6 pb-2">
<Alert
className="cursor-pointer hover:bg-accent/10 transition-colors"
onClick={handleAuthorize}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleAuthorize();
}
}}
>
<ShieldAlertIcon />
<AlertTitle>Authorization Required</AlertTitle>
<AlertDescription>
Click here to authorize this MCP server and access its tools.
</AlertDescription>
</Alert>
</div>
)}
<div className="relative hidden sm:flex w-full">
<CardContent className="flex min-w-0 w-full flex-row text-sm max-h-[320px] overflow-hidden border-r-0">
{/* Only show config to owners to prevent credential exposure */}
{isOwner && config && (
<div className="w-1/2 min-w-0 flex flex-col pr-2 border-r border-border">
<div className="flex items-center gap-2 mb-2 pt-2 pb-1 z-10">
<Settings size={14} className="text-muted-foreground" />
<h5 className="text-muted-foreground text-sm font-medium">
{t("configuration")}
</h5>
</div>
<div className="flex-1 overflow-y-auto">
<JsonView data={config} />
</div>
</div>
)}
<div
className={`${isOwner && config ? "w-1/2" : "w-full"} min-w-0 flex flex-col ${isOwner && config ? "pl-4" : ""}`}
>
<div className="flex items-center gap-2 mb-4 pt-2 pb-1 z-10">
<Wrench size={14} className="text-muted-foreground" />
<h5 className="text-muted-foreground text-sm font-medium">
{t("availableTools")}
</h5>
</div>
<div className="flex-1 overflow-y-auto">
{toolInfo.length > 0 ? (
<ToolsList tools={toolInfo} serverId={id} />
) : (
<div className="bg-secondary/30 rounded-md p-3 text-center">
<p className="text-sm text-muted-foreground">
{t("noToolsAvailable")}
</p>
</div>
)}
</div>
</div>
</CardContent>
</div>
</Card>
);
});
// Tools list component
const ToolsList = memo(
({ tools, serverId }: { tools: MCPToolInfo[]; serverId: string }) => (
<div className="space-y-2 pr-2">
{tools.map((tool) => (
<div
key={tool.name}
className="flex items-start gap-2 bg-secondary rounded-md p-2 hover:bg-input transition-colors"
>
<ToolDetailPopup tool={tool} serverId={serverId}>
<div className="flex-1 min-w-0 cursor-pointer">
<p className="font-medium text-sm mb-1 truncate">{tool.name}</p>
<p className="text-xs text-muted-foreground line-clamp-1">
{tool.description}
</p>
</div>
</ToolDetailPopup>
<div className="flex items-center px-1 justify-center self-stretch">
<ChevronRight size={16} />
</div>
</div>
))}
</div>
),
);
ToolsList.displayName = "ToolsList";
// Error alert component
const ErrorAlert = memo(({ error }: { error: string }) => (
<div className="px-6 pb-2">
<Alert variant="destructive" className="border-destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription className="whitespace-pre-wrap break-words">
{error}
</AlertDescription>
</Alert>
</div>
));
ErrorAlert.displayName = "ErrorAlert";