MCP Server List
A four-state MCP connections manager — transport and live status per server, an enable switch that is intent while status stays reality, cached tool chips with an honest count, and per-row reconnect or authorize.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/mcp-server-list.jsonPrompt
Build a React + TypeScript + Tailwind "McpServerList" component with zod and
lucide-react — the connections manager of an MCP client, not a marketing grid of
integrations.
Contract
- A zod schema (`mcpServerListItemSchema` in a sibling contract file) is the
single source of truth for ONE server: { id, name, transport, status, enabled,
toolCount, tools?: string[], target?, protocolVersion?, serverVersion?,
resourceCount?, promptCount?, error? }.
- transport is "stdio" | "http" | "sse", in MCP's own vocabulary: stdio means the
client SPAWNS the server as a child process and speaks JSON-RPC over its
stdin/stdout (its identity is the command line, there is no URL); http is
Streamable HTTP (one endpoint that answers POSTs and may upgrade to SSE); sse
is the deprecated pre-2025-03-26 HTTP+SSE transport, kept as its own value so a
row can say "migrate this one" instead of hiding it inside http.
- status is "connected" | "connecting" | "needs-auth" | "error" | "disconnected".
needs-auth is FIRST CLASS, not a flavour of error: a remote server that answers
401 and advertises an authorization server is working perfectly and is waiting
for a human to finish an OAuth flow, so its action is Authorize, not Restart.
disconnected is a resting state, not a failure, and must not be painted like one.
- `enabled` and `status` are two fields on purpose. `enabled` is the USER'S
INTENT (what the switch says); `status` is what the transport is doing. They
disagree all the time — a server you just switched off is still connected for
the second it takes to shut down. A single "state" field would have to lie.
- `toolCount` is authoritative (the last successful tools/list), `tools` is the
NAME sample this client cached. tools/list is paginated and a config endpoint
may return the count without names, so `tools.length <= toolCount` is normal
and the difference must be stated, never smoothed over. Names only: input
schemas, descriptions and per-tool switches belong to a tool browser.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
LIST's own render state, independent of any row: `status="error"` means the
client configuration could not be read, which is a different failure from a
server failing to start.
- Props: items; status; onToggleEnabled?(id, enabled); onReconnect?(id);
onAuthorize?(id); onAddServer?(); onRetry?(); expandPolicy? ("attention");
maxToolChips? (8, clamped to >= 1); addServerLabel?; emptyState?;
errorMessage?; label? ("MCP servers"); className plus the rest of the element
props, ref forwarded to the <section>.
- Affordances exist only when their callback does: no onToggleEnabled means no
switches, no onReconnect means no reconnect buttons, no onAddServer means no
add tile. A switch that switches nothing is worse than no switch.
Behavior
- The component NEVER mutates items. Callbacks may return void or a Promise; the
component owns only the in-flight feedback and the consumer owns the data.
- The switch is OPTIMISTIC: it moves on click, before any round trip, because
waiting for a server to acknowledge a local preference is what makes a settings
toggle feel broken. If the returned promise rejects, the switch snaps back and
the reason is printed on the row.
- Every action goes through ONE in-flight lock held in a ref, not in state:
several clicks dispatched inside a single task all read the same stale state,
so a state-only guard would fire three OAuth popups or spawn a process twice.
The handler is wrapped as `new Promise(resolve => resolve(handler()))`, never
`Promise.resolve(handler())` — the latter cannot catch a SYNCHRONOUS throw, and
the row would stay busy forever.
- A failure message is recorded against the row's server truth (`enabled:status`)
and only rendered while that token still matches. The moment your data says
something else, the stale complaint disappears instead of contradicting the row
it sits under.
- Rows in error or needs-auth OPEN THEMSELVES (expandPolicy="attention"): a row
asking for a decision one click away from being seen is a row that gets missed.
A reader's explicit collapse wins — until the row falls INTO error/needs-auth,
at which point the override is dropped (state adjusted during render by
comparing a status snapshot, never in an effect, so there is no frame showing
the stale disclosure).
- The header count credits only servers that are BOTH switched on and connected:
"2 of 6 servers connected · 27 tools available to the model". A disabled or
broken server's tools are not in the model's context, and counting them tells
the reader they have capability they do not have. Every other count on a
non-connected row is labelled "last known".
- A switched-off row offers no reconnect: turning it back on is the action, and
showing both would let you start a server you just stopped. A connecting row
keeps its button in place, aria-disabled with a spinner, instead of removing it
and reflowing the row.
- Truncation is always announced. Tool chips are capped at maxToolChips with an
explicit "Show all 34 cached names" toggle, an uncached remainder reads "22
more names not cached yet", and a server that reported a count with no names
says so rather than rendering an empty tool section.
- The error text is the one thing never capped: no max-height, no line clamp,
wrapped, monospace — the tail of a spawn failure is usually the line naming the
missing binary. The collapsed row truncates only the target (command or URL),
which is repeated in full in the panel.
- `status="ready"` with zero rows renders the empty state, so a mis-set envelope
can never show a header counting zero of zero servers.
- Disclosure is a real <button aria-expanded> whose panel is UNMOUNTED when
closed (a zero-height collapse leaves every control inside reachable by Tab),
and aria-controls is set only while open so it never points at a missing id.
- The component starts no timers, no observers and no requests; the only effect
is an alive-flag whose cleanup stops post-unmount state writes from a promise
that is still out.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary + text-primary-foreground for the primary action and the switch's
on-track, bg-primary/10 + text-primary for connected and needs-auth,
bg-destructive/10 + text-destructive for failures, bg-muted for resting states.
Accent is spent on meaning: primary = something you can act on, destructive =
something broke. No hard-coded colours anywhere.
- Each state is a SHAPE as well as a tone — check, spinner, key, alert, power —
so the list survives a monochrome theme and a colour-blind reader; the spinner
is motion-reduce guarded and still reads as "connecting".
- The switch is a hand-rolled <button role="switch" aria-checked> with a STATIC
accessible name ("Enable github"): a name that flipped between Enable/Disable
would read out the opposite of what aria-checked says. Native `disabled` is
never used on a busy control — it drops focus to <body> mid-action; aria-disabled
plus a synchronous guard is.
- Long names, long commands and long tool names use `wrap-anywhere` plus
`min-w-0`, NOT break-words: only the former lowers min-content width, which is
what stops a 190-character spawn command from making the list wider than its
container.
- One sr-only role="status" region announces every action outcome ("github
authorized.", "Couldn't reconnect postgres."). cn() merges the consumer's
className into the root <section>, which spreads the remaining props.
Customization levers
- Density: rows are flat cards in a gap-2 column; drop the gap and share one
border for a table-like feel, or raise the padding for a settings page.
- Sub-blocks: the detail list (transport, target, protocol, version,
capabilities), the tool chips and the header summary are independent — remove
any of them without touching the state machine.
- Policy: `expandPolicy` picks "attention" (default), "all" for a debugging
surface, or "none" for a dense settings page. `maxToolChips` decides how much
of a catalogue is drawn before the explicit reveal — never make that cap silent.
- Vocabulary: TRANSPORT_LABEL / STATE_LABEL are plain maps; translate them, or
rename "Reconnect" to "Restart" for stdio and "Reconnect" for HTTP if your
audience distinguishes the two. The transport chip can carry a brand mark
instead of a lucide glyph without touching the contract.
- Capabilities: resourceCount / promptCount are optional — add readable
resource and prompt sections next to the tools if your client surfaces them.
- Actions: pass only the callbacks you can honour. A read-only audit view passes
none and renders as a pure report; an admin view passes all four.Concepts
- Intent versus reality — the switch says what the user WANTS from this server,
statussays what the transport is doing. They disagree constantly (switched off but still shutting down, switched on but still handshaking), so the row leads with intent, keeps the raw connection state in the panel, and never collapses the two into one field that has to lie about one of them. - needs-auth is not an error — a remote server that answers 401 and advertises an authorization server is healthy; it is waiting for a human. Painting it destructive and offering "Restart" sends people to fix a process that was never broken, so it gets its own state, its own key glyph and an Authorize action that starts the OAuth flow instead.
- Optimistic switch, promised rollback — the toggle moves on click because a local preference should not wait for a round trip. The consumer's handler may return a promise; a rejection snaps the switch back and pins the reason to the row, and that reason is keyed to the server truth it happened under, so it expires by itself the moment your data changes.
- One in-flight lock in a ref — several clicks dispatched inside a single task all observe the same stale state, so a state-only guard would open three OAuth popups or spawn the same process twice. The lock is read and written synchronously in the click handler, and the handler is wrapped so a synchronous throw becomes a rejection instead of leaving the row busy forever.
- The header only counts what the model can actually call — tools are credited only when a server is both switched on and connected; everything else is labelled "last known". A count that includes a disabled or broken server tells the reader they have context they do not have.
- Cached names versus reported count —
tools/listis paginated and a config endpoint may return a count with no names, so the panel states the gap ("22 more names not cached yet") and caps the chips with an explicit "Show all 34". A silent cap makes people debug a catalogue that was never missing. - Attention rows open themselves, once — failed and unauthorized rows start expanded because a decision one click away from being seen gets missed. A reader's explicit collapse still wins, and is only dropped at the moment a row falls into that state — computed during render from a status snapshot, so there is never a frame showing the stale disclosure.
Web Preview
A browser pane an agent drives — read-only address bar, a history scrubber that follows the newest page until you step back, a load bar, fit-to-pane width presets and a console drawer.
Tool Catalog
A searchable registry of the tools an agent may call — source facets with live counts, per-tool allow switches, parameter tables that open themselves when the query lands inside a signature, and four data states.