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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/mcp-install-button.jsonPrompt
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
buildthat translates it into the shape that client actually accepts (a CLI line,mcpServers,serverswith atype, 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-disabledinstead 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.
Open In Chat
A provider menu that turns the content on the page into a written prompt and hands it to ChatGPT, Claude, Cursor or the clipboard — deeplink when it fits the URL, copy when it does not.
Commit Card
One agent commit as a card — a Conventional Commits badge parsed out of the raw git message, the files it touched behind an announced cap, an agent and timestamp line, a two-step one-shot revert, and a grouped rail for a whole run.