AI

MCP Install Button

A one-click MCP install control — one server description, translated per client into a Claude Code command, a Claude Desktop or VS Code config or a Cursor deeplink, with copy feedback, placeholder env keys and a verify-connection check.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  ChevronDown,
  CircleCheck,
  Copy,
  ExternalLink,
  FileJson,
  LoaderCircle,
  type LucideIcon,
  PlugZap,
  Terminal,

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/mcp-install-button.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "McpInstallButton": the control that sits
on an MCP server's page and gets that server into the reader's client. Use the
shadcn Button and dropdown-menu (Radix DropdownMenu), lucide-react icons and
cn() from @/lib/utils. No other dependencies.

Contract
- forwardRef<HTMLDivElement>, extends React.ComponentPropsWithoutRef<"div">
  minus onCopy; unlisted props spread onto the root, className merges via cn().
- server: McpServerConfig — the ONE description everything is derived from:
    { name: string; transport?: "stdio" | "http" | "sse";
      command?: string; args?: string[]; url?: string;
      env?: Record<string,string>; headers?: Record<string,string> }
  resolveTransport(server) = server.transport ?? (server.url ? "http" : "stdio").
- clients?: readonly McpClient[] (default: the four built-ins below).
    { id; label; hint?; disabled?: string;
      build: (server) => McpInstallTarget }
  `disabled` is a REASON string, not a boolean: "Not detected on this machine"
  is the only useful form of "you cannot pick this".
- McpInstallTarget = { kind: "command" | "config" | "deeplink"; text: string;
  location?: string; href?: string; note?: string }. `text` is REQUIRED even
  for a deeplink — see the fallback rule in Behavior.
- clientId? / defaultClientId? / onClientChange?(client) — controlled and
  uncontrolled picker, same shape as any shadcn control.
- requires?: { runtime?: string; env?: string[]; note?: string } — the hint
  line. `env` defaults to Object.keys(server.env ?? {}).
- onCopy?(text, client), onOpenClient?(client, href).
- onVerify?({ client, server }) => Promise<{ok: boolean; detail?: string} |
  boolean> — the connection probe. verifyStatus? / verifyDetail? make the
  result controlled (they override the internal machine, which is what lets a
  docs page render "checking" or "error" statically).
- verifyTimeoutMs? (15000), copiedDuration? (2000), showPayload? (true),
  verifyLabel? ("Verify connection").
- Exported helpers: MCP_CLIENTS, resolveTransport, pendingEnvKeys.

Behavior — one config, four translations
- Never ask the consumer for four configs. Each client owns a `build` that
  turns the single server description into its own shape; adding a client is
  adding a `build`, never a second source of truth.
- Claude Code (kind "command"): `claude mcp add <name> --env K=V … -- <command>
  <args…>` for stdio, `claude mcp add --transport http|sse <name> <url>
  --header "K: V"` for remote. Shell-quote every interpolated word: pass
  [A-Za-z0-9_@%+=:,./~-]+ through untouched, otherwise wrap in single quotes
  and escape inner quotes as '\''. Keep `~` in the safe set — quoting
  ~/Projects would hand the server a literal tilde instead of a home
  directory. Everything after `--` belongs to the server process, not the CLI.
- Claude Desktop (kind "config"): JSON under `mcpServers`. It launches
  processes only, so a remote server is rewritten as
  {command:"npx", args:["-y","mcp-remote", url, "--header","K: V"]} and the
  target carries a note saying so — silently proxying is worse than saying it.
- Cursor (kind "deeplink"): cursor://anysphere.cursor-deeplink/mcp/install
  ?name=…&config=<base64 of the server body>, with the .cursor/mcp.json JSON as
  the payload. btoa() is latin1-only, so encode through TextEncoder and build
  the binary string byte by byte; where btoa does not exist, emit NO href
  rather than a broken one.
- VS Code (kind "config"): JSON under `servers` with an explicit
  type: "stdio" | "http" | "sse", plus a vscode:mcp/install?<urlencoded json>
  href offered as a secondary action next to the copy button.
- A "sse" transport adds a note that SSE is the legacy transport. Notes are
  data on the target, so a custom client can carry its own caveat.

Behavior — secrets and requirements
- An env value that is blank renders as a `<KEY>` placeholder in every payload,
  and the requirements line marks that key with destructive tokens plus an
  "N values to fill in" tail. The component never invents a secret and never
  copies one it was not given; a placeholder that fails loudly beats a config
  that silently authenticates as nobody.
- The requirements line is a single wrapping paragraph: runtime, then the env
  keys as code chips, then the pending count, then a free note — joined by
  aria-hidden middots so the separators are never read aloud.

Behavior — the primary action
- kind "command" | "config": the primary Button copies. kind "deeplink": the
  primary is an <a> rendered through <Button asChild>, and the copy affordance
  lives on the payload header. A client with an href but a non-deeplink kind
  gets that link as a secondary ghost button.
- The payload block is ALWAYS shown (unless showPayload={false}): a custom URL
  scheme is a request the OS may drop with no event, so the manual path must
  never be hidden behind a button that might do nothing. After the link is
  clicked, add one muted line — "Nothing opened? … the config above still works
  by hand" — because that is the only honest thing a web page can say about a
  handoff it cannot observe.
- Copy: await navigator.clipboard.writeText(text), then a 2s "Copied" state on
  BOTH copy affordances. Copying again restarts the window — keep a nonce in
  the effect deps, otherwise the second copy inherits the first one's timer.
- If the Clipboard API is missing (http:// origin, old WebView) or the write
  rejects, do not pretend: set a copy-error state, say so in one destructive
  line, and select the payload node with a Range so ⌘/Ctrl+C works. That error
  state also FORCES the payload block on under showPayload={false} — otherwise
  the line says "the text above is selected" with no text above and the compact
  card is a dead end. Select from an effect keyed on the copy state, not from
  the handler: the node it points at only exists after that render commits.

Behavior — the verification machine
- States: idle → checking → ok | error, with "Try again" as the error label.
  idle shows one muted line ("Restart <client> first — it reads the config at
  startup"), because the most common false negative is checking before the
  client has re-read its config.
- One-shot lock: a check in flight swallows every further press, so a double
  click cannot start two probes racing to write the same line.
- Race the consumer's promise against a timeout that RESOLVES {ok:false} with
  a "no answer after Ns" detail — a probe that never settles must not spin
  forever. A rejection is a failure whose Error.message becomes the detail; a
  bare `true`/`false` is accepted and normalised.
- Every run gets an incrementing id; on settle, drop the result unless it is
  still the newest run AND the component is still mounted. Register the timeout
  id in a ref'd Set and clear the whole Set on unmount — an async handler's
  timer is not covered by any effect cleanup.
- The verify button uses aria-disabled + aria-busy while checking, NOT the
  native disabled attribute: a control that goes natively disabled under the
  user's own finger drops focus to <body> and stops telling anyone why it
  stopped working.

Behavior — picker and resets
- The picker is a DropdownMenu radio group (exactly one client is active, so a
  screen reader should hear "menuitemradio, checked"). A row with a `disabled`
  reason is aria-disabled and shows the reason where its hint was.
- Resolution order: requested id if it is enabled → first enabled client →
  requested → first. If the resolved client is disabled (nothing is installed),
  render the picker plus the reason and NOTHING else: no payload to copy and no
  check to run, because both would be about a client that cannot act.
- Switching clients resets copy state, the handed-off hint and the verification
  to idle, and invalidates any in-flight run: a command copied for Claude Code
  says nothing about Cursor, and a check that answered for one client is not an
  answer for another.

Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the shell, border,
  bg-muted/40 for the payload block, text-muted-foreground for hints,
  text-destructive + bg-destructive/10 for pending env keys and failures,
  text-primary for a successful check, ring-ring/50 for focus. No hex, no
  rgb(), no oklch().
- The payload is a <pre> with overflow-x-auto and NO max-height: a long command
  scrolls sideways inside its card and nothing is ever silently clipped. Give
  it tabIndex={0} and an aria-label, because a scrollable region has to be
  reachable by keyboard and nameable by a screen reader.
- Motion is decorative only: the chevron rotates 180° on open with
  transition-transform + motion-reduce:transition-none, the spinner is
  animate-spin + motion-reduce:animate-none. Under reduced motion the icon is a
  static ring and the state still reads, because the label says "Checking…".
- One sr-only role="status" aria-live="polite" region for both machines. It
  starts EMPTY (nothing announced on mount) and each copy or check settles into
  exactly one sentence — never a per-frame update.
- Expose data-client, data-copy and data-verify on the root so a test or a page
  can style/assert on the state without reaching inside.

Customization levers
- Clients: pass your own `clients` array — the four built-ins are just data.
  A recipe is one function from the server config to {kind, text, location,
  href, note}, so Windsurf, Zed, JetBrains or an internal client is one entry.
  Detect what is installed and set `disabled` to the reason instead of hiding
  rows: an explained absence beats a missing option.
- Density: showPayload={false} gives a picker + one action for a card or a
  table row — a blocked clipboard still reveals the block, because that is the
  only path left to the text; the shell's rounded-xl border bg-card p-3 is one
  className away from being inlined into a surrounding panel.
- Scope flags: add `--scope user|project|local` (Claude Code) or switch the
  Cursor/VS Code payload between the project file and the global one — both are
  a string change inside one `build`.
- Secrets policy: swap the `<KEY>` placeholder for VS Code's ${input:…} form,
  or drop the env chips entirely by passing requires={{ env: [] }} when your
  page documents credentials elsewhere.
- Verification: verifyTimeoutMs tunes the patience of the probe; the controlled
  verifyStatus/verifyDetail pair lets a server-rendered page show a known state
  with no probe at all. Point onVerify at your own endpoint, a local daemon, or
  a "list tools" round trip and put the tool count in `detail`.
- Copy window: copiedDuration (2000ms) is the only timing in the copy path;
  raise it if your surface is dense and the badge is easy to miss.

Concepts

  • One config, many recipes — the server is described once; each client owns a build that translates it into the shape that client actually accepts (a CLI line, mcpServers, servers with a type, a base64 blob in a URL). Adding a client can never introduce a second source of truth about the server.
  • Deeplink with a visible fallback — a custom URL scheme is a request the OS may drop without any event, so the copyable config stays on screen and a muted "nothing opened?" line appears after the handoff. The page says only what it can actually know.
  • Placeholder secrets — a blank env value becomes a <KEY> placeholder everywhere it is rendered and is counted in the requirements line as "to fill in"; a config that fails loudly at startup beats one that silently authenticates as nobody.
  • One-shot check lock — while a probe is in flight every further press is swallowed, so a double click cannot start two checks racing to write the same status line; the button stays focused via aria-disabled instead of going natively disabled.
  • Stale-run invalidation — each check carries an incrementing run id and its timeout lives in a ref'd Set; a result may only land if it is the newest run and the component is still mounted, and the Set is cleared on unmount because an async handler's timer belongs to no effect.
  • Switching client resets the answers — a command copied for Claude Code says nothing about Cursor, and a connection that answered for one client is not an answer for another, so both machines fall back to idle the moment the picker moves.

On This Page