nextui.dev
Home
Docs
Saved
Pricing
Components37
  • Forms & Inputs7
  • Cards13
  • Buttons, Badges & Avatars8
  • Feedback & Display6
  • Effects3
Agents46
  • Input & Context9
  • Thinking & Status6
  • Tools & Execution7
  • Human-in-the-Loop & Safety7
  • Multi-Agent & Workflows4
  • Output & Artifacts8
  • Voice & Memory5
Blocks20
  • Agent chatNew
  • Pill heroNew
  • Grid hero
  • Moodboard generatorNew
  • Waitlist heroNew
  • Intro bannerNew
  • Creative home heroNew
  • Chat heroNew
  • App shelfNew
  • Usage card
  • Design resultsNew
  • Pricing tiers
  • Dashboard home
  • The shiftNew
  • Launch perksNew
  • Testimonials
  • Quote
  • Team
  • Leads tableNew
  • Sign in
Pages16
  • ASCII workspace$49
  • Research agent$49
  • Support agent$49
  • Coding agent$49
  • CRM overviewNew
  • Marketing dashboardNew
  • Developer portfolio$49
  • Particle landing$49
  • Particle story$49
  • Statement converter$49
  • AI photo studio$49
  • Group card launch$49
  • Month-end storyNew
  • Task HubNew
  • Global MediaNew
  • Hexora ecosystem$49
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
UpgradeDark modeLight modeFeedbackGitHub
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextui.dev

© 2026 nextui.dev • All rights reserved.

UpgradeDark modeLight modeFeedbackGitHub
ComponentsAI & AgentsMCP servers
Copy promptCopy prompt

Customize

Header label

Loop a scripted connection and tool execution demo

JSX · copied with Copy prompt

<McpServers autoPlay />

MCP serversFreeNew

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.

Installation

Free to install: no account, access key or components.json setup needed.

$pnpm dlx shadcn@latest add https://nextui.dev/r/mcp-servers.json

Usage

1"use client";
2
3import { useState } from "react";
4import { McpServers, type McpServer } from "@/components/nextui/mcp-servers";
5
6export function AgentToolsPopover({ initial }: { initial: McpServer[] }) {
7 const [servers, setServers] = useState(initial);
8
9 return (
10 <McpServers
11 servers={servers}
12 onToggleTool={(serverId, toolName, enabled) =>
13 setServers((list) =>
14 list.map((s) =>
15 s.id === serverId
16 ? { ...s, tools: s.tools.map((t) => (t.name === toolName ? { ...t, enabled } : t)) }
17 : s,
18 ),
19 )
20 }
21 />
22 );
23}

API reference

Edit props live in the preview above, or see all 6 props
PropTypeDefaultDescription
serversMcpServer[]–Connected MCP servers with transport, status, latencyMs and their exposed tools
titlestring"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
autoPlaybooleanfalseLoop a scripted connection and tool execution demo
classNamestring–Extra classes on the root wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/mcp-servers.tsx
"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>
  );
}
Sign in