Manage Model Context Protocol (MCP) servers connected to an AI agent: connection status, OAuth reconnect, and an expandable tool drawer with per-tool switches and call counts.
Free to install: no account, access key or components.json setup needed.
$pnpm dlx shadcn@latest add https://nextui.dev/r/mcp-servers.json
1"use client";23import { useState } from "react";4import { McpServers, type McpServer } from "@/components/nextui/mcp-servers";56export function AgentToolsPopover({ initial }: { initial: McpServer[] }) {7const [servers, setServers] = useState(initial);89return (10<McpServers11servers={servers}12onToggleTool={(serverId, toolName, enabled) =>13setServers((list) =>14list.map((s) =>15s.id === serverId16? { ...s, tools: s.tools.map((t) => (t.name === toolName ? { ...t, enabled } : t)) }17: s,18),19)20}21/>22);23}
| Prop | Type | Default | Description |
|---|---|---|---|
| servers | McpServer[] | – | Connected MCP servers with transport, status, latencyMs and their exposed tools |
| title | string | "MCP servers" | Header label |
| onToggleTool | (serverId: string, toolName: string, enabled: boolean) => void | – | Called when a tool switch is toggled on or off |
| onReconnect | (serverId: string) => void | – | Called when Connect or Retry is clicked on a disconnected server |
| autoPlay | boolean | false | Loop a scripted connection and tool execution demo |
| className | string | – | Extra classes on the root wrapper |
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { ChevronDown, ExternalLink, Plug, RefreshCw, Wrench } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type McpServerStatus = "connected" | "auth-required" | "connecting" | "error";
export type McpTool = {
name: string;
description: string;
enabled: boolean;
/** Times the agent called this tool in the current session. */
calls?: number;
/** Short signature, e.g. "(query: string, limit?: number) → Match[]". */
schema?: string;
};
export type McpServer = {
id: string;
name: string;
transport?: "stdio" | "http" | "sse";
status: McpServerStatus;
latencyMs?: number;
authUrl?: string;
error?: string;
tools: McpTool[];
};
export type McpServersProps = {
servers?: McpServer[];
title?: string;
/** Called when a tool's enabled switch is toggled. */
onToggleTool?: (serverId: string, toolName: string, enabled: boolean) => void;
/** Called when Reconnect / Sign in is clicked on a server. */
onReconnect?: (serverId: string) => void;
/** Loop a scripted connection & tool-call demo. */
autoPlay?: boolean;
className?: string;
};
const DEFAULT_SERVERS: McpServer[] = [
{
id: "code",
name: "Code Host",
transport: "http",
status: "connected",
latencyMs: 38,
tools: [
{ name: "search_code", description: "Search code across repositories", enabled: true, calls: 7, schema: "(query: string, repo?: string) → Match[]" },
{ name: "create_pull_request", description: "Open a pull request from a branch", enabled: true, calls: 2, schema: "(head: string, base: string, title: string) → PR" },
{ name: "merge_pull_request", description: "Merge an approved pull request", enabled: false, schema: "(number: number, method?: \"squash\") → void" },
],
},
{
id: "warehouse",
name: "Warehouse SQL",
transport: "stdio",
status: "error",
error: "Handshake timed out after 10s",
tools: [
{ name: "run_query", description: "Run read-only SQL against the warehouse", enabled: true, calls: 4, schema: "(sql: string, limit?: number) → Row[]" },
{ name: "list_tables", description: "List schemas, tables and row counts", enabled: true, calls: 1, schema: "(schema?: string) → Table[]" },
{ name: "write_rows", description: "Insert or update rows in staging", enabled: true, schema: "(table: string, rows: Row[]) → number" },
],
},
{
id: "tracker",
name: "Issue Tracker",
transport: "sse",
status: "auth-required",
authUrl: "#",
tools: [
{ name: "list_issues", description: "Query assigned issues and cycles", enabled: true, schema: "(assignee?: string) → Issue[]" },
{ name: "create_issue", description: "File a new issue in a team", enabled: true, schema: "(team: string, title: string) → Issue" },
],
},
];
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 9400;
/** Steps a demo clock while the card is on screen. Stops when off screen or paused. */
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop };
}
type View = { servers: McpServer[]; openId: string | null; active: string | null; pressing: string | null };
function clone(servers: McpServer[]) {
return servers.map((s) => ({ ...s, tools: s.tools.map((t) => ({ ...t })) }));
}
function demoView(t: number): View {
const servers = clone(DEFAULT_SERVERS);
const [code, wh] = servers;
const bump = (s: McpServer, name: string) => {
const tool = s.tools.find((x) => x.name === name);
if (tool) tool.calls = (tool.calls ?? 0) + 1;
};
if (t >= 1700) bump(code, "search_code");
if (t >= 3300) bump(code, "create_pull_request");
if (t >= 4300 && t < 5600) wh.status = "connecting";
if (t >= 5600) Object.assign(wh, { status: "connected", latencyMs: 41, error: undefined });
if (t >= 7400) bump(wh, "run_query");
if (t >= 8200) wh.tools[2].enabled = false;
const active =
t >= 600 && t < 1700 ? "code:search_code"
: t >= 2300 && t < 3300 ? "code:create_pull_request"
: t >= 6500 && t < 7400 ? "warehouse:run_query"
: null;
return {
servers,
openId: t >= 6000 ? "warehouse" : "code",
active,
pressing: t >= 3900 && t < 4300 ? "warehouse" : t >= 7900 && t < 8200 ? "warehouse:write_rows" : null,
};
}
const STATUS_META: Record<McpServerStatus, { label: string; dot: string; text: string; bar: string }> = {
connected: { label: "Connected", dot: "bg-emerald-500", text: "text-emerald-700 dark:text-emerald-300", bar: "bg-emerald-500" },
"auth-required": { label: "Needs OAuth", dot: "bg-amber-500", text: "text-amber-700 dark:text-amber-300", bar: "bg-amber-500" },
connecting: { label: "Connecting", dot: "bg-sky-500", text: "text-sky-700 dark:text-sky-300", bar: "bg-sky-500" },
error: { label: "Error", dot: "bg-red-500", text: "text-red-600 dark:text-red-400", bar: "bg-red-500" },
};
/** MCP servers attached to an agent: live status, OAuth / reconnect, and a tool drawer with per-tool switches, schemas and call counts. */
export function McpServers({
servers: controlledServers,
title = "MCP servers",
onToggleTool,
onReconnect,
autoPlay = false,
className,
}: McpServersProps) {
const reduce = useReducedMotion() ?? false;
const baseId = useId();
const [touched, setTouched] = useState(false);
const [view, setView] = useState<View>({ servers: DEFAULT_SERVERS, openId: "code", active: null, pressing: null });
const demo = autoPlay && !reduce && !touched && !controlledServers;
const { ref, t } = useDemoClock(demo, LOOP);
// Sync the scripted frame during render (no effect → no extra commit per tick).
const [syncedT, setSyncedT] = useState(-1);
if (demo && t !== syncedT) {
setSyncedT(t);
setView(demoView(t));
}
const servers = controlledServers ?? view.servers;
const { openId, active, pressing } = view;
const setServers = (fn: (prev: McpServer[]) => McpServer[]) => setView((v) => ({ ...v, servers: fn(v.servers) }));
const toggleOpen = (id: string) => {
setTouched(true);
setView((v) => ({ ...v, openId: v.openId === id ? null : id, active: null, pressing: null }));
};
const handleToggleTool = (serverId: string, toolName: string) => {
setTouched(true);
const tool = servers.find((s) => s.id === serverId)?.tools.find((x) => x.name === toolName);
if (!tool) return;
const next = !tool.enabled;
onToggleTool?.(serverId, toolName, next);
if (!controlledServers)
setServers((prev) =>
prev.map((s) => (s.id === serverId ? { ...s, tools: s.tools.map((x) => (x.name === toolName ? { ...x, enabled: next } : x)) } : s)),
);
};
const handleReconnect = (serverId: string) => {
setTouched(true);
onReconnect?.(serverId);
if (controlledServers) return;
setServers((prev) => prev.map((s) => (s.id === serverId ? { ...s, status: "connecting" } : s)));
setTimeout(() => {
setServers((prev) =>
prev.map((s) => (s.id === serverId ? { ...s, status: "connected", latencyMs: 44, error: undefined } : s)),
);
}, 1200);
};
const live = servers.filter((s) => s.status === "connected");
const toolCount = live.reduce((n, s) => n + s.tools.filter((x) => x.enabled).length, 0);
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
{/* Header */}
<div className="flex items-center gap-3 border-b bg-gradient-to-b from-muted/50 to-transparent px-4 py-3">
<span className="grid size-8 shrink-0 place-items-center rounded-lg border bg-background shadow-xs">
<Plug className="size-4" aria-hidden />
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium leading-tight">{title}</p>
<p className="mt-0.5 text-xs text-muted-foreground tabular-nums" aria-live="polite">
{live.length} of {servers.length} connected · {toolCount} tools live
</p>
</div>
<div className="flex shrink-0 items-center gap-1" aria-hidden>
{servers.map((s) => (
<motion.span
key={s.id}
layout
className={cn("h-4 w-1.5 rounded-full transition-colors duration-500", STATUS_META[s.status].bar, s.status === "connecting" && "animate-pulse")}
/>
))}
</div>
</div>
<ul className="divide-y">
{servers.map((server) => {
const isOpen = openId === server.id;
const panelId = `${baseId}-${server.id}`;
const meta = STATUS_META[server.status];
const enabled = server.tools.filter((x) => x.enabled).length;
const calls = server.tools.reduce((n, x) => n + (x.calls ?? 0), 0);
const needsAction = server.status === "auth-required" || server.status === "error";
return (
<li key={server.id} className={cn("transition-colors duration-300", isOpen && "bg-muted/30")}>
<div className="flex items-center gap-3 px-4 py-2.5">
<button
type="button"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => toggleOpen(server.id)}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-md text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="relative grid size-8 shrink-0 place-items-center rounded-lg border bg-gradient-to-br from-background to-muted font-mono text-[11px] font-semibold tracking-tight">
{monogram(server.name)}
<span className="absolute -right-0.5 -bottom-0.5 grid size-2.5 place-items-center">
{server.status === "connecting" && !reduce && (
<span className={cn("absolute inset-0 animate-ping rounded-full opacity-70", meta.dot)} />
)}
<span className={cn("relative size-2.5 rounded-full ring-2 ring-card transition-colors duration-500", meta.dot)} />
</span>
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate font-medium">{server.name}</span>
{server.transport && (
<span className="rounded border bg-background px-1 font-mono text-[9px] uppercase tracking-wide text-muted-foreground">
{server.transport}
</span>
)}
</span>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={server.status}
initial={reduce ? false : { opacity: 0, y: 6, filter: "blur(2px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduce ? undefined : { opacity: 0, y: -6, filter: "blur(2px)" }}
transition={{ duration: 0.28, ease: EASE }}
className={cn("block truncate text-xs", server.status === "error" ? meta.text : "text-muted-foreground")}
>
{server.status === "connected"
? `${enabled}/${server.tools.length} tools · ${server.latencyMs ?? "—"}ms · ${calls} calls`
: server.status === "connecting"
? "Handshaking · listing tools…"
: server.status === "auth-required"
? "Sign in to grant access"
: (server.error ?? "Connection lost")}
</motion.span>
</AnimatePresence>
</span>
</button>
<AnimatePresence mode="popLayout" initial={false}>
{needsAction ? (
<motion.button
key="action"
type="button"
onClick={() => handleReconnect(server.id)}
initial={reduce ? false : { opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: pressing === server.id ? 0.92 : 1 }}
exit={reduce ? undefined : { opacity: 0, scale: 0.9 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className={cn(
"inline-flex h-7 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium shadow-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
server.status === "error" ? "bg-foreground text-background hover:opacity-90" : "bg-background hover:bg-muted",
)}
>
{server.status === "error" ? (
<>
<RefreshCw className="size-3" aria-hidden />
Reconnect
</>
) : (
<>
Sign in
<ExternalLink className="size-3 text-muted-foreground" aria-hidden />
</>
)}
</motion.button>
) : (
<motion.span
key="badge"
initial={reduce ? false : { opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? undefined : { opacity: 0, scale: 0.9 }}
transition={{ type: "spring", stiffness: 500, damping: 30 }}
className={cn("inline-flex h-6 shrink-0 items-center gap-1.5 rounded-full border bg-background px-2 text-[11px] font-medium", meta.text)}
>
{server.status === "connecting" ? (
<RefreshCw className={cn("size-3", !reduce && "animate-spin")} aria-hidden />
) : (
<span className={cn("size-1.5 rounded-full", meta.dot)} />
)}
{meta.label}
</motion.span>
)}
</AnimatePresence>
<button
type="button"
aria-label={`${isOpen ? "Hide" : "Show"} ${server.name} tools`}
onClick={() => toggleOpen(server.id)}
className="grid size-6 shrink-0 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<ChevronDown className={cn("size-3.5 transition-transform duration-300", isOpen && "rotate-180")} aria-hidden />
</button>
</div>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={panelId}
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: EASE }}
className="overflow-hidden"
>
<div className="px-4 pb-3">
<div className="divide-y overflow-hidden rounded-xl border bg-background">
{server.tools.map((tool, i) => {
const running = active === `${server.id}:${tool.name}`;
const off = !tool.enabled || server.status !== "connected";
return (
<motion.div
key={tool.name}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: 0.05 + i * 0.05, ease: EASE }}
className={cn(
"relative flex items-center gap-2.5 px-2.5 py-2 transition-colors duration-300",
running && "bg-muted/50",
)}
>
{running && !reduce && <span className="mcp-sweep pointer-events-none absolute inset-0" aria-hidden />}
<span className={cn("grid size-6 shrink-0 place-items-center rounded-md bg-muted text-muted-foreground transition-opacity", off && "opacity-50")}>
<Wrench className="size-3" aria-hidden />
</span>
<div className={cn("relative min-w-0 flex-1 transition-opacity duration-300", off && "opacity-55")}>
<div className="flex items-center gap-1.5">
<span className="truncate font-mono text-[11px] font-medium">{tool.name}</span>
{!!tool.calls && (
<motion.span
key={tool.calls}
initial={reduce ? false : { scale: 1.5, opacity: 0.4 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 600, damping: 18 }}
className="rounded-full bg-muted px-1.5 font-mono text-[10px] tabular-nums text-muted-foreground"
title={`${tool.calls} calls this session`}
>
{tool.calls}×
</motion.span>
)}
{running && (
<span className="inline-flex items-center gap-1 rounded-full bg-foreground px-1.5 text-[10px] font-medium text-background">
<span className={cn("size-1 rounded-full bg-background", !reduce && "animate-pulse")} />
calling
</span>
)}
</div>
<p className="mt-0.5 truncate font-mono text-[10px] text-muted-foreground" title={tool.description}>
{tool.schema ?? tool.description}
</p>
</div>
<Switch
checked={tool.enabled}
pressing={pressing === `${server.id}:${tool.name}`}
label={`Allow ${tool.name}`}
onChange={() => handleToggleTool(server.id, tool.name)}
reduce={reduce}
/>
</motion.div>
);
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</li>
);
})}
</ul>
</div>
<style>{`
.mcp-sweep {
background: linear-gradient(100deg, transparent 30%, color-mix(in oklab, var(--foreground) 7%, transparent) 50%, transparent 70%);
background-size: 250% 100%;
animation: mcp-sweep 1.2s linear infinite;
}
@keyframes mcp-sweep { from { background-position: 120% 0; } to { background-position: -20% 0; } }
`}</style>
</div>
);
}
function monogram(name: string) {
return name
.split(/\s+/)
.slice(0, 2)
.map((w) => w[0])
.join("")
.toUpperCase();
}
function Switch({
checked,
onChange,
label,
pressing,
reduce,
}: {
checked: boolean;
onChange: () => void;
label: string;
pressing?: boolean;
reduce: boolean;
}) {
return (
<motion.button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
onClick={onChange}
animate={{ scale: pressing ? 0.9 : 1 }}
className={cn(
"relative h-[18px] w-8 shrink-0 cursor-pointer rounded-full outline-none transition-colors duration-300 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
checked ? "bg-foreground" : "bg-muted-foreground/25",
)}
>
<motion.span
className="absolute top-[2px] left-[2px] size-[14px] rounded-full bg-background shadow-sm"
animate={{ x: checked ? 14 : 0 }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 700, damping: 32 }}
/>
</motion.button>
);
}
export default function McpServersDemo() {
return (
<div className="flex h-[420px] w-full items-start justify-center pt-2">
<McpServers autoPlay />
</div>
);
}