# Connect-your-MCP-client CTA

> A client-picker “add it to your client” CTA: one copyable server URL, a dropdown of clients, and the least-effort install path for whichever one you pick.

- **Category:** Onboarding
- **Source (best-in-class):** [daysurface.com](https://daysurface.com)
- **Tags:** mcp, cta, onboarding, install, deeplink, connect, client-picker
- **Dependencies:** react, tailwindcss
- **Status:** stable
- **Live demo:** https://eito.me/ui/mcp-cta

## Problem

Every MCP client takes the same server URL in a different shape — a deeplink, a CLI line, or a paste-in prompt. One flat “copy this JSON” kills most installs. This CTA derives every path from the server name + URL and shows only the shortest one for the client you pick.

## When to use

- You ship a remote (streamable-HTTP) MCP server and want fast, multi-client install.
- Clients differ in effort and the CTA should be honest about which is one-click.
- Auth is OAuth-in-client, so most clients can promise “just click Add”.

## Code — snippet.tsx

```tsx
import { useMemo, useRef, useState } from "react";

/**
 * McpConnectWidget — the "add it to your client" CTA, ported from the
 * daysurface.com landing page (Edison "Hackbox" system).
 *
 * A single copyable MCP server URL up top, a client picker below, and one panel
 * per client that shows the least-effort install path for THAT client:
 *   - deeplink   → a one-click "Add to <client>" button (Claude, Cursor, VS Code, Goose)
 *   - page-only  → a link that opens the right page but prefills nothing
 *                  (ChatGPT); the URL is copied to the clipboard on the way out
 *   - command    → a copyable one-liner for the terminal (Claude Code)
 *   - prompt     → a paste-into-the-agent block (Cline, Zed, Windsurf, any client)
 * with a numbered click-path as the fallback under every deeplink.
 *
 * Every install target is DERIVED from serverName + mcpUrl, so onboarding a new
 * server is a two-field change. The effort classification is computed, never
 * hand-set, so a client that changes method reclassifies itself.
 *
 * Deps: react + tailwindcss only. Icons are inline SVG (no icon library); logos
 * on the real site are brand marks, swapped here for monochrome glyphs so the
 * file pastes anywhere. Palette is the Edison token set, inlined as arbitrary
 * values: accent #c3fffd · fg #fff · fg-muted #9ba4a6 · fg-subtle #5e6575 ·
 * surface #0a0a0a · border #383838.
 */

/* ---- data model ------------------------------------------------------------ */

export type InstallMethod = "deeplink" | "prompt";
export type InstallEffort = "one-click" | "command" | "page-only" | "prompt";

export interface InstallTarget {
  id: string;
  name: string;
  method: InstallMethod;
  /** Deeplinks only. false = the link opens the right page but fills nothing in. */
  prefills?: boolean;
  /** Prompt targets: is `setupPrompt` a shell command or chat text? */
  setupKind?: "prompt" | "command";
  /** Prompt targets: the text to paste (or run). */
  setupPrompt?: string;
  /** The click-path: the whole flow, or the fallback under a deeplink. */
  steps?: string[];
  /** Optional note under the button / prompt. */
  note?: string;
}

export interface McpConnectWidgetProps {
  /** Machine name used in deep links + CLI, e.g. "daysurface". */
  serverName: string;
  /** Streamable-HTTP endpoint, e.g. "https://mcp.daysurface.com/mcp". */
  mcpUrl: string;
  /** Label a client shows in its install dialog, e.g. "DaySurface". */
  displayName?: string;
  /** Override the client list; defaults to the standard set. */
  targets?: InstallTarget[];
  /** Which client is selected first. */
  defaultId?: string;
  /**
   * Per-client logo URLs (SVG). Rendered as a currentColor-tinted CSS mask, so
   * one asset reads correctly on dark rows and on the accent button alike.
   * Any client without a URL falls back to a built-in monochrome glyph, so the
   * component still works when copied without assets.
   */
  logos?: Record<string, string>;
}

const effortMeta: Record<InstallEffort, { badge: string; stepsLabel: string }> = {
  "one-click": { badge: "1-click", stepsLabel: "If the link does not work" },
  command: { badge: "command", stepsLabel: "If the command does not work" },
  "page-only": { badge: "shortcut", stepsLabel: "Then, in ChatGPT" },
  prompt: { badge: "prompt", stepsLabel: "Or add it by hand" },
};

function effortOf(t: InstallTarget): InstallEffort {
  if (t.method === "deeplink") return t.prefills === false ? "page-only" : "one-click";
  if (t.setupKind === "command") return "command";
  return "prompt";
}

/** UTF-8-safe base64 — plain btoa throws on any multibyte character. */
const toBase64 = (s: string) =>
  btoa(Array.from(new TextEncoder().encode(s), (b) => String.fromCharCode(b)).join(""));

/** POSIX single-quote so a URL with `&`, spaces, etc. stays one shell argument. */
const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;

/** One-click install deep links. Formats verified against each client's docs. */
function deepLink(id: string, mcpUrl: string, serverName: string, displayName: string): string | null {
  switch (id) {
    case "claude": {
      const params = new URLSearchParams({
        modal: "add-custom-connector",
        connectorName: displayName,
        connectorUrl: mcpUrl,
      });
      // Query must come BEFORE the hash fragment for Claude to read it.
      return `https://claude.ai/new?${params.toString()}#settings/customize-connectors`;
    }
    case "chatgpt":
      // ChatGPT has no deep link that opens or prefills the create form; custom
      // MCP servers are added from the Plugins page (Add, then Create custom MCP server).
      // The old #settings/Connectors?create-connector=true route targeted the
      // removed Settings → Connectors flow. The widget copies the URL to the
      // clipboard as it navigates.
      return "https://chatgpt.com/plugins";
    case "cursor": {
      const config = toBase64(JSON.stringify({ url: mcpUrl }));
      return `cursor://anysphere.cursor-deeplink/mcp/install?name=${encodeURIComponent(serverName)}&config=${encodeURIComponent(config)}`;
    }
    case "vscode": {
      const config = encodeURIComponent(JSON.stringify({ type: "http", url: mcpUrl }));
      return `https://vscode.dev/redirect/mcp/install?name=${encodeURIComponent(serverName)}&config=${config}`;
    }
    case "goose": {
      const params = new URLSearchParams({
        url: mcpUrl,
        type: "streamable_http",
        id: serverName,
        name: serverName,
        description: `${serverName} MCP server`,
        timeout: "300",
      });
      return `goose://extension?${params.toString()}`;
    }
    default:
      return null;
  }
}

/** The standard client set, built from the server's two fields. */
function defaultTargets(serverName: string, mcpUrl: string): InstallTarget[] {
  // One prompt telling the agent to use ITS OWN config keys — they differ:
  // VS Code "servers", Cursor/Cline "mcpServers", Zed "context_servers",
  // Windsurf "serverUrl" not "url".
  const agentPrompt = `Add a remote MCP server, then verify it connects.

  Name:      ${serverName}
  Endpoint:  ${mcpUrl}
  Transport: streamable HTTP (remote, not stdio)
  Auth:      OAuth in the browser, no key to paste

Use THIS client's own config keys — they differ:
VS Code "servers", Cursor/Cline "mcpServers",
Zed "context_servers", Windsurf "serverUrl" not "url".
Keep existing servers. Show me the diff, then say
what to click to finish sign-in.`;

  return [
    {
      id: "claude",
      name: "Claude",
      method: "deeplink",
      note: "Opens Claude with the name and URL already filled in — just click Add.",
      steps: [
        "Open Claude → Settings → Connectors",
        "Click “Add custom connector”",
        "Paste the URL above, then click Add and complete sign-in",
      ],
    },
    {
      id: "chatgpt",
      name: "ChatGPT",
      method: "deeplink",
      prefills: false,
      note: "Copies the URL and opens ChatGPT Plugins.",
      steps: [
        "Add → Create custom MCP server",
        "Paste the URL, Authentication → OAuth → Create as a plugin",
      ],
    },
    {
      id: "claude-code",
      name: "Claude Code",
      method: "prompt",
      setupKind: "command",
      setupPrompt: `claude mcp add --transport http --scope user ${shQuote(serverName)} ${shQuote(mcpUrl)}`,
      note: "Run it in your terminal, then /mcp in a session to sign in.",
    },
    {
      id: "cursor",
      name: "Cursor",
      method: "deeplink",
      note: "Opens Cursor and adds the server. Not working? Copy the URL above and add it under Settings → MCP.",
    },
    {
      id: "vscode",
      name: "VS Code",
      method: "deeplink",
      note: "Opens VS Code and adds the server. Requires Copilot agent mode.",
    },
    {
      id: "goose",
      name: "Goose",
      method: "deeplink",
      note: "Opens Goose and adds the extension over streamable HTTP.",
    },
    {
      id: "windsurf",
      name: "Windsurf",
      method: "prompt",
      setupPrompt: agentPrompt,
      note: "Windsurf uses “serverUrl”, not “url” — a snippet copied from another client will fail silently.",
    },
    {
      id: "other",
      name: "Any MCP client",
      method: "prompt",
      setupPrompt: agentPrompt,
      note: "Anything that speaks MCP over streamable HTTP works — there is nothing to install.",
    },
  ];
}

/* ---- inline glyphs (monochrome, tinted via currentColor) ------------------- */

const CopyIcon = ({ done }: { done: boolean }) =>
  done ? (
    <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M20 6 9 17l-5-5" />
    </svg>
  ) : (
    <svg className="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <rect x="9" y="9" width="13" height="13" />
      <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
    </svg>
  );

const glyphs: Record<string, JSX.Element> = {
  claude: <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 2c.5 3.6 1.7 4.8 5.3 5.3-3.6.5-4.8 1.7-5.3 5.3-.5-3.6-1.7-4.8-5.3-5.3C10.3 6.8 11.5 5.6 12 2Zm6 9.5c.3 2.2 1 2.9 3.2 3.2-2.2.3-2.9 1-3.2 3.2-.3-2.2-1-2.9-3.2-3.2 2.2-.3 2.9-1 3.2-3.2Zm-9.5 2c.24 1.8.85 2.4 2.65 2.65-1.8.25-2.4.85-2.65 2.65-.25-1.8-.85-2.4-2.65-2.65 1.8-.25 2.4-.85 2.65-2.65Z" /></svg>,
  chatgpt: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><path d="M12 3.2a4 4 0 0 1 3.8 2.8 4 4 0 0 1 1.9 6.7 4 4 0 0 1-3.8 5.3 4 4 0 0 1-7.6-1.1 4 4 0 0 1-1.9-6.7A4 4 0 0 1 8.2 4.3 4 4 0 0 1 12 3.2Z" /><path d="M12 8.2 15.4 10v4L12 15.8 8.6 14v-4L12 8.2Z" /></svg>,
  cursor: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m5 3 14 8.5-6 1.6L9.6 20 5 3Z" /></svg>,
  vscode: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 6 2.5 12 8 18M16 6l5.5 6L16 18M12 3v18" /></svg>,
  goose: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M8 4a4 4 0 0 1 4 4v5a5 5 0 0 1-5 5H6M12 8h4a3 3 0 0 1 0 6" /><circle cx="7" cy="6.5" r="0.6" fill="currentColor" /></svg>,
  "claude-code": <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m5 8 3 3-3 3M11 15h5" /><rect x="2.5" y="4.5" width="19" height="15" rx="1.5" /></svg>,
  mcp: <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" aria-hidden="true"><circle cx="6" cy="12" r="2.2" /><circle cx="18" cy="6" r="2.2" /><circle cx="18" cy="18" r="2.2" /><path d="M8 11 16 7M8 13l8 4" strokeLinecap="round" /></svg>,
};

/** A client's mark: a tinted CSS mask of its real logo, or a built-in glyph. */
const ClientMark = ({
  id,
  logos,
  className,
}: {
  id: string;
  logos?: Record<string, string>;
  className?: string;
}) => {
  const url = logos?.[id];
  if (url) {
    return (
      <span
        aria-hidden="true"
        className={className}
        style={{
          display: "inline-block",
          backgroundColor: "currentColor",
          WebkitMask: `url("${url}") center / contain no-repeat`,
          mask: `url("${url}") center / contain no-repeat`,
        }}
      />
    );
  }
  return <span className={className}>{glyphs[id] ?? glyphs.mcp}</span>;
};

/* ---- component ------------------------------------------------------------- */

export default function McpConnectWidget({
  serverName,
  mcpUrl,
  displayName = serverName,
  targets,
  defaultId = "claude",
  logos,
}: McpConnectWidgetProps) {
  const list = useMemo(() => targets ?? defaultTargets(serverName, mcpUrl), [targets, serverName, mcpUrl]);
  const [selectedId, setSelectedId] = useState(defaultId);
  const [open, setOpen] = useState(false);
  const [copied, setCopied] = useState<string | null>(null);
  const rootRef = useRef<HTMLDivElement>(null);

  const selected = list.find((t) => t.id === selectedId) ?? list[0];

  const copy = (key: string, text: string) => {
    if (!navigator.clipboard) return;
    navigator.clipboard
      .writeText(text)
      .then(() => {
        setCopied(key);
        setTimeout(() => setCopied((k) => (k === key ? null : k)), 1500);
      })
      .catch(() => {
        /* permission denied / non-secure context — ignore */
      });
  };

  // No clients configured (e.g. targets={[]}): render an empty state rather
  // than dereferencing an undefined `selected`.
  if (!selected) {
    return (
      <div className="w-full max-w-lg border-2 border-[#383838] bg-[#0a0a0a] p-4 text-sm text-[#9ba4a6]">
        No MCP clients configured.
      </div>
    );
  }

  const CopyBtn = ({ code, keyName, label }: { code: string; keyName: string; label: string }) => (
    <button
      type="button"
      onClick={() => copy(keyName, code)}
      aria-label={label}
      className={`grid min-h-11 w-11 shrink-0 place-items-center border-l border-[#383838] transition hover:bg-[#c3fffd]/10 ${copied === keyName ? "bg-[#c3fffd]/10 text-[#c3fffd]" : "text-white/80 hover:text-[#c3fffd]"}`}
    >
      <CopyIcon done={copied === keyName} />
    </button>
  );

  return (
    <div
      ref={rootRef}
      className="w-full min-w-0 max-w-lg border-2 border-[#c3fffd] bg-[#0a0a0a] p-3 text-left shadow-2xl shadow-black/40 sm:p-4"
    >
      {/* MCP server URL — always copyable */}
      <span className="font-mono text-[11px] uppercase tracking-widest text-[#5e6575]">
        MCP server URL
      </span>
      <div className="mt-1.5 flex items-stretch border border-[#383838] bg-black">
        <code className="min-w-0 flex-1 truncate px-3 py-2.5 font-mono text-xs text-[#c3fffd] sm:text-sm" title={mcpUrl}>
          {mcpUrl}
        </code>
        <CopyBtn code={mcpUrl} keyName="url" label="Copy the server URL" />
      </div>

      {/* Client picker */}
      <div className="relative mt-4">
        <button
          type="button"
          onClick={() => setOpen((v) => !v)}
          aria-haspopup="listbox"
          aria-expanded={open}
          className="flex min-h-11 w-full items-center gap-2.5 border border-[#383838] bg-black px-3 text-left transition hover:border-[#c3fffd]/60"
        >
          <ClientMark id={selected.id} logos={logos} className="size-5 shrink-0 text-white [&_svg]:size-5" />
          <span className="min-w-0 flex-1 truncate text-sm font-medium text-white">{selected.name}</span>
          <svg className={`size-4 text-[#9ba4a6] transition-transform ${open ? "rotate-180" : ""}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
            <path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        </button>

        {open && (
          <ul
            role="listbox"
            className="absolute z-20 mt-1 max-h-72 w-full overflow-y-auto border border-[#c3fffd]/50 bg-[#1c1c1c] shadow-2xl shadow-black/60"
          >
            {list.map((t) => {
              const effort = effortOf(t);
              const isDeep = t.method === "deeplink" && t.prefills !== false;
              return (
                <li key={t.id} role="option" aria-selected={t.id === selected.id}>
                  <button
                    type="button"
                    onClick={() => {
                      setSelectedId(t.id);
                      setOpen(false);
                    }}
                    className="flex min-h-11 w-full items-center gap-2.5 px-3 text-left text-sm text-[#9ba4a6] transition hover:bg-black hover:text-white"
                  >
                    <ClientMark id={t.id} logos={logos} className="size-5 shrink-0 [&_svg]:size-5" />
                    <span className="flex-1">{t.name}</span>
                    <span className={`font-mono text-[10px] uppercase tracking-wider ${isDeep ? "text-[#c3fffd]" : "text-[#5e6575]"}`}>
                      {effortMeta[effort].badge}
                    </span>
                  </button>
                </li>
              );
            })}
          </ul>
        )}
      </div>

      {/* Panel for the selected client */}
      <div className="mt-4">
        <Panel target={selected} mcpUrl={mcpUrl} displayName={displayName} serverName={serverName} logos={logos} copied={copied} copy={copy} />
      </div>
    </div>
  );
}

/* ---- per-client panel ------------------------------------------------------ */

function Panel({
  target,
  mcpUrl,
  displayName,
  serverName,
  logos,
  copied,
  copy,
}: {
  target: InstallTarget;
  mcpUrl: string;
  displayName: string;
  serverName: string;
  logos?: Record<string, string>;
  copied: string | null;
  copy: (key: string, text: string) => void;
}) {
  const effort = effortOf(target);
  const href = deepLink(target.id, mcpUrl, serverName, displayName);

  // Deeplink → a button (+ fallback steps disclosure)
  if (href) {
    const pageOnly = effort === "page-only";
    return (
      <>
        <a
          href={href}
          onClick={() => {
            // Page-only links prefill nothing, so copy the URL on the way out.
            if (pageOnly) copy("url", mcpUrl);
          }}
          className="flex min-h-12 w-full items-center justify-center gap-2.5 bg-[#c3fffd] px-4 text-center font-semibold text-black transition hover:bg-[#7de6e2]"
        >
          <ClientMark id={target.id} logos={logos} className="size-5 shrink-0 text-black [&_svg]:size-5" />
          {pageOnly ? `Open ${target.name} Plugins` : `Add to ${target.name}`}
        </a>
        {target.note && <p className="mt-2.5 text-xs leading-relaxed text-[#5e6575]">{target.note}</p>}
        {target.steps && target.steps.length > 0 && (
          <details className="mt-3" open={effort === "page-only"}>
            <summary className="cursor-pointer list-none text-xs text-[#5e6575] transition hover:text-[#c3fffd]">
              <span className="underline decoration-dotted underline-offset-4">
                {effortMeta[effort].stepsLabel}
              </span>
            </summary>
            <Steps steps={target.steps} />
          </details>
        )}
      </>
    );
  }

  // Prompt / command → copyable block
  if (target.setupPrompt) {
    const isCommand = target.setupKind === "command";
    return (
      <>
        <p className="text-xs leading-relaxed text-[#5e6575]">
          {isCommand
            ? `Run this in your terminal — it adds the server to ${target.name}.`
            : `Paste this into ${target.name} — it will add the server for you.`}
        </p>
        <div className="mt-2 flex items-stretch border border-[#383838] bg-black">
          <pre className="max-h-64 flex-1 overflow-y-auto whitespace-pre-wrap break-words px-3 py-2.5 font-mono text-xs leading-relaxed text-[#9ba4a6]">
            {target.setupPrompt}
          </pre>
          <button
            type="button"
            onClick={() => copy(`prompt-${target.id}`, target.setupPrompt!)}
            aria-label={`Copy the ${target.name} setup ${isCommand ? "command" : "prompt"}`}
            className={`grid shrink-0 place-items-center border-l border-[#383838] px-3 transition hover:bg-[#c3fffd]/10 ${copied === `prompt-${target.id}` ? "bg-[#c3fffd]/10 text-[#c3fffd]" : "text-white/80 hover:text-[#c3fffd]"}`}
          >
            <CopyIcon done={copied === `prompt-${target.id}`} />
          </button>
        </div>
        {target.note && <p className="mt-2.5 text-xs leading-relaxed text-[#5e6575]">{target.note}</p>}
      </>
    );
  }

  // Manual → numbered steps only
  return <Steps steps={target.steps ?? []} />;
}

function Steps({ steps }: { steps: string[] }) {
  return (
    <ol className="mt-2.5 space-y-2.5">
      {steps.map((step, i) => (
        <li key={i} className="flex gap-3 text-sm text-[#9ba4a6]">
          <span className="grid size-5 shrink-0 place-items-center border border-[#c3fffd]/50 font-mono text-[11px] text-[#c3fffd]">
            {i + 1}
          </span>
          <span className="leading-relaxed">{step}</span>
        </li>
      ))}
    </ol>
  );
}
```

## Related

- Machine-readable index (all patterns, with code): https://eito.me/ui/manifest.json
- Plain-HTML mirror: https://eito.me/ui/all.html
- Interactive gallery: https://eito.me/ui
