diff --git a/.changeset/mcp-install.md b/.changeset/mcp-install.md new file mode 100644 index 00000000..897d39d9 --- /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, 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/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/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 bef0fcce..23cb1592 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -15,6 +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_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"; @@ -466,6 +470,81 @@ 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 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" }, + ]); + + 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_CHOICES]) + .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 claude --client vscode", + description: "Install into specific clients", + }, + ]) + .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 in human mode: pick from installed; in agent mode: all clients.", + ) + .choices([...MCP_CLIENT_CHOICES]) + .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: "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)); + 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..95f44288 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, 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 new file mode 100644 index 00000000..f8894249 --- /dev/null +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -0,0 +1,57 @@ +/** + * `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 + * that comes with it. Imports only the light `collect`/`probe` helpers. + */ + +import { collectEntries } from "../mcp/collect.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. + const entries = await collectEntries(process.cwd()); + if (entries.length === 0) { + return { name: NAME, status: "pass", message: "Skipped (no Clerk MCP entry installed)" }; + } + + // 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 subject = + probes.length === 1 ? "Configured MCP server is" : "One or more configured MCP servers are"; + return { + name: NAME, + status: "warn", + 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 a857c381..d0e852eb 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); @@ -70,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/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..f0bc0670 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/README.md @@ -0,0 +1,100 @@ +# `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'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 each +distinct configured URL whenever a Clerk MCP entry is installed. + +## Supported clients + +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` (`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` + +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`, 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. | + +**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 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 each distinct configured MCP URL via the +> `initialize` handshake when an entry is installed (warns, does not fail, when +> any is 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` | The provided `--url` is malformed or uses a non-http(s) scheme. | diff --git a/packages/cli-core/src/commands/mcp/clients/claude.ts b/packages/cli-core/src/commands/mcp/clients/claude.ts new file mode 100644 index 00000000..cc372a97 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/claude.ts @@ -0,0 +1,21 @@ +/** + * 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, userPath } from "./paths.ts"; + +export const claudeClient = makeJsonClient({ + id: "claude", + displayName: "Claude Code", + 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: () => 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 new file mode 100644 index 00000000..3d45a8f4 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -0,0 +1,126 @@ +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import * as realOs from "node:os"; +import { join } from "node:path"; +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 { claudeClient } = await import("./claude.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 + 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", + client: claudeClient, + 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, + 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] }, + }, +]; + +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 }); + }); + + 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.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 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(); + }); + + 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/cursor.ts b/packages/cli-core/src/commands/mcp/clients/cursor.ts new file mode 100644 index 00000000..f48b6a0e --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/cursor.ts @@ -0,0 +1,20 @@ +/** + * 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, userPath } from "./paths.ts"; + +export const cursorClient = makeJsonClient({ + id: "cursor", + displayName: "Cursor", + 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: () => 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 new file mode 100644 index 00000000..271ed885 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/gemini.ts @@ -0,0 +1,38 @@ +/** + * 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..4d376eaa --- /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. + * + * 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 + * 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..e9f3c360 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; +import * as realOs from "node:os"; +import { join } from "node:path"; +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"; +const URL_B = "http://localhost:8787/mcp"; + +describe("make-json-client (via cursor)", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-cursor-")); + mockHome = cwd; + }); + + 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..13d6bc3e --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts @@ -0,0 +1,168 @@ +/** + * Factory for file-backed MCP clients. + * + * 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, + 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 FileClientSpec { + 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; +} + +/** 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); + return parsed.hostname === "mcp.clerk.com" || parsed.hostname.endsWith(".clerk.com"); + } catch { + return false; + } +} + +function makeFileClient(spec: FileClientSpec, codec: ConfigCodec): 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 codec.read(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 codec.write(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 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 codec.write(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 codec.read(configPath); + servers = getServerMap(config, spec.topKey, configPath); + } catch (error) { + if (error instanceof CliError && error.code === ERROR_CODE.MCP_CLIENT_CONFIG_INVALID) { + // Warn rather than silently returning [] — the user must know their + // config was skipped, not 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; + }, + }; +} + +/** 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 new file mode 100644 index 00000000..36d7fbb8 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/paths.ts @@ -0,0 +1,49 @@ +/** + * Cross-platform path + filesystem helpers for MCP client integrations. + * + * 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, platform } from "node:os"; +import { join } from "node:path"; + +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(); + const appData = process.env.APPDATA?.trim(); + const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); + switch (platform()) { + case "win32": + return join(appData || join(home, "AppData", "Roaming"), "Code", "User"); + case "darwin": + return join(home, "Library", "Application Support", "Code", "User"); + default: + return join(xdgConfigHome || 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 + * 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..c8933802 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/registry.ts @@ -0,0 +1,37 @@ +/** + * Registry of supported MCP clients. Order is the display order in the + * human-mode multiselect picker. + */ + +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"; +import { vscodeClient } from "./vscode.ts"; +import { windsurfClient } from "./windsurf.ts"; + +export const CLIENTS: readonly McpClient[] = [ + claudeClient, + cursorClient, + 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 new file mode 100644 index 00000000..2461b09c --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/types.ts @@ -0,0 +1,61 @@ +/** + * Shared types for MCP client integrations. + * + * 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" | "codex"; + +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; + configPath(cwd: string): string; + /** Heuristic: is this client installed for this user? */ + detect(cwd: string): Promise; + upsert(entry: McpServerEntry, cwd: string, force: boolean): Promise; + 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..e721f6fd --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -0,0 +1,300 @@ +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"; + +// 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 })); +afterAll(() => mock.restore()); + +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 { 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"); + +const captured = useCaptureLog(); + +const URL = "https://mcp.clerk.com/mcp"; +const ALL_CLIENT_IDS = ["claude", "cursor", "vscode", "windsurf", "gemini", "codex"]; + +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 = (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] }); + }); + + 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 Bun.write( + 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 = (await Bun.file(windsurfClient.configPath("/ignored")).json()) 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 }), + ]); + }); + }); + + 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 +// 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; + 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(); + }); + + test("install --all targets every detected client", async () => { + // 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(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 }); + + 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; + 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 + // 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-")); + 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; + 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"); + }); + + 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 new file mode 100644 index 00000000..570a9322 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/vscode.ts @@ -0,0 +1,23 @@ +/** + * 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, vscodeUserDir } from "./paths.ts"; + +export const vscodeClient = makeJsonClient({ + id: "vscode", + displayName: "GitHub Copilot", + 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: () => 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 new file mode 100644 index 00000000..d3138047 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/windsurf.ts @@ -0,0 +1,19 @@ +/** + * 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..8b86c22c --- /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_ID_CHOICES, 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..722dc034 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -0,0 +1,220 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import * as realOs 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"), +})); + +// 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"; +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(realOs.tmpdir(), "clerk-mcp-install-")); + mockHome = cwd; + 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("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"], + ["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"], url: URL_A }); + + const payload = JSON.parse(captured.out) as { results: { client: string; status: string }[] }; + expect(payload.results).toEqual([ + expect.objectContaining({ client: "claude", 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..dffa5508 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -0,0 +1,98 @@ +/** + * `clerk mcp install` — register the Clerk remote MCP server in supported clients. + * + * 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`. + */ + +import { log } from "../../lib/log.ts"; +import { cyan, dim, green, yellow } from "../../lib/color.ts"; +import { isAgent } from "../../mode.ts"; +import { withGutter } from "../../lib/spinner.ts"; +import { + pickClients, + 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), "Select MCP clients to install into:", { + autoSelectSingle: true, + }); +} + +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 installNextSteps(settled: ClientUpsert[]): string[] { + const activated = settled.filter( + ({ result }) => result.status === "added" || result.status === "updated", + ); + 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 { + 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) { + if (json) log.data(JSON.stringify({ url, name, results: [] }, null, 2)); + else log.warn("No MCP clients selected."); + return; + } + + 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 new file mode 100644 index 00000000..005c03fb --- /dev/null +++ b/packages/cli-core/src/commands/mcp/list.test.ts @@ -0,0 +1,90 @@ +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 { captureUi, 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"), +})); + +// 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"); + +const URL = "https://mcp.clerk.com/mcp"; + +describe("mcp list", () => { + const captured = useCaptureLog(); + let uiCapture: ReturnType; + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + 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(); + }); + + 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 shows the hint on the prompt rail, nothing to stdout", async () => { + mockIsAgent.mockReturnValue(false); + await mcpList({}); + expect(captured.out).toBe(""); + expect(uiCapture.out).toContain("No Clerk MCP entries"); + }); + + 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({}); + + // 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 new file mode 100644 index 00000000..0ed94079 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/list.ts @@ -0,0 +1,67 @@ +/** + * `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 { withGutter } from "../../lib/spinner.ts"; +import { ui } from "../../lib/ui.ts"; +import type { ListEntry } from "./clients/types.ts"; +import { collectEntries } from "./collect.ts"; +import { 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 = 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), + ); + + 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 all: ListEntry[] = await collectEntries(process.cwd()); + + if (wantsJson(options)) { + log.data(JSON.stringify(all, null, 2)); + return; + } + + 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/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..03cac11a --- /dev/null +++ b/packages/cli-core/src/commands/mcp/probe.ts @@ -0,0 +1,95 @@ +/** + * 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. +// 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", + 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..c6f8a264 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -0,0 +1,136 @@ +/** + * Shared options and helpers for `clerk mcp` subcommands. + */ + +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_ALIASES, 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 { + // `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(); + // 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])); + 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(", ")}.`, + undefined, + ERROR_CODE.MCP_CLIENT_NOT_SUPPORTED, + ); + } + if (seen.has(client.id)) return []; + seen.add(client.id); + return [client]; + }); +} + +export async function pickClients( + detected: McpClient[], + message: string, + 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: preselect ? detected.map((c) => c.id) : [], + required: options.required ?? true, + }); + return resolveClients(selected); +} + +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(); +} + +/** + * 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[] = []; + for (const [i, outcome] of settled.entries()) { + const client = clients[i]!; + if (outcome.status === "fulfilled") { + succeeded.push({ client, result: outcome.value }); + 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 new file mode 100644 index 00000000..56577b49 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -0,0 +1,174 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import * as realOs 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"), +})); + +// 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 mockMultiselect = mock(); +mock.module("../../lib/prompts.ts", () => ({ + multiselect: (...args: unknown[]) => mockMultiselect(...args), +})); + +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(realOs.tmpdir(), "clerk-mcp-uninstall-")); + mockHome = cwd; + process.chdir(cwd); + mockIsAgent.mockReturnValue(true); + }); + + afterEach(async () => { + 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 () => { + 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("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 () => { + 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: warns how to install when nothing is registered (no error)", async () => { + mockIsAgent.mockReturnValue(false); + 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 () => { + 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"); + }); + + 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"]); // select Cursor → remove 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(); + const claudeCfg = JSON.parse(await readFile(join(cwd, ".claude.json"), "utf8")) as { + mcpServers: Record; + }; + 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 () => { + 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 new file mode 100644 index 00000000..ebc0356e --- /dev/null +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -0,0 +1,104 @@ +/** + * `clerk mcp uninstall` — remove the `clerk` MCP entry from supported clients. + */ + +import { cyan, dim, green, yellow } from "../../lib/color.ts"; +import { log } from "../../lib/log.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, + 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")}`); +} + +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)), + ); + return CLIENTS.filter((_, i) => present[i]); +} + +// 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, + }); +} + +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)); + 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 }) => { + settled.forEach(({ client, result }) => printResult(client, result)); + setNextSteps( + settled + .filter(({ result }) => result.removed) + .map(({ client }) => `Reload ${client.displayName} to drop the active connection.`), + ); + }); +} + +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/environment.ts b/packages/cli-core/src/lib/environment.ts index 91643a28..a695badd 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -19,8 +19,12 @@ export interface EnvProfileConfig { platformApiUrl: string; backendApiUrl: string; dashboardUrl?: string; + 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", @@ -28,6 +32,7 @@ const DEFAULT_PROFILES: Record = { platformApiUrl: "https://api.clerk.com", backendApiUrl: "https://api.clerk.dev", dashboardUrl: "https://dashboard.clerk.com", + mcpUrl: DEFAULT_MCP_URL, }, }; @@ -145,3 +150,18 @@ export function getDashboardUrl(): string { process.env.CLERK_DASHBOARD_URL ?? getCurrentEnv().dashboardUrl ?? "https://dashboard.clerk.com" ); } + +/** + * Remote MCP server URL for the active environment. + * + * 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 { + const envUrl = process.env.CLERK_MCP_URL?.trim(); + const profileUrl = getCurrentEnv().mcpUrl?.trim(); + return envUrl || profileUrl || DEFAULT_MCP_URL; +} diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index f2f1d8aa..8e5f25d2 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -55,6 +55,14 @@ 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", + /** The provided `--url` is malformed or uses a non-http(s) scheme. */ + MCP_URL_REQUIRED: "mcp_url_required", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; 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;