Files
claudia/src/components/MCPServerList.tsx
2025-06-19 19:24:01 +05:30

407 lines
13 KiB
TypeScript

import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import {
Network,
Globe,
Terminal,
Trash2,
Play,
CheckCircle,
Loader2,
RefreshCw,
FolderOpen,
User,
FileText,
ChevronDown,
ChevronUp,
Copy
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { api, type MCPServer } from "@/lib/api";
interface MCPServerListProps {
/**
* List of MCP servers to display
*/
servers: MCPServer[];
/**
* Whether the list is loading
*/
loading: boolean;
/**
* Callback when a server is removed
*/
onServerRemoved: (name: string) => void;
/**
* Callback to refresh the server list
*/
onRefresh: () => void;
}
/**
* Component for displaying a list of MCP servers
* Shows servers grouped by scope with status indicators
*/
export const MCPServerList: React.FC<MCPServerListProps> = ({
servers,
loading,
onServerRemoved,
onRefresh,
}) => {
const [removingServer, setRemovingServer] = useState<string | null>(null);
const [testingServer, setTestingServer] = useState<string | null>(null);
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
const [copiedServer, setCopiedServer] = useState<string | null>(null);
// Group servers by scope
const serversByScope = servers.reduce((acc, server) => {
const scope = server.scope || "local";
if (!acc[scope]) acc[scope] = [];
acc[scope].push(server);
return acc;
}, {} as Record<string, MCPServer[]>);
/**
* Toggles expanded state for a server
*/
const toggleExpanded = (serverName: string) => {
setExpandedServers(prev => {
const next = new Set(prev);
if (next.has(serverName)) {
next.delete(serverName);
} else {
next.add(serverName);
}
return next;
});
};
/**
* Copies command to clipboard
*/
const copyCommand = async (command: string, serverName: string) => {
try {
await navigator.clipboard.writeText(command);
setCopiedServer(serverName);
setTimeout(() => setCopiedServer(null), 2000);
} catch (error) {
console.error("Failed to copy command:", error);
}
};
/**
* Removes a server
*/
const handleRemoveServer = async (name: string) => {
try {
setRemovingServer(name);
await api.mcpRemove(name);
onServerRemoved(name);
} catch (error) {
console.error("Failed to remove server:", error);
} finally {
setRemovingServer(null);
}
};
/**
* Tests connection to a server
*/
const handleTestConnection = async (name: string) => {
try {
setTestingServer(name);
const result = await api.mcpTestConnection(name);
// TODO: Show result in a toast or modal
console.log("Test result:", result);
} catch (error) {
console.error("Failed to test connection:", error);
} finally {
setTestingServer(null);
}
};
/**
* Gets icon for transport type
*/
const getTransportIcon = (transport: string) => {
switch (transport) {
case "stdio":
return <Terminal className="h-4 w-4 text-amber-500" />;
case "sse":
return <Globe className="h-4 w-4 text-emerald-500" />;
default:
return <Network className="h-4 w-4 text-blue-500" />;
}
};
/**
* Gets icon for scope
*/
const getScopeIcon = (scope: string) => {
switch (scope) {
case "local":
return <User className="h-3 w-3 text-slate-500" />;
case "project":
return <FolderOpen className="h-3 w-3 text-orange-500" />;
case "user":
return <FileText className="h-3 w-3 text-purple-500" />;
default:
return null;
}
};
/**
* Gets scope display name
*/
const getScopeDisplayName = (scope: string) => {
switch (scope) {
case "local":
return "Local (Project-specific)";
case "project":
return "Project (Shared via .mcp.json)";
case "user":
return "User (All projects)";
default:
return scope;
}
};
/**
* Renders a single server item
*/
const renderServerItem = (server: MCPServer) => {
const isExpanded = expandedServers.has(server.name);
const isCopied = copiedServer === server.name;
return (
<motion.div
key={server.name}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="group p-4 rounded-lg border border-border bg-card hover:bg-accent/5 hover:border-primary/20 transition-all overflow-hidden"
>
<div className="space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-primary/10 rounded">
{getTransportIcon(server.transport)}
</div>
<h4 className="font-medium truncate">{server.name}</h4>
{server.status?.running && (
<Badge variant="outline" className="gap-1 flex-shrink-0 border-green-500/50 text-green-600 bg-green-500/10">
<CheckCircle className="h-3 w-3" />
Running
</Badge>
)}
</div>
{server.command && !isExpanded && (
<div className="flex items-center gap-2">
<p className="text-xs text-muted-foreground font-mono truncate pl-9 flex-1" title={server.command}>
{server.command}
</p>
<Button
variant="ghost"
size="sm"
onClick={() => toggleExpanded(server.name)}
className="h-6 px-2 text-xs hover:bg-primary/10"
>
<ChevronDown className="h-3 w-3 mr-1" />
Show full
</Button>
</div>
)}
{server.transport === "sse" && server.url && !isExpanded && (
<div className="overflow-hidden">
<p className="text-xs text-muted-foreground font-mono truncate pl-9" title={server.url}>
{server.url}
</p>
</div>
)}
{Object.keys(server.env).length > 0 && !isExpanded && (
<div className="flex items-center gap-1 text-xs text-muted-foreground pl-9">
<span>Environment variables: {Object.keys(server.env).length}</span>
</div>
)}
</div>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestConnection(server.name)}
disabled={testingServer === server.name}
className="hover:bg-green-500/10 hover:text-green-600"
>
{testingServer === server.name ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveServer(server.name)}
disabled={removingServer === server.name}
className="hover:bg-destructive/10 hover:text-destructive"
>
{removingServer === server.name ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
</div>
</div>
{/* Expanded Details */}
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="pl-9 space-y-3 pt-2 border-t border-border/50"
>
{server.command && (
<div className="space-y-1">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">Command</p>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => copyCommand(server.command!, server.name)}
className="h-6 px-2 text-xs hover:bg-primary/10"
>
<Copy className="h-3 w-3 mr-1" />
{isCopied ? "Copied!" : "Copy"}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => toggleExpanded(server.name)}
className="h-6 px-2 text-xs hover:bg-primary/10"
>
<ChevronUp className="h-3 w-3 mr-1" />
Hide
</Button>
</div>
</div>
<p className="text-xs font-mono bg-muted/50 p-2 rounded break-all">
{server.command}
</p>
</div>
)}
{server.args && server.args.length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Arguments</p>
<div className="text-xs font-mono bg-muted/50 p-2 rounded space-y-1">
{server.args.map((arg, idx) => (
<div key={idx} className="break-all">
<span className="text-muted-foreground mr-2">[{idx}]</span>
{arg}
</div>
))}
</div>
</div>
)}
{server.transport === "sse" && server.url && (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">URL</p>
<p className="text-xs font-mono bg-muted/50 p-2 rounded break-all">
{server.url}
</p>
</div>
)}
{Object.keys(server.env).length > 0 && (
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Environment Variables</p>
<div className="text-xs font-mono bg-muted/50 p-2 rounded space-y-1">
{Object.entries(server.env).map(([key, value]) => (
<div key={key} className="break-all">
<span className="text-primary">{key}</span>
<span className="text-muted-foreground mx-1">=</span>
<span>{value}</span>
</div>
))}
</div>
</div>
)}
</motion.div>
)}
</div>
</motion.div>
);
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-base font-semibold">Configured Servers</h3>
<p className="text-sm text-muted-foreground">
{servers.length} server{servers.length !== 1 ? "s" : ""} configured
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={onRefresh}
className="gap-2 hover:bg-primary/10 hover:text-primary hover:border-primary/50"
>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</div>
{/* Server List */}
{servers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<div className="p-4 bg-primary/10 rounded-full mb-4">
<Network className="h-12 w-12 text-primary" />
</div>
<p className="text-muted-foreground mb-2 font-medium">No MCP servers configured</p>
<p className="text-sm text-muted-foreground">
Add a server to get started with Model Context Protocol
</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(serversByScope).map(([scope, scopeServers]) => (
<div key={scope} className="space-y-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{getScopeIcon(scope)}
<span className="font-medium">{getScopeDisplayName(scope)}</span>
<span className="text-muted-foreground/60">({scopeServers.length})</span>
</div>
<AnimatePresence>
<div className="space-y-2">
{scopeServers.map(renderServerItem)}
</div>
</AnimatePresence>
</div>
))}
</div>
)}
</div>
);
};