From 5132abb37fed5d85466340638090b307dcb65002 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 25 May 2026 18:17:16 -0300 Subject: [PATCH 01/11] feat(mcp): add clerk mcp install/list/uninstall commands Register the Clerk remote MCP server (https://mcp.clerk.com/mcp) in Claude Code, Cursor, VS Code, Windsurf, and Gemini, each with a JSON/agent mode and human-mode 'next steps' guidance (reload + sign-in). clerk doctor gains an MCP reachability check that probes the configured server via the MCP initialize handshake when an entry is installed (warns, never fails). URL resolution: --url > CLERK_MCP_URL > active env profile mcpUrl. --- .changeset/mcp-install.md | 5 + README.md | 1 + packages/cli-core/src/cli-program.ts | 70 ++++++ .../cli-core/src/commands/doctor/README.md | 21 +- .../cli-core/src/commands/doctor/check-mcp.ts | 39 ++++ .../cli-core/src/commands/doctor/index.ts | 2 + packages/cli-core/src/commands/mcp/README.md | 77 +++++++ .../src/commands/mcp/clients/claude-code.ts | 22 ++ .../src/commands/mcp/clients/clients.test.ts | 108 +++++++++ .../src/commands/mcp/clients/cursor.ts | 21 ++ .../src/commands/mcp/clients/gemini.ts | 40 ++++ .../src/commands/mcp/clients/json-config.ts | 65 ++++++ .../mcp/clients/make-json-client.test.ts | 162 ++++++++++++++ .../commands/mcp/clients/make-json-client.ts | 147 +++++++++++++ .../src/commands/mcp/clients/paths.ts | 34 +++ .../src/commands/mcp/clients/registry.ts | 26 +++ .../src/commands/mcp/clients/types.ts | 65 ++++++ .../commands/mcp/clients/user-scope.test.ts | 206 ++++++++++++++++++ .../src/commands/mcp/clients/vscode.ts | 23 ++ .../src/commands/mcp/clients/windsurf.ts | 21 ++ packages/cli-core/src/commands/mcp/collect.ts | 28 +++ packages/cli-core/src/commands/mcp/index.ts | 10 + .../cli-core/src/commands/mcp/install.test.ts | 192 ++++++++++++++++ packages/cli-core/src/commands/mcp/install.ts | 102 +++++++++ .../cli-core/src/commands/mcp/list.test.ts | 78 +++++++ packages/cli-core/src/commands/mcp/list.ts | 54 +++++ .../cli-core/src/commands/mcp/probe.test.ts | 107 +++++++++ packages/cli-core/src/commands/mcp/probe.ts | 94 ++++++++ packages/cli-core/src/commands/mcp/shared.ts | 147 +++++++++++++ .../src/commands/mcp/uninstall.test.ts | 101 +++++++++ .../cli-core/src/commands/mcp/uninstall.ts | 62 ++++++ packages/cli-core/src/lib/environment.ts | 14 ++ packages/cli-core/src/lib/errors.ts | 10 + 33 files changed, 2144 insertions(+), 10 deletions(-) create mode 100644 .changeset/mcp-install.md create mode 100644 packages/cli-core/src/commands/doctor/check-mcp.ts create mode 100644 packages/cli-core/src/commands/mcp/README.md create mode 100644 packages/cli-core/src/commands/mcp/clients/claude-code.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/clients.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/cursor.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/gemini.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/json-config.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/make-json-client.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/paths.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/registry.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/types.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/user-scope.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/vscode.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/windsurf.ts create mode 100644 packages/cli-core/src/commands/mcp/collect.ts create mode 100644 packages/cli-core/src/commands/mcp/index.ts create mode 100644 packages/cli-core/src/commands/mcp/install.test.ts create mode 100644 packages/cli-core/src/commands/mcp/install.ts create mode 100644 packages/cli-core/src/commands/mcp/list.test.ts create mode 100644 packages/cli-core/src/commands/mcp/list.ts create mode 100644 packages/cli-core/src/commands/mcp/probe.test.ts create mode 100644 packages/cli-core/src/commands/mcp/probe.ts create mode 100644 packages/cli-core/src/commands/mcp/shared.ts create mode 100644 packages/cli-core/src/commands/mcp/uninstall.test.ts create mode 100644 packages/cli-core/src/commands/mcp/uninstall.ts diff --git a/.changeset/mcp-install.md b/.changeset/mcp-install.md new file mode 100644 index 00000000..9f2b360c --- /dev/null +++ b/.changeset/mcp-install.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, VS Code, Windsurf, and Gemini. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. The URL comes from the active env profile's new `mcpUrl` field (or the `CLERK_MCP_URL` override) and can be overridden per-invocation with `--url` for local worker development. diff --git a/README.md b/README.md index e372cef3..80a10311 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Commands: open Open Clerk resources in your browser apps Manage your Clerk applications users [options] Manage Clerk users + mcp Manage the Clerk remote MCP server connection for AI editors and CLIs env Manage environment variables config Manage instance configuration enable Enable Clerk features on the linked instance diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index bef0fcce..5e48aec9 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -15,6 +15,7 @@ import { link } from "./commands/link/index.ts"; import { unlink } from "./commands/unlink/index.ts"; import { apps as appsHandlers } from "./commands/apps/index.ts"; import { users as usersHandlers } from "./commands/users/index.ts"; +import { mcp as mcpHandlers, CLIENT_IDS as MCP_CLIENT_IDS } from "./commands/mcp/index.ts"; import { doctor } from "./commands/doctor/index.ts"; import { switchEnv } from "./commands/switch-env/index.ts"; import { openDashboard } from "./commands/open/index.ts"; @@ -466,6 +467,75 @@ export function createProgram() { }), ); + const mcp = program + .command("mcp") + .description("Manage the Clerk remote MCP server connection for AI editors and CLIs") + .setExamples([ + { command: "clerk mcp install", description: "Install into all detected MCP clients" }, + { command: "clerk mcp install --client cursor", description: "Install into Cursor only" }, + { + command: "clerk mcp install --url http://localhost:8787/mcp", + description: "Use a local worker URL", + }, + { command: "clerk mcp list", description: "Show registered Clerk entries" }, + { command: "clerk mcp uninstall", description: "Remove the Clerk entry from all clients" }, + ]); + + mcp + .command("install") + .description("Register the Clerk remote MCP server in supported clients") + .addOption( + createOption("--client ", "MCP client to target (repeatable). Default: all detected.") + .choices([...MCP_CLIENT_IDS]) + .argParser(collectOptionValues) + .default([] as string[]), + ) + .option("--url ", "Override the MCP server URL (default: from active env profile)") + .option("--name ", 'Entry name in the client config (default: "clerk")') + .option("--all", "Install into every detected client without prompting") + .option("--force", "Overwrite an existing entry pointing at a different URL") + .option("--json", "Output as JSON") + .setExamples([ + { + command: "clerk mcp install", + description: "Pick clients interactively (or all in agent mode)", + }, + { command: "clerk mcp install --all", description: "Install into every detected client" }, + { + command: "clerk mcp install --client cursor --client vscode", + description: "Install into specific clients", + }, + { + command: "clerk mcp install --url http://localhost:8787/mcp", + description: "Target a local worker for development", + }, + ]) + .action((options) => mcpHandlers.install(options)); + + mcp + .command("list") + .description("List Clerk MCP entries registered across detected clients") + .option("--json", "Output as JSON") + .setExamples([{ command: "clerk mcp list", description: "List Clerk entries everywhere" }]) + .action((options) => mcpHandlers.list(options)); + + mcp + .command("uninstall") + .description("Remove the Clerk MCP entry from supported clients") + .addOption( + createOption("--client ", "MCP client to target (repeatable). Default: all clients.") + .choices([...MCP_CLIENT_IDS]) + .argParser(collectOptionValues) + .default([] as string[]), + ) + .option("--name ", 'Entry name to remove (default: "clerk")') + .option("--json", "Output as JSON") + .setExamples([ + { command: "clerk mcp uninstall", description: "Remove from every client" }, + { command: "clerk mcp uninstall --client cursor", description: "Remove from Cursor only" }, + ]) + .action((options) => mcpHandlers.uninstall(options)); + const env = program .command("env") .description("Manage environment variables") diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index 6bab3171..1ead10e2 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -25,16 +25,17 @@ clerk doctor --fix # Offer to auto-fix issues ## Checks -| Check | Category | What it verifies | -| --------------------- | -------------- | ------------------------------------------------------------------ | -| Authentication token | Authentication | Credential store has a stored token | -| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | -| Project linkage | Project | Current directory is linked to a Clerk app | -| Linked application | Project | Linked application ID is accessible via the API | -| Instances | Project | Configured dev/prod instance IDs match the application's instances | -| Environment variables | Environment | .env.local or .env has Clerk keys | -| CLI configuration | Configuration | CLI config file exists and parses | -| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | +| Check | Category | What it verifies | +| --------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Authentication token | Authentication | Credential store has a stored token | +| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | +| Project linkage | Project | Current directory is linked to a Clerk app | +| Linked application | Project | Linked application ID is accessible via the API | +| Instances | Project | Configured dev/prod instance IDs match the application's instances | +| Environment variables | Environment | .env.local or .env has Clerk keys | +| CLI configuration | Configuration | CLI config file exists and parses | +| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | +| MCP server | Integration | If a Clerk MCP entry is installed, the configured server answers the `initialize` handshake (skipped otherwise; warns, never fails) | ## Auto-Fix (`--fix`) diff --git a/packages/cli-core/src/commands/doctor/check-mcp.ts b/packages/cli-core/src/commands/doctor/check-mcp.ts new file mode 100644 index 00000000..93b47f5b --- /dev/null +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -0,0 +1,39 @@ +/** + * `clerk doctor` MCP reachability check (folded in from the former + * `clerk mcp doctor` subcommand). + * + * Kept in its own file — rather than `checks.ts` — so the doctor check graph + * doesn't import `mcp/shared.ts` (env profiles, prompts) and the module cycle + * that comes with it. Imports only the light `collect`/`probe` helpers. + */ + +import { collectEntries } from "../mcp/collect.ts"; +import { probeMcp } from "../mcp/probe.ts"; +import type { CheckResult } from "./types.ts"; + +const NAME = "MCP server"; + +export async function checkMcp(): Promise { + // Only meaningful if the user actually registered a Clerk MCP entry — + // otherwise skip silently rather than probing a server they don't use. + const entries = await collectEntries(process.cwd()); + if (entries.length === 0) { + return { name: NAME, status: "pass", message: "Skipped (no Clerk MCP entry installed)" }; + } + + const url = entries[0]!.url; + const result = await probeMcp(url); + if (result.ok) { + return { name: NAME, status: "pass", message: `Reachable — ${result.serverName} (${url})` }; + } + + const detail = + result.error ?? (result.status !== undefined ? `HTTP ${result.status}` : "unknown"); + return { + name: NAME, + status: "warn", + message: `Configured MCP server is not reachable (${url})`, + detail, + remedy: "Verify the server is running, or re-run `clerk mcp install` if the URL changed.", + }; +} diff --git a/packages/cli-core/src/commands/doctor/index.ts b/packages/cli-core/src/commands/doctor/index.ts index c5d6ca25..3e18621b 100644 --- a/packages/cli-core/src/commands/doctor/index.ts +++ b/packages/cli-core/src/commands/doctor/index.ts @@ -16,6 +16,7 @@ import { checkShellCompletion, checkCliVersion, } from "./checks.ts"; +import { checkMcp } from "./check-mcp.ts"; import { formatCheckResult, formatJson } from "./format.ts"; import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts"; @@ -29,6 +30,7 @@ const BASE_CHECKS: CheckFn[] = [ checkEnvVars, checkConfigFile, checkShellCompletion, + checkMcp, ]; function getChecks(): CheckFn[] { diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md new file mode 100644 index 00000000..ec9b176f --- /dev/null +++ b/packages/cli-core/src/commands/mcp/README.md @@ -0,0 +1,77 @@ +# `clerk mcp` + +Manage the Clerk remote MCP server connection in supported AI clients. + +The Clerk MCP server is hosted at `https://mcp.clerk.com/mcp` (source: +[clerk/cloudflare-workers/workers/remote-mcp-server](https://github.com/clerk/cloudflare-workers/tree/main/workers/remote-mcp-server)). +These subcommands register, list, remove, and probe that URL in each client's +own config file. The URL is resolved in order: `--url` > the `CLERK_MCP_URL` +environment variable > the active environment profile's `mcpUrl` field +(`switch-env` carries the profile value automatically). `CLERK_MCP_URL` is the +convenient override when developing the worker locally (e.g. +`http://localhost:8787/mcp`). + +No Clerk API endpoints are called. To verify the server is reachable, run +`clerk doctor` — its MCP check performs the `initialize` handshake against the +configured URL whenever a Clerk MCP entry is installed. + +## Supported clients + +| ID | Client | Scope | Config file | +| ------------- | ------------------------ | ------- | ------------------------------------- | +| `claude-code` | Claude Code | project | `/.mcp.json` | +| `cursor` | Cursor | project | `/.cursor/mcp.json` | +| `vscode` | VS Code (Copilot) | project | `/.vscode/mcp.json` | +| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | +| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | + +## Subcommands + +### `clerk mcp install` + +Register the Clerk MCP server in one or more clients. + +| Flag | Description | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--client ` | Target a specific client. Repeat for multiple. Default in agent mode: all detected. Default in human mode: interactive multiselect over detected clients. | +| `--all` | Install into every detected client without prompting. | +| `--url ` | Override the MCP URL. Defaults to the active env profile's `mcpUrl`. | +| `--name ` | Entry key in the client config. Default: `clerk`. | +| `--force` | Overwrite an entry already pointing at a different URL. Without it, the conflict is reported and skipped. | +| `--json` | Emit a JSON summary on stdout instead of human-formatted output. | + +**Conflict policy:** if an entry with the same `--name` already exists and +points at the same URL, the install is a silent no-op (`status: unchanged`). +If it points at a different URL, the install is skipped with a `reason` +unless `--force` is passed. + +**After install:** writing the config does not connect the server on its own. +In human mode, `install` prints per-client next steps — the server only goes +live once you **reload the editor**. If the server requires authentication, the +editor opens a browser to **sign in** on first connect. Gemini additionally +needs `npx` on `PATH`, since its entry launches `mcp-remote` as a stdio bridge. + +### `clerk mcp list` + +Print every Clerk-flavored MCP entry across all supported clients (entries +named `clerk` or pointing at any `*.clerk.com` host). + +### `clerk mcp uninstall` + +Remove the named entry from each client. Throws `mcp_not_installed` (exit +code 1) when nothing was removed. Removing the entry doesn't drop a live editor +session, so (in human mode) it prints a next step to reload each affected editor. + +> **Reachability:** there is no `mcp doctor` subcommand. Server health is part +> of `clerk doctor`, which probes the configured MCP URL via the `initialize` +> handshake when an entry is installed (warns, does not fail, when unreachable). + +## Error codes + +| Code | Meaning | +| --------------------------- | --------------------------------------------------------------- | +| `mcp_no_client_detected` | No supported client found on the system. | +| `mcp_client_not_supported` | `--client ` is not in the supported list. | +| `mcp_client_config_invalid` | An existing client config file is malformed. | +| `mcp_url_required` | No `--url` provided and the active env profile has no `mcpUrl`. | +| `mcp_not_installed` | `uninstall` removed nothing because no entry matched. | diff --git a/packages/cli-core/src/commands/mcp/clients/claude-code.ts b/packages/cli-core/src/commands/mcp/clients/claude-code.ts new file mode 100644 index 00000000..f5d42b5b --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/claude-code.ts @@ -0,0 +1,22 @@ +/** + * Claude Code MCP client integration. + * + * Writes to `.mcp.json` in the current working directory — the project-scope + * config Claude Code reads automatically. Schema follows the MCP spec's HTTP + * transport form: `{ type: "http", url: "" }`. + */ + +import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { pathExists, projectPath, userPath } from "./paths.ts"; + +export const claudeCodeClient = makeJsonClient({ + id: "claude-code", + displayName: "Claude Code", + scope: "project", + activation: "Restart Claude Code, then run `/mcp` to connect (sign in if prompted).", + topKey: "mcpServers", + encode: (url) => ({ type: "http", url }), + extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + configPath: (cwd) => projectPath(cwd, ".mcp.json"), + detect: () => pathExists(userPath(".claude")), +}); diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts new file mode 100644 index 00000000..5c6bf72e --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { claudeCodeClient } from "./claude-code.ts"; +import { cursorClient } from "./cursor.ts"; +import { vscodeClient } from "./vscode.ts"; +import { windsurfClient } from "./windsurf.ts"; +import { geminiClient } from "./gemini.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; + +useCaptureLog(); + +const URL = "https://mcp.clerk.com/mcp"; + +// Path shape is part of the public contract — each client targets a specific, +// documented config file. Test against the format, not the absolute prefix +// (which depends on cwd/homedir). +const projectClients = [ + { client: claudeCodeClient, suffix: ".mcp.json", topKey: "mcpServers" }, + { client: cursorClient, suffix: join(".cursor", "mcp.json"), topKey: "mcpServers" }, + { client: vscodeClient, suffix: join(".vscode", "mcp.json"), topKey: "servers" }, +]; + +const userClients = [ + { + client: windsurfClient, + relPath: join(".codeium", "windsurf", "mcp_config.json"), + topKey: "mcpServers", + }, + { client: geminiClient, relPath: join(".gemini", "settings.json"), topKey: "mcpServers" }, +]; + +describe("project-scope client config paths", () => { + test.each(projectClients)("$client.id resolves under cwd", ({ client, suffix }) => { + const path = client.configPath("/tmp/foo"); + expect(path).toBe(join("/tmp/foo", suffix)); + expect(client.scope).toBe("project"); + }); +}); + +describe("user-scope client config paths", () => { + test.each(userClients)("$client.id resolves under homedir", ({ client, relPath }) => { + expect(client.configPath("/ignored")).toBe(join(homedir(), relPath)); + expect(client.scope).toBe("user"); + }); +}); + +describe("per-client encoded shape (written JSON)", () => { + // Project clients are easiest to exercise — write into a tmpdir-as-cwd and + // assert what landed under their top-level key. + test.each(projectClients)( + "$client.id writes the expected entry shape", + async ({ client, topKey }) => { + const cwd = await mkdtemp(join(tmpdir(), `clerk-mcp-${client.id}-`)); + try { + await client.upsert({ name: "clerk", url: URL }, cwd, false); + const text = await readFile(client.configPath(cwd), "utf8"); + const parsed = JSON.parse(text) as Record>; + expect(parsed[topKey]).toBeDefined(); + expect(parsed[topKey]?.clerk).toBeDefined(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, + ); + + test("claude-code emits the MCP-spec HTTP transport shape", async () => { + const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cc-shape-")); + try { + await claudeCodeClient.upsert({ name: "clerk", url: URL }, cwd, false); + const parsed = JSON.parse(await readFile(claudeCodeClient.configPath(cwd), "utf8")) as { + mcpServers: { clerk: { type: string; url: string } }; + }; + expect(parsed.mcpServers.clerk).toEqual({ type: "http", url: URL }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("cursor emits a bare {url} entry", async () => { + const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cu-shape-")); + try { + await cursorClient.upsert({ name: "clerk", url: URL }, cwd, false); + const parsed = JSON.parse(await readFile(cursorClient.configPath(cwd), "utf8")) as { + mcpServers: { clerk: { url: string } }; + }; + expect(parsed.mcpServers.clerk).toEqual({ url: URL }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("vscode emits under top-level `servers`, not `mcpServers`", async () => { + const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-vs-shape-")); + try { + await vscodeClient.upsert({ name: "clerk", url: URL }, cwd, false); + const parsed = JSON.parse(await readFile(vscodeClient.configPath(cwd), "utf8")) as { + servers: { clerk: { type: string; url: string } }; + mcpServers?: unknown; + }; + expect(parsed.servers.clerk).toEqual({ type: "http", url: URL }); + expect(parsed.mcpServers).toBeUndefined(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli-core/src/commands/mcp/clients/cursor.ts b/packages/cli-core/src/commands/mcp/clients/cursor.ts new file mode 100644 index 00000000..1a8655ec --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/cursor.ts @@ -0,0 +1,21 @@ +/** + * Cursor MCP client integration. + * + * Writes to `.cursor/mcp.json` in the current working directory. Cursor's + * MCP descriptor is a bare `{ url }` without a `type` discriminator. + */ + +import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { pathExists, projectPath, userPath } from "./paths.ts"; + +export const cursorClient = makeJsonClient({ + id: "cursor", + displayName: "Cursor", + scope: "project", + activation: "Reload Cursor, then enable the server under `Settings → MCP` (sign in if prompted).", + topKey: "mcpServers", + encode: (url) => ({ url }), + extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + configPath: (cwd) => projectPath(cwd, ".cursor", "mcp.json"), + detect: () => pathExists(userPath(".cursor")), +}); diff --git a/packages/cli-core/src/commands/mcp/clients/gemini.ts b/packages/cli-core/src/commands/mcp/clients/gemini.ts new file mode 100644 index 00000000..607b6b8f --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/gemini.ts @@ -0,0 +1,40 @@ +/** + * Gemini Code Assist / Gemini CLI MCP client integration. + * + * Writes to `~/.gemini/settings.json`. Gemini doesn't support HTTP transport + * directly — it requires `mcp-remote` as a stdio bridge, hence the + * `{ command: "npx", args: ["-y", "mcp-remote", ] }` shape. + */ + +import { makeJsonClient } from "./make-json-client.ts"; +import { pathExists, userPath } from "./paths.ts"; + +interface McpRemoteEntry { + command: string; + args: [string, string, string, ...string[]]; +} + +// Match the exact shape we wrote: `{command: "npx", args: ["-y", "mcp-remote", ]}`. +// Permissive matching ("last arg of any args[]") would round-trip unrelated +// stdio bridges as if they were Clerk MCP entries. +function isMcpRemoteEntry(value: unknown): value is McpRemoteEntry { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { command?: unknown; args?: unknown }; + if (candidate.command !== "npx") return false; + if (!Array.isArray(candidate.args) || candidate.args.length < 3) return false; + if (!candidate.args.every((a) => typeof a === "string")) return false; + return candidate.args[0] === "-y" && candidate.args[1] === "mcp-remote"; +} + +export const geminiClient = makeJsonClient({ + id: "gemini", + displayName: "Gemini Code Assist / CLI", + scope: "user", + activation: + "Restart Gemini (needs `npx` on PATH); `mcp-remote` opens a browser to sign in if the server requires it.", + topKey: "mcpServers", + encode: (url) => ({ command: "npx", args: ["-y", "mcp-remote", url] }), + extractUrl: (d) => (isMcpRemoteEntry(d) ? d.args[2] : undefined), + configPath: () => userPath(".gemini", "settings.json"), + detect: () => pathExists(userPath(".gemini")), +}); diff --git a/packages/cli-core/src/commands/mcp/clients/json-config.ts b/packages/cli-core/src/commands/mcp/clients/json-config.ts new file mode 100644 index 00000000..c01cf4e3 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/json-config.ts @@ -0,0 +1,65 @@ +/** + * Shared JSON read/write helper for MCP client configs. + * + * Four of the five supported clients (Claude Code, Cursor, VS Code, Windsurf, + * Gemini) store their MCP servers in a JSON file under a single top-level key + * (`mcpServers` for most, `servers` for VS Code). The entry shape varies + * (`url` vs `serverUrl` vs `command`+`args`) — that's per-client. This module + * only handles the surrounding I/O: read, parse, write back with stable + * formatting and a 2-space indent. + */ + +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { log } from "../../../lib/log.ts"; +import { CliError, ERROR_CODE, errorMessage } from "../../../lib/errors.ts"; + +export interface JsonConfig { + [key: string]: unknown; +} + +export async function readJsonConfig(path: string): Promise { + const file = Bun.file(path); + if (!(await file.exists())) return {}; + const text = await file.text(); + if (text.trim().length === 0) return {}; + try { + const parsed: unknown = JSON.parse(text); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliError(`Config at ${path} is not a JSON object`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } + return parsed as JsonConfig; + } catch (error) { + if (error instanceof CliError) throw error; + throw new CliError(`Could not parse ${path} as JSON: ${errorMessage(error)}`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } +} + +export async function writeJsonConfig(path: string, config: JsonConfig): Promise { + log.debug(`mcp: write ${path}`); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, JSON.stringify(config, null, 2) + "\n"); +} + +/** + * Get an object-typed nested value, returning a fresh empty object if missing. + * Throws MCP_CLIENT_CONFIG_INVALID if the path exists but is not an object. + */ +export function getServerMap( + config: JsonConfig, + key: string, + configPath: string, +): Record { + const existing = config[key]; + if (existing === undefined) return {}; + if (existing === null || typeof existing !== "object" || Array.isArray(existing)) { + throw new CliError(`"${key}" in ${configPath} is not an object`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } + return existing as Record; +} diff --git a/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts b/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts new file mode 100644 index 00000000..7fc14afd --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cursorClient } from "./cursor.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; + +useCaptureLog(); + +const URL_A = "https://mcp.clerk.com/mcp"; +const URL_B = "http://localhost:8787/mcp"; + +describe("make-json-client (via cursor)", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cursor-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + async function read(): Promise<{ + otherTopLevel?: string; + mcpServers?: Record; + }> { + const text = await readFile(join(cwd, ".cursor", "mcp.json"), "utf8"); + return JSON.parse(text); + } + + describe("upsert", () => { + test("creates the config file when it does not exist", async () => { + const result = await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + expect(result.status).toBe("added"); + const written = await read(); + expect(written.mcpServers?.clerk).toEqual({ url: URL_A }); + }); + + test("preserves unrelated keys in the file", async () => { + const configPath = join(cwd, ".cursor", "mcp.json"); + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ otherTopLevel: "keep", mcpServers: { other: { url: "http://x" } } }), + ); + await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + const written = await read(); + expect(written.otherTopLevel).toBe("keep"); + expect(written.mcpServers?.other).toEqual({ url: "http://x" }); + expect(written.mcpServers?.clerk).toEqual({ url: URL_A }); + }); + + test("returns unchanged when the URL already matches", async () => { + await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + const result = await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + expect(result.status).toBe("unchanged"); + }); + + test("skips when URL conflicts and force is false", async () => { + await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + const result = await cursorClient.upsert({ name: "clerk", url: URL_B }, cwd, false); + expect(result.status).toBe("skipped"); + // Narrow the discriminated union so `reason` is typed as present. + if (result.status === "skipped") expect(result.reason).toContain("--force"); + const written = await read(); + expect(written.mcpServers?.clerk?.url).toBe(URL_A); + }); + + test("overwrites when URL conflicts and force is true", async () => { + await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + const result = await cursorClient.upsert({ name: "clerk", url: URL_B }, cwd, true); + expect(result.status).toBe("updated"); + const written = await read(); + expect(written.mcpServers?.clerk?.url).toBe(URL_B); + }); + + test("rejects a config whose top-level is not an object", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "[1,2,3]"); + await expect(cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false)).rejects.toThrow( + /not a JSON object/, + ); + }); + + test("treats an inherited Object property name as absent (no false skip)", async () => { + // `toString` lives on Object.prototype; a naive `servers[name]` lookup would + // read back the inherited function and wrongly report an existing entry. + const result = await cursorClient.upsert({ name: "toString", url: URL_A }, cwd, false); + expect(result.status).toBe("added"); + const written = await read(); + expect(written.mcpServers?.["toString"]).toEqual({ url: URL_A }); + }); + }); + + describe("remove", () => { + test("removes a present entry", async () => { + await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + const result = await cursorClient.remove("clerk", cwd); + expect(result.removed).toBe(true); + const written = await read(); + expect(written.mcpServers?.clerk).toBeUndefined(); + }); + + test("is a no-op when the entry is absent", async () => { + const result = await cursorClient.remove("clerk", cwd); + expect(result.removed).toBe(false); + }); + + test("does not false-remove an inherited Object property name", async () => { + // `"toString" in servers` is true via the prototype; the own-property guard + // must report removed:false rather than rewriting the file. + const result = await cursorClient.remove("toString", cwd); + expect(result.removed).toBe(false); + }); + }); + + describe("list", () => { + test("returns clerk-named and clerk-hosted entries, ignores others", async () => { + const configPath = join(cwd, ".cursor", "mcp.json"); + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ + mcpServers: { + clerk: { url: URL_A }, + "other-clerk": { url: "https://mcp.clerk.com/mcp" }, + unrelated: { url: "https://example.com/mcp" }, + }, + }), + ); + const entries = await cursorClient.list(cwd); + const names = entries.map((e) => e.name).sort(); + expect(names).toEqual(["clerk", "other-clerk"]); + }); + + test("returns empty when no config file exists", async () => { + const entries = await cursorClient.list(cwd); + expect(entries).toEqual([]); + }); + + test("returns empty (does not throw) on malformed JSON", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "not json"); + const entries = await cursorClient.list(cwd); + expect(entries).toEqual([]); + }); + + test("returns empty (does not throw) when the top-level key is not an object", async () => { + // Valid JSON, wrong shape: `mcpServers` is an array. getServerMap throws + // MCP_CLIENT_CONFIG_INVALID, which list() must swallow so one bad config + // can't crash `mcp list` across the other clients. + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + join(cwd, ".cursor", "mcp.json"), + JSON.stringify({ mcpServers: ["not", "an", "object"] }), + ); + const entries = await cursorClient.list(cwd); + expect(entries).toEqual([]); + }); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts new file mode 100644 index 00000000..ebd7fc79 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts @@ -0,0 +1,147 @@ +/** + * Factory for JSON-backed MCP clients. + * + * Five of the supported clients share the same upsert/remove/list shape — a + * JSON file with a top-level object whose keys are server names and whose + * values are per-client server descriptors. The only differences are the + * top-level key name (`mcpServers` vs `servers`) and the descriptor encoding + * (`{ url }` vs `{ serverUrl }` vs `{ command, args }`). This factory captures + * those differences as `topKey` + `encode` + `extractUrl` and reuses the rest. + */ + +import { CliError, ERROR_CODE } from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { getServerMap, readJsonConfig, writeJsonConfig, type JsonConfig } from "./json-config.ts"; +import type { + ClientId, + ListEntry, + McpClient, + McpServerEntry, + RemoveResult, + Scope, + UpsertResult, +} from "./types.ts"; + +export function hasStringProp( + value: unknown, + key: K, +): value is Record { + return ( + typeof value === "object" && + value !== null && + Object.prototype.hasOwnProperty.call(value, key) && + typeof (value as Record)[key] === "string" + ); +} + +interface JsonClientSpec { + id: ClientId; + displayName: string; + scope: Scope; + activation: string; + topKey: string; + /** Encode the per-client server descriptor for this URL. */ + encode: (url: string) => Record; + /** Extract a URL back out of a server descriptor (for `list`). Returns undefined when the shape doesn't match. */ + extractUrl: (descriptor: unknown) => string | undefined; + configPath: (cwd: string) => string; + detect: (cwd: string) => Promise; +} + +function isClerkUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.hostname === "mcp.clerk.com" || parsed.hostname.endsWith(".clerk.com"); + } catch { + return false; + } +} + +export function makeJsonClient(spec: JsonClientSpec): McpClient { + return { + id: spec.id, + displayName: spec.displayName, + scope: spec.scope, + activation: spec.activation, + configPath: spec.configPath, + detect: spec.detect, + + async upsert(entry: McpServerEntry, cwd: string, force: boolean): Promise { + const configPath = spec.configPath(cwd); + const config = await readJsonConfig(configPath); + const servers = getServerMap(config, spec.topKey, configPath); + + // Own-property only: `servers[name]` / `name in servers` would walk the + // prototype chain, so names like `toString` or `constructor` would read + // back an inherited function and falsely look like an existing entry. + const hasExisting = Object.prototype.hasOwnProperty.call(servers, entry.name); + const existing = hasExisting ? servers[entry.name] : undefined; + + if (existing !== undefined) { + const existingUrl = spec.extractUrl(existing); + if (existingUrl === entry.url) { + return { client: spec.id, configPath, status: "unchanged" }; + } + if (!force) { + return { + client: spec.id, + configPath, + status: "skipped", + reason: `entry "${entry.name}" already points at ${existingUrl ?? "another server"} — pass --force to overwrite`, + }; + } + } + + const desired = spec.encode(entry.url); + const next: JsonConfig = { ...config, [spec.topKey]: { ...servers, [entry.name]: desired } }; + await writeJsonConfig(configPath, next); + const status = hasExisting ? "updated" : "added"; + log.debug(`mcp: ${spec.id} ${status} "${entry.name}"`); + return { client: spec.id, configPath, status }; + }, + + async remove(name: string, cwd: string): Promise { + const configPath = spec.configPath(cwd); + const config = await readJsonConfig(configPath); + const servers = getServerMap(config, spec.topKey, configPath); + if (!Object.prototype.hasOwnProperty.call(servers, name)) { + return { client: spec.id, configPath, removed: false }; + } + const { [name]: _removed, ...rest } = servers; + const next: JsonConfig = { ...config, [spec.topKey]: rest }; + await writeJsonConfig(configPath, next); + log.debug(`mcp: ${spec.id} removed "${name}"`); + return { client: spec.id, configPath, removed: true }; + }, + + async list(cwd: string): Promise { + const configPath = spec.configPath(cwd); + // A half-written or structurally-invalid config (unparseable JSON, or a + // non-object `mcpServers`/`servers` value) shouldn't crash `mcp list` + // across the other clients — treat it as "no entries". Both readJsonConfig + // and getServerMap raise MCP_CLIENT_CONFIG_INVALID, so they share one guard. + let servers: Record; + try { + const config = await readJsonConfig(configPath); + servers = getServerMap(config, spec.topKey, configPath); + } catch (error) { + if (error instanceof CliError && error.code === ERROR_CODE.MCP_CLIENT_CONFIG_INVALID) { + // Don't crash `list` across the other clients — but the user must + // know their config was skipped, not silently treated as empty. + log.warn(`${spec.displayName}: ${error.message}`); + return []; + } + throw error; + } + const entries: ListEntry[] = []; + for (const [name, descriptor] of Object.entries(servers)) { + const url = spec.extractUrl(descriptor); + if (!url) continue; + if (name === "clerk" || isClerkUrl(url)) { + entries.push({ client: spec.id, configPath, name, url }); + } + } + return entries; + }, + }; +} diff --git a/packages/cli-core/src/commands/mcp/clients/paths.ts b/packages/cli-core/src/commands/mcp/clients/paths.ts new file mode 100644 index 00000000..a589698d --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/paths.ts @@ -0,0 +1,34 @@ +/** + * Cross-platform path + filesystem helpers for MCP client integrations. + * + * We deliberately avoid OS-specific layout (no XDG, no AppData) — every + * documented client config path is rooted at `~/./` regardless of + * platform, so a single homedir join is enough. + */ + +import { stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export function projectPath(cwd: string, ...segments: string[]): string { + return join(cwd, ...segments); +} + +export function userPath(...segments: string[]): string { + return join(homedir(), ...segments); +} + +/** + * Returns true when *anything* exists at `path` — file, directory, symlink. + * Detection only needs to know "did the user install this tool?", which is + * adequately answered by "does the well-known config dir exist?". A regular + * file at a directory path is impossible in practice for the tools we check. + */ +export async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} diff --git a/packages/cli-core/src/commands/mcp/clients/registry.ts b/packages/cli-core/src/commands/mcp/clients/registry.ts new file mode 100644 index 00000000..109fc6d9 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/registry.ts @@ -0,0 +1,26 @@ +/** + * Registry of supported MCP clients. Order is the display order in the + * human-mode multiselect picker. + */ + +import { claudeCodeClient } from "./claude-code.ts"; +import { cursorClient } from "./cursor.ts"; +import { geminiClient } from "./gemini.ts"; +import type { ClientId, McpClient } from "./types.ts"; +import { vscodeClient } from "./vscode.ts"; +import { windsurfClient } from "./windsurf.ts"; + +export const CLIENTS: readonly McpClient[] = [ + claudeCodeClient, + cursorClient, + vscodeClient, + windsurfClient, + geminiClient, +]; + +export const CLIENT_IDS: readonly ClientId[] = CLIENTS.map((c) => c.id); + +export async function detectInstalledClients(cwd: string): Promise { + const flags = await Promise.all(CLIENTS.map((c) => c.detect(cwd))); + return CLIENTS.filter((_, i) => flags[i]); +} diff --git a/packages/cli-core/src/commands/mcp/clients/types.ts b/packages/cli-core/src/commands/mcp/clients/types.ts new file mode 100644 index 00000000..5a05e3fc --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/types.ts @@ -0,0 +1,65 @@ +/** + * Shared types for MCP client integrations. + * + * Each supported MCP client (Claude Code, Cursor, VS Code, Windsurf, Gemini) + * exposes an {@link McpClient} that knows how to read, upsert, and remove + * the `clerk` server entry in its own config file format. + */ + +export type ClientId = "claude-code" | "cursor" | "vscode" | "windsurf" | "gemini"; + +/** Where the client config file lives relative to the user / project. */ +export type Scope = "project" | "user"; + +export interface McpServerEntry { + /** Entry name (key under `mcpServers` / `servers`). Default: `clerk`. */ + name: string; + /** Remote MCP endpoint. */ + url: string; +} + +export type DiffStatus = "added" | "updated" | "unchanged" | "skipped"; + +/** + * `reason` is present iff the entry was skipped — the union makes the + * "reason only on skip" contract a compile-time guarantee instead of a comment. + */ +export type UpsertResult = + | { client: ClientId; configPath: string; status: Exclude } + | { client: ClientId; configPath: string; status: "skipped"; reason: string }; + +export interface RemoveResult { + client: ClientId; + configPath: string; + removed: boolean; +} + +export interface ListEntry { + client: ClientId; + configPath: string; + name: string; + url: string; +} + +export interface McpClient { + id: ClientId; + displayName: string; + scope: Scope; + /** + * What the user must do *after* the config is written for this client to + * connect — typically reload the editor, and sign in if the server requires + * it. Writing the file is not enough on its own, so `install` surfaces this + * as a next step. + */ + activation: string; + /** Where the entry would be written for the given cwd. */ + configPath(cwd: string): string; + /** Heuristic: is this client installed for this user? */ + detect(cwd: string): Promise; + /** Add or update the `name` entry pointing at `url`. */ + upsert(entry: McpServerEntry, cwd: string, force: boolean): Promise; + /** Remove the `name` entry. */ + remove(name: string, cwd: string): Promise; + /** List `clerk`-flavored entries currently registered (those pointing at clerk.com URLs or named explicitly). */ + list(cwd: string): Promise; +} diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts new file mode 100644 index 00000000..d8548d45 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import * as realOs from "node:os"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; + +// Gemini and Windsurf write under the user's home (`~/.gemini`, `~/.codeium`), +// so their encode/extract logic can't be exercised through a cwd tmpdir like the +// project-scope clients. Bun's os.homedir() ignores $HOME, so redirect it via the +// module mock instead — registered at file top, before the clients are imported. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + +const mockIsAgent = mock(); +mock.module("../../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +const { geminiClient } = await import("./gemini.ts"); +const { windsurfClient } = await import("./windsurf.ts"); +const { mcpInstall } = await import("../install.ts"); +const { mcpUninstall } = await import("../uninstall.ts"); +const { checkMcp } = await import("../../doctor/check-mcp.ts"); + +const captured = useCaptureLog(); + +const URL = "https://mcp.clerk.com/mcp"; +const ALL_CLIENT_IDS = ["claude-code", "cursor", "vscode", "windsurf", "gemini"]; + +describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { + beforeEach(async () => { + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-home-")); + }); + + afterEach(async () => { + await rm(mockHome, { recursive: true, force: true }); + }); + + describe("gemini", () => { + test("encodes the mcp-remote stdio-bridge shape", async () => { + await geminiClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const parsed = JSON.parse(await readFile(geminiClient.configPath("/ignored"), "utf8")) as { + mcpServers: { clerk: { command: string; args: string[] } }; + }; + expect(parsed.mcpServers.clerk).toEqual({ command: "npx", args: ["-y", "mcp-remote", URL] }); + }); + + test("round-trips the URL back out of args[2] on list", async () => { + await geminiClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const entries = await geminiClient.list("/ignored"); + expect(entries).toEqual([ + expect.objectContaining({ client: "gemini", name: "clerk", url: URL }), + ]); + }); + + test("ignores a foreign npx entry that is not an mcp-remote bridge", async () => { + // Only `{command:"npx", args:["-y","mcp-remote", ]}` is ours; an + // unrelated npx tool must not round-trip as a Clerk MCP entry. + const dir = join(mockHome, ".gemini"); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, "settings.json"), + JSON.stringify({ + mcpServers: { + clerk: { command: "npx", args: ["-y", "mcp-remote", URL] }, + "other-tool": { command: "npx", args: ["serve", "--port", "3000"] }, + }, + }), + ); + const entries = await geminiClient.list("/ignored"); + expect(entries.map((e) => e.name)).toEqual(["clerk"]); + }); + }); + + describe("windsurf", () => { + test("encodes the serverUrl shape and round-trips it on list", async () => { + await windsurfClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const parsed = JSON.parse(await readFile(windsurfClient.configPath("/ignored"), "utf8")) as { + mcpServers: { clerk: { serverUrl: string } }; + }; + expect(parsed.mcpServers.clerk).toEqual({ serverUrl: URL }); + + const entries = await windsurfClient.list("/ignored"); + expect(entries).toEqual([ + expect.objectContaining({ client: "windsurf", name: "clerk", url: URL }), + ]); + }); + }); +}); + +// These exercise the command-level "all clients" defaults, which touch the +// user-scoped clients (gemini, windsurf) — so they live here, alongside the +// single homedir redirect, rather than in a second file that re-mocks node:os. +describe("install/uninstall across all clients (homedir + cwd redirected)", () => { + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-all-cwd-")); + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-all-home-")); + process.chdir(cwd); + mockIsAgent.mockReturnValue(true); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + await rm(mockHome, { recursive: true, force: true }); + mockIsAgent.mockReset(); + }); + + test("install --all targets every detected client", async () => { + // detect() keys off each client's marker directory under home. + await Promise.all([ + mkdir(join(mockHome, ".claude"), { recursive: true }), + mkdir(join(mockHome, ".cursor"), { recursive: true }), + mkdir(join(mockHome, ".vscode"), { recursive: true }), + mkdir(join(mockHome, ".codeium", "windsurf"), { recursive: true }), + mkdir(join(mockHome, ".gemini"), { recursive: true }), + ]); + + await mcpInstall({ all: true, url: URL }); + + const payload = JSON.parse(captured.out) as { results: { client: string; status: string }[] }; + expect(payload.results.map((r) => r.client).sort()).toEqual([...ALL_CLIENT_IDS].sort()); + expect(payload.results.every((r) => r.status === "added")).toBe(true); + }); + + test("uninstall with no --client removes from every client", async () => { + await mcpInstall({ client: ["cursor", "gemini"], url: URL }); + captured.clear(); + + await mcpUninstall({}); + + const payload = JSON.parse(captured.out) as { results: { client: string; removed: boolean }[] }; + expect(payload.results.map((r) => r.client).sort()).toEqual([...ALL_CLIENT_IDS].sort()); + expect(payload.results.find((r) => r.client === "cursor")?.removed).toBe(true); + expect(payload.results.find((r) => r.client === "gemini")?.removed).toBe(true); + }); +}); + +// `clerk doctor`'s MCP check (folded in from the former `clerk mcp doctor`). +// Lives here because it scans the user-scoped clients, needing the homedir +// redirect; a second os-mocking file would collide in the single-process runner. +describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { + let cwd: string; + let originalCwd: string; + const originalFetch = globalThis.fetch; + + // Assign globalThis.fetch directly (cast to its own type) rather than via the + // typed stubFetch helper: this file imports node:* which pulls in undici's + // `Response` type, and stubFetch expects Bun's — the two aren't assignable. + function stubFetchWith(body: string, init: ResponseInit): void { + globalThis.fetch = (async () => new Response(body, init)) as unknown as typeof globalThis.fetch; + } + + const HANDSHAKE_BODY = `event: message\ndata: {"result":{"serverInfo":{"name":"Clerk MCP Server"}},"jsonrpc":"2.0","id":1}\n\n`; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-check-cwd-")); + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-check-home-")); + process.chdir(cwd); + mockIsAgent.mockReturnValue(true); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + await rm(mockHome, { recursive: true, force: true }); + globalThis.fetch = originalFetch; + mockIsAgent.mockReset(); + }); + + test("passes (skipped) when no MCP entry is installed", async () => { + const result = await checkMcp(); + expect(result.status).toBe("pass"); + expect(result.message).toContain("Skipped"); + }); + + test("passes when the installed MCP server answers the handshake", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + captured.clear(); + stubFetchWith(HANDSHAKE_BODY, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + + const result = await checkMcp(); + expect(result.status).toBe("pass"); + expect(result.message).toContain("Reachable"); + }); + + test("warns when the installed MCP server is unreachable", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + captured.clear(); + stubFetchWith("nope", { status: 503 }); + + const result = await checkMcp(); + expect(result.status).toBe("warn"); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/clients/vscode.ts b/packages/cli-core/src/commands/mcp/clients/vscode.ts new file mode 100644 index 00000000..60b7ecde --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/vscode.ts @@ -0,0 +1,23 @@ +/** + * VS Code (Copilot) MCP client integration. + * + * Writes to `.vscode/mcp.json` in the current working directory. VS Code uses + * the top-level key `servers` (not `mcpServers`) and the HTTP transport form + * `{ type: "http", url }`. + */ + +import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { pathExists, projectPath, userPath } from "./paths.ts"; + +export const vscodeClient = makeJsonClient({ + id: "vscode", + displayName: "VS Code", + scope: "project", + activation: + "Reload the VS Code window, then start the server from `MCP: List Servers` (sign in if prompted).", + topKey: "servers", + encode: (url) => ({ type: "http", url }), + extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + configPath: (cwd) => projectPath(cwd, ".vscode", "mcp.json"), + detect: () => pathExists(userPath(".vscode")), +}); diff --git a/packages/cli-core/src/commands/mcp/clients/windsurf.ts b/packages/cli-core/src/commands/mcp/clients/windsurf.ts new file mode 100644 index 00000000..bca239f2 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/windsurf.ts @@ -0,0 +1,21 @@ +/** + * Windsurf MCP client integration. + * + * Writes to `~/.codeium/windsurf/mcp_config.json` (user scope). Server + * descriptor uses `serverUrl`, not `url`. + */ + +import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { pathExists, userPath } from "./paths.ts"; + +export const windsurfClient = makeJsonClient({ + id: "windsurf", + displayName: "Windsurf", + scope: "user", + activation: "Reload Windsurf, then turn on the server in `Cascade → MCP` (sign in if prompted).", + topKey: "mcpServers", + encode: (url) => ({ serverUrl: url }), + extractUrl: (d) => (hasStringProp(d, "serverUrl") ? d.serverUrl : undefined), + configPath: () => userPath(".codeium", "windsurf", "mcp_config.json"), + detect: () => pathExists(userPath(".codeium", "windsurf")), +}); diff --git a/packages/cli-core/src/commands/mcp/collect.ts b/packages/cli-core/src/commands/mcp/collect.ts new file mode 100644 index 00000000..14dc2820 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/collect.ts @@ -0,0 +1,28 @@ +/** + * Aggregate Clerk MCP entries across every supported client. + * + * Deliberately light on imports (just the client registry) so it can be reused + * by `clerk doctor`'s MCP check without dragging in `shared.ts`'s heavier graph + * (env profiles, interactive prompts) and the module cycle that comes with it. + * Each client's `list` already swallows-and-warns on its own malformed config, + * so a single bad client can't sink the aggregate. + */ + +import { errorMessage } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { CLIENTS } from "./clients/registry.ts"; +import type { ListEntry } from "./clients/types.ts"; + +export async function collectEntries(cwd: string): Promise { + const settled = await Promise.allSettled(CLIENTS.map((c) => c.list(cwd))); + return settled.flatMap((outcome, i) => { + if (outcome.status === "fulfilled") return outcome.value; + // A client's own `list` already warns on its malformed config; reaching the + // rejected branch means an unexpected error (e.g. an unreadable file), so + // surface it instead of silently dropping that client. + log.warn( + `${CLIENTS[i]!.displayName}: could not read MCP config — ${errorMessage(outcome.reason)}`, + ); + return []; + }); +} diff --git a/packages/cli-core/src/commands/mcp/index.ts b/packages/cli-core/src/commands/mcp/index.ts new file mode 100644 index 00000000..462f827c --- /dev/null +++ b/packages/cli-core/src/commands/mcp/index.ts @@ -0,0 +1,10 @@ +import { mcpInstall } from "./install.ts"; +import { mcpList } from "./list.ts"; +import { mcpUninstall } from "./uninstall.ts"; + +export const mcp = { + install: mcpInstall, + list: mcpList, + uninstall: mcpUninstall, +}; +export { CLIENT_IDS } from "./clients/registry.ts"; diff --git a/packages/cli-core/src/commands/mcp/install.test.ts b/packages/cli-core/src/commands/mcp/install.test.ts new file mode 100644 index 00000000..8245b618 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +const { mcpInstall } = await import("./install.ts"); + +const URL_A = "https://mcp.clerk.com/mcp"; +const URL_B = "http://localhost:8787/mcp"; + +describe("mcp install", () => { + const captured = useCaptureLog(); + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-install-")); + process.chdir(cwd); + mockIsAgent.mockReturnValue(false); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + mockIsAgent.mockReset(); + }); + + test("writes the Cursor config when --client cursor is passed", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A }); + + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: { clerk: { url: string } }; + }; + expect(parsed.mcpServers.clerk).toEqual({ url: URL_A }); + }); + + test("emits JSON to stdout in agent mode and skips intro/outro", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"], url: URL_A }); + + const payload = JSON.parse(captured.out) as { + url: string; + name: string; + results: { client: string; status: string }[]; + }; + expect(payload.url).toBe(URL_A); + expect(payload.name).toBe("clerk"); + expect(payload.results).toEqual([ + expect.objectContaining({ client: "cursor", status: "added" }), + ]); + expect(captured.err).not.toContain("┌"); // intro suppressed in agent mode + }); + + test("--json forces JSON output even in human mode", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A, json: true }); + expect(() => JSON.parse(captured.out)).not.toThrow(); + }); + + test("returns `unchanged` on idempotent re-install", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"], url: URL_A }); + captured.clear(); + await mcpInstall({ client: ["cursor"], url: URL_A }); + + const payload = JSON.parse(captured.out) as { + results: { status: string }[]; + }; + expect(payload.results[0]?.status).toBe("unchanged"); + }); + + test("skips with reason on URL conflict without --force", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"], url: URL_A }); + captured.clear(); + await mcpInstall({ client: ["cursor"], url: URL_B }); + + const payload = JSON.parse(captured.out) as { + results: { status: string; reason?: string }[]; + }; + expect(payload.results[0]?.status).toBe("skipped"); + expect(payload.results[0]?.reason).toContain("--force"); + }); + + test("overwrites on URL conflict with --force", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"], url: URL_A }); + captured.clear(); + await mcpInstall({ client: ["cursor"], url: URL_B, force: true }); + + const payload = JSON.parse(captured.out) as { + results: { status: string }[]; + }; + expect(payload.results[0]?.status).toBe("updated"); + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: { clerk: { url: string } }; + }; + expect(parsed.mcpServers.clerk.url).toBe(URL_B); + }); + + test("uses --name to customize the entry key", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A, name: "clerk-staging" }); + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(parsed.mcpServers["clerk-staging"]).toEqual({ url: URL_A }); + expect(parsed.mcpServers.clerk).toBeUndefined(); + }); + + test("uses the active env profile URL when --url is not given", async () => { + // Default production profile has mcpUrl=https://mcp.clerk.com/mcp. + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"] }); + const payload = JSON.parse(captured.out) as { url: string }; + expect(payload.url).toBe("https://mcp.clerk.com/mcp"); + }); + + test.each([ + ["file:///etc/passwd"], + ["data:text/plain,clerk"], + ["javascript:alert(1)"], + ["ftp://example.com/mcp"], + ])("rejects non-http(s) URL: %s", async (badUrl) => { + await expect(mcpInstall({ client: ["cursor"], url: badUrl })).rejects.toMatchObject({ + code: "mcp_url_required", + }); + }); + + test("rejects unparseable URL", async () => { + await expect(mcpInstall({ client: ["cursor"], url: "not a url" })).rejects.toMatchObject({ + code: "mcp_url_required", + }); + }); + + test("prints next steps with a sign-in reminder after a human-mode install", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A }); + expect(captured.err).toContain("Next steps:"); + expect(captured.err).toContain("Reload Cursor"); + expect(captured.err).toContain("sign in"); + }); + + test("omits next steps from JSON output", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A, json: true }); + expect(captured.err).not.toContain("Next steps:"); + }); + + test("does not print next steps when the entry was unchanged", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A }); + captured.clear(); + await mcpInstall({ client: ["cursor"], url: URL_A }); + expect(captured.err).not.toContain("Next steps:"); + }); + + test("rejects an unknown --client id", async () => { + await expect(mcpInstall({ client: ["bogus"], url: URL_A })).rejects.toMatchObject({ + code: "mcp_client_not_supported", + }); + }); + + test("installs the healthy clients and warns when one config is corrupt", async () => { + mockIsAgent.mockReturnValue(true); + // Pre-corrupt Cursor's config; Claude Code's is absent (clean). + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "{ not json"); + + await mcpInstall({ client: ["cursor", "claude-code"], url: URL_A }); + + const payload = JSON.parse(captured.out) as { results: { client: string; status: string }[] }; + expect(payload.results).toEqual([ + expect.objectContaining({ client: "claude-code", status: "added" }), + ]); + expect(captured.err).toContain("Cursor"); // per-client warning for the failure + }); + + test("throws when every targeted client fails", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "{ not json"); + + await expect(mcpInstall({ client: ["cursor"], url: URL_A })).rejects.toMatchObject({ + code: "mcp_client_config_invalid", + }); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts new file mode 100644 index 00000000..a31ef6cb --- /dev/null +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -0,0 +1,102 @@ +/** + * `clerk mcp install` — register the Clerk remote MCP server in supported clients. + * + * URL resolution: `--url` > active env profile `mcpUrl` > error. + * Target clients: `--client ` (repeatable) > `--all` > human picker > all detected (agent mode). + * Conflict policy: same URL → unchanged; different URL → skip unless `--force`. + */ + +import { log } from "../../lib/log.ts"; +import { cyan, dim, green, yellow } from "../../lib/color.ts"; +import { isAgent, isHuman } from "../../mode.ts"; +import { intro, outro } from "../../lib/spinner.ts"; +import { + pickClients, + printNextSteps, + resolveName, + resolveUrl, + settleClients, + targetClients, + wantsJson, + type McpOptions, +} from "./shared.ts"; +import { detectInstalledClients } from "./clients/registry.ts"; +import type { McpClient, UpsertResult } from "./clients/types.ts"; + +async function chooseClients(options: McpOptions, cwd: string): Promise { + if (options.client?.length || options.all || isAgent()) return targetClients(options, cwd); + return pickClients(await detectInstalledClients(cwd)); +} + +function statusLabel(status: UpsertResult["status"]): string { + switch (status) { + case "added": + return green("added"); + case "updated": + return green("updated"); + case "unchanged": + return dim("unchanged"); + case "skipped": + return yellow("skipped"); + } +} + +function printResult(client: McpClient, result: UpsertResult): void { + const label = `${client.displayName} → ${dim(result.configPath)}`; + if (result.status === "skipped") { + log.warn(`${label}: ${statusLabel(result.status)} (${result.reason})`); + return; + } + log.info(`${label}: ${statusLabel(result.status)}`); +} + +type ClientUpsert = { client: McpClient; result: UpsertResult }; + +// Writing the config isn't enough — the editor must reload before it connects +// (and sign in, if the server requires it). Surface that for every client we +// just wrote, so "added" doesn't read as "done and working". +function printInstallNextSteps(settled: ClientUpsert[]): void { + const activated = settled.filter( + ({ result }) => result.status === "added" || result.status === "updated", + ); + if (activated.length === 0) return; + + printNextSteps(activated.map(({ client }) => `${client.displayName}: ${client.activation}`)); + log.info( + dim( + "If the server requires authentication, your editor opens a browser to sign in on first connect.", + ), + ); +} + +export async function mcpInstall(options: McpOptions = {}): Promise { + const url = resolveUrl(options); + const name = resolveName(options); + const cwd = process.cwd(); + const clients = await chooseClients(options, cwd); + const force = Boolean(options.force); + const json = wantsJson(options); + + if (clients.length === 0 && json) { + log.data(JSON.stringify({ url, name, results: [] }, null, 2)); + return; + } + if (clients.length === 0) { + log.warn("No MCP clients selected."); + return; + } + + if (isHuman() && !json) intro(`Installing Clerk MCP (${cyan(url)})`); + + const settled = await settleClients(clients, (c) => c.upsert({ name, url }, cwd, force)); + const results = settled.map((s) => s.result); + + if (json) { + log.data(JSON.stringify({ url, name, results }, null, 2)); + return; + } + + if (isHuman()) settled.forEach(({ client, result }) => printResult(client, result)); + printInstallNextSteps(settled); + outro("Done"); +} diff --git a/packages/cli-core/src/commands/mcp/list.test.ts b/packages/cli-core/src/commands/mcp/list.test.ts new file mode 100644 index 00000000..d2ab7ffe --- /dev/null +++ b/packages/cli-core/src/commands/mcp/list.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +const { mcpInstall } = await import("./install.ts"); +const { mcpList } = await import("./list.ts"); + +const URL = "https://mcp.clerk.com/mcp"; + +describe("mcp list", () => { + const captured = useCaptureLog(); + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-list-")); + process.chdir(cwd); + mockIsAgent.mockReturnValue(true); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + mockIsAgent.mockReset(); + }); + + test("returns an empty JSON array when no clients have entries", async () => { + await mcpList({}); + expect(JSON.parse(captured.out)).toEqual([]); + }); + + test("returns the cursor entry after install", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + captured.clear(); + await mcpList({}); + const payload = JSON.parse(captured.out) as { + client: string; + name: string; + url: string; + }[]; + expect(payload).toEqual([ + expect.objectContaining({ client: "cursor", name: "clerk", url: URL }), + ]); + }); + + test("human-mode empty state writes the hint to stderr, nothing to stdout", async () => { + mockIsAgent.mockReturnValue(false); + await mcpList({}); + expect(captured.out).toBe(""); + expect(captured.err).toContain("No Clerk MCP entries"); + }); + + test("human-mode prints the formatted table to stdout after an install", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"], url: URL }); + mockIsAgent.mockReturnValue(false); + captured.clear(); + await mcpList({}); + + expect(captured.out).toContain("cursor"); + expect(captured.out).toContain("clerk"); + expect(captured.out).toContain(URL); + expect(captured.err).toContain("1 entry"); + expect(captured.err).toContain("Next steps:"); + expect(captured.err).toContain("clerk doctor"); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/list.ts b/packages/cli-core/src/commands/mcp/list.ts new file mode 100644 index 00000000..26e7e951 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/list.ts @@ -0,0 +1,54 @@ +/** + * `clerk mcp list` — show Clerk-named entries across detected MCP clients. + * + * Walks the registry, reads each client's config (if present), and reports + * any entry whose name is `clerk` or whose URL hostname is under `clerk.com`. + */ + +import { cyan, dim } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import type { ListEntry } from "./clients/types.ts"; +import { collectEntries } from "./collect.ts"; +import { printNextSteps, wantsJson, type McpOptions } from "./shared.ts"; + +const COLUMN_PADDING = 2; + +function formatTable(entries: ListEntry[]): void { + const clientWidth = + Math.max("CLIENT".length, ...entries.map((e) => e.client.length)) + COLUMN_PADDING; + const nameWidth = Math.max("NAME".length, ...entries.map((e) => e.name.length)) + COLUMN_PADDING; + const urlWidth = Math.max("URL".length, ...entries.map((e) => e.url.length)) + COLUMN_PADDING; + + log.data( + dim(`${"CLIENT".padEnd(clientWidth)}${"NAME".padEnd(nameWidth)}${"URL".padEnd(urlWidth)}PATH`), + ); + for (const entry of entries) { + const client = cyan(entry.client.padEnd(clientWidth)); + const name = entry.name.padEnd(nameWidth); + const url = entry.url.padEnd(urlWidth); + log.data(`${client}${name}${url}${dim(entry.configPath)}`); + } +} + +export async function mcpList(options: McpOptions = {}): Promise { + const cwd = process.cwd(); + const all: ListEntry[] = await collectEntries(cwd); + + if (wantsJson(options)) { + log.data(JSON.stringify(all, null, 2)); + return; + } + + if (all.length === 0) { + log.warn("No Clerk MCP entries found. Run `clerk mcp install` to register one."); + return; + } + + formatTable(all); + log.info(`\n${all.length} entr${all.length === 1 ? "y" : "ies"}`); + + printNextSteps([ + "Verify a server is reachable with `clerk doctor`.", + "Remove an entry with `clerk mcp uninstall`.", + ]); +} diff --git a/packages/cli-core/src/commands/mcp/probe.test.ts b/packages/cli-core/src/commands/mcp/probe.test.ts new file mode 100644 index 00000000..5f631ae9 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/probe.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import { probeMcp } from "./probe.ts"; + +const URL = "https://mcp.clerk.com/mcp"; + +const INITIALIZE_RESULT = { + result: { serverInfo: { name: "Clerk MCP Server", version: "0.0.0" } }, + jsonrpc: "2.0", + id: 1, +}; + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function sseResponse(payload: unknown): Response { + return new Response(`event: message\ndata: ${JSON.stringify(payload)}\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function sse(body: string): Response { + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +describe("probeMcp", () => { + useCaptureLog(); + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test.each([ + ["text/event-stream", sseResponse(INITIALIZE_RESULT)], + ["application/json", jsonResponse(INITIALIZE_RESULT)], + ])("returns ok with the server name via %s", async (_label, response) => { + stubFetch(async () => response); + expect(await probeMcp(URL)).toEqual({ ok: true, status: 200, serverName: "Clerk MCP Server" }); + }); + + test("POSTs an initialize handshake", async () => { + let method = ""; + let body = ""; + stubFetch(async (_input, init) => { + method = init?.method ?? ""; + body = typeof init?.body === "string" ? init.body : ""; + return sseResponse(INITIALIZE_RESULT); + }); + await probeMcp(URL); + expect(method).toBe("POST"); + expect(body).toContain('"method":"initialize"'); + }); + + test("parses an SSE frame with CRLF line endings", async () => { + stubFetch(async () => + sse(`event: message\r\ndata: ${JSON.stringify(INITIALIZE_RESULT)}\r\n\r\n`), + ); + expect((await probeMcp(URL)).ok).toBe(true); + }); + + test("reassembles an SSE payload split across multiple data lines", async () => { + stubFetch(async () => + sse( + `event: message\n` + + `data: {"result":{"serverInfo":{"name":"Clerk MCP Server"}},\n` + + `data: "jsonrpc":"2.0","id":1}\n\n`, + ), + ); + expect(await probeMcp(URL)).toMatchObject({ ok: true, serverName: "Clerk MCP Server" }); + }); + + test("fails when the SSE frame has no data line", async () => { + stubFetch(async () => sse("event: message\n\n")); + expect(await probeMcp(URL)).toMatchObject({ ok: false }); + }); + + test("fails when the SSE data line is malformed JSON", async () => { + stubFetch(async () => sse("event: message\ndata: {broken\n\n")); + expect(await probeMcp(URL)).toMatchObject({ ok: false }); + }); + + test("fails when 200 but not an MCP initialize result", async () => { + stubFetch(async () => jsonResponse({ hello: "world" })); + expect(await probeMcp(URL)).toMatchObject({ ok: false, status: 200 }); + }); + + test("fails on non-2xx, carrying the status", async () => { + stubFetch(async () => new Response("nope", { status: 404 })); + expect(await probeMcp(URL)).toEqual({ ok: false, status: 404 }); + }); + + test("fails on a network error, carrying the message", async () => { + stubFetch(async () => { + throw new Error("ECONNREFUSED"); + }); + expect(await probeMcp(URL)).toMatchObject({ + ok: false, + error: expect.stringContaining("ECONNREFUSED"), + }); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/probe.ts b/packages/cli-core/src/commands/mcp/probe.ts new file mode 100644 index 00000000..09c922fa --- /dev/null +++ b/packages/cli-core/src/commands/mcp/probe.ts @@ -0,0 +1,94 @@ +/** + * MCP `initialize` handshake probe. + * + * Performs the JSON-RPC `initialize` call every MCP client makes and confirms + * the server answers with a result carrying `serverInfo` — the actual MCP + * contract, independent of any OAuth-metadata side channel. Used by the + * `clerk doctor` MCP health check. Returns a result rather than throwing so the + * caller can fold it into a `CheckResult`. + */ + +import { errorMessage } from "../../lib/errors.ts"; +import { loggedFetch } from "../../lib/fetch.ts"; +import { DEV_CLI_VERSION, resolveCliVersion } from "../../lib/version.ts"; + +// Discriminated on `ok`: a healthy probe always carries a server name; a failed +// one never does. "ok but no serverName" is unrepresentable. +export type McpProbeResult = + | { ok: true; status: number; serverName: string } + | { ok: false; status?: number; error?: string }; + +// A hostile or wrong URL shouldn't hang the CLI: cap the probe so a slow or +// never-ending response surfaces as a failure instead of blocking forever. +const PROBE_TIMEOUT_MS = 10_000; + +const INITIALIZE_REQUEST = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "clerk-cli", version: resolveCliVersion() ?? DEV_CLI_VERSION }, + }, +}; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +// The streamable-HTTP transport answers `initialize` as either application/json +// or a text/event-stream frame (`event: message\ndata: {…}`). Pull the JSON-RPC +// payload out of whichever the server returned. For SSE, reassemble the first +// event's `data:` lines (the spec allows a payload to span several). +function parseHandshake(contentType: string, body: string): unknown { + if (!contentType.includes("text/event-stream")) return safeJsonParse(body); + const firstEvent = body.split(/\r?\n\r?\n/)[0] ?? ""; + const data = firstEvent + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .join("\n"); + return data === "" ? undefined : safeJsonParse(data); +} + +// A valid `initialize` result carries `serverInfo.name`; its presence is what +// distinguishes a real MCP server from a URL that merely returns 200. +function readServerName(payload: unknown): string | undefined { + if (typeof payload !== "object" || payload === null) return undefined; + const result = (payload as { result?: unknown }).result; + if (typeof result !== "object" || result === null) return undefined; + const serverInfo = (result as { serverInfo?: unknown }).serverInfo; + if (typeof serverInfo !== "object" || serverInfo === null) return undefined; + const name = (serverInfo as { name?: unknown }).name; + return typeof name === "string" ? name : undefined; +} + +export async function probeMcp(url: string): Promise { + try { + const response = await loggedFetch(url, { + tag: "mcp", + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify(INITIALIZE_REQUEST), + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + if (!response.ok) return { ok: false, status: response.status }; + + const contentType = response.headers.get("content-type") ?? ""; + const serverName = readServerName(parseHandshake(contentType, await response.text())); + if (serverName === undefined) { + return { ok: false, status: response.status, error: "no MCP initialize result" }; + } + return { ok: true, status: response.status, serverName }; + } catch (error) { + return { ok: false, error: errorMessage(error) }; + } +} diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts new file mode 100644 index 00000000..f580b650 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -0,0 +1,147 @@ +/** + * Shared options and helpers for `clerk mcp` subcommands. + */ + +import { checkbox } from "@inquirer/prompts"; +import { ttyContext } from "../../lib/listage.ts"; +import { getMcpUrl } from "../../lib/environment.ts"; +import { CliError, ERROR_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; +import { CLIENT_IDS, CLIENTS, detectInstalledClients } from "./clients/registry.ts"; +import type { ClientId, McpClient } from "./clients/types.ts"; + +export type McpOptions = { + json?: boolean; + url?: string; + name?: string; + /** Raw client IDs from the CLI. Validated through {@link resolveClients}. */ + client?: string[]; + all?: boolean; + force?: boolean; +}; + +export const DEFAULT_ENTRY_NAME = "clerk"; + +export function resolveUrl(options: McpOptions): string { + const candidate = options.url ?? getMcpUrl(); + if (!candidate) { + throw new CliError( + "No MCP URL available. Set one with `--url`, or switch to an environment whose profile defines `mcpUrl`.", + { code: ERROR_CODE.MCP_URL_REQUIRED }, + ); + } + // Reject non-network schemes so a stray `file:` or `data:` URL can't be + // written into an editor's MCP config or probed by `doctor` via fetch. + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + throwUsageError(`Invalid MCP URL "${candidate}".`, undefined, ERROR_CODE.MCP_URL_REQUIRED); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throwUsageError( + `MCP URL must use http or https — got "${parsed.protocol}".`, + undefined, + ERROR_CODE.MCP_URL_REQUIRED, + ); + } + return candidate; +} + +export function resolveName(options: McpOptions): string { + return options.name ?? DEFAULT_ENTRY_NAME; +} + +export function resolveClients(ids: readonly string[]): McpClient[] { + const byId = new Map(CLIENTS.map((c) => [c.id, c])); + return ids.map((id) => { + const client = byId.get(id); + if (!client) { + throwUsageError( + `Unknown MCP client "${id}". Supported: ${CLIENT_IDS.join(", ")}.`, + undefined, + ERROR_CODE.MCP_CLIENT_NOT_SUPPORTED, + ); + } + return client; + }); +} + +export async function pickClients(detected: McpClient[]): Promise { + if (detected.length === 0) return []; + if (detected.length === 1) return detected; + const tty = ttyContext(); + try { + const selected = await checkbox( + { + message: "Select MCP clients to install into:", + choices: detected.map((c) => ({ + name: `${c.displayName} (${c.scope})`, + value: c.id, + checked: true, + })), + required: true, + }, + tty ? { input: tty.input } : undefined, + ); + return resolveClients(selected); + } finally { + tty?.close(); + } +} + +export async function targetClients(options: McpOptions, cwd: string): Promise { + if (options.client && options.client.length > 0) { + return resolveClients(options.client); + } + const detected = await detectInstalledClients(cwd); + if (detected.length === 0) { + throw new CliError( + "No supported MCP clients detected. Install one of: " + + CLIENTS.map((c) => c.displayName).join(", ") + + ", or target a specific client with `--client `.", + { code: ERROR_CODE.MCP_NO_CLIENT_DETECTED }, + ); + } + return detected; +} + +export function wantsJson(options: McpOptions): boolean { + return Boolean(options.json) || isAgent(); +} + +/** Render a "Next steps:" block to stderr (human mode). No-op for an empty list. */ +export function printNextSteps(lines: string[]): void { + if (lines.length === 0) return; + log.blank(); + log.info("Next steps:"); + for (const line of lines) log.info(` ${line}`); +} + +/** + * Run an async op against each client without letting one client's failure + * abort the rest — `Promise.all` is fail-fast and would discard every other + * client's result on the first rejection. Failures are warned per-client; + * successes are returned. If *every* client failed, the first error is + * rethrown so the command still exits non-zero with a real message. + */ +export async function settleClients( + clients: readonly McpClient[], + op: (client: McpClient) => Promise, +): Promise<{ client: McpClient; result: T }[]> { + const settled = await Promise.allSettled(clients.map(op)); + const succeeded: { client: McpClient; result: T }[] = []; + const failures: unknown[] = []; + settled.forEach((outcome, i) => { + const client = clients[i]!; + if (outcome.status === "fulfilled") { + succeeded.push({ client, result: outcome.value }); + return; + } + failures.push(outcome.reason); + log.warn(`${client.displayName}: ${errorMessage(outcome.reason)}`); + }); + if (succeeded.length === 0 && failures.length > 0) throw failures[0]; + return succeeded; +} diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts new file mode 100644 index 00000000..1d103096 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +const { mcpInstall } = await import("./install.ts"); +const { mcpUninstall } = await import("./uninstall.ts"); + +const URL = "https://mcp.clerk.com/mcp"; + +describe("mcp uninstall", () => { + const captured = useCaptureLog(); + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-uninstall-")); + process.chdir(cwd); + mockIsAgent.mockReturnValue(true); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + mockIsAgent.mockReset(); + }); + + test("removes the entry an install-uninstall round-trip leaves no trace under mcpServers", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + captured.clear(); + await mcpUninstall({ client: ["cursor"] }); + + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(parsed.mcpServers.clerk).toBeUndefined(); + }); + + test("emits JSON results on stdout in agent mode", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + captured.clear(); + await mcpUninstall({ client: ["cursor"] }); + + const payload = JSON.parse(captured.out) as { + name: string; + results: { client: string; removed: boolean }[]; + }; + expect(payload.name).toBe("clerk"); + expect(payload.results).toEqual([expect.objectContaining({ client: "cursor", removed: true })]); + }); + + test("throws MCP_NOT_INSTALLED when nothing is registered", async () => { + await expect(mcpUninstall({ client: ["cursor"] })).rejects.toMatchObject({ + code: "mcp_not_installed", + }); + }); + + test("respects --name", async () => { + await mcpInstall({ client: ["cursor"], url: URL, name: "clerk-staging" }); + captured.clear(); + await mcpUninstall({ client: ["cursor"], name: "clerk-staging" }); + + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(parsed.mcpServers["clerk-staging"]).toBeUndefined(); + }); + + test("rejects an unknown --client id", async () => { + await expect(mcpUninstall({ client: ["bogus"] })).rejects.toMatchObject({ + code: "mcp_client_not_supported", + }); + }); + + test("human mode: nothing-to-remove throws without a contradictory success outro", async () => { + mockIsAgent.mockReturnValue(false); + await expect(mcpUninstall({ client: ["cursor"] })).rejects.toMatchObject({ + code: "mcp_not_installed", + }); + expect(captured.err).not.toContain("Nothing to remove"); + }); + + test("human mode: prints reload next steps after a successful removal", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + mockIsAgent.mockReturnValue(false); + captured.clear(); + await mcpUninstall({ client: ["cursor"] }); + expect(captured.err).toContain("Next steps:"); + expect(captured.err).toContain("Reload Cursor"); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts new file mode 100644 index 00000000..6f65c24f --- /dev/null +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -0,0 +1,62 @@ +/** + * `clerk mcp uninstall` — remove the `clerk` MCP entry from supported clients. + */ + +import { cyan, dim, green, yellow } from "../../lib/color.ts"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { isHuman } from "../../mode.ts"; +import { intro, outro } from "../../lib/spinner.ts"; +import { CLIENTS } from "./clients/registry.ts"; +import type { McpClient, RemoveResult } from "./clients/types.ts"; +import { + printNextSteps, + resolveClients, + resolveName, + settleClients, + wantsJson, + type McpOptions, +} from "./shared.ts"; + +function printResult(client: McpClient, result: RemoveResult): void { + const label = `${client.displayName} → ${dim(result.configPath)}`; + log.info(`${label}: ${result.removed ? green("removed") : yellow("not present")}`); +} + +export async function mcpUninstall(options: McpOptions = {}): Promise { + const name = resolveName(options); + const cwd = process.cwd(); + const clients = + options.client && options.client.length > 0 + ? resolveClients(options.client) + : Array.from(CLIENTS); + const json = wantsJson(options); + + if (isHuman() && !json) intro(`Removing MCP entry ${cyan(name)}`); + + const settled = await settleClients(clients, (c) => c.remove(name, cwd)); + const results = settled.map((s) => s.result); + if (isHuman() && !json) settled.forEach(({ client, result }) => printResult(client, result)); + + const removedCount = results.filter((r) => r.removed).length; + const notInstalled = new CliError(`No MCP entry named "${name}" found in any client.`, { + code: ERROR_CODE.MCP_NOT_INSTALLED, + }); + + if (json) { + log.data(JSON.stringify({ name, results }, null, 2)); + if (removedCount === 0) throw notInstalled; + return; + } + + if (removedCount === 0) throw notInstalled; + + // Removing the config entry doesn't drop a live editor session — it lingers + // until the editor reloads. Tell the user for each client we removed from. + printNextSteps( + settled + .filter(({ result }) => result.removed) + .map(({ client }) => `Reload ${client.displayName} to drop the active connection.`), + ); + outro("Done"); +} diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index 91643a28..dd780a6d 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -19,6 +19,7 @@ export interface EnvProfileConfig { platformApiUrl: string; backendApiUrl: string; dashboardUrl?: string; + mcpUrl?: string; } const DEFAULT_PROFILES: Record = { @@ -28,6 +29,7 @@ const DEFAULT_PROFILES: Record = { platformApiUrl: "https://api.clerk.com", backendApiUrl: "https://api.clerk.dev", dashboardUrl: "https://dashboard.clerk.com", + mcpUrl: "https://mcp.clerk.com/mcp", }, }; @@ -145,3 +147,15 @@ export function getDashboardUrl(): string { process.env.CLERK_DASHBOARD_URL ?? getCurrentEnv().dashboardUrl ?? "https://dashboard.clerk.com" ); } + +/** + * Remote MCP server URL for the active environment. + * + * Sourced from the active env profile so `switch-env` carries it + * automatically. `CLERK_MCP_URL` overrides for local worker development + * (e.g. `http://localhost:8787/mcp` against the `remote-mcp-server` repo). + * Returns `undefined` if the active profile has no `mcpUrl` configured. + */ +export function getMcpUrl(): string | undefined { + return process.env.CLERK_MCP_URL ?? getCurrentEnv().mcpUrl; +} diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index f2f1d8aa..7d745f90 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -55,6 +55,16 @@ export const ERROR_CODE = { HOME_URL_TAKEN: "home_url_taken", /** PLAPI rejected a request parameter as malformed. */ FORM_PARAM_INVALID: "form_param_invalid", + /** No MCP client detected on the system. */ + MCP_NO_CLIENT_DETECTED: "mcp_no_client_detected", + /** Requested MCP client is not in the supported registry. */ + MCP_CLIENT_NOT_SUPPORTED: "mcp_client_not_supported", + /** Existing MCP client config is malformed or has a conflicting entry. */ + MCP_CLIENT_CONFIG_INVALID: "mcp_client_config_invalid", + /** No MCP URL available — active env profile has no mcpUrl and `--url` not given. */ + MCP_URL_REQUIRED: "mcp_url_required", + /** No matching MCP entry to remove. */ + MCP_NOT_INSTALLED: "mcp_not_installed", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; From dc188a25fe529903ee07755ec1cd12d2671e691f Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 25 May 2026 18:23:57 -0300 Subject: [PATCH 02/11] fix(mcp): import `checkbox` lazily in pickClients A top-level import of @inquirer/prompts is resolved at module load, which breaks integration tests that mock the module with a partial shape omitting checkbox. Defer it to call-time like doctor/update already do. --- packages/cli-core/src/commands/mcp/shared.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index f580b650..26aa04e4 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -2,7 +2,6 @@ * Shared options and helpers for `clerk mcp` subcommands. */ -import { checkbox } from "@inquirer/prompts"; import { ttyContext } from "../../lib/listage.ts"; import { getMcpUrl } from "../../lib/environment.ts"; import { CliError, ERROR_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts"; @@ -71,6 +70,10 @@ export function resolveClients(ids: readonly string[]): McpClient[] { export async function pickClients(detected: McpClient[]): Promise { if (detected.length === 0) return []; if (detected.length === 1) return detected; + // Imported lazily (like `doctor`/`update` do): a top-level import of + // `@inquirer/prompts` is resolved at module load, which breaks tests that + // mock the module with a partial shape that omits `checkbox`. + const { checkbox } = await import("@inquirer/prompts"); const tty = ttyContext(); try { const selected = await checkbox( From 0365f991820ebca5587be77d035be1fd78d45ea7 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 25 May 2026 19:11:42 -0300 Subject: [PATCH 03/11] test(doctor): use restorable spyOn for config mock in context.test context.test.ts replaced the entire config.ts module via mock.module, which is process-lifetime in Bun and omitted getConfigFile/_setConfigDir. When the doctor folder ran in a single `bun test` process, the polluted mock leaked into doctor.test.ts (which needs the real module), crashing with "Export named 'getConfigFile' not found". Swap the config mock for a spyOn on resolveProfile (the only symbol context.ts imports) and restore it in afterAll, so doctor.test.ts gets the real config.ts back. --- .../src/commands/doctor/context.test.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/commands/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index a857c381..79e6d4e6 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -1,11 +1,6 @@ -import { test, expect, describe, mock, beforeEach, afterEach } from "bun:test"; -import { - useCaptureLog, - credentialStoreStubs, - configStubs, - gitStubs, - stubFetch, -} from "../../test/lib/stubs.ts"; +import { test, expect, describe, mock, spyOn, beforeEach, afterEach, afterAll } from "bun:test"; +import { useCaptureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; +import * as config from "../../lib/config.ts"; import type { Application } from "../../lib/plapi.ts"; const mockGetToken = mock(); @@ -15,12 +10,13 @@ mock.module("../../lib/credential-store.ts", () => ({ getToken: (...args: unknown[]) => mockGetToken(...args), })); +// spyOn (not mock.module) for config: a spy is restorable, so afterAll hands the +// real module back to doctor.test.ts when both run in one `bun test` process. const mockResolveProfile = mock(); - -mock.module("../../lib/config.ts", () => ({ - ...configStubs, - resolveProfile: (...args: unknown[]) => mockResolveProfile(...args), -})); +const resolveProfileSpy = spyOn(config, "resolveProfile").mockImplementation((...args: unknown[]) => + mockResolveProfile(...(args as [string])), +); +afterAll(() => resolveProfileSpy.mockRestore()); mock.module("../../lib/git.ts", () => gitStubs); From 5f09817c96f0c8abed35c25c5560a8ddbbb09bc6 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Sun, 31 May 2026 09:12:48 -0300 Subject: [PATCH 04/11] test(mcp): address CodeRabbit review on user-scope.test.ts Add afterAll mock cleanup and replace node:fs/promises readFile/writeFile with Bun.file().json(), Bun.file().text(), and Bun.write() per project standards. --- .../src/commands/mcp/clients/user-scope.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index d8548d45..f7b4cfbb 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import * as realOs from "node:os"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; @@ -10,6 +10,7 @@ import { useCaptureLog } from "../../../test/lib/stubs.ts"; // module mock instead — registered at file top, before the clients are imported. let mockHome = realOs.tmpdir(); mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); +afterAll(() => mock.restore()); const mockIsAgent = mock(); mock.module("../../../mode.ts", () => ({ @@ -42,7 +43,7 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { describe("gemini", () => { test("encodes the mcp-remote stdio-bridge shape", async () => { await geminiClient.upsert({ name: "clerk", url: URL }, "/ignored", false); - const parsed = JSON.parse(await readFile(geminiClient.configPath("/ignored"), "utf8")) as { + const parsed = (await Bun.file(geminiClient.configPath("/ignored")).json()) as { mcpServers: { clerk: { command: string; args: string[] } }; }; expect(parsed.mcpServers.clerk).toEqual({ command: "npx", args: ["-y", "mcp-remote", URL] }); @@ -61,7 +62,7 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { // unrelated npx tool must not round-trip as a Clerk MCP entry. const dir = join(mockHome, ".gemini"); await mkdir(dir, { recursive: true }); - await writeFile( + await Bun.write( join(dir, "settings.json"), JSON.stringify({ mcpServers: { @@ -78,7 +79,7 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { describe("windsurf", () => { test("encodes the serverUrl shape and round-trips it on list", async () => { await windsurfClient.upsert({ name: "clerk", url: URL }, "/ignored", false); - const parsed = JSON.parse(await readFile(windsurfClient.configPath("/ignored"), "utf8")) as { + const parsed = (await Bun.file(windsurfClient.configPath("/ignored")).json()) as { mcpServers: { clerk: { serverUrl: string } }; }; expect(parsed.mcpServers.clerk).toEqual({ serverUrl: URL }); From 0f775d71000efe70e8da2e0a9186c6463daf7205 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 4 Jun 2026 00:16:51 -0300 Subject: [PATCH 05/11] fix(mcp): install to user-global config and default to Clerk's hosted server Write Claude Code, Cursor, and VS Code entries to each editor's user-global config (~/.claude.json, ~/.cursor/mcp.json, VS Code's per-OS user mcp.json) instead of project-scoped files. Project scope tied "did it install?" to the run directory matching the editor's launch dir plus a trust prompt and restart; user scope makes the server available in every project, regardless of where the CLI is run. Default the MCP URL to the hosted server (https://mcp.clerk.com/mcp) when no profile defines mcpUrl, so install works out of the box on published builds whose injected env profile omits the field. Resolution order: --url > CLERK_MCP_URL > active profile mcpUrl > hosted server. Drop the localhost --url examples from --help (kept in the README for contributors), and tidy redundant docblock titles plus settleClients' loop. Tests migrated to redirect homedir to a tmpdir so user-scoped client writes stay isolated. --- .changeset/mcp-install.md | 2 +- packages/cli-core/src/cli-program.ts | 8 - .../cli-core/src/commands/doctor/check-mcp.ts | 3 +- packages/cli-core/src/commands/mcp/README.md | 34 ++-- .../src/commands/mcp/clients/claude-code.ts | 15 +- .../src/commands/mcp/clients/clients.test.ts | 157 ++++++++---------- .../src/commands/mcp/clients/cursor.ts | 13 +- .../src/commands/mcp/clients/gemini.ts | 2 - .../src/commands/mcp/clients/json-config.ts | 4 +- .../mcp/clients/make-json-client.test.ts | 16 +- .../commands/mcp/clients/make-json-client.ts | 4 +- .../src/commands/mcp/clients/paths.ts | 25 ++- .../commands/mcp/clients/user-scope.test.ts | 6 +- .../src/commands/mcp/clients/vscode.ts | 18 +- .../src/commands/mcp/clients/windsurf.ts | 2 - .../cli-core/src/commands/mcp/install.test.ts | 32 +++- packages/cli-core/src/commands/mcp/install.ts | 4 +- .../cli-core/src/commands/mcp/list.test.ts | 10 +- packages/cli-core/src/commands/mcp/list.ts | 20 ++- packages/cli-core/src/commands/mcp/shared.ts | 15 +- .../src/commands/mcp/uninstall.test.ts | 10 +- packages/cli-core/src/lib/environment.ts | 18 +- packages/cli-core/src/lib/errors.ts | 2 +- 23 files changed, 242 insertions(+), 178 deletions(-) diff --git a/.changeset/mcp-install.md b/.changeset/mcp-install.md index 9f2b360c..331d7cc1 100644 --- a/.changeset/mcp-install.md +++ b/.changeset/mcp-install.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, VS Code, Windsurf, and Gemini. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. The URL comes from the active env profile's new `mcpUrl` field (or the `CLERK_MCP_URL` override) and can be overridden per-invocation with `--url` for local worker development. +Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, VS Code, Windsurf, and Gemini. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`), so the server is available across every project regardless of the directory you run the CLI from. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. By default the commands target Clerk's hosted server, so `clerk mcp install` works with no flags. The URL resolves in order: `--url` > the `CLERK_MCP_URL` override (for local worker development) > the active env profile's new `mcpUrl` field > the hosted server. diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 5e48aec9..2b4c3585 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -473,10 +473,6 @@ export function createProgram() { .setExamples([ { command: "clerk mcp install", description: "Install into all detected MCP clients" }, { command: "clerk mcp install --client cursor", description: "Install into Cursor only" }, - { - command: "clerk mcp install --url http://localhost:8787/mcp", - description: "Use a local worker URL", - }, { command: "clerk mcp list", description: "Show registered Clerk entries" }, { command: "clerk mcp uninstall", description: "Remove the Clerk entry from all clients" }, ]); @@ -505,10 +501,6 @@ export function createProgram() { command: "clerk mcp install --client cursor --client vscode", description: "Install into specific clients", }, - { - command: "clerk mcp install --url http://localhost:8787/mcp", - description: "Target a local worker for development", - }, ]) .action((options) => mcpHandlers.install(options)); diff --git a/packages/cli-core/src/commands/doctor/check-mcp.ts b/packages/cli-core/src/commands/doctor/check-mcp.ts index 93b47f5b..a897670c 100644 --- a/packages/cli-core/src/commands/doctor/check-mcp.ts +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -1,6 +1,5 @@ /** - * `clerk doctor` MCP reachability check (folded in from the former - * `clerk mcp doctor` subcommand). + * `clerk doctor` MCP reachability check. * * Kept in its own file — rather than `checks.ts` — so the doctor check graph * doesn't import `mcp/shared.ts` (env profiles, prompts) and the module cycle diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index ec9b176f..90e070cc 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -7,9 +7,11 @@ The Clerk MCP server is hosted at `https://mcp.clerk.com/mcp` (source: These subcommands register, list, remove, and probe that URL in each client's own config file. The URL is resolved in order: `--url` > the `CLERK_MCP_URL` environment variable > the active environment profile's `mcpUrl` field -(`switch-env` carries the profile value automatically). `CLERK_MCP_URL` is the -convenient override when developing the worker locally (e.g. -`http://localhost:8787/mcp`). +(`switch-env` carries the profile value automatically) > Clerk's hosted server +(`https://mcp.clerk.com/mcp`). Because the hosted server is the final fallback, +`clerk mcp install` works out of the box with no flags or profile setup. +`CLERK_MCP_URL` is the convenient override when developing the worker locally +(e.g. `http://localhost:8787/mcp`). No Clerk API endpoints are called. To verify the server is reachable, run `clerk doctor` — its MCP check performs the `initialize` handshake against the @@ -17,13 +19,21 @@ configured URL whenever a Clerk MCP entry is installed. ## Supported clients -| ID | Client | Scope | Config file | -| ------------- | ------------------------ | ------- | ------------------------------------- | -| `claude-code` | Claude Code | project | `/.mcp.json` | -| `cursor` | Cursor | project | `/.cursor/mcp.json` | -| `vscode` | VS Code (Copilot) | project | `/.vscode/mcp.json` | -| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | -| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | +All entries are written to each client's **user-global** config, so the server +is available in every project (no per-project approval, no dependence on which +directory you run the CLI from). + +| ID | Client | Scope | Config file | +| ------------- | ------------------------ | ----- | --------------------------------------- | +| `claude-code` | Claude Code | user | `~/.claude.json` (`mcpServers`) | +| `cursor` | Cursor | user | `~/.cursor/mcp.json` | +| `vscode` | VS Code (Copilot) | user | VS Code user `mcp.json` (per-OS, below) | +| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | +| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | + +VS Code's user config dir is OS-specific: `~/Library/Application Support/Code/User/mcp.json` +(macOS), `%APPDATA%\Code\User\mcp.json` (Windows), `$XDG_CONFIG_HOME/Code/User/mcp.json` +(Linux) — the file behind **MCP: Open User Configuration**. ## Subcommands @@ -35,7 +45,7 @@ Register the Clerk MCP server in one or more clients. | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--client ` | Target a specific client. Repeat for multiple. Default in agent mode: all detected. Default in human mode: interactive multiselect over detected clients. | | `--all` | Install into every detected client without prompting. | -| `--url ` | Override the MCP URL. Defaults to the active env profile's `mcpUrl`. | +| `--url ` | Override the MCP URL. Defaults to the active env profile's `mcpUrl`, then Clerk's hosted server. | | `--name ` | Entry key in the client config. Default: `clerk`. | | `--force` | Overwrite an entry already pointing at a different URL. Without it, the conflict is reported and skipped. | | `--json` | Emit a JSON summary on stdout instead of human-formatted output. | @@ -73,5 +83,5 @@ session, so (in human mode) it prints a next step to reload each affected editor | `mcp_no_client_detected` | No supported client found on the system. | | `mcp_client_not_supported` | `--client ` is not in the supported list. | | `mcp_client_config_invalid` | An existing client config file is malformed. | -| `mcp_url_required` | No `--url` provided and the active env profile has no `mcpUrl`. | +| `mcp_url_required` | The provided `--url` is malformed or uses a non-http(s) scheme. | | `mcp_not_installed` | `uninstall` removed nothing because no entry matched. | diff --git a/packages/cli-core/src/commands/mcp/clients/claude-code.ts b/packages/cli-core/src/commands/mcp/clients/claude-code.ts index f5d42b5b..1b8affb8 100644 --- a/packages/cli-core/src/commands/mcp/clients/claude-code.ts +++ b/packages/cli-core/src/commands/mcp/clients/claude-code.ts @@ -1,22 +1,21 @@ /** - * Claude Code MCP client integration. - * - * Writes to `.mcp.json` in the current working directory — the project-scope - * config Claude Code reads automatically. Schema follows the MCP spec's HTTP - * transport form: `{ type: "http", url: "" }`. + * Writes to the user-global `~/.claude.json` under `mcpServers`, so the server + * is available in every project (the same store `claude mcp add -s user` uses) + * rather than gated behind a per-project `.mcp.json` approval. Schema follows + * the MCP spec's HTTP transport form: `{ type: "http", url: "" }`. */ import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; -import { pathExists, projectPath, userPath } from "./paths.ts"; +import { pathExists, userPath } from "./paths.ts"; export const claudeCodeClient = makeJsonClient({ id: "claude-code", displayName: "Claude Code", - scope: "project", + scope: "user", activation: "Restart Claude Code, then run `/mcp` to connect (sign in if prompted).", topKey: "mcpServers", encode: (url) => ({ type: "http", url }), extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), - configPath: (cwd) => projectPath(cwd, ".mcp.json"), + configPath: () => userPath(".claude.json"), detect: () => pathExists(userPath(".claude")), }); diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts index 5c6bf72e..2fb4bc81 100644 --- a/packages/cli-core/src/commands/mcp/clients/clients.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -1,108 +1,97 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir, homedir } from "node:os"; +import * as realOs from "node:os"; import { join } from "node:path"; -import { claudeCodeClient } from "./claude-code.ts"; -import { cursorClient } from "./cursor.ts"; -import { vscodeClient } from "./vscode.ts"; -import { windsurfClient } from "./windsurf.ts"; -import { geminiClient } from "./gemini.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; +// Every client writes under the user's home now, so redirect homedir to a +// tmpdir (Bun's os.homedir() ignores $HOME) — registered before the clients +// load so paths.ts binds the redirected homedir. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); +afterAll(() => mock.restore()); + +const { claudeCodeClient } = await import("./claude-code.ts"); +const { cursorClient } = await import("./cursor.ts"); +const { vscodeClient } = await import("./vscode.ts"); +const { windsurfClient } = await import("./windsurf.ts"); +const { geminiClient } = await import("./gemini.ts"); +const { vscodeUserDir } = await import("./paths.ts"); + useCaptureLog(); const URL = "https://mcp.clerk.com/mcp"; -// Path shape is part of the public contract — each client targets a specific, -// documented config file. Test against the format, not the absolute prefix -// (which depends on cwd/homedir). -const projectClients = [ - { client: claudeCodeClient, suffix: ".mcp.json", topKey: "mcpServers" }, - { client: cursorClient, suffix: join(".cursor", "mcp.json"), topKey: "mcpServers" }, - { client: vscodeClient, suffix: join(".vscode", "mcp.json"), topKey: "servers" }, -]; - -const userClients = [ +// Path + entry shape are part of the public contract: each client targets a +// specific user-global config file and encodes the server its own way. +const cases = [ + { + name: "claude-code", + client: claudeCodeClient, + expectedPath: () => join(mockHome, ".claude.json"), + topKey: "mcpServers", + shape: { type: "http", url: URL }, + }, + { + name: "cursor", + client: cursorClient, + expectedPath: () => join(mockHome, ".cursor", "mcp.json"), + topKey: "mcpServers", + shape: { url: URL }, + }, { + name: "vscode", + client: vscodeClient, + expectedPath: () => join(vscodeUserDir(), "mcp.json"), + topKey: "servers", + shape: { type: "http", url: URL }, + }, + { + name: "windsurf", client: windsurfClient, - relPath: join(".codeium", "windsurf", "mcp_config.json"), + expectedPath: () => join(mockHome, ".codeium", "windsurf", "mcp_config.json"), + topKey: "mcpServers", + shape: { serverUrl: URL }, + }, + { + name: "gemini", + client: geminiClient, + expectedPath: () => join(mockHome, ".gemini", "settings.json"), topKey: "mcpServers", + shape: { command: "npx", args: ["-y", "mcp-remote", URL] }, }, - { client: geminiClient, relPath: join(".gemini", "settings.json"), topKey: "mcpServers" }, ]; -describe("project-scope client config paths", () => { - test.each(projectClients)("$client.id resolves under cwd", ({ client, suffix }) => { - const path = client.configPath("/tmp/foo"); - expect(path).toBe(join("/tmp/foo", suffix)); - expect(client.scope).toBe("project"); +describe("client config paths + encoded shapes (homedir redirected)", () => { + beforeEach(async () => { + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-clients-")); }); -}); -describe("user-scope client config paths", () => { - test.each(userClients)("$client.id resolves under homedir", ({ client, relPath }) => { - expect(client.configPath("/ignored")).toBe(join(homedir(), relPath)); - expect(client.scope).toBe("user"); + afterEach(async () => { + await rm(mockHome, { recursive: true, force: true }); }); -}); -describe("per-client encoded shape (written JSON)", () => { - // Project clients are easiest to exercise — write into a tmpdir-as-cwd and - // assert what landed under their top-level key. - test.each(projectClients)( - "$client.id writes the expected entry shape", - async ({ client, topKey }) => { - const cwd = await mkdtemp(join(tmpdir(), `clerk-mcp-${client.id}-`)); - try { - await client.upsert({ name: "clerk", url: URL }, cwd, false); - const text = await readFile(client.configPath(cwd), "utf8"); - const parsed = JSON.parse(text) as Record>; - expect(parsed[topKey]).toBeDefined(); - expect(parsed[topKey]?.clerk).toBeDefined(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }, - ); - - test("claude-code emits the MCP-spec HTTP transport shape", async () => { - const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cc-shape-")); - try { - await claudeCodeClient.upsert({ name: "clerk", url: URL }, cwd, false); - const parsed = JSON.parse(await readFile(claudeCodeClient.configPath(cwd), "utf8")) as { - mcpServers: { clerk: { type: string; url: string } }; - }; - expect(parsed.mcpServers.clerk).toEqual({ type: "http", url: URL }); - } finally { - await rm(cwd, { recursive: true, force: true }); - } + test.each(cases)("$name is user-scoped at its documented path", ({ client, expectedPath }) => { + expect(client.scope).toBe("user"); + expect(client.configPath("/ignored")).toBe(expectedPath()); }); - test("cursor emits a bare {url} entry", async () => { - const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cu-shape-")); - try { - await cursorClient.upsert({ name: "clerk", url: URL }, cwd, false); - const parsed = JSON.parse(await readFile(cursorClient.configPath(cwd), "utf8")) as { - mcpServers: { clerk: { url: string } }; - }; - expect(parsed.mcpServers.clerk).toEqual({ url: URL }); - } finally { - await rm(cwd, { recursive: true, force: true }); - } + test.each(cases)("$name writes the documented entry shape", async ({ client, topKey, shape }) => { + await client.upsert({ name: "clerk", url: URL }, "/ignored", false); + const parsed = JSON.parse(await readFile(client.configPath("/ignored"), "utf8")) as Record< + string, + Record + >; + expect(parsed[topKey]?.clerk).toEqual(shape); }); - test("vscode emits under top-level `servers`, not `mcpServers`", async () => { - const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-vs-shape-")); - try { - await vscodeClient.upsert({ name: "clerk", url: URL }, cwd, false); - const parsed = JSON.parse(await readFile(vscodeClient.configPath(cwd), "utf8")) as { - servers: { clerk: { type: string; url: string } }; - mcpServers?: unknown; - }; - expect(parsed.servers.clerk).toEqual({ type: "http", url: URL }); - expect(parsed.mcpServers).toBeUndefined(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } + test("vscode writes under `servers`, not `mcpServers`", async () => { + await vscodeClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const parsed = JSON.parse(await readFile(vscodeClient.configPath("/ignored"), "utf8")) as { + servers?: unknown; + mcpServers?: unknown; + }; + expect(parsed.servers).toBeDefined(); + expect(parsed.mcpServers).toBeUndefined(); }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/cursor.ts b/packages/cli-core/src/commands/mcp/clients/cursor.ts index 1a8655ec..f48b6a0e 100644 --- a/packages/cli-core/src/commands/mcp/clients/cursor.ts +++ b/packages/cli-core/src/commands/mcp/clients/cursor.ts @@ -1,21 +1,20 @@ /** - * Cursor MCP client integration. - * - * Writes to `.cursor/mcp.json` in the current working directory. Cursor's - * MCP descriptor is a bare `{ url }` without a `type` discriminator. + * Writes to the user-global `~/.cursor/mcp.json`, so the server is available in + * every project rather than only the cwd it was installed from. Cursor's MCP + * descriptor is a bare `{ url }` without a `type` discriminator. */ import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; -import { pathExists, projectPath, userPath } from "./paths.ts"; +import { pathExists, userPath } from "./paths.ts"; export const cursorClient = makeJsonClient({ id: "cursor", displayName: "Cursor", - scope: "project", + scope: "user", activation: "Reload Cursor, then enable the server under `Settings → MCP` (sign in if prompted).", topKey: "mcpServers", encode: (url) => ({ url }), extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), - configPath: (cwd) => projectPath(cwd, ".cursor", "mcp.json"), + configPath: () => userPath(".cursor", "mcp.json"), detect: () => pathExists(userPath(".cursor")), }); diff --git a/packages/cli-core/src/commands/mcp/clients/gemini.ts b/packages/cli-core/src/commands/mcp/clients/gemini.ts index 607b6b8f..271ed885 100644 --- a/packages/cli-core/src/commands/mcp/clients/gemini.ts +++ b/packages/cli-core/src/commands/mcp/clients/gemini.ts @@ -1,6 +1,4 @@ /** - * Gemini Code Assist / Gemini CLI MCP client integration. - * * Writes to `~/.gemini/settings.json`. Gemini doesn't support HTTP transport * directly — it requires `mcp-remote` as a stdio bridge, hence the * `{ command: "npx", args: ["-y", "mcp-remote", ] }` shape. diff --git a/packages/cli-core/src/commands/mcp/clients/json-config.ts b/packages/cli-core/src/commands/mcp/clients/json-config.ts index c01cf4e3..4d376eaa 100644 --- a/packages/cli-core/src/commands/mcp/clients/json-config.ts +++ b/packages/cli-core/src/commands/mcp/clients/json-config.ts @@ -1,8 +1,8 @@ /** * Shared JSON read/write helper for MCP client configs. * - * Four of the five supported clients (Claude Code, Cursor, VS Code, Windsurf, - * Gemini) store their MCP servers in a JSON file under a single top-level key + * All five supported clients (Claude Code, Cursor, VS Code, Windsurf, Gemini) + * store their MCP servers in a JSON file under a single top-level key * (`mcpServers` for most, `servers` for VS Code). The entry shape varies * (`url` vs `serverUrl` vs `command`+`args`) — that's per-client. This module * only handles the surrounding I/O: read, parse, write back with stable diff --git a/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts b/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts index 7fc14afd..e9f3c360 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts @@ -1,10 +1,17 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import * as realOs from "node:os"; import { join } from "node:path"; -import { cursorClient } from "./cursor.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; +// cursorClient writes under home now; redirect homedir to the cwd tmpdir so the +// `join(cwd, ".cursor", ...)` reads below stay isolated. Mock before importing +// the client so paths.ts binds the redirected homedir. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + +const { cursorClient } = await import("./cursor.ts"); + useCaptureLog(); const URL_A = "https://mcp.clerk.com/mcp"; @@ -14,7 +21,8 @@ describe("make-json-client (via cursor)", () => { let cwd: string; beforeEach(async () => { - cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cursor-")); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-cursor-")); + mockHome = cwd; }); afterEach(async () => { diff --git a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts index ebd7fc79..a39355f7 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts @@ -126,8 +126,8 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { servers = getServerMap(config, spec.topKey, configPath); } catch (error) { if (error instanceof CliError && error.code === ERROR_CODE.MCP_CLIENT_CONFIG_INVALID) { - // Don't crash `list` across the other clients — but the user must - // know their config was skipped, not silently treated as empty. + // Warn rather than silently returning [] — the user must know their + // config was skipped, not treated as empty. log.warn(`${spec.displayName}: ${error.message}`); return []; } diff --git a/packages/cli-core/src/commands/mcp/clients/paths.ts b/packages/cli-core/src/commands/mcp/clients/paths.ts index a589698d..0f95bad4 100644 --- a/packages/cli-core/src/commands/mcp/clients/paths.ts +++ b/packages/cli-core/src/commands/mcp/clients/paths.ts @@ -1,13 +1,13 @@ /** * Cross-platform path + filesystem helpers for MCP client integrations. * - * We deliberately avoid OS-specific layout (no XDG, no AppData) — every - * documented client config path is rooted at `~/./` regardless of - * platform, so a single homedir join is enough. + * Most clients root their config at `~/./` regardless of platform, so a + * single homedir join is enough. VS Code is the exception — its user-level + * config lives under the OS-specific app-support dir (see `vscodeUserDir`). */ import { stat } from "node:fs/promises"; -import { homedir } from "node:os"; +import { homedir, platform } from "node:os"; import { join } from "node:path"; export function projectPath(cwd: string, ...segments: string[]): string { @@ -18,6 +18,23 @@ export function userPath(...segments: string[]): string { return join(homedir(), ...segments); } +/** + * VS Code's per-user (global) config directory, where its `mcp.json` lives. + * Unlike the other clients this is OS-specific: Application Support on macOS, + * %APPDATA% on Windows, XDG config on Linux. + */ +export function vscodeUserDir(): string { + const home = homedir(); + switch (platform()) { + case "win32": + return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Code", "User"); + case "darwin": + return join(home, "Library", "Application Support", "Code", "User"); + default: + return join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "Code", "User"); + } +} + /** * Returns true when *anything* exists at `path` — file, directory, symlink. * Detection only needs to know "did the user install this tool?", which is diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index f7b4cfbb..f1936c1f 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -22,6 +22,7 @@ mock.module("../../../mode.ts", () => ({ const { geminiClient } = await import("./gemini.ts"); const { windsurfClient } = await import("./windsurf.ts"); +const { vscodeUserDir } = await import("./paths.ts"); const { mcpInstall } = await import("../install.ts"); const { mcpUninstall } = await import("../uninstall.ts"); const { checkMcp } = await import("../../doctor/check-mcp.ts"); @@ -115,11 +116,12 @@ describe("install/uninstall across all clients (homedir + cwd redirected)", () = }); test("install --all targets every detected client", async () => { - // detect() keys off each client's marker directory under home. + // detect() keys off each client's marker directory under home (VS Code uses + // its per-OS user config dir). await Promise.all([ mkdir(join(mockHome, ".claude"), { recursive: true }), mkdir(join(mockHome, ".cursor"), { recursive: true }), - mkdir(join(mockHome, ".vscode"), { recursive: true }), + mkdir(vscodeUserDir(), { recursive: true }), mkdir(join(mockHome, ".codeium", "windsurf"), { recursive: true }), mkdir(join(mockHome, ".gemini"), { recursive: true }), ]); diff --git a/packages/cli-core/src/commands/mcp/clients/vscode.ts b/packages/cli-core/src/commands/mcp/clients/vscode.ts index 60b7ecde..c3ecc553 100644 --- a/packages/cli-core/src/commands/mcp/clients/vscode.ts +++ b/packages/cli-core/src/commands/mcp/clients/vscode.ts @@ -1,23 +1,23 @@ /** - * VS Code (Copilot) MCP client integration. - * - * Writes to `.vscode/mcp.json` in the current working directory. VS Code uses - * the top-level key `servers` (not `mcpServers`) and the HTTP transport form - * `{ type: "http", url }`. + * Writes to the user-global `mcp.json` under VS Code's per-OS user config dir + * (the file behind `MCP: Open User Configuration`), so the server is available + * across every workspace. VS Code uses the top-level key `servers` (not + * `mcpServers`) and the HTTP transport form `{ type: "http", url }`. */ +import { join } from "node:path"; import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; -import { pathExists, projectPath, userPath } from "./paths.ts"; +import { pathExists, vscodeUserDir } from "./paths.ts"; export const vscodeClient = makeJsonClient({ id: "vscode", displayName: "VS Code", - scope: "project", + scope: "user", activation: "Reload the VS Code window, then start the server from `MCP: List Servers` (sign in if prompted).", topKey: "servers", encode: (url) => ({ type: "http", url }), extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), - configPath: (cwd) => projectPath(cwd, ".vscode", "mcp.json"), - detect: () => pathExists(userPath(".vscode")), + configPath: () => join(vscodeUserDir(), "mcp.json"), + detect: () => pathExists(vscodeUserDir()), }); diff --git a/packages/cli-core/src/commands/mcp/clients/windsurf.ts b/packages/cli-core/src/commands/mcp/clients/windsurf.ts index bca239f2..d3138047 100644 --- a/packages/cli-core/src/commands/mcp/clients/windsurf.ts +++ b/packages/cli-core/src/commands/mcp/clients/windsurf.ts @@ -1,6 +1,4 @@ /** - * Windsurf MCP client integration. - * * Writes to `~/.codeium/windsurf/mcp_config.json` (user scope). Server * descriptor uses `serverUrl`, not `url`. */ diff --git a/packages/cli-core/src/commands/mcp/install.test.ts b/packages/cli-core/src/commands/mcp/install.test.ts index 8245b618..d0c7b908 100644 --- a/packages/cli-core/src/commands/mcp/install.test.ts +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import * as realOs from "node:os"; import { join } from "node:path"; import { useCaptureLog } from "../../test/lib/stubs.ts"; @@ -12,6 +12,12 @@ mock.module("../../mode.ts", () => ({ getMode: () => (mockIsAgent() ? "agent" : "human"), })); +// All clients write under the user's home now. Redirect homedir to the same +// tmpdir we use as cwd (Bun's os.homedir() ignores $HOME) so writes stay +// isolated and the `join(cwd, ...)` assertions below still resolve. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + const { mcpInstall } = await import("./install.ts"); const URL_A = "https://mcp.clerk.com/mcp"; @@ -24,7 +30,8 @@ describe("mcp install", () => { beforeEach(async () => { originalCwd = process.cwd(); - cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-install-")); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-install-")); + mockHome = cwd; process.chdir(cwd); mockIsAgent.mockReturnValue(false); }); @@ -124,6 +131,27 @@ describe("mcp install", () => { expect(payload.url).toBe("https://mcp.clerk.com/mcp"); }); + test("falls back to Clerk's hosted MCP URL when the active profile has no mcpUrl", async () => { + // Reproduces the published snapshot: a build-time env profile that omits + // `mcpUrl`. `getMcpUrl()` must still resolve to the hosted server so a bare + // `clerk mcp install` works without `--url` or any profile setup. + await writeFile( + join(cwd, ".env-profiles.json"), + JSON.stringify({ + production: { + oauthClientId: "ins_test", + oauthBaseUrl: "https://clerk.clerk.com", + platformApiUrl: "https://api.clerk.com", + backendApiUrl: "https://api.clerk.dev", + }, + }), + ); + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"] }); + const payload = JSON.parse(captured.out) as { url: string }; + expect(payload.url).toBe("https://mcp.clerk.com/mcp"); + }); + test.each([ ["file:///etc/passwd"], ["data:text/plain,clerk"], diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts index a31ef6cb..c8b83b5c 100644 --- a/packages/cli-core/src/commands/mcp/install.ts +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -1,7 +1,7 @@ /** * `clerk mcp install` — register the Clerk remote MCP server in supported clients. * - * URL resolution: `--url` > active env profile `mcpUrl` > error. + * URL resolution: `--url` > `CLERK_MCP_URL` > active env profile `mcpUrl` > Clerk's hosted server. * Target clients: `--client ` (repeatable) > `--all` > human picker > all detected (agent mode). * Conflict policy: same URL → unchanged; different URL → skip unless `--force`. */ @@ -96,7 +96,7 @@ export async function mcpInstall(options: McpOptions = {}): Promise { return; } - if (isHuman()) settled.forEach(({ client, result }) => printResult(client, result)); + settled.forEach(({ client, result }) => printResult(client, result)); printInstallNextSteps(settled); outro("Done"); } diff --git a/packages/cli-core/src/commands/mcp/list.test.ts b/packages/cli-core/src/commands/mcp/list.test.ts index d2ab7ffe..c36169b6 100644 --- a/packages/cli-core/src/commands/mcp/list.test.ts +++ b/packages/cli-core/src/commands/mcp/list.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import * as realOs from "node:os"; import { join } from "node:path"; import { useCaptureLog } from "../../test/lib/stubs.ts"; @@ -12,6 +12,11 @@ mock.module("../../mode.ts", () => ({ getMode: () => (mockIsAgent() ? "agent" : "human"), })); +// User-scoped clients write under home; redirect homedir to the cwd tmpdir so +// install writes (read back by list) stay isolated to the test. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + const { mcpInstall } = await import("./install.ts"); const { mcpList } = await import("./list.ts"); @@ -24,7 +29,8 @@ describe("mcp list", () => { beforeEach(async () => { originalCwd = process.cwd(); - cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-list-")); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-list-")); + mockHome = cwd; process.chdir(cwd); mockIsAgent.mockReturnValue(true); }); diff --git a/packages/cli-core/src/commands/mcp/list.ts b/packages/cli-core/src/commands/mcp/list.ts index 26e7e951..c61d4451 100644 --- a/packages/cli-core/src/commands/mcp/list.ts +++ b/packages/cli-core/src/commands/mcp/list.ts @@ -13,11 +13,23 @@ import { printNextSteps, wantsJson, type McpOptions } from "./shared.ts"; const COLUMN_PADDING = 2; +function columnWidth(header: string, values: string[]): number { + return Math.max(header.length, ...values.map((v) => v.length)) + COLUMN_PADDING; +} + function formatTable(entries: ListEntry[]): void { - const clientWidth = - Math.max("CLIENT".length, ...entries.map((e) => e.client.length)) + COLUMN_PADDING; - const nameWidth = Math.max("NAME".length, ...entries.map((e) => e.name.length)) + COLUMN_PADDING; - const urlWidth = Math.max("URL".length, ...entries.map((e) => e.url.length)) + COLUMN_PADDING; + const clientWidth = columnWidth( + "CLIENT", + entries.map((e) => e.client), + ); + const nameWidth = columnWidth( + "NAME", + entries.map((e) => e.name), + ); + const urlWidth = columnWidth( + "URL", + entries.map((e) => e.url), + ); log.data( dim(`${"CLIENT".padEnd(clientWidth)}${"NAME".padEnd(nameWidth)}${"URL".padEnd(urlWidth)}PATH`), diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index 26aa04e4..9dca21db 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -23,13 +23,10 @@ export type McpOptions = { export const DEFAULT_ENTRY_NAME = "clerk"; export function resolveUrl(options: McpOptions): string { + // `getMcpUrl()` always resolves to a usable URL (Clerk's hosted server by + // default), so the only failure mode left is an explicit `--url` that is + // malformed or uses a non-network scheme. const candidate = options.url ?? getMcpUrl(); - if (!candidate) { - throw new CliError( - "No MCP URL available. Set one with `--url`, or switch to an environment whose profile defines `mcpUrl`.", - { code: ERROR_CODE.MCP_URL_REQUIRED }, - ); - } // Reject non-network schemes so a stray `file:` or `data:` URL can't be // written into an editor's MCP config or probed by `doctor` via fetch. let parsed: URL; @@ -136,15 +133,15 @@ export async function settleClients( const settled = await Promise.allSettled(clients.map(op)); const succeeded: { client: McpClient; result: T }[] = []; const failures: unknown[] = []; - settled.forEach((outcome, i) => { + for (const [i, outcome] of settled.entries()) { const client = clients[i]!; if (outcome.status === "fulfilled") { succeeded.push({ client, result: outcome.value }); - return; + continue; } failures.push(outcome.reason); log.warn(`${client.displayName}: ${errorMessage(outcome.reason)}`); - }); + } if (succeeded.length === 0 && failures.length > 0) throw failures[0]; return succeeded; } diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index 1d103096..f7e339de 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import * as realOs from "node:os"; import { join } from "node:path"; import { useCaptureLog } from "../../test/lib/stubs.ts"; @@ -12,6 +12,11 @@ mock.module("../../mode.ts", () => ({ getMode: () => (mockIsAgent() ? "agent" : "human"), })); +// User-scoped clients write under home; redirect homedir to the cwd tmpdir so +// writes stay isolated and `join(cwd, ...)` reads still resolve. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + const { mcpInstall } = await import("./install.ts"); const { mcpUninstall } = await import("./uninstall.ts"); @@ -24,7 +29,8 @@ describe("mcp uninstall", () => { beforeEach(async () => { originalCwd = process.cwd(); - cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-uninstall-")); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-uninstall-")); + mockHome = cwd; process.chdir(cwd); mockIsAgent.mockReturnValue(true); }); diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index dd780a6d..22fe46ec 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -22,6 +22,9 @@ export interface EnvProfileConfig { mcpUrl?: string; } +/** Clerk's hosted remote MCP server — the default for every environment. */ +const DEFAULT_MCP_URL = "https://mcp.clerk.com/mcp"; + const DEFAULT_PROFILES: Record = { production: { oauthClientId: "ins_1lyWDZiobr600AKUeQDoSlrEmoM", @@ -29,7 +32,7 @@ const DEFAULT_PROFILES: Record = { platformApiUrl: "https://api.clerk.com", backendApiUrl: "https://api.clerk.dev", dashboardUrl: "https://dashboard.clerk.com", - mcpUrl: "https://mcp.clerk.com/mcp", + mcpUrl: DEFAULT_MCP_URL, }, }; @@ -151,11 +154,12 @@ export function getDashboardUrl(): string { /** * Remote MCP server URL for the active environment. * - * Sourced from the active env profile so `switch-env` carries it - * automatically. `CLERK_MCP_URL` overrides for local worker development - * (e.g. `http://localhost:8787/mcp` against the `remote-mcp-server` repo). - * Returns `undefined` if the active profile has no `mcpUrl` configured. + * Resolution: `CLERK_MCP_URL` (local worker dev, e.g. + * `http://localhost:8787/mcp`) > the active env profile's `mcpUrl` + * (carried automatically by `switch-env`) > Clerk's hosted server. Always + * returns a usable URL — the hosted default applies even when a build-time + * profile omits `mcpUrl`, mirroring `getDashboardUrl`. */ -export function getMcpUrl(): string | undefined { - return process.env.CLERK_MCP_URL ?? getCurrentEnv().mcpUrl; +export function getMcpUrl(): string { + return process.env.CLERK_MCP_URL ?? getCurrentEnv().mcpUrl ?? DEFAULT_MCP_URL; } diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 7d745f90..9083fb6f 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -61,7 +61,7 @@ export const ERROR_CODE = { MCP_CLIENT_NOT_SUPPORTED: "mcp_client_not_supported", /** Existing MCP client config is malformed or has a conflicting entry. */ MCP_CLIENT_CONFIG_INVALID: "mcp_client_config_invalid", - /** No MCP URL available — active env profile has no mcpUrl and `--url` not given. */ + /** The provided `--url` is malformed or uses a non-http(s) scheme. */ MCP_URL_REQUIRED: "mcp_url_required", /** No matching MCP entry to remove. */ MCP_NOT_INSTALLED: "mcp_not_installed", From 329cc859faaad4d2a099a245e38f230f06f8cb32 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 4 Jun 2026 09:15:28 -0300 Subject: [PATCH 06/11] fix: treat empty env vars as unset in vscodeUserDir/getMcpUrl + test isolation - vscodeUserDir(): use || instead of ?? so empty APPDATA/XDG_CONFIG_HOME fall back to the homedir-based default instead of producing a relative path - getMcpUrl(): trim and check truthiness so CLERK_MCP_URL="" doesn't win - clients.test.ts, user-scope.test.ts: clear XDG_CONFIG_HOME/APPDATA in beforeEach and restore in afterEach to prevent VS Code path leaking to the runner's real config directory (fixes CI test failure) --- .../src/commands/mcp/clients/clients.test.ts | 17 ++++++++++ .../src/commands/mcp/clients/paths.ts | 6 ++-- .../commands/mcp/clients/user-scope.test.ts | 32 +++++++++++++++++++ packages/cli-core/src/lib/environment.ts | 4 ++- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts index 2fb4bc81..89998347 100644 --- a/packages/cli-core/src/commands/mcp/clients/clients.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -63,11 +63,28 @@ const cases = [ ]; describe("client config paths + encoded shapes (homedir redirected)", () => { + let origXdgConfigHome: string | undefined; + let origAppData: string | undefined; + beforeEach(async () => { mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-clients-")); + origXdgConfigHome = process.env.XDG_CONFIG_HOME; + origAppData = process.env.APPDATA; + process.env.XDG_CONFIG_HOME = ""; + process.env.APPDATA = ""; }); afterEach(async () => { + if (origXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = origXdgConfigHome; + } + if (origAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = origAppData; + } await rm(mockHome, { recursive: true, force: true }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/paths.ts b/packages/cli-core/src/commands/mcp/clients/paths.ts index 0f95bad4..f80b7812 100644 --- a/packages/cli-core/src/commands/mcp/clients/paths.ts +++ b/packages/cli-core/src/commands/mcp/clients/paths.ts @@ -25,13 +25,15 @@ export function userPath(...segments: string[]): string { */ export function vscodeUserDir(): string { const home = homedir(); + const appData = process.env.APPDATA?.trim(); + const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); switch (platform()) { case "win32": - return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Code", "User"); + return join(appData || join(home, "AppData", "Roaming"), "Code", "User"); case "darwin": return join(home, "Library", "Application Support", "Code", "User"); default: - return join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "Code", "User"); + return join(xdgConfigHome || join(home, ".config"), "Code", "User"); } } diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index f1936c1f..6cffb810 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -99,17 +99,33 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { describe("install/uninstall across all clients (homedir + cwd redirected)", () => { let cwd: string; let originalCwd: string; + let origXdgConfigHome: string | undefined; + let origAppData: string | undefined; beforeEach(async () => { originalCwd = process.cwd(); cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-all-cwd-")); mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-all-home-")); + origXdgConfigHome = process.env.XDG_CONFIG_HOME; + origAppData = process.env.APPDATA; + process.env.XDG_CONFIG_HOME = ""; + process.env.APPDATA = ""; process.chdir(cwd); mockIsAgent.mockReturnValue(true); }); afterEach(async () => { process.chdir(originalCwd); + if (origXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = origXdgConfigHome; + } + if (origAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = origAppData; + } await rm(cwd, { recursive: true, force: true }); await rm(mockHome, { recursive: true, force: true }); mockIsAgent.mockReset(); @@ -152,6 +168,8 @@ describe("install/uninstall across all clients (homedir + cwd redirected)", () = describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { let cwd: string; let originalCwd: string; + let origXdgConfigHome: string | undefined; + let origAppData: string | undefined; const originalFetch = globalThis.fetch; // Assign globalThis.fetch directly (cast to its own type) rather than via the @@ -167,12 +185,26 @@ describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { originalCwd = process.cwd(); cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-check-cwd-")); mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-check-home-")); + origXdgConfigHome = process.env.XDG_CONFIG_HOME; + origAppData = process.env.APPDATA; + process.env.XDG_CONFIG_HOME = ""; + process.env.APPDATA = ""; process.chdir(cwd); mockIsAgent.mockReturnValue(true); }); afterEach(async () => { process.chdir(originalCwd); + if (origXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = origXdgConfigHome; + } + if (origAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = origAppData; + } await rm(cwd, { recursive: true, force: true }); await rm(mockHome, { recursive: true, force: true }); globalThis.fetch = originalFetch; diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index 22fe46ec..a695badd 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -161,5 +161,7 @@ export function getDashboardUrl(): string { * profile omits `mcpUrl`, mirroring `getDashboardUrl`. */ export function getMcpUrl(): string { - return process.env.CLERK_MCP_URL ?? getCurrentEnv().mcpUrl ?? DEFAULT_MCP_URL; + const envUrl = process.env.CLERK_MCP_URL?.trim(); + const profileUrl = getCurrentEnv().mcpUrl?.trim(); + return envUrl || profileUrl || DEFAULT_MCP_URL; } From 2a1f32c87ec6e1d5ab8cfa1463a16a2c41bc1cd1 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 8 Jun 2026 14:33:14 -0300 Subject: [PATCH 07/11] fix(mcp): use Clack multiselect for the client picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #305 Clack migration removed @inquirer/prompts, but the mcp install client picker still imported checkbox from it — undeclared after the rebase and broken on a clean install. Add a multiselect wrapper to lib/prompts.ts (matching the existing confirm/text/password Clack wrappers, with the same cancel→throwUserAbort handling) and route pickClients through it. No behavior change to the picker. --- packages/cli-core/src/commands/mcp/shared.ts | 33 ++++++-------------- packages/cli-core/src/lib/prompts.ts | 26 +++++++++++++++ 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index 9dca21db..afb87969 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -2,7 +2,6 @@ * Shared options and helpers for `clerk mcp` subcommands. */ -import { ttyContext } from "../../lib/listage.ts"; import { getMcpUrl } from "../../lib/environment.ts"; import { CliError, ERROR_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; @@ -67,28 +66,16 @@ export function resolveClients(ids: readonly string[]): McpClient[] { export async function pickClients(detected: McpClient[]): Promise { if (detected.length === 0) return []; if (detected.length === 1) return detected; - // Imported lazily (like `doctor`/`update` do): a top-level import of - // `@inquirer/prompts` is resolved at module load, which breaks tests that - // mock the module with a partial shape that omits `checkbox`. - const { checkbox } = await import("@inquirer/prompts"); - const tty = ttyContext(); - try { - const selected = await checkbox( - { - message: "Select MCP clients to install into:", - choices: detected.map((c) => ({ - name: `${c.displayName} (${c.scope})`, - value: c.id, - checked: true, - })), - required: true, - }, - tty ? { input: tty.input } : undefined, - ); - return resolveClients(selected); - } finally { - tty?.close(); - } + // Imported lazily (like `doctor` does) so the prompt layer stays off the + // module-load path for non-interactive callers and tests. + const { multiselect } = await import("../../lib/prompts.ts"); + const selected = await multiselect({ + message: "Select MCP clients to install into:", + options: detected.map((c) => ({ value: c.id, label: `${c.displayName} (${c.scope})` })), + initialValues: detected.map((c) => c.id), + required: true, + }); + return resolveClients(selected); } export async function targetClients(options: McpOptions, cwd: string): Promise { diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index 3c4ff11a..533924e8 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -9,6 +9,8 @@ import { isCancel, text as clackText, password as clackPassword, + multiselect as clackMultiselect, + type Option as ClackOption, } from "@clack/prompts"; import { editAsync } from "external-editor"; import { throwUserAbort } from "./errors.ts"; @@ -80,6 +82,30 @@ export async function confirm(config: { message: string; default?: boolean }): P } } +/** Multi-select checklist. Returns the chosen values (at least one when required). */ +export async function multiselect(config: { + message: string; + options: { value: T; label: string; hint?: string }[]; + initialValues?: T[]; + required?: boolean; +}): Promise { + const tty = ttyContext(); + try { + const result = await clackMultiselect({ + message: config.message, + // `Option` is a conditional type a naked generic can't satisfy + // structurally; our shape provides `value` + `label`, valid in both branches. + options: config.options as ClackOption[], + initialValues: config.initialValues, + required: config.required ?? true, + input: tty?.input, + }); + return unwrap(result); + } finally { + tty?.close(); + } +} + /** Single-line text input. */ export async function text(config: { message: string; From f38f68143d93424581369db7417fc8fe0baa99f7 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 8 Jun 2026 15:04:29 -0300 Subject: [PATCH 08/11] refactor(mcp): adopt Clack gutter/rail conventions for install/list/uninstall Follow the patterns the #305 Clack migration established: wrap install/list/uninstall human flows in withGutter() (guaranteed intro/outro cleanup, paused/failed outros on abort/error), render next steps via outro([...]) instead of a custom printNextSteps helper, and render the list table through the ui.* prompt rail (ui.message) like apps/list. No behavior change to JSON/agent output. --- .../cli-core/src/commands/mcp/install.test.ts | 6 +-- packages/cli-core/src/commands/mcp/install.ts | 54 +++++++++---------- .../cli-core/src/commands/mcp/list.test.ts | 26 +++++---- packages/cli-core/src/commands/mcp/list.ts | 49 ++++++++--------- packages/cli-core/src/commands/mcp/shared.ts | 8 --- .../src/commands/mcp/uninstall.test.ts | 2 +- .../cli-core/src/commands/mcp/uninstall.ts | 27 ++++------ 7 files changed, 80 insertions(+), 92 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/install.test.ts b/packages/cli-core/src/commands/mcp/install.test.ts index d0c7b908..c210699b 100644 --- a/packages/cli-core/src/commands/mcp/install.test.ts +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -171,21 +171,21 @@ describe("mcp install", () => { test("prints next steps with a sign-in reminder after a human-mode install", async () => { await mcpInstall({ client: ["cursor"], url: URL_A }); - expect(captured.err).toContain("Next steps:"); + expect(captured.err).toContain("Next steps"); expect(captured.err).toContain("Reload Cursor"); expect(captured.err).toContain("sign in"); }); test("omits next steps from JSON output", async () => { await mcpInstall({ client: ["cursor"], url: URL_A, json: true }); - expect(captured.err).not.toContain("Next steps:"); + expect(captured.err).not.toContain("Next steps"); }); test("does not print next steps when the entry was unchanged", async () => { await mcpInstall({ client: ["cursor"], url: URL_A }); captured.clear(); await mcpInstall({ client: ["cursor"], url: URL_A }); - expect(captured.err).not.toContain("Next steps:"); + expect(captured.err).not.toContain("Next steps"); }); test("rejects an unknown --client id", async () => { diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts index c8b83b5c..69234566 100644 --- a/packages/cli-core/src/commands/mcp/install.ts +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -8,11 +8,10 @@ import { log } from "../../lib/log.ts"; import { cyan, dim, green, yellow } from "../../lib/color.ts"; -import { isAgent, isHuman } from "../../mode.ts"; -import { intro, outro } from "../../lib/spinner.ts"; +import { isAgent } from "../../mode.ts"; +import { withGutter } from "../../lib/spinner.ts"; import { pickClients, - printNextSteps, resolveName, resolveUrl, settleClients, @@ -55,18 +54,15 @@ type ClientUpsert = { client: McpClient; result: UpsertResult }; // Writing the config isn't enough — the editor must reload before it connects // (and sign in, if the server requires it). Surface that for every client we // just wrote, so "added" doesn't read as "done and working". -function printInstallNextSteps(settled: ClientUpsert[]): void { +function installNextSteps(settled: ClientUpsert[]): string[] { const activated = settled.filter( ({ result }) => result.status === "added" || result.status === "updated", ); - if (activated.length === 0) return; - - printNextSteps(activated.map(({ client }) => `${client.displayName}: ${client.activation}`)); - log.info( - dim( - "If the server requires authentication, your editor opens a browser to sign in on first connect.", - ), - ); + if (activated.length === 0) return []; + return [ + ...activated.map(({ client }) => `${client.displayName}: ${client.activation}`), + "If the server requires authentication, your editor opens a browser to sign in on first connect.", + ]; } export async function mcpInstall(options: McpOptions = {}): Promise { @@ -77,26 +73,24 @@ export async function mcpInstall(options: McpOptions = {}): Promise { const force = Boolean(options.force); const json = wantsJson(options); - if (clients.length === 0 && json) { - log.data(JSON.stringify({ url, name, results: [] }, null, 2)); - return; - } if (clients.length === 0) { - log.warn("No MCP clients selected."); - return; - } - - if (isHuman() && !json) intro(`Installing Clerk MCP (${cyan(url)})`); - - const settled = await settleClients(clients, (c) => c.upsert({ name, url }, cwd, force)); - const results = settled.map((s) => s.result); - - if (json) { - log.data(JSON.stringify({ url, name, results }, null, 2)); + if (json) log.data(JSON.stringify({ url, name, results: [] }, null, 2)); + else log.warn("No MCP clients selected."); return; } - settled.forEach(({ client, result }) => printResult(client, result)); - printInstallNextSteps(settled); - outro("Done"); + await withGutter( + `Installing Clerk MCP (${cyan(url)})`, + async ({ setNextSteps }) => { + const settled = await settleClients(clients, (c) => c.upsert({ name, url }, cwd, force)); + if (json) { + log.data(JSON.stringify({ url, name, results: settled.map((s) => s.result) }, null, 2)); + return; + } + settled.forEach(({ client, result }) => printResult(client, result)); + const steps = installNextSteps(settled); + if (steps.length > 0) setNextSteps(steps); + }, + { skip: json }, + ); } diff --git a/packages/cli-core/src/commands/mcp/list.test.ts b/packages/cli-core/src/commands/mcp/list.test.ts index c36169b6..005c03fb 100644 --- a/packages/cli-core/src/commands/mcp/list.test.ts +++ b/packages/cli-core/src/commands/mcp/list.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import * as realOs from "node:os"; import { join } from "node:path"; -import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { captureUi, useCaptureLog } from "../../test/lib/stubs.ts"; const mockIsAgent = mock(); mock.module("../../mode.ts", () => ({ @@ -24,6 +24,7 @@ const URL = "https://mcp.clerk.com/mcp"; describe("mcp list", () => { const captured = useCaptureLog(); + let uiCapture: ReturnType; let cwd: string; let originalCwd: string; @@ -32,10 +33,13 @@ describe("mcp list", () => { cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-list-")); mockHome = cwd; process.chdir(cwd); + uiCapture = captureUi(); + uiCapture.install(); mockIsAgent.mockReturnValue(true); }); afterEach(async () => { + uiCapture.teardown(); process.chdir(originalCwd); await rm(cwd, { recursive: true, force: true }); mockIsAgent.mockReset(); @@ -60,25 +64,27 @@ describe("mcp list", () => { ]); }); - test("human-mode empty state writes the hint to stderr, nothing to stdout", async () => { + test("human-mode empty state shows the hint on the prompt rail, nothing to stdout", async () => { mockIsAgent.mockReturnValue(false); await mcpList({}); expect(captured.out).toBe(""); - expect(captured.err).toContain("No Clerk MCP entries"); + expect(uiCapture.out).toContain("No Clerk MCP entries"); }); - test("human-mode prints the formatted table to stdout after an install", async () => { + test("human-mode renders the table and next steps after an install", async () => { mockIsAgent.mockReturnValue(true); await mcpInstall({ client: ["cursor"], url: URL }); mockIsAgent.mockReturnValue(false); captured.clear(); await mcpList({}); - expect(captured.out).toContain("cursor"); - expect(captured.out).toContain("clerk"); - expect(captured.out).toContain(URL); - expect(captured.err).toContain("1 entry"); - expect(captured.err).toContain("Next steps:"); - expect(captured.err).toContain("clerk doctor"); + // Table, count, and the outro next-steps all render on the clack prompt + // rail; with captureUi installed, intro/outro write to that stream too. + expect(uiCapture.out).toContain("cursor"); + expect(uiCapture.out).toContain("clerk"); + expect(uiCapture.out).toContain(URL); + expect(uiCapture.out).toContain("1 entry"); + expect(uiCapture.out).toContain("Next steps"); + expect(uiCapture.out).toContain("clerk doctor"); }); }); diff --git a/packages/cli-core/src/commands/mcp/list.ts b/packages/cli-core/src/commands/mcp/list.ts index c61d4451..0ed94079 100644 --- a/packages/cli-core/src/commands/mcp/list.ts +++ b/packages/cli-core/src/commands/mcp/list.ts @@ -7,9 +7,11 @@ import { cyan, dim } from "../../lib/color.ts"; import { log } from "../../lib/log.ts"; +import { withGutter } from "../../lib/spinner.ts"; +import { ui } from "../../lib/ui.ts"; import type { ListEntry } from "./clients/types.ts"; import { collectEntries } from "./collect.ts"; -import { printNextSteps, wantsJson, type McpOptions } from "./shared.ts"; +import { wantsJson, type McpOptions } from "./shared.ts"; const COLUMN_PADDING = 2; @@ -31,36 +33,35 @@ function formatTable(entries: ListEntry[]): void { entries.map((e) => e.url), ); - log.data( - dim(`${"CLIENT".padEnd(clientWidth)}${"NAME".padEnd(nameWidth)}${"URL".padEnd(urlWidth)}PATH`), - ); - for (const entry of entries) { - const client = cyan(entry.client.padEnd(clientWidth)); - const name = entry.name.padEnd(nameWidth); - const url = entry.url.padEnd(urlWidth); - log.data(`${client}${name}${url}${dim(entry.configPath)}`); - } + const header = `${"CLIENT".padEnd(clientWidth)}${"NAME".padEnd(nameWidth)}${"URL".padEnd(urlWidth)}PATH`; + const rows = entries.map((e) => { + const client = cyan(e.client.padEnd(clientWidth)); + const name = e.name.padEnd(nameWidth); + const url = e.url.padEnd(urlWidth); + return `${client}${name}${url}${dim(e.configPath)}`; + }); + + ui.message([dim(header), ...rows]); } export async function mcpList(options: McpOptions = {}): Promise { - const cwd = process.cwd(); - const all: ListEntry[] = await collectEntries(cwd); + const all: ListEntry[] = await collectEntries(process.cwd()); if (wantsJson(options)) { log.data(JSON.stringify(all, null, 2)); return; } - if (all.length === 0) { - log.warn("No Clerk MCP entries found. Run `clerk mcp install` to register one."); - return; - } - - formatTable(all); - log.info(`\n${all.length} entr${all.length === 1 ? "y" : "ies"}`); - - printNextSteps([ - "Verify a server is reachable with `clerk doctor`.", - "Remove an entry with `clerk mcp uninstall`.", - ]); + await withGutter("Listing Clerk MCP entries", async ({ setNextSteps }) => { + if (all.length === 0) { + ui.warn("No Clerk MCP entries found. Run `clerk mcp install` to register one."); + return; + } + formatTable(all); + ui.message(`${all.length} entr${all.length === 1 ? "y" : "ies"}`); + setNextSteps([ + "Verify a server is reachable with `clerk doctor`.", + "Remove an entry with `clerk mcp uninstall`.", + ]); + }); } diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index afb87969..32b9855b 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -98,14 +98,6 @@ export function wantsJson(options: McpOptions): boolean { return Boolean(options.json) || isAgent(); } -/** Render a "Next steps:" block to stderr (human mode). No-op for an empty list. */ -export function printNextSteps(lines: string[]): void { - if (lines.length === 0) return; - log.blank(); - log.info("Next steps:"); - for (const line of lines) log.info(` ${line}`); -} - /** * Run an async op against each client without letting one client's failure * abort the rest — `Promise.all` is fail-fast and would discard every other diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index f7e339de..b919803b 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -101,7 +101,7 @@ describe("mcp uninstall", () => { mockIsAgent.mockReturnValue(false); captured.clear(); await mcpUninstall({ client: ["cursor"] }); - expect(captured.err).toContain("Next steps:"); + expect(captured.err).toContain("Next steps"); expect(captured.err).toContain("Reload Cursor"); }); }); diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts index 6f65c24f..a81e78dc 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -5,12 +5,10 @@ import { cyan, dim, green, yellow } from "../../lib/color.ts"; import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; -import { isHuman } from "../../mode.ts"; -import { intro, outro } from "../../lib/spinner.ts"; +import { withGutter } from "../../lib/spinner.ts"; import { CLIENTS } from "./clients/registry.ts"; import type { McpClient, RemoveResult } from "./clients/types.ts"; import { - printNextSteps, resolveClients, resolveName, settleClients, @@ -32,12 +30,8 @@ export async function mcpUninstall(options: McpOptions = {}): Promise { : Array.from(CLIENTS); const json = wantsJson(options); - if (isHuman() && !json) intro(`Removing MCP entry ${cyan(name)}`); - const settled = await settleClients(clients, (c) => c.remove(name, cwd)); const results = settled.map((s) => s.result); - if (isHuman() && !json) settled.forEach(({ client, result }) => printResult(client, result)); - const removedCount = results.filter((r) => r.removed).length; const notInstalled = new CliError(`No MCP entry named "${name}" found in any client.`, { code: ERROR_CODE.MCP_NOT_INSTALLED, @@ -49,14 +43,15 @@ export async function mcpUninstall(options: McpOptions = {}): Promise { return; } - if (removedCount === 0) throw notInstalled; - // Removing the config entry doesn't drop a live editor session — it lingers - // until the editor reloads. Tell the user for each client we removed from. - printNextSteps( - settled - .filter(({ result }) => result.removed) - .map(({ client }) => `Reload ${client.displayName} to drop the active connection.`), - ); - outro("Done"); + // until the editor reloads. Surface that as a next step per removed client. + await withGutter(`Removing MCP entry ${cyan(name)}`, async ({ setNextSteps }) => { + if (removedCount === 0) throw notInstalled; + settled.forEach(({ client, result }) => printResult(client, result)); + setNextSteps( + settled + .filter(({ result }) => result.removed) + .map(({ client }) => `Reload ${client.displayName} to drop the active connection.`), + ); + }); } From 63d826f33a3d3d3ac3b91f4b85485180bc626418 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 10:34:31 -0300 Subject: [PATCH 09/11] feat(mcp): interactive uninstall picker + rename `claude-code` client to `claude` Uninstall: in human mode with no --client/--all, prompt with a multiselect of the clients that actually have the entry, so you choose exactly which to remove from (mirrors the install picker). Add --all to remove from every client without prompting; agent mode still targets all. --client still targets specific clients. Rename the Claude Code client id from `claude-code` to `claude` (shorter `--client claude`); the file and export follow (claude.ts / claudeClient). Display name stays "Claude Code". Help examples now lead with `--client claude`. --- packages/cli-core/src/cli-program.ts | 24 ++++++++--- packages/cli-core/src/commands/mcp/README.md | 24 ++++++----- .../mcp/clients/{claude-code.ts => claude.ts} | 4 +- .../src/commands/mcp/clients/clients.test.ts | 6 +-- .../src/commands/mcp/clients/registry.ts | 4 +- .../src/commands/mcp/clients/types.ts | 2 +- .../commands/mcp/clients/user-scope.test.ts | 2 +- .../cli-core/src/commands/mcp/install.test.ts | 4 +- packages/cli-core/src/commands/mcp/install.ts | 4 +- packages/cli-core/src/commands/mcp/shared.ts | 10 +++-- .../src/commands/mcp/uninstall.test.ts | 41 +++++++++++++++++++ .../cli-core/src/commands/mcp/uninstall.ts | 32 +++++++++++---- 12 files changed, 120 insertions(+), 37 deletions(-) rename packages/cli-core/src/commands/mcp/clients/{claude-code.ts => claude.ts} (92%) diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 2b4c3585..f5afc0ba 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -472,7 +472,10 @@ export function createProgram() { .description("Manage the Clerk remote MCP server connection for AI editors and CLIs") .setExamples([ { command: "clerk mcp install", description: "Install into all detected MCP clients" }, - { command: "clerk mcp install --client cursor", description: "Install into Cursor only" }, + { + command: "clerk mcp install --client claude", + description: "Install into Claude Code only", + }, { command: "clerk mcp list", description: "Show registered Clerk entries" }, { command: "clerk mcp uninstall", description: "Remove the Clerk entry from all clients" }, ]); @@ -498,7 +501,7 @@ export function createProgram() { }, { command: "clerk mcp install --all", description: "Install into every detected client" }, { - command: "clerk mcp install --client cursor --client vscode", + command: "clerk mcp install --client claude --client vscode", description: "Install into specific clients", }, ]) @@ -515,16 +518,27 @@ export function createProgram() { .command("uninstall") .description("Remove the Clerk MCP entry from supported clients") .addOption( - createOption("--client ", "MCP client to target (repeatable). Default: all clients.") + createOption( + "--client ", + "MCP client to target (repeatable). Default in human mode: pick from installed; in agent mode: all clients.", + ) .choices([...MCP_CLIENT_IDS]) .argParser(collectOptionValues) .default([] as string[]), ) + .option("--all", "Remove from every client without prompting") .option("--name ", 'Entry name to remove (default: "clerk")') .option("--json", "Output as JSON") .setExamples([ - { command: "clerk mcp uninstall", description: "Remove from every client" }, - { command: "clerk mcp uninstall --client cursor", description: "Remove from Cursor only" }, + { + command: "clerk mcp uninstall", + description: "Pick which installed clients to remove from", + }, + { command: "clerk mcp uninstall --all", description: "Remove from every client" }, + { + command: "clerk mcp uninstall --client claude", + description: "Remove from Claude Code only", + }, ]) .action((options) => mcpHandlers.uninstall(options)); diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index 90e070cc..cc566598 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -23,13 +23,13 @@ All entries are written to each client's **user-global** config, so the server is available in every project (no per-project approval, no dependence on which directory you run the CLI from). -| ID | Client | Scope | Config file | -| ------------- | ------------------------ | ----- | --------------------------------------- | -| `claude-code` | Claude Code | user | `~/.claude.json` (`mcpServers`) | -| `cursor` | Cursor | user | `~/.cursor/mcp.json` | -| `vscode` | VS Code (Copilot) | user | VS Code user `mcp.json` (per-OS, below) | -| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | -| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | +| ID | Client | Scope | Config file | +| ---------- | ------------------------ | ----- | --------------------------------------- | +| `claude` | Claude Code | user | `~/.claude.json` (`mcpServers`) | +| `cursor` | Cursor | user | `~/.cursor/mcp.json` | +| `vscode` | VS Code (Copilot) | user | VS Code user `mcp.json` (per-OS, below) | +| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | +| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | VS Code's user config dir is OS-specific: `~/Library/Application Support/Code/User/mcp.json` (macOS), `%APPDATA%\Code\User\mcp.json` (Windows), `$XDG_CONFIG_HOME/Code/User/mcp.json` @@ -68,9 +68,13 @@ named `clerk` or pointing at any `*.clerk.com` host). ### `clerk mcp uninstall` -Remove the named entry from each client. Throws `mcp_not_installed` (exit -code 1) when nothing was removed. Removing the entry doesn't drop a live editor -session, so (in human mode) it prints a next step to reload each affected editor. +Remove the named entry. In human mode with no `--client`/`--all`, it prompts +with a multiselect of the clients that **currently have the entry**, so you +choose exactly which to remove from. `--all` removes from every client without +prompting; agent mode targets all clients; `--client ` (repeatable) targets +specific clients. Throws `mcp_not_installed` (exit code 1) when nothing matched. +Removing the entry doesn't drop a live editor session, so (in human mode) it +prints a next step to reload each affected editor. > **Reachability:** there is no `mcp doctor` subcommand. Server health is part > of `clerk doctor`, which probes the configured MCP URL via the `initialize` diff --git a/packages/cli-core/src/commands/mcp/clients/claude-code.ts b/packages/cli-core/src/commands/mcp/clients/claude.ts similarity index 92% rename from packages/cli-core/src/commands/mcp/clients/claude-code.ts rename to packages/cli-core/src/commands/mcp/clients/claude.ts index 1b8affb8..cc372a97 100644 --- a/packages/cli-core/src/commands/mcp/clients/claude-code.ts +++ b/packages/cli-core/src/commands/mcp/clients/claude.ts @@ -8,8 +8,8 @@ import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; import { pathExists, userPath } from "./paths.ts"; -export const claudeCodeClient = makeJsonClient({ - id: "claude-code", +export const claudeClient = makeJsonClient({ + id: "claude", displayName: "Claude Code", scope: "user", activation: "Restart Claude Code, then run `/mcp` to connect (sign in if prompted).", diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts index 89998347..1aebbfac 100644 --- a/packages/cli-core/src/commands/mcp/clients/clients.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -11,7 +11,7 @@ let mockHome = realOs.tmpdir(); mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); afterAll(() => mock.restore()); -const { claudeCodeClient } = await import("./claude-code.ts"); +const { claudeClient } = await import("./claude.ts"); const { cursorClient } = await import("./cursor.ts"); const { vscodeClient } = await import("./vscode.ts"); const { windsurfClient } = await import("./windsurf.ts"); @@ -26,8 +26,8 @@ const URL = "https://mcp.clerk.com/mcp"; // specific user-global config file and encodes the server its own way. const cases = [ { - name: "claude-code", - client: claudeCodeClient, + name: "claude", + client: claudeClient, expectedPath: () => join(mockHome, ".claude.json"), topKey: "mcpServers", shape: { type: "http", url: URL }, diff --git a/packages/cli-core/src/commands/mcp/clients/registry.ts b/packages/cli-core/src/commands/mcp/clients/registry.ts index 109fc6d9..e278accd 100644 --- a/packages/cli-core/src/commands/mcp/clients/registry.ts +++ b/packages/cli-core/src/commands/mcp/clients/registry.ts @@ -3,7 +3,7 @@ * human-mode multiselect picker. */ -import { claudeCodeClient } from "./claude-code.ts"; +import { claudeClient } from "./claude.ts"; import { cursorClient } from "./cursor.ts"; import { geminiClient } from "./gemini.ts"; import type { ClientId, McpClient } from "./types.ts"; @@ -11,7 +11,7 @@ import { vscodeClient } from "./vscode.ts"; import { windsurfClient } from "./windsurf.ts"; export const CLIENTS: readonly McpClient[] = [ - claudeCodeClient, + claudeClient, cursorClient, vscodeClient, windsurfClient, diff --git a/packages/cli-core/src/commands/mcp/clients/types.ts b/packages/cli-core/src/commands/mcp/clients/types.ts index 5a05e3fc..098aca49 100644 --- a/packages/cli-core/src/commands/mcp/clients/types.ts +++ b/packages/cli-core/src/commands/mcp/clients/types.ts @@ -6,7 +6,7 @@ * the `clerk` server entry in its own config file format. */ -export type ClientId = "claude-code" | "cursor" | "vscode" | "windsurf" | "gemini"; +export type ClientId = "claude" | "cursor" | "vscode" | "windsurf" | "gemini"; /** Where the client config file lives relative to the user / project. */ export type Scope = "project" | "user"; diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index 6cffb810..f2dad05e 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -30,7 +30,7 @@ const { checkMcp } = await import("../../doctor/check-mcp.ts"); const captured = useCaptureLog(); const URL = "https://mcp.clerk.com/mcp"; -const ALL_CLIENT_IDS = ["claude-code", "cursor", "vscode", "windsurf", "gemini"]; +const ALL_CLIENT_IDS = ["claude", "cursor", "vscode", "windsurf", "gemini"]; describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { beforeEach(async () => { diff --git a/packages/cli-core/src/commands/mcp/install.test.ts b/packages/cli-core/src/commands/mcp/install.test.ts index c210699b..722dc034 100644 --- a/packages/cli-core/src/commands/mcp/install.test.ts +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -200,11 +200,11 @@ describe("mcp install", () => { await mkdir(join(cwd, ".cursor"), { recursive: true }); await writeFile(join(cwd, ".cursor", "mcp.json"), "{ not json"); - await mcpInstall({ client: ["cursor", "claude-code"], url: URL_A }); + await mcpInstall({ client: ["cursor", "claude"], url: URL_A }); const payload = JSON.parse(captured.out) as { results: { client: string; status: string }[] }; expect(payload.results).toEqual([ - expect.objectContaining({ client: "claude-code", status: "added" }), + expect.objectContaining({ client: "claude", status: "added" }), ]); expect(captured.err).toContain("Cursor"); // per-client warning for the failure }); diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts index 69234566..dffa5508 100644 --- a/packages/cli-core/src/commands/mcp/install.ts +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -24,7 +24,9 @@ import type { McpClient, UpsertResult } from "./clients/types.ts"; async function chooseClients(options: McpOptions, cwd: string): Promise { if (options.client?.length || options.all || isAgent()) return targetClients(options, cwd); - return pickClients(await detectInstalledClients(cwd)); + return pickClients(await detectInstalledClients(cwd), "Select MCP clients to install into:", { + autoSelectSingle: true, + }); } function statusLabel(status: UpsertResult["status"]): string { diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index 32b9855b..693cb961 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -63,14 +63,18 @@ export function resolveClients(ids: readonly string[]): McpClient[] { }); } -export async function pickClients(detected: McpClient[]): Promise { +export async function pickClients( + detected: McpClient[], + message: string, + options: { autoSelectSingle?: boolean } = {}, +): Promise { if (detected.length === 0) return []; - if (detected.length === 1) return detected; + if (detected.length === 1 && options.autoSelectSingle) return detected; // Imported lazily (like `doctor` does) so the prompt layer stays off the // module-load path for non-interactive callers and tests. const { multiselect } = await import("../../lib/prompts.ts"); const selected = await multiselect({ - message: "Select MCP clients to install into:", + message, options: detected.map((c) => ({ value: c.id, label: `${c.displayName} (${c.scope})` })), initialValues: detected.map((c) => c.id), required: true, diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index b919803b..9064dc9e 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -17,6 +17,13 @@ mock.module("../../mode.ts", () => ({ let mockHome = realOs.tmpdir(); mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); +// The human picker resolves the Clack multiselect lazily from lib/prompts.ts; +// stub it so tests drive which clients get selected without a real prompt. +const mockMultiselect = mock(); +mock.module("../../lib/prompts.ts", () => ({ + multiselect: (...args: unknown[]) => mockMultiselect(...args), +})); + const { mcpInstall } = await import("./install.ts"); const { mcpUninstall } = await import("./uninstall.ts"); @@ -39,6 +46,7 @@ describe("mcp uninstall", () => { process.chdir(originalCwd); await rm(cwd, { recursive: true, force: true }); mockIsAgent.mockReset(); + mockMultiselect.mockReset(); }); test("removes the entry an install-uninstall round-trip leaves no trace under mcpServers", async () => { @@ -104,4 +112,37 @@ describe("mcp uninstall", () => { expect(captured.err).toContain("Next steps"); expect(captured.err).toContain("Reload Cursor"); }); + + test("human mode: prompts to pick which installed clients to remove from", async () => { + await mcpInstall({ client: ["cursor", "claude"], url: URL }); + mockIsAgent.mockReturnValue(false); + mockMultiselect.mockResolvedValueOnce(["cursor"]); // pick only Cursor + captured.clear(); + + await mcpUninstall({}); + + expect(mockMultiselect).toHaveBeenCalledTimes(1); + const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(cursorCfg.mcpServers.clerk).toBeUndefined(); // removed + const claudeCfg = JSON.parse(await readFile(join(cwd, ".claude.json"), "utf8")) as { + mcpServers: Record; + }; + expect(claudeCfg.mcpServers.clerk).toBeDefined(); // untouched + }); + + test("human mode: --all removes from every client without prompting", async () => { + await mcpInstall({ client: ["cursor", "claude"], url: URL }); + mockIsAgent.mockReturnValue(false); + captured.clear(); + + await mcpUninstall({ all: true }); + + expect(mockMultiselect).not.toHaveBeenCalled(); + const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(cursorCfg.mcpServers.clerk).toBeUndefined(); + }); }); diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts index a81e78dc..70623770 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -5,10 +5,12 @@ import { cyan, dim, green, yellow } from "../../lib/color.ts"; import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; import { withGutter } from "../../lib/spinner.ts"; import { CLIENTS } from "./clients/registry.ts"; import type { McpClient, RemoveResult } from "./clients/types.ts"; import { + pickClients, resolveClients, resolveName, settleClients, @@ -21,21 +23,37 @@ function printResult(client: McpClient, result: RemoveResult): void { log.info(`${label}: ${result.removed ? green("removed") : yellow("not present")}`); } +/** Supported clients that currently have the `name` entry registered. */ +async function installedClients(name: string, cwd: string): Promise { + const present = await Promise.all( + CLIENTS.map(async (client) => (await client.list(cwd)).some((entry) => entry.name === name)), + ); + return CLIENTS.filter((_, i) => present[i]); +} + +// `--client` targets exactly those; `--all` (and agent mode, which can't +// prompt) targets every client; otherwise the human picks which of the clients +// that actually have the entry to remove it from. +async function chooseClients(options: McpOptions, name: string, cwd: string): Promise { + if (options.client && options.client.length > 0) return resolveClients(options.client); + if (options.all || isAgent()) return Array.from(CLIENTS); + return pickClients(await installedClients(name, cwd), `Select clients to remove "${name}" from:`); +} + export async function mcpUninstall(options: McpOptions = {}): Promise { const name = resolveName(options); const cwd = process.cwd(); - const clients = - options.client && options.client.length > 0 - ? resolveClients(options.client) - : Array.from(CLIENTS); const json = wantsJson(options); + const notInstalled = new CliError(`No MCP entry named "${name}" found in any client.`, { + code: ERROR_CODE.MCP_NOT_INSTALLED, + }); + + const clients = await chooseClients(options, name, cwd); + if (clients.length === 0) throw notInstalled; const settled = await settleClients(clients, (c) => c.remove(name, cwd)); const results = settled.map((s) => s.result); const removedCount = results.filter((r) => r.removed).length; - const notInstalled = new CliError(`No MCP entry named "${name}" found in any client.`, { - code: ERROR_CODE.MCP_NOT_INSTALLED, - }); if (json) { log.data(JSON.stringify({ name, results }, null, 2)); From 364d22b8764b086f4793e24dfc67722057f961dd Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 10:40:02 -0300 Subject: [PATCH 10/11] refactor(mcp): drop explanatory comments added in the uninstall picker --- packages/cli-core/src/commands/mcp/uninstall.test.ts | 2 -- packages/cli-core/src/commands/mcp/uninstall.ts | 4 ---- 2 files changed, 6 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index 9064dc9e..56aeec07 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -17,8 +17,6 @@ mock.module("../../mode.ts", () => ({ let mockHome = realOs.tmpdir(); mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); -// The human picker resolves the Clack multiselect lazily from lib/prompts.ts; -// stub it so tests drive which clients get selected without a real prompt. const mockMultiselect = mock(); mock.module("../../lib/prompts.ts", () => ({ multiselect: (...args: unknown[]) => mockMultiselect(...args), diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts index 70623770..ef0b1acd 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -23,7 +23,6 @@ function printResult(client: McpClient, result: RemoveResult): void { log.info(`${label}: ${result.removed ? green("removed") : yellow("not present")}`); } -/** Supported clients that currently have the `name` entry registered. */ async function installedClients(name: string, cwd: string): Promise { const present = await Promise.all( CLIENTS.map(async (client) => (await client.list(cwd)).some((entry) => entry.name === name)), @@ -31,9 +30,6 @@ async function installedClients(name: string, cwd: string): Promise return CLIENTS.filter((_, i) => present[i]); } -// `--client` targets exactly those; `--all` (and agent mode, which can't -// prompt) targets every client; otherwise the human picks which of the clients -// that actually have the entry to remove it from. async function chooseClients(options: McpOptions, name: string, cwd: string): Promise { if (options.client && options.client.length > 0) return resolveClients(options.client); if (options.all || isAgent()) return Array.from(CLIENTS); From 19b28207738bf8f9eed536e4b813ed6c840c0a25 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 11:58:35 -0300 Subject: [PATCH 11/11] feat(mcp): add Codex client, GitHub Copilot alias, refine uninstall/doctor - Add Codex MCP client backed by ~/.codex/config.toml (smol-toml + a makeTomlClient codec on the shared makeFileClient factory); uses Codex's native Streamable HTTP transport, no mcp-remote bridge - Rename the VS Code client display to "GitHub Copilot"; accept `--client copilot` as an alias for `vscode` (same config) - uninstall: prompt lists only clients that have the entry, nothing pre-checked (select-to-remove), and warns how to install instead of erroring when nothing is registered - doctor: probe every distinct configured MCP URL (not just the first); lower the probe timeout to 5s - resolveClients dedupes aliases/repeats; remove dead projectPath export --- .changeset/mcp-install.md | 2 +- bun.lock | 3 + packages/cli-core/package.json | 1 + packages/cli-core/src/cli-program.ts | 9 ++- .../cli-core/src/commands/doctor/README.md | 22 +++--- .../cli-core/src/commands/doctor/check-mcp.ts | 37 +++++++--- .../src/commands/doctor/context.test.ts | 2 +- packages/cli-core/src/commands/mcp/README.md | 53 ++++++++------ .../src/commands/mcp/clients/clients.test.ts | 12 ++++ .../src/commands/mcp/clients/codex.ts | 20 ++++++ .../commands/mcp/clients/make-json-client.ts | 49 +++++++++---- .../src/commands/mcp/clients/paths.ts | 4 -- .../src/commands/mcp/clients/registry.ts | 11 +++ .../src/commands/mcp/clients/toml-config.ts | 49 +++++++++++++ .../src/commands/mcp/clients/types.ts | 12 ++-- .../commands/mcp/clients/user-scope.test.ts | 61 +++++++++++++++- .../src/commands/mcp/clients/vscode.ts | 2 +- packages/cli-core/src/commands/mcp/index.ts | 2 +- packages/cli-core/src/commands/mcp/probe.ts | 3 +- packages/cli-core/src/commands/mcp/shared.ts | 20 ++++-- .../src/commands/mcp/uninstall.test.ts | 54 ++++++++++---- .../cli-core/src/commands/mcp/uninstall.ts | 71 ++++++++++++++----- packages/cli-core/src/lib/errors.ts | 2 - 23 files changed, 383 insertions(+), 118 deletions(-) create mode 100644 packages/cli-core/src/commands/mcp/clients/codex.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/toml-config.ts diff --git a/.changeset/mcp-install.md b/.changeset/mcp-install.md index 331d7cc1..897d39d9 100644 --- a/.changeset/mcp-install.md +++ b/.changeset/mcp-install.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, VS Code, Windsurf, and Gemini. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`), so the server is available across every project regardless of the directory you run the CLI from. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. By default the commands target Clerk's hosted server, so `clerk mcp install` works with no flags. The URL resolves in order: `--url` > the `CLERK_MCP_URL` override (for local worker development) > the active env profile's new `mcpUrl` field > the hosted server. +Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, GitHub Copilot (VS Code; `--client vscode` or `--client copilot`), Windsurf, Gemini, and Codex. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`, `~/.codex/config.toml`), so the server is available across every project regardless of the directory you run the CLI from. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. By default the commands target Clerk's hosted server, so `clerk mcp install` works with no flags. The URL resolves in order: `--url` > the `CLERK_MCP_URL` override (for local worker development) > the active env profile's new `mcpUrl` field > the hosted server. diff --git a/bun.lock b/bun.lock index 878c5435..94a20c07 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,7 @@ "external-editor": "^3.1.0", "magicast": "^0.5.3", "semver": "^7.8.1", + "smol-toml": "^1.6.1", "yaml": "^2.9.0", }, "devDependencies": { @@ -416,6 +417,8 @@ "slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "smol-toml": ["smol-toml@1.6.1", "", {}, "sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "spawndamnit": ["spawndamnit@3.0.1", "", { "dependencies": { "cross-spawn": "^7.0.5", "signal-exit": "^4.0.1" } }, "sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg=="], diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index 92e95b00..689894b8 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -26,6 +26,7 @@ "external-editor": "^3.1.0", "magicast": "^0.5.3", "semver": "^7.8.1", + "smol-toml": "^1.6.1", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index f5afc0ba..23cb1592 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -15,7 +15,10 @@ import { link } from "./commands/link/index.ts"; import { unlink } from "./commands/unlink/index.ts"; import { apps as appsHandlers } from "./commands/apps/index.ts"; import { users as usersHandlers } from "./commands/users/index.ts"; -import { mcp as mcpHandlers, CLIENT_IDS as MCP_CLIENT_IDS } from "./commands/mcp/index.ts"; +import { + mcp as mcpHandlers, + CLIENT_ID_CHOICES as MCP_CLIENT_CHOICES, +} from "./commands/mcp/index.ts"; import { doctor } from "./commands/doctor/index.ts"; import { switchEnv } from "./commands/switch-env/index.ts"; import { openDashboard } from "./commands/open/index.ts"; @@ -485,7 +488,7 @@ export function createProgram() { .description("Register the Clerk remote MCP server in supported clients") .addOption( createOption("--client ", "MCP client to target (repeatable). Default: all detected.") - .choices([...MCP_CLIENT_IDS]) + .choices([...MCP_CLIENT_CHOICES]) .argParser(collectOptionValues) .default([] as string[]), ) @@ -522,7 +525,7 @@ export function createProgram() { "--client ", "MCP client to target (repeatable). Default in human mode: pick from installed; in agent mode: all clients.", ) - .choices([...MCP_CLIENT_IDS]) + .choices([...MCP_CLIENT_CHOICES]) .argParser(collectOptionValues) .default([] as string[]), ) diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index 1ead10e2..95f44288 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -25,17 +25,17 @@ clerk doctor --fix # Offer to auto-fix issues ## Checks -| Check | Category | What it verifies | -| --------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Authentication token | Authentication | Credential store has a stored token | -| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | -| Project linkage | Project | Current directory is linked to a Clerk app | -| Linked application | Project | Linked application ID is accessible via the API | -| Instances | Project | Configured dev/prod instance IDs match the application's instances | -| Environment variables | Environment | .env.local or .env has Clerk keys | -| CLI configuration | Configuration | CLI config file exists and parses | -| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | -| MCP server | Integration | If a Clerk MCP entry is installed, the configured server answers the `initialize` handshake (skipped otherwise; warns, never fails) | +| Check | Category | What it verifies | +| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Authentication token | Authentication | Credential store has a stored token | +| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | +| Project linkage | Project | Current directory is linked to a Clerk app | +| Linked application | Project | Linked application ID is accessible via the API | +| Instances | Project | Configured dev/prod instance IDs match the application's instances | +| Environment variables | Environment | .env.local or .env has Clerk keys | +| CLI configuration | Configuration | CLI config file exists and parses | +| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | +| MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake (skipped otherwise; warns, never fails) | ## Auto-Fix (`--fix`) diff --git a/packages/cli-core/src/commands/doctor/check-mcp.ts b/packages/cli-core/src/commands/doctor/check-mcp.ts index a897670c..f8894249 100644 --- a/packages/cli-core/src/commands/doctor/check-mcp.ts +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -7,11 +7,25 @@ */ import { collectEntries } from "../mcp/collect.ts"; -import { probeMcp } from "../mcp/probe.ts"; +import { probeMcp, type McpProbeResult } from "../mcp/probe.ts"; import type { CheckResult } from "./types.ts"; const NAME = "MCP server"; +type UrlProbe = { url: string; result: McpProbeResult }; + +function describeReachable(probes: UrlProbe[]): string { + return probes + .map(({ url, result }) => (result.ok ? `${result.serverName} (${url})` : url)) + .join(", "); +} + +function describeFailure(result: McpProbeResult): string { + if (result.ok) return "unknown"; + if (result.error !== undefined) return result.error; + return result.status !== undefined ? `HTTP ${result.status}` : "unknown"; +} + export async function checkMcp(): Promise { // Only meaningful if the user actually registered a Clerk MCP entry — // otherwise skip silently rather than probing a server they don't use. @@ -20,19 +34,24 @@ export async function checkMcp(): Promise { return { name: NAME, status: "pass", message: "Skipped (no Clerk MCP entry installed)" }; } - const url = entries[0]!.url; - const result = await probeMcp(url); - if (result.ok) { - return { name: NAME, status: "pass", message: `Reachable — ${result.serverName} (${url})` }; + // Clients can point at different URLs (e.g. local dev in one, hosted in + // another), so probe every distinct one — a healthy first entry must not mask + // a broken second. + const urls = [...new Set(entries.map((e) => e.url))]; + const probes = await Promise.all(urls.map(async (url) => ({ url, result: await probeMcp(url) }))); + + const unreachable = probes.find(({ result }) => !result.ok); + if (!unreachable) { + return { name: NAME, status: "pass", message: `Reachable — ${describeReachable(probes)}` }; } - const detail = - result.error ?? (result.status !== undefined ? `HTTP ${result.status}` : "unknown"); + const subject = + probes.length === 1 ? "Configured MCP server is" : "One or more configured MCP servers are"; return { name: NAME, status: "warn", - message: `Configured MCP server is not reachable (${url})`, - detail, + message: `${subject} not reachable (${unreachable.url})`, + detail: describeFailure(unreachable.result), remedy: "Verify the server is running, or re-run `clerk mcp install` if the URL changed.", }; } diff --git a/packages/cli-core/src/commands/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index 79e6d4e6..d0e852eb 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -66,7 +66,7 @@ describe("createDoctorContext", () => { const p1 = ctx.getToken(); const p2 = ctx.getToken(); - expect(p1).toBe(p2); // Same promise reference + expect(p1).toBe(p2); expect(await p1).toBe("test_token"); expect(mockGetToken).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index cc566598..f0bc0670 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -14,8 +14,8 @@ environment variable > the active environment profile's `mcpUrl` field (e.g. `http://localhost:8787/mcp`). No Clerk API endpoints are called. To verify the server is reachable, run -`clerk doctor` — its MCP check performs the `initialize` handshake against the -configured URL whenever a Clerk MCP entry is installed. +`clerk doctor` — its MCP check performs the `initialize` handshake against each +distinct configured URL whenever a Clerk MCP entry is installed. ## Supported clients @@ -23,18 +23,25 @@ All entries are written to each client's **user-global** config, so the server is available in every project (no per-project approval, no dependence on which directory you run the CLI from). -| ID | Client | Scope | Config file | -| ---------- | ------------------------ | ----- | --------------------------------------- | -| `claude` | Claude Code | user | `~/.claude.json` (`mcpServers`) | -| `cursor` | Cursor | user | `~/.cursor/mcp.json` | -| `vscode` | VS Code (Copilot) | user | VS Code user `mcp.json` (per-OS, below) | -| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | -| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | - -VS Code's user config dir is OS-specific: `~/Library/Application Support/Code/User/mcp.json` -(macOS), `%APPDATA%\Code\User\mcp.json` (Windows), `$XDG_CONFIG_HOME/Code/User/mcp.json` +| ID | Client | Scope | Config file | +| -------------------- | ------------------------ | ----- | --------------------------------------- | +| `claude` | Claude Code | user | `~/.claude.json` (`mcpServers`) | +| `cursor` | Cursor | user | `~/.cursor/mcp.json` | +| `vscode` (`copilot`) | GitHub Copilot (VS Code) | user | VS Code user `mcp.json` (per-OS, below) | +| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | +| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | +| `codex` | Codex | user | `~/.codex/config.toml` (`mcp_servers`) | + +GitHub Copilot's MCP server lives in VS Code's config, so `--client copilot` and +`--client vscode` are aliases for the same client. Its user config dir is +OS-specific: `~/Library/Application Support/Code/User/mcp.json` (macOS), +`%APPDATA%\Code\User\mcp.json` (Windows), `$XDG_CONFIG_HOME/Code/User/mcp.json` (Linux) — the file behind **MCP: Open User Configuration**. +Codex is the one TOML-backed client; the entry uses Codex's native Streamable +HTTP transport (`url = "…"` under `[mcp_servers.]`), so it needs no +`mcp-remote` bridge. Rewriting `config.toml` does not preserve comments. + ## Subcommands ### `clerk mcp install` @@ -68,17 +75,20 @@ named `clerk` or pointing at any `*.clerk.com` host). ### `clerk mcp uninstall` -Remove the named entry. In human mode with no `--client`/`--all`, it prompts -with a multiselect of the clients that **currently have the entry**, so you -choose exactly which to remove from. `--all` removes from every client without -prompting; agent mode targets all clients; `--client ` (repeatable) targets -specific clients. Throws `mcp_not_installed` (exit code 1) when nothing matched. -Removing the entry doesn't drop a live editor session, so (in human mode) it -prints a next step to reload each affected editor. +Remove the entry. In human mode with no `--client`/`--all`, it prompts with a +multiselect of the clients that **currently have the entry**, all unchecked: +check the clients to remove the entry from and leave the rest unchecked, so the +default (nothing checked) removes nothing. `--all` removes from every client +without prompting; agent mode targets all clients; `--client ` (repeatable) +targets specific clients. When nothing matches, it prints a warm hint to run +`clerk mcp install` (no error, exit 0). Removing the entry doesn't drop a live +editor session, so (in human mode) it prints a next step to reload each affected +editor. > **Reachability:** there is no `mcp doctor` subcommand. Server health is part -> of `clerk doctor`, which probes the configured MCP URL via the `initialize` -> handshake when an entry is installed (warns, does not fail, when unreachable). +> of `clerk doctor`, which probes each distinct configured MCP URL via the +> `initialize` handshake when an entry is installed (warns, does not fail, when +> any is unreachable). ## Error codes @@ -88,4 +98,3 @@ prints a next step to reload each affected editor. | `mcp_client_not_supported` | `--client ` is not in the supported list. | | `mcp_client_config_invalid` | An existing client config file is malformed. | | `mcp_url_required` | The provided `--url` is malformed or uses a non-http(s) scheme. | -| `mcp_not_installed` | `uninstall` removed nothing because no entry matched. | diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts index 1aebbfac..3d45a8f4 100644 --- a/packages/cli-core/src/commands/mcp/clients/clients.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -111,4 +111,16 @@ describe("client config paths + encoded shapes (homedir redirected)", () => { expect(parsed.servers).toBeDefined(); expect(parsed.mcpServers).toBeUndefined(); }); + + test("`copilot` resolves to the same client as `vscode`", async () => { + const { resolveClients } = await import("../shared.ts"); + expect(resolveClients(["copilot"])).toEqual([vscodeClient]); + expect(resolveClients(["copilot"])).toEqual(resolveClients(["vscode"])); + }); + + test("resolveClients dedupes an alias and its canonical id to one client", async () => { + const { resolveClients } = await import("../shared.ts"); + expect(resolveClients(["copilot", "vscode"])).toEqual([vscodeClient]); + expect(resolveClients(["cursor", "cursor"])).toEqual([cursorClient]); + }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/codex.ts b/packages/cli-core/src/commands/mcp/clients/codex.ts new file mode 100644 index 00000000..b0efc5dd --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/codex.ts @@ -0,0 +1,20 @@ +/** + * Writes to `~/.codex/config.toml` under the `[mcp_servers.]` table. + * Codex supports Streamable HTTP MCP servers directly, so the descriptor is + * just `{ url }` — no `mcp-remote` stdio bridge (unlike Gemini). + */ + +import { hasStringProp, makeTomlClient } from "./make-json-client.ts"; +import { pathExists, userPath } from "./paths.ts"; + +export const codexClient = makeTomlClient({ + id: "codex", + displayName: "Codex", + scope: "user", + activation: "Restart Codex; it opens a browser to sign in if the server requires it.", + topKey: "mcp_servers", + encode: (url) => ({ url }), + extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + configPath: () => userPath(".codex", "config.toml"), + detect: () => pathExists(userPath(".codex")), +}); diff --git a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts index a39355f7..13d6bc3e 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts @@ -1,17 +1,19 @@ /** - * Factory for JSON-backed MCP clients. + * Factory for file-backed MCP clients. * - * Five of the supported clients share the same upsert/remove/list shape — a - * JSON file with a top-level object whose keys are server names and whose - * values are per-client server descriptors. The only differences are the - * top-level key name (`mcpServers` vs `servers`) and the descriptor encoding - * (`{ url }` vs `{ serverUrl }` vs `{ command, args }`). This factory captures - * those differences as `topKey` + `encode` + `extractUrl` and reuses the rest. + * Every supported client shares the same upsert/remove/list shape — a config + * file with a top-level map whose keys are server names and whose values are + * per-client server descriptors. The differences are the serialization format + * (JSON for five clients, TOML for Codex), the top-level key name (`mcpServers` + * vs `servers` vs `mcp_servers`) and the descriptor encoding (`{ url }` vs + * `{ serverUrl }` vs `{ command, args }`). This factory captures those as a + * codec + `topKey` + `encode` + `extractUrl` and reuses the rest. */ import { CliError, ERROR_CODE } from "../../../lib/errors.ts"; import { log } from "../../../lib/log.ts"; import { getServerMap, readJsonConfig, writeJsonConfig, type JsonConfig } from "./json-config.ts"; +import { readTomlConfig, writeTomlConfig } from "./toml-config.ts"; import type { ClientId, ListEntry, @@ -34,7 +36,7 @@ export function hasStringProp( ); } -interface JsonClientSpec { +interface FileClientSpec { id: ClientId; displayName: string; scope: Scope; @@ -48,6 +50,15 @@ interface JsonClientSpec { detect: (cwd: string) => Promise; } +/** Read/write codec abstracting the on-disk format (JSON vs TOML). */ +interface ConfigCodec { + read: (path: string) => Promise; + write: (path: string, config: JsonConfig) => Promise; +} + +const JSON_CODEC: ConfigCodec = { read: readJsonConfig, write: writeJsonConfig }; +const TOML_CODEC: ConfigCodec = { read: readTomlConfig, write: writeTomlConfig }; + function isClerkUrl(url: string): boolean { try { const parsed = new URL(url); @@ -57,7 +68,7 @@ function isClerkUrl(url: string): boolean { } } -export function makeJsonClient(spec: JsonClientSpec): McpClient { +function makeFileClient(spec: FileClientSpec, codec: ConfigCodec): McpClient { return { id: spec.id, displayName: spec.displayName, @@ -68,7 +79,7 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { async upsert(entry: McpServerEntry, cwd: string, force: boolean): Promise { const configPath = spec.configPath(cwd); - const config = await readJsonConfig(configPath); + const config = await codec.read(configPath); const servers = getServerMap(config, spec.topKey, configPath); // Own-property only: `servers[name]` / `name in servers` would walk the @@ -94,7 +105,7 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { const desired = spec.encode(entry.url); const next: JsonConfig = { ...config, [spec.topKey]: { ...servers, [entry.name]: desired } }; - await writeJsonConfig(configPath, next); + await codec.write(configPath, next); const status = hasExisting ? "updated" : "added"; log.debug(`mcp: ${spec.id} ${status} "${entry.name}"`); return { client: spec.id, configPath, status }; @@ -102,14 +113,14 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { async remove(name: string, cwd: string): Promise { const configPath = spec.configPath(cwd); - const config = await readJsonConfig(configPath); + const config = await codec.read(configPath); const servers = getServerMap(config, spec.topKey, configPath); if (!Object.prototype.hasOwnProperty.call(servers, name)) { return { client: spec.id, configPath, removed: false }; } const { [name]: _removed, ...rest } = servers; const next: JsonConfig = { ...config, [spec.topKey]: rest }; - await writeJsonConfig(configPath, next); + await codec.write(configPath, next); log.debug(`mcp: ${spec.id} removed "${name}"`); return { client: spec.id, configPath, removed: true }; }, @@ -122,7 +133,7 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { // and getServerMap raise MCP_CLIENT_CONFIG_INVALID, so they share one guard. let servers: Record; try { - const config = await readJsonConfig(configPath); + const config = await codec.read(configPath); servers = getServerMap(config, spec.topKey, configPath); } catch (error) { if (error instanceof CliError && error.code === ERROR_CODE.MCP_CLIENT_CONFIG_INVALID) { @@ -145,3 +156,13 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { }, }; } + +/** A client whose config is a JSON file (Claude Code, Cursor, VS Code, Windsurf, Gemini). */ +export function makeJsonClient(spec: FileClientSpec): McpClient { + return makeFileClient(spec, JSON_CODEC); +} + +/** A client whose config is a TOML file (Codex). */ +export function makeTomlClient(spec: FileClientSpec): McpClient { + return makeFileClient(spec, TOML_CODEC); +} diff --git a/packages/cli-core/src/commands/mcp/clients/paths.ts b/packages/cli-core/src/commands/mcp/clients/paths.ts index f80b7812..36d7fbb8 100644 --- a/packages/cli-core/src/commands/mcp/clients/paths.ts +++ b/packages/cli-core/src/commands/mcp/clients/paths.ts @@ -10,10 +10,6 @@ import { stat } from "node:fs/promises"; import { homedir, platform } from "node:os"; import { join } from "node:path"; -export function projectPath(cwd: string, ...segments: string[]): string { - return join(cwd, ...segments); -} - export function userPath(...segments: string[]): string { return join(homedir(), ...segments); } diff --git a/packages/cli-core/src/commands/mcp/clients/registry.ts b/packages/cli-core/src/commands/mcp/clients/registry.ts index e278accd..c8933802 100644 --- a/packages/cli-core/src/commands/mcp/clients/registry.ts +++ b/packages/cli-core/src/commands/mcp/clients/registry.ts @@ -4,6 +4,7 @@ */ import { claudeClient } from "./claude.ts"; +import { codexClient } from "./codex.ts"; import { cursorClient } from "./cursor.ts"; import { geminiClient } from "./gemini.ts"; import type { ClientId, McpClient } from "./types.ts"; @@ -16,10 +17,20 @@ export const CLIENTS: readonly McpClient[] = [ vscodeClient, windsurfClient, geminiClient, + codexClient, ]; export const CLIENT_IDS: readonly ClientId[] = CLIENTS.map((c) => c.id); +/** + * Accepted `--client` aliases → canonical id. GitHub Copilot runs inside VS + * Code and shares its `mcp.json`, so `copilot` and `vscode` target the same + * client. + */ +export const CLIENT_ALIASES: Readonly> = { copilot: "vscode" }; + +export const CLIENT_ID_CHOICES: readonly string[] = [...CLIENT_IDS, ...Object.keys(CLIENT_ALIASES)]; + export async function detectInstalledClients(cwd: string): Promise { const flags = await Promise.all(CLIENTS.map((c) => c.detect(cwd))); return CLIENTS.filter((_, i) => flags[i]); diff --git a/packages/cli-core/src/commands/mcp/clients/toml-config.ts b/packages/cli-core/src/commands/mcp/clients/toml-config.ts new file mode 100644 index 00000000..a7a11ba1 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/toml-config.ts @@ -0,0 +1,49 @@ +/** + * Shared TOML read/write helper for MCP client configs. + * + * Codex stores its MCP servers in `~/.codex/config.toml` under the + * `[mcp_servers.]` table — same logical shape as the JSON clients (a + * top-level map of server name → descriptor), just a different on-disk format. + * This module is the TOML counterpart to `json-config.ts`: it only handles the + * surrounding I/O, parsing into and serializing from the plain object tree that + * the client factory and `getServerMap` already operate on. + * + * Note: serializing drops comments and original formatting, matching how the + * JSON clients rewrite their (sometimes JSONC) configs. + */ + +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { parse, stringify } from "smol-toml"; +import { log } from "../../../lib/log.ts"; +import { CliError, ERROR_CODE, errorMessage } from "../../../lib/errors.ts"; +import type { JsonConfig } from "./json-config.ts"; + +export async function readTomlConfig(path: string): Promise { + const file = Bun.file(path); + if (!(await file.exists())) return {}; + const text = await file.text(); + if (text.trim().length === 0) return {}; + try { + const parsed: unknown = parse(text); + // A valid TOML document is always a table, so `parse` can't hand back a + // non-object — but guard anyway so a future parser swap can't surprise us. + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliError(`Config at ${path} is not a TOML table`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } + return parsed as JsonConfig; + } catch (error) { + if (error instanceof CliError) throw error; + throw new CliError(`Could not parse ${path} as TOML: ${errorMessage(error)}`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } +} + +export async function writeTomlConfig(path: string, config: JsonConfig): Promise { + log.debug(`mcp: write ${path}`); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, stringify(config) + "\n"); +} diff --git a/packages/cli-core/src/commands/mcp/clients/types.ts b/packages/cli-core/src/commands/mcp/clients/types.ts index 098aca49..2461b09c 100644 --- a/packages/cli-core/src/commands/mcp/clients/types.ts +++ b/packages/cli-core/src/commands/mcp/clients/types.ts @@ -1,14 +1,13 @@ /** * Shared types for MCP client integrations. * - * Each supported MCP client (Claude Code, Cursor, VS Code, Windsurf, Gemini) - * exposes an {@link McpClient} that knows how to read, upsert, and remove - * the `clerk` server entry in its own config file format. + * Each supported MCP client (Claude Code, Cursor, GitHub Copilot, Windsurf, + * Gemini, Codex) exposes an {@link McpClient} that knows how to read, upsert, + * and remove the `clerk` server entry in its own config file format. */ -export type ClientId = "claude" | "cursor" | "vscode" | "windsurf" | "gemini"; +export type ClientId = "claude" | "cursor" | "vscode" | "windsurf" | "gemini" | "codex"; -/** Where the client config file lives relative to the user / project. */ export type Scope = "project" | "user"; export interface McpServerEntry { @@ -52,13 +51,10 @@ export interface McpClient { * as a next step. */ activation: string; - /** Where the entry would be written for the given cwd. */ configPath(cwd: string): string; /** Heuristic: is this client installed for this user? */ detect(cwd: string): Promise; - /** Add or update the `name` entry pointing at `url`. */ upsert(entry: McpServerEntry, cwd: string, force: boolean): Promise; - /** Remove the `name` entry. */ remove(name: string, cwd: string): Promise; /** List `clerk`-flavored entries currently registered (those pointing at clerk.com URLs or named explicitly). */ list(cwd: string): Promise; diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index f2dad05e..e721f6fd 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -22,7 +22,9 @@ mock.module("../../../mode.ts", () => ({ const { geminiClient } = await import("./gemini.ts"); const { windsurfClient } = await import("./windsurf.ts"); +const { codexClient } = await import("./codex.ts"); const { vscodeUserDir } = await import("./paths.ts"); +const { parse: parseToml } = await import("smol-toml"); const { mcpInstall } = await import("../install.ts"); const { mcpUninstall } = await import("../uninstall.ts"); const { checkMcp } = await import("../../doctor/check-mcp.ts"); @@ -30,7 +32,7 @@ const { checkMcp } = await import("../../doctor/check-mcp.ts"); const captured = useCaptureLog(); const URL = "https://mcp.clerk.com/mcp"; -const ALL_CLIENT_IDS = ["claude", "cursor", "vscode", "windsurf", "gemini"]; +const ALL_CLIENT_IDS = ["claude", "cursor", "vscode", "windsurf", "gemini", "codex"]; describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { beforeEach(async () => { @@ -91,6 +93,43 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { ]); }); }); + + describe("codex", () => { + test("writes the HTTP url under the [mcp_servers.] TOML table", async () => { + await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const text = await Bun.file(codexClient.configPath("/ignored")).text(); + expect(text).toContain("[mcp_servers.clerk]"); + const parsed = parseToml(text) as { mcp_servers: { clerk: { url: string } } }; + expect(parsed.mcp_servers.clerk).toEqual({ url: URL }); + }); + + test("round-trips the URL back out on list", async () => { + await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const entries = await codexClient.list("/ignored"); + expect(entries).toEqual([ + expect.objectContaining({ client: "codex", name: "clerk", url: URL }), + ]); + }); + + test("preserves unrelated tables when removing the entry", async () => { + const dir = join(mockHome, ".codex"); + await mkdir(dir, { recursive: true }); + await Bun.write( + join(dir, "config.toml"), + 'model = "o3"\n\n[mcp_servers.clerk]\nurl = "https://mcp.clerk.com/mcp"\n\n[mcp_servers.other]\ncommand = "npx"\n', + ); + + await codexClient.remove("clerk", "/ignored"); + + const parsed = parseToml(await Bun.file(join(dir, "config.toml")).text()) as { + model: string; + mcp_servers: Record; + }; + expect(parsed.model).toBe("o3"); // top-level key untouched + expect(parsed.mcp_servers.clerk).toBeUndefined(); // removed + expect(parsed.mcp_servers.other).toEqual({ command: "npx" }); // sibling kept + }); + }); }); // These exercise the command-level "all clients" defaults, which touch the @@ -140,6 +179,7 @@ describe("install/uninstall across all clients (homedir + cwd redirected)", () = mkdir(vscodeUserDir(), { recursive: true }), mkdir(join(mockHome, ".codeium", "windsurf"), { recursive: true }), mkdir(join(mockHome, ".gemini"), { recursive: true }), + mkdir(join(mockHome, ".codex"), { recursive: true }), ]); await mcpInstall({ all: true, url: URL }); @@ -238,4 +278,23 @@ describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { const result = await checkMcp(); expect(result.status).toBe("warn"); }); + + test("warns when a second client points at a different, unreachable URL", async () => { + const BAD_URL = "https://staging.clerk.com/mcp"; + await mcpInstall({ client: ["cursor"], url: URL }); + await mcpInstall({ client: ["claude"], url: BAD_URL }); + captured.clear(); + // Healthy for the hosted URL, 503 for the second — proves every distinct URL is probed. + globalThis.fetch = (async (input: unknown) => + String(input) === BAD_URL + ? new Response("nope", { status: 503 }) + : new Response(HANDSHAKE_BODY, { + status: 200, + headers: { "content-type": "text/event-stream" }, + })) as unknown as typeof globalThis.fetch; + + const result = await checkMcp(); + expect(result.status).toBe("warn"); + expect(result.message).toContain(BAD_URL); + }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/vscode.ts b/packages/cli-core/src/commands/mcp/clients/vscode.ts index c3ecc553..570a9322 100644 --- a/packages/cli-core/src/commands/mcp/clients/vscode.ts +++ b/packages/cli-core/src/commands/mcp/clients/vscode.ts @@ -11,7 +11,7 @@ import { pathExists, vscodeUserDir } from "./paths.ts"; export const vscodeClient = makeJsonClient({ id: "vscode", - displayName: "VS Code", + displayName: "GitHub Copilot", scope: "user", activation: "Reload the VS Code window, then start the server from `MCP: List Servers` (sign in if prompted).", diff --git a/packages/cli-core/src/commands/mcp/index.ts b/packages/cli-core/src/commands/mcp/index.ts index 462f827c..8b86c22c 100644 --- a/packages/cli-core/src/commands/mcp/index.ts +++ b/packages/cli-core/src/commands/mcp/index.ts @@ -7,4 +7,4 @@ export const mcp = { list: mcpList, uninstall: mcpUninstall, }; -export { CLIENT_IDS } from "./clients/registry.ts"; +export { CLIENT_ID_CHOICES, CLIENT_IDS } from "./clients/registry.ts"; diff --git a/packages/cli-core/src/commands/mcp/probe.ts b/packages/cli-core/src/commands/mcp/probe.ts index 09c922fa..03cac11a 100644 --- a/packages/cli-core/src/commands/mcp/probe.ts +++ b/packages/cli-core/src/commands/mcp/probe.ts @@ -20,7 +20,8 @@ export type McpProbeResult = // A hostile or wrong URL shouldn't hang the CLI: cap the probe so a slow or // never-ending response surfaces as a failure instead of blocking forever. -const PROBE_TIMEOUT_MS = 10_000; +// 5s covers a cold-start server while keeping `clerk doctor` snappy on a dead one. +const PROBE_TIMEOUT_MS = 5_000; const INITIALIZE_REQUEST = { jsonrpc: "2.0", diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index 693cb961..c6f8a264 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -6,7 +6,7 @@ import { getMcpUrl } from "../../lib/environment.ts"; import { CliError, ERROR_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { isAgent } from "../../mode.ts"; -import { CLIENT_IDS, CLIENTS, detectInstalledClients } from "./clients/registry.ts"; +import { CLIENT_ALIASES, CLIENT_IDS, CLIENTS, detectInstalledClients } from "./clients/registry.ts"; import type { ClientId, McpClient } from "./clients/types.ts"; export type McpOptions = { @@ -50,8 +50,11 @@ export function resolveName(options: McpOptions): string { export function resolveClients(ids: readonly string[]): McpClient[] { const byId = new Map(CLIENTS.map((c) => [c.id, c])); - return ids.map((id) => { - const client = byId.get(id); + const seen = new Set(); + // Dedupe by canonical id so `--client copilot --client vscode` (aliases of the + // same client) or a repeated flag operates on each client once. + return ids.flatMap((id) => { + const client = byId.get(CLIENT_ALIASES[id] ?? id); if (!client) { throwUsageError( `Unknown MCP client "${id}". Supported: ${CLIENT_IDS.join(", ")}.`, @@ -59,25 +62,28 @@ export function resolveClients(ids: readonly string[]): McpClient[] { ERROR_CODE.MCP_CLIENT_NOT_SUPPORTED, ); } - return client; + if (seen.has(client.id)) return []; + seen.add(client.id); + return [client]; }); } export async function pickClients( detected: McpClient[], message: string, - options: { autoSelectSingle?: boolean } = {}, + options: { autoSelectSingle?: boolean; required?: boolean; preselect?: boolean } = {}, ): Promise { if (detected.length === 0) return []; if (detected.length === 1 && options.autoSelectSingle) return detected; // Imported lazily (like `doctor` does) so the prompt layer stays off the // module-load path for non-interactive callers and tests. const { multiselect } = await import("../../lib/prompts.ts"); + const preselect = options.preselect ?? true; const selected = await multiselect({ message, options: detected.map((c) => ({ value: c.id, label: `${c.displayName} (${c.scope})` })), - initialValues: detected.map((c) => c.id), - required: true, + initialValues: preselect ? detected.map((c) => c.id) : [], + required: options.required ?? true, }); return resolveClients(selected); } diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index 56aeec07..56577b49 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -71,10 +71,12 @@ describe("mcp uninstall", () => { expect(payload.results).toEqual([expect.objectContaining({ client: "cursor", removed: true })]); }); - test("throws MCP_NOT_INSTALLED when nothing is registered", async () => { - await expect(mcpUninstall({ client: ["cursor"] })).rejects.toMatchObject({ - code: "mcp_not_installed", - }); + test("agent mode: reports removed:false when nothing is registered (no error)", async () => { + await mcpUninstall({ client: ["cursor"] }); + const payload = JSON.parse(captured.out) as { results: { client: string; removed: boolean }[] }; + expect(payload.results).toEqual([ + expect.objectContaining({ client: "cursor", removed: false }), + ]); }); test("respects --name", async () => { @@ -94,12 +96,11 @@ describe("mcp uninstall", () => { }); }); - test("human mode: nothing-to-remove throws without a contradictory success outro", async () => { + test("human mode: warns how to install when nothing is registered (no error)", async () => { mockIsAgent.mockReturnValue(false); - await expect(mcpUninstall({ client: ["cursor"] })).rejects.toMatchObject({ - code: "mcp_not_installed", - }); - expect(captured.err).not.toContain("Nothing to remove"); + await mcpUninstall({ client: ["cursor"] }); + expect(captured.err).toContain("clerk mcp install"); + expect(captured.err).not.toContain("Removing MCP entry"); // no success gutter for a no-op }); test("human mode: prints reload next steps after a successful removal", async () => { @@ -111,10 +112,10 @@ describe("mcp uninstall", () => { expect(captured.err).toContain("Reload Cursor"); }); - test("human mode: prompts to pick which installed clients to remove from", async () => { + test("human mode: removes the selected clients and leaves the rest", async () => { await mcpInstall({ client: ["cursor", "claude"], url: URL }); mockIsAgent.mockReturnValue(false); - mockMultiselect.mockResolvedValueOnce(["cursor"]); // pick only Cursor + mockMultiselect.mockResolvedValueOnce(["cursor"]); // select Cursor → remove Cursor captured.clear(); await mcpUninstall({}); @@ -123,11 +124,38 @@ describe("mcp uninstall", () => { const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { mcpServers: Record; }; - expect(cursorCfg.mcpServers.clerk).toBeUndefined(); // removed + expect(cursorCfg.mcpServers.clerk).toBeUndefined(); const claudeCfg = JSON.parse(await readFile(join(cwd, ".claude.json"), "utf8")) as { mcpServers: Record; }; - expect(claudeCfg.mcpServers.clerk).toBeDefined(); // untouched + expect(claudeCfg.mcpServers.clerk).toBeDefined(); + }); + + test("human mode: picker only lists clients that actually have the entry", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + mockIsAgent.mockReturnValue(false); + mockMultiselect.mockResolvedValueOnce([]); + captured.clear(); + + await mcpUninstall({}); + + const arg = mockMultiselect.mock.calls[0]![0] as { options: { value: string }[] }; + expect(arg.options.map((o) => o.value)).toEqual(["cursor"]); + }); + + test("human mode: selecting nothing removes nothing", async () => { + await mcpInstall({ client: ["cursor", "claude"], url: URL }); + mockIsAgent.mockReturnValue(false); + mockMultiselect.mockResolvedValueOnce([]); + captured.clear(); + + await mcpUninstall({}); + + expect(captured.err).toContain("Nothing removed"); + const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(cursorCfg.mcpServers.clerk).toBeDefined(); }); test("human mode: --all removes from every client without prompting", async () => { diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts index ef0b1acd..ebc0356e 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -3,9 +3,7 @@ */ import { cyan, dim, green, yellow } from "../../lib/color.ts"; -import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; -import { isAgent } from "../../mode.ts"; import { withGutter } from "../../lib/spinner.ts"; import { CLIENTS } from "./clients/registry.ts"; import type { McpClient, RemoveResult } from "./clients/types.ts"; @@ -23,6 +21,10 @@ function printResult(client: McpClient, result: RemoveResult): void { log.info(`${label}: ${result.removed ? green("removed") : yellow("not present")}`); } +function warnNotInstalled(name: string): void { + log.warn(`No \`${name}\` MCP entry is installed. Run \`clerk mcp install\` to add it.`); +} + async function installedClients(name: string, cwd: string): Promise { const present = await Promise.all( CLIENTS.map(async (client) => (await client.list(cwd)).some((entry) => entry.name === name)), @@ -30,37 +32,38 @@ async function installedClients(name: string, cwd: string): Promise return CLIENTS.filter((_, i) => present[i]); } -async function chooseClients(options: McpOptions, name: string, cwd: string): Promise { - if (options.client && options.client.length > 0) return resolveClients(options.client); - if (options.all || isAgent()) return Array.from(CLIENTS); - return pickClients(await installedClients(name, cwd), `Select clients to remove "${name}" from:`); -} - -export async function mcpUninstall(options: McpOptions = {}): Promise { - const name = resolveName(options); - const cwd = process.cwd(); - const json = wantsJson(options); - const notInstalled = new CliError(`No MCP entry named "${name}" found in any client.`, { - code: ERROR_CODE.MCP_NOT_INSTALLED, +// The checked clients are removed; nothing is pre-checked, so the safe default +// (submitting with no selection) removes nothing. +async function pickClientsToRemove(installed: McpClient[], name: string): Promise { + return pickClients(installed, `Select the clients to remove \`${name}\` from:`, { + required: false, + preselect: false, }); +} - const clients = await chooseClients(options, name, cwd); - if (clients.length === 0) throw notInstalled; - +async function removeFrom( + clients: McpClient[], + name: string, + cwd: string, + json: boolean, +): Promise { const settled = await settleClients(clients, (c) => c.remove(name, cwd)); const results = settled.map((s) => s.result); const removedCount = results.filter((r) => r.removed).length; if (json) { log.data(JSON.stringify({ name, results }, null, 2)); - if (removedCount === 0) throw notInstalled; + return; + } + + if (removedCount === 0) { + warnNotInstalled(name); return; } // Removing the config entry doesn't drop a live editor session — it lingers // until the editor reloads. Surface that as a next step per removed client. await withGutter(`Removing MCP entry ${cyan(name)}`, async ({ setNextSteps }) => { - if (removedCount === 0) throw notInstalled; settled.forEach(({ client, result }) => printResult(client, result)); setNextSteps( settled @@ -69,3 +72,33 @@ export async function mcpUninstall(options: McpOptions = {}): Promise { ); }); } + +export async function mcpUninstall(options: McpOptions = {}): Promise { + const name = resolveName(options); + const cwd = process.cwd(); + const json = wantsJson(options); + const explicit = + options.client && options.client.length > 0 ? resolveClients(options.client) : undefined; + + // Non-interactive: explicit `--client`, `--all`, agent mode, or `--json` + // operate directly on the targeted clients. + if (json || explicit || options.all) { + await removeFrom(explicit ?? Array.from(CLIENTS), name, cwd, json); + return; + } + + // Human, interactive: pick which installed clients to keep; remove the rest. + const installed = await installedClients(name, cwd); + if (installed.length === 0) { + warnNotInstalled(name); + return; + } + + const toRemove = await pickClientsToRemove(installed, name); + if (toRemove.length === 0) { + log.info("No clients selected. Nothing removed."); + return; + } + + await removeFrom(toRemove, name, cwd, json); +} diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 9083fb6f..8e5f25d2 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -63,8 +63,6 @@ export const ERROR_CODE = { MCP_CLIENT_CONFIG_INVALID: "mcp_client_config_invalid", /** The provided `--url` is malformed or uses a non-http(s) scheme. */ MCP_URL_REQUIRED: "mcp_url_required", - /** No matching MCP entry to remove. */ - MCP_NOT_INSTALLED: "mcp_not_installed", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE];