From cb59ee34858b33427a3a85d2f49200383a5d45ca Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 25 May 2026 18:17:16 -0300 Subject: [PATCH 01/21] feat(mcp): add clerk mcp install/list/uninstall commands Register the Clerk remote MCP server (https://mcp.clerk.com/mcp) in Claude Code, Cursor, VS Code, Windsurf, and Gemini, each with a JSON/agent mode and human-mode 'next steps' guidance (reload + sign-in). clerk doctor gains an MCP reachability check that probes the configured server via the MCP initialize handshake when an entry is installed (warns, never fails). URL resolution: --url > CLERK_MCP_URL > active env profile mcpUrl. --- .changeset/mcp-install.md | 5 + README.md | 1 + packages/cli-core/src/cli-program.ts | 2 + .../cli-core/src/commands/doctor/README.md | 21 +- .../cli-core/src/commands/doctor/check-mcp.ts | 39 ++++ .../cli-core/src/commands/doctor/index.ts | 2 + packages/cli-core/src/commands/mcp/README.md | 77 +++++++ .../src/commands/mcp/clients/claude-code.ts | 22 ++ .../src/commands/mcp/clients/clients.test.ts | 108 +++++++++ .../src/commands/mcp/clients/cursor.ts | 21 ++ .../src/commands/mcp/clients/gemini.ts | 40 ++++ .../src/commands/mcp/clients/json-config.ts | 65 ++++++ .../mcp/clients/make-json-client.test.ts | 162 ++++++++++++++ .../commands/mcp/clients/make-json-client.ts | 147 +++++++++++++ .../src/commands/mcp/clients/paths.ts | 34 +++ .../src/commands/mcp/clients/registry.ts | 26 +++ .../src/commands/mcp/clients/types.ts | 65 ++++++ .../commands/mcp/clients/user-scope.test.ts | 206 ++++++++++++++++++ .../src/commands/mcp/clients/vscode.ts | 23 ++ .../src/commands/mcp/clients/windsurf.ts | 21 ++ packages/cli-core/src/commands/mcp/collect.ts | 28 +++ packages/cli-core/src/commands/mcp/index.ts | 80 +++++++ .../cli-core/src/commands/mcp/install.test.ts | 192 ++++++++++++++++ packages/cli-core/src/commands/mcp/install.ts | 102 +++++++++ .../cli-core/src/commands/mcp/list.test.ts | 78 +++++++ packages/cli-core/src/commands/mcp/list.ts | 54 +++++ .../cli-core/src/commands/mcp/probe.test.ts | 107 +++++++++ packages/cli-core/src/commands/mcp/probe.ts | 94 ++++++++ packages/cli-core/src/commands/mcp/shared.ts | 147 +++++++++++++ .../src/commands/mcp/uninstall.test.ts | 101 +++++++++ .../cli-core/src/commands/mcp/uninstall.ts | 62 ++++++ packages/cli-core/src/lib/environment.ts | 14 ++ packages/cli-core/src/lib/errors.ts | 10 + 33 files changed, 2146 insertions(+), 10 deletions(-) create mode 100644 .changeset/mcp-install.md create mode 100644 packages/cli-core/src/commands/doctor/check-mcp.ts create mode 100644 packages/cli-core/src/commands/mcp/README.md create mode 100644 packages/cli-core/src/commands/mcp/clients/claude-code.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/clients.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/cursor.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/gemini.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/json-config.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/make-json-client.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/paths.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/registry.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/types.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/user-scope.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/vscode.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/windsurf.ts create mode 100644 packages/cli-core/src/commands/mcp/collect.ts create mode 100644 packages/cli-core/src/commands/mcp/index.ts create mode 100644 packages/cli-core/src/commands/mcp/install.test.ts create mode 100644 packages/cli-core/src/commands/mcp/install.ts create mode 100644 packages/cli-core/src/commands/mcp/list.test.ts create mode 100644 packages/cli-core/src/commands/mcp/list.ts create mode 100644 packages/cli-core/src/commands/mcp/probe.test.ts create mode 100644 packages/cli-core/src/commands/mcp/probe.ts create mode 100644 packages/cli-core/src/commands/mcp/shared.ts create mode 100644 packages/cli-core/src/commands/mcp/uninstall.test.ts create mode 100644 packages/cli-core/src/commands/mcp/uninstall.ts diff --git a/.changeset/mcp-install.md b/.changeset/mcp-install.md new file mode 100644 index 00000000..9f2b360c --- /dev/null +++ b/.changeset/mcp-install.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, VS Code, Windsurf, and Gemini. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. The URL comes from the active env profile's new `mcpUrl` field (or the `CLERK_MCP_URL` override) and can be overridden per-invocation with `--url` for local worker development. diff --git a/README.md b/README.md index b6eb6d61..62d75178 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Commands: apps Manage your Clerk applications users [options] Manage Clerk users impersonate|imp [options] [user] Impersonate a Clerk user + mcp Manage the Clerk remote MCP server connection for AI editors and CLIs env Manage environment variables config Manage instance configuration enable Enable Clerk features on the linked instance diff --git a/packages/cli-core/src/cli-program.ts b/packages/cli-core/src/cli-program.ts index 02a27d29..9ea89cb5 100644 --- a/packages/cli-core/src/cli-program.ts +++ b/packages/cli-core/src/cli-program.ts @@ -16,6 +16,7 @@ import { registerConfig } from "./commands/config/index.ts"; import { registerToggles } from "./commands/toggles/index.ts"; import { registerApi } from "./commands/api/index.ts"; import { registerDoctor } from "./commands/doctor/index.ts"; +import { registerMcp } from "./commands/mcp/index.ts"; import { registerSwitchEnv } from "./commands/switch-env/index.ts"; import { registerCompletion } from "./commands/completion/index.ts"; import { registerUpdate } from "./commands/update/index.ts"; @@ -68,6 +69,7 @@ const registrants: CommandRegistrant[] = [ registerToggles, registerApi, registerDoctor, + registerMcp, registerSwitchEnv, registerCompletion, registerUpdate, diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index 6bab3171..1ead10e2 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -25,16 +25,17 @@ clerk doctor --fix # Offer to auto-fix issues ## Checks -| Check | Category | What it verifies | -| --------------------- | -------------- | ------------------------------------------------------------------ | -| Authentication token | Authentication | Credential store has a stored token | -| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | -| Project linkage | Project | Current directory is linked to a Clerk app | -| Linked application | Project | Linked application ID is accessible via the API | -| Instances | Project | Configured dev/prod instance IDs match the application's instances | -| Environment variables | Environment | .env.local or .env has Clerk keys | -| CLI configuration | Configuration | CLI config file exists and parses | -| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | +| Check | Category | What it verifies | +| --------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Authentication token | Authentication | Credential store has a stored token | +| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | +| Project linkage | Project | Current directory is linked to a Clerk app | +| Linked application | Project | Linked application ID is accessible via the API | +| Instances | Project | Configured dev/prod instance IDs match the application's instances | +| Environment variables | Environment | .env.local or .env has Clerk keys | +| CLI configuration | Configuration | CLI config file exists and parses | +| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | +| MCP server | Integration | If a Clerk MCP entry is installed, the configured server answers the `initialize` handshake (skipped otherwise; warns, never fails) | ## Auto-Fix (`--fix`) diff --git a/packages/cli-core/src/commands/doctor/check-mcp.ts b/packages/cli-core/src/commands/doctor/check-mcp.ts new file mode 100644 index 00000000..93b47f5b --- /dev/null +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -0,0 +1,39 @@ +/** + * `clerk doctor` MCP reachability check (folded in from the former + * `clerk mcp doctor` subcommand). + * + * Kept in its own file — rather than `checks.ts` — so the doctor check graph + * doesn't import `mcp/shared.ts` (env profiles, prompts) and the module cycle + * that comes with it. Imports only the light `collect`/`probe` helpers. + */ + +import { collectEntries } from "../mcp/collect.ts"; +import { probeMcp } from "../mcp/probe.ts"; +import type { CheckResult } from "./types.ts"; + +const NAME = "MCP server"; + +export async function checkMcp(): Promise { + // Only meaningful if the user actually registered a Clerk MCP entry — + // otherwise skip silently rather than probing a server they don't use. + const entries = await collectEntries(process.cwd()); + if (entries.length === 0) { + return { name: NAME, status: "pass", message: "Skipped (no Clerk MCP entry installed)" }; + } + + const url = entries[0]!.url; + const result = await probeMcp(url); + if (result.ok) { + return { name: NAME, status: "pass", message: `Reachable — ${result.serverName} (${url})` }; + } + + const detail = + result.error ?? (result.status !== undefined ? `HTTP ${result.status}` : "unknown"); + return { + name: NAME, + status: "warn", + message: `Configured MCP server is not reachable (${url})`, + detail, + remedy: "Verify the server is running, or re-run `clerk mcp install` if the URL changed.", + }; +} diff --git a/packages/cli-core/src/commands/doctor/index.ts b/packages/cli-core/src/commands/doctor/index.ts index 4ac9bf95..a0b38541 100644 --- a/packages/cli-core/src/commands/doctor/index.ts +++ b/packages/cli-core/src/commands/doctor/index.ts @@ -17,6 +17,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"; @@ -30,6 +31,7 @@ const BASE_CHECKS: CheckFn[] = [ checkEnvVars, checkConfigFile, checkShellCompletion, + checkMcp, ]; function getChecks(): CheckFn[] { diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md new file mode 100644 index 00000000..ec9b176f --- /dev/null +++ b/packages/cli-core/src/commands/mcp/README.md @@ -0,0 +1,77 @@ +# `clerk mcp` + +Manage the Clerk remote MCP server connection in supported AI clients. + +The Clerk MCP server is hosted at `https://mcp.clerk.com/mcp` (source: +[clerk/cloudflare-workers/workers/remote-mcp-server](https://github.com/clerk/cloudflare-workers/tree/main/workers/remote-mcp-server)). +These subcommands register, list, remove, and probe that URL in each client's +own config file. The URL is resolved in order: `--url` > the `CLERK_MCP_URL` +environment variable > the active environment profile's `mcpUrl` field +(`switch-env` carries the profile value automatically). `CLERK_MCP_URL` is the +convenient override when developing the worker locally (e.g. +`http://localhost:8787/mcp`). + +No Clerk API endpoints are called. To verify the server is reachable, run +`clerk doctor` — its MCP check performs the `initialize` handshake against the +configured URL whenever a Clerk MCP entry is installed. + +## Supported clients + +| ID | Client | Scope | Config file | +| ------------- | ------------------------ | ------- | ------------------------------------- | +| `claude-code` | Claude Code | project | `/.mcp.json` | +| `cursor` | Cursor | project | `/.cursor/mcp.json` | +| `vscode` | VS Code (Copilot) | project | `/.vscode/mcp.json` | +| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | +| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | + +## Subcommands + +### `clerk mcp install` + +Register the Clerk MCP server in one or more clients. + +| Flag | Description | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--client ` | Target a specific client. Repeat for multiple. Default in agent mode: all detected. Default in human mode: interactive multiselect over detected clients. | +| `--all` | Install into every detected client without prompting. | +| `--url ` | Override the MCP URL. Defaults to the active env profile's `mcpUrl`. | +| `--name ` | Entry key in the client config. Default: `clerk`. | +| `--force` | Overwrite an entry already pointing at a different URL. Without it, the conflict is reported and skipped. | +| `--json` | Emit a JSON summary on stdout instead of human-formatted output. | + +**Conflict policy:** if an entry with the same `--name` already exists and +points at the same URL, the install is a silent no-op (`status: unchanged`). +If it points at a different URL, the install is skipped with a `reason` +unless `--force` is passed. + +**After install:** writing the config does not connect the server on its own. +In human mode, `install` prints per-client next steps — the server only goes +live once you **reload the editor**. If the server requires authentication, the +editor opens a browser to **sign in** on first connect. Gemini additionally +needs `npx` on `PATH`, since its entry launches `mcp-remote` as a stdio bridge. + +### `clerk mcp list` + +Print every Clerk-flavored MCP entry across all supported clients (entries +named `clerk` or pointing at any `*.clerk.com` host). + +### `clerk mcp uninstall` + +Remove the named entry from each client. Throws `mcp_not_installed` (exit +code 1) when nothing was removed. Removing the entry doesn't drop a live editor +session, so (in human mode) it prints a next step to reload each affected editor. + +> **Reachability:** there is no `mcp doctor` subcommand. Server health is part +> of `clerk doctor`, which probes the configured MCP URL via the `initialize` +> handshake when an entry is installed (warns, does not fail, when unreachable). + +## Error codes + +| Code | Meaning | +| --------------------------- | --------------------------------------------------------------- | +| `mcp_no_client_detected` | No supported client found on the system. | +| `mcp_client_not_supported` | `--client ` is not in the supported list. | +| `mcp_client_config_invalid` | An existing client config file is malformed. | +| `mcp_url_required` | No `--url` provided and the active env profile has no `mcpUrl`. | +| `mcp_not_installed` | `uninstall` removed nothing because no entry matched. | diff --git a/packages/cli-core/src/commands/mcp/clients/claude-code.ts b/packages/cli-core/src/commands/mcp/clients/claude-code.ts new file mode 100644 index 00000000..f5d42b5b --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/claude-code.ts @@ -0,0 +1,22 @@ +/** + * Claude Code MCP client integration. + * + * Writes to `.mcp.json` in the current working directory — the project-scope + * config Claude Code reads automatically. Schema follows the MCP spec's HTTP + * transport form: `{ type: "http", url: "" }`. + */ + +import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { pathExists, projectPath, userPath } from "./paths.ts"; + +export const claudeCodeClient = makeJsonClient({ + id: "claude-code", + displayName: "Claude Code", + scope: "project", + activation: "Restart Claude Code, then run `/mcp` to connect (sign in if prompted).", + topKey: "mcpServers", + encode: (url) => ({ type: "http", url }), + extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + configPath: (cwd) => projectPath(cwd, ".mcp.json"), + detect: () => pathExists(userPath(".claude")), +}); diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts new file mode 100644 index 00000000..5c6bf72e --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { claudeCodeClient } from "./claude-code.ts"; +import { cursorClient } from "./cursor.ts"; +import { vscodeClient } from "./vscode.ts"; +import { windsurfClient } from "./windsurf.ts"; +import { geminiClient } from "./gemini.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; + +useCaptureLog(); + +const URL = "https://mcp.clerk.com/mcp"; + +// Path shape is part of the public contract — each client targets a specific, +// documented config file. Test against the format, not the absolute prefix +// (which depends on cwd/homedir). +const projectClients = [ + { client: claudeCodeClient, suffix: ".mcp.json", topKey: "mcpServers" }, + { client: cursorClient, suffix: join(".cursor", "mcp.json"), topKey: "mcpServers" }, + { client: vscodeClient, suffix: join(".vscode", "mcp.json"), topKey: "servers" }, +]; + +const userClients = [ + { + client: windsurfClient, + relPath: join(".codeium", "windsurf", "mcp_config.json"), + topKey: "mcpServers", + }, + { client: geminiClient, relPath: join(".gemini", "settings.json"), topKey: "mcpServers" }, +]; + +describe("project-scope client config paths", () => { + test.each(projectClients)("$client.id resolves under cwd", ({ client, suffix }) => { + const path = client.configPath("/tmp/foo"); + expect(path).toBe(join("/tmp/foo", suffix)); + expect(client.scope).toBe("project"); + }); +}); + +describe("user-scope client config paths", () => { + test.each(userClients)("$client.id resolves under homedir", ({ client, relPath }) => { + expect(client.configPath("/ignored")).toBe(join(homedir(), relPath)); + expect(client.scope).toBe("user"); + }); +}); + +describe("per-client encoded shape (written JSON)", () => { + // Project clients are easiest to exercise — write into a tmpdir-as-cwd and + // assert what landed under their top-level key. + test.each(projectClients)( + "$client.id writes the expected entry shape", + async ({ client, topKey }) => { + const cwd = await mkdtemp(join(tmpdir(), `clerk-mcp-${client.id}-`)); + try { + await client.upsert({ name: "clerk", url: URL }, cwd, false); + const text = await readFile(client.configPath(cwd), "utf8"); + const parsed = JSON.parse(text) as Record>; + expect(parsed[topKey]).toBeDefined(); + expect(parsed[topKey]?.clerk).toBeDefined(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }, + ); + + test("claude-code emits the MCP-spec HTTP transport shape", async () => { + const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cc-shape-")); + try { + await claudeCodeClient.upsert({ name: "clerk", url: URL }, cwd, false); + const parsed = JSON.parse(await readFile(claudeCodeClient.configPath(cwd), "utf8")) as { + mcpServers: { clerk: { type: string; url: string } }; + }; + expect(parsed.mcpServers.clerk).toEqual({ type: "http", url: URL }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("cursor emits a bare {url} entry", async () => { + const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cu-shape-")); + try { + await cursorClient.upsert({ name: "clerk", url: URL }, cwd, false); + const parsed = JSON.parse(await readFile(cursorClient.configPath(cwd), "utf8")) as { + mcpServers: { clerk: { url: string } }; + }; + expect(parsed.mcpServers.clerk).toEqual({ url: URL }); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + test("vscode emits under top-level `servers`, not `mcpServers`", async () => { + const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-vs-shape-")); + try { + await vscodeClient.upsert({ name: "clerk", url: URL }, cwd, false); + const parsed = JSON.parse(await readFile(vscodeClient.configPath(cwd), "utf8")) as { + servers: { clerk: { type: string; url: string } }; + mcpServers?: unknown; + }; + expect(parsed.servers.clerk).toEqual({ type: "http", url: URL }); + expect(parsed.mcpServers).toBeUndefined(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli-core/src/commands/mcp/clients/cursor.ts b/packages/cli-core/src/commands/mcp/clients/cursor.ts new file mode 100644 index 00000000..1a8655ec --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/cursor.ts @@ -0,0 +1,21 @@ +/** + * Cursor MCP client integration. + * + * Writes to `.cursor/mcp.json` in the current working directory. Cursor's + * MCP descriptor is a bare `{ url }` without a `type` discriminator. + */ + +import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { pathExists, projectPath, userPath } from "./paths.ts"; + +export const cursorClient = makeJsonClient({ + id: "cursor", + displayName: "Cursor", + scope: "project", + activation: "Reload Cursor, then enable the server under `Settings → MCP` (sign in if prompted).", + topKey: "mcpServers", + encode: (url) => ({ url }), + extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + configPath: (cwd) => projectPath(cwd, ".cursor", "mcp.json"), + detect: () => pathExists(userPath(".cursor")), +}); diff --git a/packages/cli-core/src/commands/mcp/clients/gemini.ts b/packages/cli-core/src/commands/mcp/clients/gemini.ts new file mode 100644 index 00000000..607b6b8f --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/gemini.ts @@ -0,0 +1,40 @@ +/** + * Gemini Code Assist / Gemini CLI MCP client integration. + * + * Writes to `~/.gemini/settings.json`. Gemini doesn't support HTTP transport + * directly — it requires `mcp-remote` as a stdio bridge, hence the + * `{ command: "npx", args: ["-y", "mcp-remote", ] }` shape. + */ + +import { makeJsonClient } from "./make-json-client.ts"; +import { pathExists, userPath } from "./paths.ts"; + +interface McpRemoteEntry { + command: string; + args: [string, string, string, ...string[]]; +} + +// Match the exact shape we wrote: `{command: "npx", args: ["-y", "mcp-remote", ]}`. +// Permissive matching ("last arg of any args[]") would round-trip unrelated +// stdio bridges as if they were Clerk MCP entries. +function isMcpRemoteEntry(value: unknown): value is McpRemoteEntry { + if (typeof value !== "object" || value === null) return false; + const candidate = value as { command?: unknown; args?: unknown }; + if (candidate.command !== "npx") return false; + if (!Array.isArray(candidate.args) || candidate.args.length < 3) return false; + if (!candidate.args.every((a) => typeof a === "string")) return false; + return candidate.args[0] === "-y" && candidate.args[1] === "mcp-remote"; +} + +export const geminiClient = makeJsonClient({ + id: "gemini", + displayName: "Gemini Code Assist / CLI", + scope: "user", + activation: + "Restart Gemini (needs `npx` on PATH); `mcp-remote` opens a browser to sign in if the server requires it.", + topKey: "mcpServers", + encode: (url) => ({ command: "npx", args: ["-y", "mcp-remote", url] }), + extractUrl: (d) => (isMcpRemoteEntry(d) ? d.args[2] : undefined), + configPath: () => userPath(".gemini", "settings.json"), + detect: () => pathExists(userPath(".gemini")), +}); diff --git a/packages/cli-core/src/commands/mcp/clients/json-config.ts b/packages/cli-core/src/commands/mcp/clients/json-config.ts new file mode 100644 index 00000000..c01cf4e3 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/json-config.ts @@ -0,0 +1,65 @@ +/** + * Shared JSON read/write helper for MCP client configs. + * + * Four of the five supported clients (Claude Code, Cursor, VS Code, Windsurf, + * Gemini) store their MCP servers in a JSON file under a single top-level key + * (`mcpServers` for most, `servers` for VS Code). The entry shape varies + * (`url` vs `serverUrl` vs `command`+`args`) — that's per-client. This module + * only handles the surrounding I/O: read, parse, write back with stable + * formatting and a 2-space indent. + */ + +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { log } from "../../../lib/log.ts"; +import { CliError, ERROR_CODE, errorMessage } from "../../../lib/errors.ts"; + +export interface JsonConfig { + [key: string]: unknown; +} + +export async function readJsonConfig(path: string): Promise { + const file = Bun.file(path); + if (!(await file.exists())) return {}; + const text = await file.text(); + if (text.trim().length === 0) return {}; + try { + const parsed: unknown = JSON.parse(text); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliError(`Config at ${path} is not a JSON object`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } + return parsed as JsonConfig; + } catch (error) { + if (error instanceof CliError) throw error; + throw new CliError(`Could not parse ${path} as JSON: ${errorMessage(error)}`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } +} + +export async function writeJsonConfig(path: string, config: JsonConfig): Promise { + log.debug(`mcp: write ${path}`); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, JSON.stringify(config, null, 2) + "\n"); +} + +/** + * Get an object-typed nested value, returning a fresh empty object if missing. + * Throws MCP_CLIENT_CONFIG_INVALID if the path exists but is not an object. + */ +export function getServerMap( + config: JsonConfig, + key: string, + configPath: string, +): Record { + const existing = config[key]; + if (existing === undefined) return {}; + if (existing === null || typeof existing !== "object" || Array.isArray(existing)) { + throw new CliError(`"${key}" in ${configPath} is not an object`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } + return existing as Record; +} diff --git a/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts b/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts new file mode 100644 index 00000000..7fc14afd --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts @@ -0,0 +1,162 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { cursorClient } from "./cursor.ts"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; + +useCaptureLog(); + +const URL_A = "https://mcp.clerk.com/mcp"; +const URL_B = "http://localhost:8787/mcp"; + +describe("make-json-client (via cursor)", () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cursor-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + async function read(): Promise<{ + otherTopLevel?: string; + mcpServers?: Record; + }> { + const text = await readFile(join(cwd, ".cursor", "mcp.json"), "utf8"); + return JSON.parse(text); + } + + describe("upsert", () => { + test("creates the config file when it does not exist", async () => { + const result = await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + expect(result.status).toBe("added"); + const written = await read(); + expect(written.mcpServers?.clerk).toEqual({ url: URL_A }); + }); + + test("preserves unrelated keys in the file", async () => { + const configPath = join(cwd, ".cursor", "mcp.json"); + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ otherTopLevel: "keep", mcpServers: { other: { url: "http://x" } } }), + ); + await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + const written = await read(); + expect(written.otherTopLevel).toBe("keep"); + expect(written.mcpServers?.other).toEqual({ url: "http://x" }); + expect(written.mcpServers?.clerk).toEqual({ url: URL_A }); + }); + + test("returns unchanged when the URL already matches", async () => { + await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + const result = await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + expect(result.status).toBe("unchanged"); + }); + + test("skips when URL conflicts and force is false", async () => { + await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + const result = await cursorClient.upsert({ name: "clerk", url: URL_B }, cwd, false); + expect(result.status).toBe("skipped"); + // Narrow the discriminated union so `reason` is typed as present. + if (result.status === "skipped") expect(result.reason).toContain("--force"); + const written = await read(); + expect(written.mcpServers?.clerk?.url).toBe(URL_A); + }); + + test("overwrites when URL conflicts and force is true", async () => { + await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + const result = await cursorClient.upsert({ name: "clerk", url: URL_B }, cwd, true); + expect(result.status).toBe("updated"); + const written = await read(); + expect(written.mcpServers?.clerk?.url).toBe(URL_B); + }); + + test("rejects a config whose top-level is not an object", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "[1,2,3]"); + await expect(cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false)).rejects.toThrow( + /not a JSON object/, + ); + }); + + test("treats an inherited Object property name as absent (no false skip)", async () => { + // `toString` lives on Object.prototype; a naive `servers[name]` lookup would + // read back the inherited function and wrongly report an existing entry. + const result = await cursorClient.upsert({ name: "toString", url: URL_A }, cwd, false); + expect(result.status).toBe("added"); + const written = await read(); + expect(written.mcpServers?.["toString"]).toEqual({ url: URL_A }); + }); + }); + + describe("remove", () => { + test("removes a present entry", async () => { + await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + const result = await cursorClient.remove("clerk", cwd); + expect(result.removed).toBe(true); + const written = await read(); + expect(written.mcpServers?.clerk).toBeUndefined(); + }); + + test("is a no-op when the entry is absent", async () => { + const result = await cursorClient.remove("clerk", cwd); + expect(result.removed).toBe(false); + }); + + test("does not false-remove an inherited Object property name", async () => { + // `"toString" in servers` is true via the prototype; the own-property guard + // must report removed:false rather than rewriting the file. + const result = await cursorClient.remove("toString", cwd); + expect(result.removed).toBe(false); + }); + }); + + describe("list", () => { + test("returns clerk-named and clerk-hosted entries, ignores others", async () => { + const configPath = join(cwd, ".cursor", "mcp.json"); + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ + mcpServers: { + clerk: { url: URL_A }, + "other-clerk": { url: "https://mcp.clerk.com/mcp" }, + unrelated: { url: "https://example.com/mcp" }, + }, + }), + ); + const entries = await cursorClient.list(cwd); + const names = entries.map((e) => e.name).sort(); + expect(names).toEqual(["clerk", "other-clerk"]); + }); + + test("returns empty when no config file exists", async () => { + const entries = await cursorClient.list(cwd); + expect(entries).toEqual([]); + }); + + test("returns empty (does not throw) on malformed JSON", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "not json"); + const entries = await cursorClient.list(cwd); + expect(entries).toEqual([]); + }); + + test("returns empty (does not throw) when the top-level key is not an object", async () => { + // Valid JSON, wrong shape: `mcpServers` is an array. getServerMap throws + // MCP_CLIENT_CONFIG_INVALID, which list() must swallow so one bad config + // can't crash `mcp list` across the other clients. + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + join(cwd, ".cursor", "mcp.json"), + JSON.stringify({ mcpServers: ["not", "an", "object"] }), + ); + const entries = await cursorClient.list(cwd); + expect(entries).toEqual([]); + }); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts new file mode 100644 index 00000000..ebd7fc79 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts @@ -0,0 +1,147 @@ +/** + * Factory for JSON-backed MCP clients. + * + * Five of the supported clients share the same upsert/remove/list shape — a + * JSON file with a top-level object whose keys are server names and whose + * values are per-client server descriptors. The only differences are the + * top-level key name (`mcpServers` vs `servers`) and the descriptor encoding + * (`{ url }` vs `{ serverUrl }` vs `{ command, args }`). This factory captures + * those differences as `topKey` + `encode` + `extractUrl` and reuses the rest. + */ + +import { CliError, ERROR_CODE } from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { getServerMap, readJsonConfig, writeJsonConfig, type JsonConfig } from "./json-config.ts"; +import type { + ClientId, + ListEntry, + McpClient, + McpServerEntry, + RemoveResult, + Scope, + UpsertResult, +} from "./types.ts"; + +export function hasStringProp( + value: unknown, + key: K, +): value is Record { + return ( + typeof value === "object" && + value !== null && + Object.prototype.hasOwnProperty.call(value, key) && + typeof (value as Record)[key] === "string" + ); +} + +interface JsonClientSpec { + id: ClientId; + displayName: string; + scope: Scope; + activation: string; + topKey: string; + /** Encode the per-client server descriptor for this URL. */ + encode: (url: string) => Record; + /** Extract a URL back out of a server descriptor (for `list`). Returns undefined when the shape doesn't match. */ + extractUrl: (descriptor: unknown) => string | undefined; + configPath: (cwd: string) => string; + detect: (cwd: string) => Promise; +} + +function isClerkUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.hostname === "mcp.clerk.com" || parsed.hostname.endsWith(".clerk.com"); + } catch { + return false; + } +} + +export function makeJsonClient(spec: JsonClientSpec): McpClient { + return { + id: spec.id, + displayName: spec.displayName, + scope: spec.scope, + activation: spec.activation, + configPath: spec.configPath, + detect: spec.detect, + + async upsert(entry: McpServerEntry, cwd: string, force: boolean): Promise { + const configPath = spec.configPath(cwd); + const config = await readJsonConfig(configPath); + const servers = getServerMap(config, spec.topKey, configPath); + + // Own-property only: `servers[name]` / `name in servers` would walk the + // prototype chain, so names like `toString` or `constructor` would read + // back an inherited function and falsely look like an existing entry. + const hasExisting = Object.prototype.hasOwnProperty.call(servers, entry.name); + const existing = hasExisting ? servers[entry.name] : undefined; + + if (existing !== undefined) { + const existingUrl = spec.extractUrl(existing); + if (existingUrl === entry.url) { + return { client: spec.id, configPath, status: "unchanged" }; + } + if (!force) { + return { + client: spec.id, + configPath, + status: "skipped", + reason: `entry "${entry.name}" already points at ${existingUrl ?? "another server"} — pass --force to overwrite`, + }; + } + } + + const desired = spec.encode(entry.url); + const next: JsonConfig = { ...config, [spec.topKey]: { ...servers, [entry.name]: desired } }; + await writeJsonConfig(configPath, next); + const status = hasExisting ? "updated" : "added"; + log.debug(`mcp: ${spec.id} ${status} "${entry.name}"`); + return { client: spec.id, configPath, status }; + }, + + async remove(name: string, cwd: string): Promise { + const configPath = spec.configPath(cwd); + const config = await readJsonConfig(configPath); + const servers = getServerMap(config, spec.topKey, configPath); + if (!Object.prototype.hasOwnProperty.call(servers, name)) { + return { client: spec.id, configPath, removed: false }; + } + const { [name]: _removed, ...rest } = servers; + const next: JsonConfig = { ...config, [spec.topKey]: rest }; + await writeJsonConfig(configPath, next); + log.debug(`mcp: ${spec.id} removed "${name}"`); + return { client: spec.id, configPath, removed: true }; + }, + + async list(cwd: string): Promise { + const configPath = spec.configPath(cwd); + // A half-written or structurally-invalid config (unparseable JSON, or a + // non-object `mcpServers`/`servers` value) shouldn't crash `mcp list` + // across the other clients — treat it as "no entries". Both readJsonConfig + // and getServerMap raise MCP_CLIENT_CONFIG_INVALID, so they share one guard. + let servers: Record; + try { + const config = await readJsonConfig(configPath); + servers = getServerMap(config, spec.topKey, configPath); + } catch (error) { + if (error instanceof CliError && error.code === ERROR_CODE.MCP_CLIENT_CONFIG_INVALID) { + // Don't crash `list` across the other clients — but the user must + // know their config was skipped, not silently treated as empty. + log.warn(`${spec.displayName}: ${error.message}`); + return []; + } + throw error; + } + const entries: ListEntry[] = []; + for (const [name, descriptor] of Object.entries(servers)) { + const url = spec.extractUrl(descriptor); + if (!url) continue; + if (name === "clerk" || isClerkUrl(url)) { + entries.push({ client: spec.id, configPath, name, url }); + } + } + return entries; + }, + }; +} diff --git a/packages/cli-core/src/commands/mcp/clients/paths.ts b/packages/cli-core/src/commands/mcp/clients/paths.ts new file mode 100644 index 00000000..a589698d --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/paths.ts @@ -0,0 +1,34 @@ +/** + * Cross-platform path + filesystem helpers for MCP client integrations. + * + * We deliberately avoid OS-specific layout (no XDG, no AppData) — every + * documented client config path is rooted at `~/./` regardless of + * platform, so a single homedir join is enough. + */ + +import { stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +export function projectPath(cwd: string, ...segments: string[]): string { + return join(cwd, ...segments); +} + +export function userPath(...segments: string[]): string { + return join(homedir(), ...segments); +} + +/** + * Returns true when *anything* exists at `path` — file, directory, symlink. + * Detection only needs to know "did the user install this tool?", which is + * adequately answered by "does the well-known config dir exist?". A regular + * file at a directory path is impossible in practice for the tools we check. + */ +export async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} diff --git a/packages/cli-core/src/commands/mcp/clients/registry.ts b/packages/cli-core/src/commands/mcp/clients/registry.ts new file mode 100644 index 00000000..109fc6d9 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/registry.ts @@ -0,0 +1,26 @@ +/** + * Registry of supported MCP clients. Order is the display order in the + * human-mode multiselect picker. + */ + +import { claudeCodeClient } from "./claude-code.ts"; +import { cursorClient } from "./cursor.ts"; +import { geminiClient } from "./gemini.ts"; +import type { ClientId, McpClient } from "./types.ts"; +import { vscodeClient } from "./vscode.ts"; +import { windsurfClient } from "./windsurf.ts"; + +export const CLIENTS: readonly McpClient[] = [ + claudeCodeClient, + cursorClient, + vscodeClient, + windsurfClient, + geminiClient, +]; + +export const CLIENT_IDS: readonly ClientId[] = CLIENTS.map((c) => c.id); + +export async function detectInstalledClients(cwd: string): Promise { + const flags = await Promise.all(CLIENTS.map((c) => c.detect(cwd))); + return CLIENTS.filter((_, i) => flags[i]); +} diff --git a/packages/cli-core/src/commands/mcp/clients/types.ts b/packages/cli-core/src/commands/mcp/clients/types.ts new file mode 100644 index 00000000..5a05e3fc --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/types.ts @@ -0,0 +1,65 @@ +/** + * Shared types for MCP client integrations. + * + * Each supported MCP client (Claude Code, Cursor, VS Code, Windsurf, Gemini) + * exposes an {@link McpClient} that knows how to read, upsert, and remove + * the `clerk` server entry in its own config file format. + */ + +export type ClientId = "claude-code" | "cursor" | "vscode" | "windsurf" | "gemini"; + +/** Where the client config file lives relative to the user / project. */ +export type Scope = "project" | "user"; + +export interface McpServerEntry { + /** Entry name (key under `mcpServers` / `servers`). Default: `clerk`. */ + name: string; + /** Remote MCP endpoint. */ + url: string; +} + +export type DiffStatus = "added" | "updated" | "unchanged" | "skipped"; + +/** + * `reason` is present iff the entry was skipped — the union makes the + * "reason only on skip" contract a compile-time guarantee instead of a comment. + */ +export type UpsertResult = + | { client: ClientId; configPath: string; status: Exclude } + | { client: ClientId; configPath: string; status: "skipped"; reason: string }; + +export interface RemoveResult { + client: ClientId; + configPath: string; + removed: boolean; +} + +export interface ListEntry { + client: ClientId; + configPath: string; + name: string; + url: string; +} + +export interface McpClient { + id: ClientId; + displayName: string; + scope: Scope; + /** + * What the user must do *after* the config is written for this client to + * connect — typically reload the editor, and sign in if the server requires + * it. Writing the file is not enough on its own, so `install` surfaces this + * as a next step. + */ + activation: string; + /** Where the entry would be written for the given cwd. */ + configPath(cwd: string): string; + /** Heuristic: is this client installed for this user? */ + detect(cwd: string): Promise; + /** Add or update the `name` entry pointing at `url`. */ + upsert(entry: McpServerEntry, cwd: string, force: boolean): Promise; + /** Remove the `name` entry. */ + remove(name: string, cwd: string): Promise; + /** List `clerk`-flavored entries currently registered (those pointing at clerk.com URLs or named explicitly). */ + list(cwd: string): Promise; +} diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts new file mode 100644 index 00000000..d8548d45 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -0,0 +1,206 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import * as realOs from "node:os"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; + +// Gemini and Windsurf write under the user's home (`~/.gemini`, `~/.codeium`), +// so their encode/extract logic can't be exercised through a cwd tmpdir like the +// project-scope clients. Bun's os.homedir() ignores $HOME, so redirect it via the +// module mock instead — registered at file top, before the clients are imported. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + +const mockIsAgent = mock(); +mock.module("../../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +const { geminiClient } = await import("./gemini.ts"); +const { windsurfClient } = await import("./windsurf.ts"); +const { mcpInstall } = await import("../install.ts"); +const { mcpUninstall } = await import("../uninstall.ts"); +const { checkMcp } = await import("../../doctor/check-mcp.ts"); + +const captured = useCaptureLog(); + +const URL = "https://mcp.clerk.com/mcp"; +const ALL_CLIENT_IDS = ["claude-code", "cursor", "vscode", "windsurf", "gemini"]; + +describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { + beforeEach(async () => { + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-home-")); + }); + + afterEach(async () => { + await rm(mockHome, { recursive: true, force: true }); + }); + + describe("gemini", () => { + test("encodes the mcp-remote stdio-bridge shape", async () => { + await geminiClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const parsed = JSON.parse(await readFile(geminiClient.configPath("/ignored"), "utf8")) as { + mcpServers: { clerk: { command: string; args: string[] } }; + }; + expect(parsed.mcpServers.clerk).toEqual({ command: "npx", args: ["-y", "mcp-remote", URL] }); + }); + + test("round-trips the URL back out of args[2] on list", async () => { + await geminiClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const entries = await geminiClient.list("/ignored"); + expect(entries).toEqual([ + expect.objectContaining({ client: "gemini", name: "clerk", url: URL }), + ]); + }); + + test("ignores a foreign npx entry that is not an mcp-remote bridge", async () => { + // Only `{command:"npx", args:["-y","mcp-remote", ]}` is ours; an + // unrelated npx tool must not round-trip as a Clerk MCP entry. + const dir = join(mockHome, ".gemini"); + await mkdir(dir, { recursive: true }); + await writeFile( + join(dir, "settings.json"), + JSON.stringify({ + mcpServers: { + clerk: { command: "npx", args: ["-y", "mcp-remote", URL] }, + "other-tool": { command: "npx", args: ["serve", "--port", "3000"] }, + }, + }), + ); + const entries = await geminiClient.list("/ignored"); + expect(entries.map((e) => e.name)).toEqual(["clerk"]); + }); + }); + + describe("windsurf", () => { + test("encodes the serverUrl shape and round-trips it on list", async () => { + await windsurfClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const parsed = JSON.parse(await readFile(windsurfClient.configPath("/ignored"), "utf8")) as { + mcpServers: { clerk: { serverUrl: string } }; + }; + expect(parsed.mcpServers.clerk).toEqual({ serverUrl: URL }); + + const entries = await windsurfClient.list("/ignored"); + expect(entries).toEqual([ + expect.objectContaining({ client: "windsurf", name: "clerk", url: URL }), + ]); + }); + }); +}); + +// These exercise the command-level "all clients" defaults, which touch the +// user-scoped clients (gemini, windsurf) — so they live here, alongside the +// single homedir redirect, rather than in a second file that re-mocks node:os. +describe("install/uninstall across all clients (homedir + cwd redirected)", () => { + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-all-cwd-")); + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-all-home-")); + process.chdir(cwd); + mockIsAgent.mockReturnValue(true); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + await rm(mockHome, { recursive: true, force: true }); + mockIsAgent.mockReset(); + }); + + test("install --all targets every detected client", async () => { + // detect() keys off each client's marker directory under home. + await Promise.all([ + mkdir(join(mockHome, ".claude"), { recursive: true }), + mkdir(join(mockHome, ".cursor"), { recursive: true }), + mkdir(join(mockHome, ".vscode"), { recursive: true }), + mkdir(join(mockHome, ".codeium", "windsurf"), { recursive: true }), + mkdir(join(mockHome, ".gemini"), { recursive: true }), + ]); + + await mcpInstall({ all: true, url: URL }); + + const payload = JSON.parse(captured.out) as { results: { client: string; status: string }[] }; + expect(payload.results.map((r) => r.client).sort()).toEqual([...ALL_CLIENT_IDS].sort()); + expect(payload.results.every((r) => r.status === "added")).toBe(true); + }); + + test("uninstall with no --client removes from every client", async () => { + await mcpInstall({ client: ["cursor", "gemini"], url: URL }); + captured.clear(); + + await mcpUninstall({}); + + const payload = JSON.parse(captured.out) as { results: { client: string; removed: boolean }[] }; + expect(payload.results.map((r) => r.client).sort()).toEqual([...ALL_CLIENT_IDS].sort()); + expect(payload.results.find((r) => r.client === "cursor")?.removed).toBe(true); + expect(payload.results.find((r) => r.client === "gemini")?.removed).toBe(true); + }); +}); + +// `clerk doctor`'s MCP check (folded in from the former `clerk mcp doctor`). +// Lives here because it scans the user-scoped clients, needing the homedir +// redirect; a second os-mocking file would collide in the single-process runner. +describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { + let cwd: string; + let originalCwd: string; + const originalFetch = globalThis.fetch; + + // Assign globalThis.fetch directly (cast to its own type) rather than via the + // typed stubFetch helper: this file imports node:* which pulls in undici's + // `Response` type, and stubFetch expects Bun's — the two aren't assignable. + function stubFetchWith(body: string, init: ResponseInit): void { + globalThis.fetch = (async () => new Response(body, init)) as unknown as typeof globalThis.fetch; + } + + const HANDSHAKE_BODY = `event: message\ndata: {"result":{"serverInfo":{"name":"Clerk MCP Server"}},"jsonrpc":"2.0","id":1}\n\n`; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-check-cwd-")); + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-check-home-")); + process.chdir(cwd); + mockIsAgent.mockReturnValue(true); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + await rm(mockHome, { recursive: true, force: true }); + globalThis.fetch = originalFetch; + mockIsAgent.mockReset(); + }); + + test("passes (skipped) when no MCP entry is installed", async () => { + const result = await checkMcp(); + expect(result.status).toBe("pass"); + expect(result.message).toContain("Skipped"); + }); + + test("passes when the installed MCP server answers the handshake", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + captured.clear(); + stubFetchWith(HANDSHAKE_BODY, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + + const result = await checkMcp(); + expect(result.status).toBe("pass"); + expect(result.message).toContain("Reachable"); + }); + + test("warns when the installed MCP server is unreachable", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + captured.clear(); + stubFetchWith("nope", { status: 503 }); + + const result = await checkMcp(); + expect(result.status).toBe("warn"); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/clients/vscode.ts b/packages/cli-core/src/commands/mcp/clients/vscode.ts new file mode 100644 index 00000000..60b7ecde --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/vscode.ts @@ -0,0 +1,23 @@ +/** + * VS Code (Copilot) MCP client integration. + * + * Writes to `.vscode/mcp.json` in the current working directory. VS Code uses + * the top-level key `servers` (not `mcpServers`) and the HTTP transport form + * `{ type: "http", url }`. + */ + +import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { pathExists, projectPath, userPath } from "./paths.ts"; + +export const vscodeClient = makeJsonClient({ + id: "vscode", + displayName: "VS Code", + scope: "project", + activation: + "Reload the VS Code window, then start the server from `MCP: List Servers` (sign in if prompted).", + topKey: "servers", + encode: (url) => ({ type: "http", url }), + extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + configPath: (cwd) => projectPath(cwd, ".vscode", "mcp.json"), + detect: () => pathExists(userPath(".vscode")), +}); diff --git a/packages/cli-core/src/commands/mcp/clients/windsurf.ts b/packages/cli-core/src/commands/mcp/clients/windsurf.ts new file mode 100644 index 00000000..bca239f2 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/windsurf.ts @@ -0,0 +1,21 @@ +/** + * Windsurf MCP client integration. + * + * Writes to `~/.codeium/windsurf/mcp_config.json` (user scope). Server + * descriptor uses `serverUrl`, not `url`. + */ + +import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { pathExists, userPath } from "./paths.ts"; + +export const windsurfClient = makeJsonClient({ + id: "windsurf", + displayName: "Windsurf", + scope: "user", + activation: "Reload Windsurf, then turn on the server in `Cascade → MCP` (sign in if prompted).", + topKey: "mcpServers", + encode: (url) => ({ serverUrl: url }), + extractUrl: (d) => (hasStringProp(d, "serverUrl") ? d.serverUrl : undefined), + configPath: () => userPath(".codeium", "windsurf", "mcp_config.json"), + detect: () => pathExists(userPath(".codeium", "windsurf")), +}); diff --git a/packages/cli-core/src/commands/mcp/collect.ts b/packages/cli-core/src/commands/mcp/collect.ts new file mode 100644 index 00000000..14dc2820 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/collect.ts @@ -0,0 +1,28 @@ +/** + * Aggregate Clerk MCP entries across every supported client. + * + * Deliberately light on imports (just the client registry) so it can be reused + * by `clerk doctor`'s MCP check without dragging in `shared.ts`'s heavier graph + * (env profiles, interactive prompts) and the module cycle that comes with it. + * Each client's `list` already swallows-and-warns on its own malformed config, + * so a single bad client can't sink the aggregate. + */ + +import { errorMessage } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { CLIENTS } from "./clients/registry.ts"; +import type { ListEntry } from "./clients/types.ts"; + +export async function collectEntries(cwd: string): Promise { + const settled = await Promise.allSettled(CLIENTS.map((c) => c.list(cwd))); + return settled.flatMap((outcome, i) => { + if (outcome.status === "fulfilled") return outcome.value; + // A client's own `list` already warns on its malformed config; reaching the + // rejected branch means an unexpected error (e.g. an unreadable file), so + // surface it instead of silently dropping that client. + log.warn( + `${CLIENTS[i]!.displayName}: could not read MCP config — ${errorMessage(outcome.reason)}`, + ); + return []; + }); +} diff --git a/packages/cli-core/src/commands/mcp/index.ts b/packages/cli-core/src/commands/mcp/index.ts new file mode 100644 index 00000000..2deb8be3 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/index.ts @@ -0,0 +1,80 @@ +import { createOption } from "@commander-js/extra-typings"; +import type { Program } from "../../cli-program.ts"; +import { mcpInstall } from "./install.ts"; +import { mcpList } from "./list.ts"; +import { mcpUninstall } from "./uninstall.ts"; +import { CLIENT_IDS } from "./clients/registry.ts"; + +export const mcp = { + install: mcpInstall, + list: mcpList, + uninstall: mcpUninstall, +}; +export { CLIENT_IDS } from "./clients/registry.ts"; + +function collectOptionValues(value: string, previous: string[] = []): string[] { + return [...previous, value]; +} + +export function registerMcp(program: Program): void { + const mcpCmd = program + .command("mcp") + .description("Manage the Clerk remote MCP server connection for AI editors and CLIs") + .setExamples([ + { command: "clerk mcp install", description: "Install into all detected MCP clients" }, + { command: "clerk mcp install --client cursor", description: "Install into Cursor only" }, + { command: "clerk mcp list", description: "Show registered Clerk entries" }, + { command: "clerk mcp uninstall", description: "Remove the Clerk entry from all clients" }, + ]); + + mcpCmd + .command("install") + .description("Register the Clerk remote MCP server in supported clients") + .addOption( + createOption("--client ", "MCP client to target (repeatable). Default: all detected.") + .choices([...CLIENT_IDS]) + .argParser(collectOptionValues) + .default([] as string[]), + ) + .option("--url ", "Override the MCP server URL (default: from active env profile)") + .option("--name ", 'Entry name in the client config (default: "clerk")') + .option("--all", "Install into every detected client without prompting") + .option("--force", "Overwrite an existing entry pointing at a different URL") + .option("--json", "Output as JSON") + .setExamples([ + { + command: "clerk mcp install", + description: "Pick clients interactively (or all in agent mode)", + }, + { command: "clerk mcp install --all", description: "Install into every detected client" }, + { + command: "clerk mcp install --client cursor --client vscode", + description: "Install into specific clients", + }, + ]) + .action((options) => mcp.install(options)); + + mcpCmd + .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) => mcp.list(options)); + + mcpCmd + .command("uninstall") + .description("Remove the Clerk MCP entry from supported clients") + .addOption( + createOption("--client ", "MCP client to target (repeatable). Default: all clients.") + .choices([...CLIENT_IDS]) + .argParser(collectOptionValues) + .default([] as string[]), + ) + .option("--name ", 'Entry name to remove (default: "clerk")') + .option("--json", "Output as JSON") + .setExamples([ + { command: "clerk mcp uninstall", description: "Remove from every client" }, + { command: "clerk mcp uninstall --client cursor", description: "Remove from Cursor only" }, + ]) + .action((options) => mcp.uninstall(options)); +} diff --git a/packages/cli-core/src/commands/mcp/install.test.ts b/packages/cli-core/src/commands/mcp/install.test.ts new file mode 100644 index 00000000..8245b618 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +const { mcpInstall } = await import("./install.ts"); + +const URL_A = "https://mcp.clerk.com/mcp"; +const URL_B = "http://localhost:8787/mcp"; + +describe("mcp install", () => { + const captured = useCaptureLog(); + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-install-")); + process.chdir(cwd); + mockIsAgent.mockReturnValue(false); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + mockIsAgent.mockReset(); + }); + + test("writes the Cursor config when --client cursor is passed", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A }); + + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: { clerk: { url: string } }; + }; + expect(parsed.mcpServers.clerk).toEqual({ url: URL_A }); + }); + + test("emits JSON to stdout in agent mode and skips intro/outro", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"], url: URL_A }); + + const payload = JSON.parse(captured.out) as { + url: string; + name: string; + results: { client: string; status: string }[]; + }; + expect(payload.url).toBe(URL_A); + expect(payload.name).toBe("clerk"); + expect(payload.results).toEqual([ + expect.objectContaining({ client: "cursor", status: "added" }), + ]); + expect(captured.err).not.toContain("┌"); // intro suppressed in agent mode + }); + + test("--json forces JSON output even in human mode", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A, json: true }); + expect(() => JSON.parse(captured.out)).not.toThrow(); + }); + + test("returns `unchanged` on idempotent re-install", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"], url: URL_A }); + captured.clear(); + await mcpInstall({ client: ["cursor"], url: URL_A }); + + const payload = JSON.parse(captured.out) as { + results: { status: string }[]; + }; + expect(payload.results[0]?.status).toBe("unchanged"); + }); + + test("skips with reason on URL conflict without --force", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"], url: URL_A }); + captured.clear(); + await mcpInstall({ client: ["cursor"], url: URL_B }); + + const payload = JSON.parse(captured.out) as { + results: { status: string; reason?: string }[]; + }; + expect(payload.results[0]?.status).toBe("skipped"); + expect(payload.results[0]?.reason).toContain("--force"); + }); + + test("overwrites on URL conflict with --force", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"], url: URL_A }); + captured.clear(); + await mcpInstall({ client: ["cursor"], url: URL_B, force: true }); + + const payload = JSON.parse(captured.out) as { + results: { status: string }[]; + }; + expect(payload.results[0]?.status).toBe("updated"); + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: { clerk: { url: string } }; + }; + expect(parsed.mcpServers.clerk.url).toBe(URL_B); + }); + + test("uses --name to customize the entry key", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A, name: "clerk-staging" }); + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(parsed.mcpServers["clerk-staging"]).toEqual({ url: URL_A }); + expect(parsed.mcpServers.clerk).toBeUndefined(); + }); + + test("uses the active env profile URL when --url is not given", async () => { + // Default production profile has mcpUrl=https://mcp.clerk.com/mcp. + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"] }); + const payload = JSON.parse(captured.out) as { url: string }; + expect(payload.url).toBe("https://mcp.clerk.com/mcp"); + }); + + test.each([ + ["file:///etc/passwd"], + ["data:text/plain,clerk"], + ["javascript:alert(1)"], + ["ftp://example.com/mcp"], + ])("rejects non-http(s) URL: %s", async (badUrl) => { + await expect(mcpInstall({ client: ["cursor"], url: badUrl })).rejects.toMatchObject({ + code: "mcp_url_required", + }); + }); + + test("rejects unparseable URL", async () => { + await expect(mcpInstall({ client: ["cursor"], url: "not a url" })).rejects.toMatchObject({ + code: "mcp_url_required", + }); + }); + + test("prints next steps with a sign-in reminder after a human-mode install", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A }); + expect(captured.err).toContain("Next steps:"); + expect(captured.err).toContain("Reload Cursor"); + expect(captured.err).toContain("sign in"); + }); + + test("omits next steps from JSON output", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A, json: true }); + expect(captured.err).not.toContain("Next steps:"); + }); + + test("does not print next steps when the entry was unchanged", async () => { + await mcpInstall({ client: ["cursor"], url: URL_A }); + captured.clear(); + await mcpInstall({ client: ["cursor"], url: URL_A }); + expect(captured.err).not.toContain("Next steps:"); + }); + + test("rejects an unknown --client id", async () => { + await expect(mcpInstall({ client: ["bogus"], url: URL_A })).rejects.toMatchObject({ + code: "mcp_client_not_supported", + }); + }); + + test("installs the healthy clients and warns when one config is corrupt", async () => { + mockIsAgent.mockReturnValue(true); + // Pre-corrupt Cursor's config; Claude Code's is absent (clean). + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "{ not json"); + + await mcpInstall({ client: ["cursor", "claude-code"], url: URL_A }); + + const payload = JSON.parse(captured.out) as { results: { client: string; status: string }[] }; + expect(payload.results).toEqual([ + expect.objectContaining({ client: "claude-code", status: "added" }), + ]); + expect(captured.err).toContain("Cursor"); // per-client warning for the failure + }); + + test("throws when every targeted client fails", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "{ not json"); + + await expect(mcpInstall({ client: ["cursor"], url: URL_A })).rejects.toMatchObject({ + code: "mcp_client_config_invalid", + }); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts new file mode 100644 index 00000000..a31ef6cb --- /dev/null +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -0,0 +1,102 @@ +/** + * `clerk mcp install` — register the Clerk remote MCP server in supported clients. + * + * URL resolution: `--url` > active env profile `mcpUrl` > error. + * Target clients: `--client ` (repeatable) > `--all` > human picker > all detected (agent mode). + * Conflict policy: same URL → unchanged; different URL → skip unless `--force`. + */ + +import { log } from "../../lib/log.ts"; +import { cyan, dim, green, yellow } from "../../lib/color.ts"; +import { isAgent, isHuman } from "../../mode.ts"; +import { intro, outro } from "../../lib/spinner.ts"; +import { + pickClients, + printNextSteps, + resolveName, + resolveUrl, + settleClients, + targetClients, + wantsJson, + type McpOptions, +} from "./shared.ts"; +import { detectInstalledClients } from "./clients/registry.ts"; +import type { McpClient, UpsertResult } from "./clients/types.ts"; + +async function chooseClients(options: McpOptions, cwd: string): Promise { + if (options.client?.length || options.all || isAgent()) return targetClients(options, cwd); + return pickClients(await detectInstalledClients(cwd)); +} + +function statusLabel(status: UpsertResult["status"]): string { + switch (status) { + case "added": + return green("added"); + case "updated": + return green("updated"); + case "unchanged": + return dim("unchanged"); + case "skipped": + return yellow("skipped"); + } +} + +function printResult(client: McpClient, result: UpsertResult): void { + const label = `${client.displayName} → ${dim(result.configPath)}`; + if (result.status === "skipped") { + log.warn(`${label}: ${statusLabel(result.status)} (${result.reason})`); + return; + } + log.info(`${label}: ${statusLabel(result.status)}`); +} + +type ClientUpsert = { client: McpClient; result: UpsertResult }; + +// Writing the config isn't enough — the editor must reload before it connects +// (and sign in, if the server requires it). Surface that for every client we +// just wrote, so "added" doesn't read as "done and working". +function printInstallNextSteps(settled: ClientUpsert[]): void { + const activated = settled.filter( + ({ result }) => result.status === "added" || result.status === "updated", + ); + if (activated.length === 0) return; + + printNextSteps(activated.map(({ client }) => `${client.displayName}: ${client.activation}`)); + log.info( + dim( + "If the server requires authentication, your editor opens a browser to sign in on first connect.", + ), + ); +} + +export async function mcpInstall(options: McpOptions = {}): Promise { + const url = resolveUrl(options); + const name = resolveName(options); + const cwd = process.cwd(); + const clients = await chooseClients(options, cwd); + const force = Boolean(options.force); + const json = wantsJson(options); + + if (clients.length === 0 && json) { + log.data(JSON.stringify({ url, name, results: [] }, null, 2)); + return; + } + if (clients.length === 0) { + log.warn("No MCP clients selected."); + return; + } + + if (isHuman() && !json) intro(`Installing Clerk MCP (${cyan(url)})`); + + const settled = await settleClients(clients, (c) => c.upsert({ name, url }, cwd, force)); + const results = settled.map((s) => s.result); + + if (json) { + log.data(JSON.stringify({ url, name, results }, null, 2)); + return; + } + + if (isHuman()) settled.forEach(({ client, result }) => printResult(client, result)); + printInstallNextSteps(settled); + outro("Done"); +} diff --git a/packages/cli-core/src/commands/mcp/list.test.ts b/packages/cli-core/src/commands/mcp/list.test.ts new file mode 100644 index 00000000..d2ab7ffe --- /dev/null +++ b/packages/cli-core/src/commands/mcp/list.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +const { mcpInstall } = await import("./install.ts"); +const { mcpList } = await import("./list.ts"); + +const URL = "https://mcp.clerk.com/mcp"; + +describe("mcp list", () => { + const captured = useCaptureLog(); + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-list-")); + process.chdir(cwd); + mockIsAgent.mockReturnValue(true); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + mockIsAgent.mockReset(); + }); + + test("returns an empty JSON array when no clients have entries", async () => { + await mcpList({}); + expect(JSON.parse(captured.out)).toEqual([]); + }); + + test("returns the cursor entry after install", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + captured.clear(); + await mcpList({}); + const payload = JSON.parse(captured.out) as { + client: string; + name: string; + url: string; + }[]; + expect(payload).toEqual([ + expect.objectContaining({ client: "cursor", name: "clerk", url: URL }), + ]); + }); + + test("human-mode empty state writes the hint to stderr, nothing to stdout", async () => { + mockIsAgent.mockReturnValue(false); + await mcpList({}); + expect(captured.out).toBe(""); + expect(captured.err).toContain("No Clerk MCP entries"); + }); + + test("human-mode prints the formatted table to stdout after an install", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"], url: URL }); + mockIsAgent.mockReturnValue(false); + captured.clear(); + await mcpList({}); + + expect(captured.out).toContain("cursor"); + expect(captured.out).toContain("clerk"); + expect(captured.out).toContain(URL); + expect(captured.err).toContain("1 entry"); + expect(captured.err).toContain("Next steps:"); + expect(captured.err).toContain("clerk doctor"); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/list.ts b/packages/cli-core/src/commands/mcp/list.ts new file mode 100644 index 00000000..26e7e951 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/list.ts @@ -0,0 +1,54 @@ +/** + * `clerk mcp list` — show Clerk-named entries across detected MCP clients. + * + * Walks the registry, reads each client's config (if present), and reports + * any entry whose name is `clerk` or whose URL hostname is under `clerk.com`. + */ + +import { cyan, dim } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import type { ListEntry } from "./clients/types.ts"; +import { collectEntries } from "./collect.ts"; +import { printNextSteps, wantsJson, type McpOptions } from "./shared.ts"; + +const COLUMN_PADDING = 2; + +function formatTable(entries: ListEntry[]): void { + const clientWidth = + Math.max("CLIENT".length, ...entries.map((e) => e.client.length)) + COLUMN_PADDING; + const nameWidth = Math.max("NAME".length, ...entries.map((e) => e.name.length)) + COLUMN_PADDING; + const urlWidth = Math.max("URL".length, ...entries.map((e) => e.url.length)) + COLUMN_PADDING; + + log.data( + dim(`${"CLIENT".padEnd(clientWidth)}${"NAME".padEnd(nameWidth)}${"URL".padEnd(urlWidth)}PATH`), + ); + for (const entry of entries) { + const client = cyan(entry.client.padEnd(clientWidth)); + const name = entry.name.padEnd(nameWidth); + const url = entry.url.padEnd(urlWidth); + log.data(`${client}${name}${url}${dim(entry.configPath)}`); + } +} + +export async function mcpList(options: McpOptions = {}): Promise { + const cwd = process.cwd(); + const all: ListEntry[] = await collectEntries(cwd); + + if (wantsJson(options)) { + log.data(JSON.stringify(all, null, 2)); + return; + } + + if (all.length === 0) { + log.warn("No Clerk MCP entries found. Run `clerk mcp install` to register one."); + return; + } + + formatTable(all); + log.info(`\n${all.length} entr${all.length === 1 ? "y" : "ies"}`); + + printNextSteps([ + "Verify a server is reachable with `clerk doctor`.", + "Remove an entry with `clerk mcp uninstall`.", + ]); +} diff --git a/packages/cli-core/src/commands/mcp/probe.test.ts b/packages/cli-core/src/commands/mcp/probe.test.ts new file mode 100644 index 00000000..5f631ae9 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/probe.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import { probeMcp } from "./probe.ts"; + +const URL = "https://mcp.clerk.com/mcp"; + +const INITIALIZE_RESULT = { + result: { serverInfo: { name: "Clerk MCP Server", version: "0.0.0" } }, + jsonrpc: "2.0", + id: 1, +}; + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function sseResponse(payload: unknown): Response { + return new Response(`event: message\ndata: ${JSON.stringify(payload)}\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function sse(body: string): Response { + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +describe("probeMcp", () => { + useCaptureLog(); + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test.each([ + ["text/event-stream", sseResponse(INITIALIZE_RESULT)], + ["application/json", jsonResponse(INITIALIZE_RESULT)], + ])("returns ok with the server name via %s", async (_label, response) => { + stubFetch(async () => response); + expect(await probeMcp(URL)).toEqual({ ok: true, status: 200, serverName: "Clerk MCP Server" }); + }); + + test("POSTs an initialize handshake", async () => { + let method = ""; + let body = ""; + stubFetch(async (_input, init) => { + method = init?.method ?? ""; + body = typeof init?.body === "string" ? init.body : ""; + return sseResponse(INITIALIZE_RESULT); + }); + await probeMcp(URL); + expect(method).toBe("POST"); + expect(body).toContain('"method":"initialize"'); + }); + + test("parses an SSE frame with CRLF line endings", async () => { + stubFetch(async () => + sse(`event: message\r\ndata: ${JSON.stringify(INITIALIZE_RESULT)}\r\n\r\n`), + ); + expect((await probeMcp(URL)).ok).toBe(true); + }); + + test("reassembles an SSE payload split across multiple data lines", async () => { + stubFetch(async () => + sse( + `event: message\n` + + `data: {"result":{"serverInfo":{"name":"Clerk MCP Server"}},\n` + + `data: "jsonrpc":"2.0","id":1}\n\n`, + ), + ); + expect(await probeMcp(URL)).toMatchObject({ ok: true, serverName: "Clerk MCP Server" }); + }); + + test("fails when the SSE frame has no data line", async () => { + stubFetch(async () => sse("event: message\n\n")); + expect(await probeMcp(URL)).toMatchObject({ ok: false }); + }); + + test("fails when the SSE data line is malformed JSON", async () => { + stubFetch(async () => sse("event: message\ndata: {broken\n\n")); + expect(await probeMcp(URL)).toMatchObject({ ok: false }); + }); + + test("fails when 200 but not an MCP initialize result", async () => { + stubFetch(async () => jsonResponse({ hello: "world" })); + expect(await probeMcp(URL)).toMatchObject({ ok: false, status: 200 }); + }); + + test("fails on non-2xx, carrying the status", async () => { + stubFetch(async () => new Response("nope", { status: 404 })); + expect(await probeMcp(URL)).toEqual({ ok: false, status: 404 }); + }); + + test("fails on a network error, carrying the message", async () => { + stubFetch(async () => { + throw new Error("ECONNREFUSED"); + }); + expect(await probeMcp(URL)).toMatchObject({ + ok: false, + error: expect.stringContaining("ECONNREFUSED"), + }); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/probe.ts b/packages/cli-core/src/commands/mcp/probe.ts new file mode 100644 index 00000000..09c922fa --- /dev/null +++ b/packages/cli-core/src/commands/mcp/probe.ts @@ -0,0 +1,94 @@ +/** + * MCP `initialize` handshake probe. + * + * Performs the JSON-RPC `initialize` call every MCP client makes and confirms + * the server answers with a result carrying `serverInfo` — the actual MCP + * contract, independent of any OAuth-metadata side channel. Used by the + * `clerk doctor` MCP health check. Returns a result rather than throwing so the + * caller can fold it into a `CheckResult`. + */ + +import { errorMessage } from "../../lib/errors.ts"; +import { loggedFetch } from "../../lib/fetch.ts"; +import { DEV_CLI_VERSION, resolveCliVersion } from "../../lib/version.ts"; + +// Discriminated on `ok`: a healthy probe always carries a server name; a failed +// one never does. "ok but no serverName" is unrepresentable. +export type McpProbeResult = + | { ok: true; status: number; serverName: string } + | { ok: false; status?: number; error?: string }; + +// A hostile or wrong URL shouldn't hang the CLI: cap the probe so a slow or +// never-ending response surfaces as a failure instead of blocking forever. +const PROBE_TIMEOUT_MS = 10_000; + +const INITIALIZE_REQUEST = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "clerk-cli", version: resolveCliVersion() ?? DEV_CLI_VERSION }, + }, +}; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +// The streamable-HTTP transport answers `initialize` as either application/json +// or a text/event-stream frame (`event: message\ndata: {…}`). Pull the JSON-RPC +// payload out of whichever the server returned. For SSE, reassemble the first +// event's `data:` lines (the spec allows a payload to span several). +function parseHandshake(contentType: string, body: string): unknown { + if (!contentType.includes("text/event-stream")) return safeJsonParse(body); + const firstEvent = body.split(/\r?\n\r?\n/)[0] ?? ""; + const data = firstEvent + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .join("\n"); + return data === "" ? undefined : safeJsonParse(data); +} + +// A valid `initialize` result carries `serverInfo.name`; its presence is what +// distinguishes a real MCP server from a URL that merely returns 200. +function readServerName(payload: unknown): string | undefined { + if (typeof payload !== "object" || payload === null) return undefined; + const result = (payload as { result?: unknown }).result; + if (typeof result !== "object" || result === null) return undefined; + const serverInfo = (result as { serverInfo?: unknown }).serverInfo; + if (typeof serverInfo !== "object" || serverInfo === null) return undefined; + const name = (serverInfo as { name?: unknown }).name; + return typeof name === "string" ? name : undefined; +} + +export async function probeMcp(url: string): Promise { + try { + const response = await loggedFetch(url, { + tag: "mcp", + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify(INITIALIZE_REQUEST), + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + if (!response.ok) return { ok: false, status: response.status }; + + const contentType = response.headers.get("content-type") ?? ""; + const serverName = readServerName(parseHandshake(contentType, await response.text())); + if (serverName === undefined) { + return { ok: false, status: response.status, error: "no MCP initialize result" }; + } + return { ok: true, status: response.status, serverName }; + } catch (error) { + return { ok: false, error: errorMessage(error) }; + } +} diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts new file mode 100644 index 00000000..f580b650 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -0,0 +1,147 @@ +/** + * Shared options and helpers for `clerk mcp` subcommands. + */ + +import { checkbox } from "@inquirer/prompts"; +import { ttyContext } from "../../lib/listage.ts"; +import { getMcpUrl } from "../../lib/environment.ts"; +import { CliError, ERROR_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; +import { CLIENT_IDS, CLIENTS, detectInstalledClients } from "./clients/registry.ts"; +import type { ClientId, McpClient } from "./clients/types.ts"; + +export type McpOptions = { + json?: boolean; + url?: string; + name?: string; + /** Raw client IDs from the CLI. Validated through {@link resolveClients}. */ + client?: string[]; + all?: boolean; + force?: boolean; +}; + +export const DEFAULT_ENTRY_NAME = "clerk"; + +export function resolveUrl(options: McpOptions): string { + const candidate = options.url ?? getMcpUrl(); + if (!candidate) { + throw new CliError( + "No MCP URL available. Set one with `--url`, or switch to an environment whose profile defines `mcpUrl`.", + { code: ERROR_CODE.MCP_URL_REQUIRED }, + ); + } + // Reject non-network schemes so a stray `file:` or `data:` URL can't be + // written into an editor's MCP config or probed by `doctor` via fetch. + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + throwUsageError(`Invalid MCP URL "${candidate}".`, undefined, ERROR_CODE.MCP_URL_REQUIRED); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + throwUsageError( + `MCP URL must use http or https — got "${parsed.protocol}".`, + undefined, + ERROR_CODE.MCP_URL_REQUIRED, + ); + } + return candidate; +} + +export function resolveName(options: McpOptions): string { + return options.name ?? DEFAULT_ENTRY_NAME; +} + +export function resolveClients(ids: readonly string[]): McpClient[] { + const byId = new Map(CLIENTS.map((c) => [c.id, c])); + return ids.map((id) => { + const client = byId.get(id); + if (!client) { + throwUsageError( + `Unknown MCP client "${id}". Supported: ${CLIENT_IDS.join(", ")}.`, + undefined, + ERROR_CODE.MCP_CLIENT_NOT_SUPPORTED, + ); + } + return client; + }); +} + +export async function pickClients(detected: McpClient[]): Promise { + if (detected.length === 0) return []; + if (detected.length === 1) return detected; + const tty = ttyContext(); + try { + const selected = await checkbox( + { + message: "Select MCP clients to install into:", + choices: detected.map((c) => ({ + name: `${c.displayName} (${c.scope})`, + value: c.id, + checked: true, + })), + required: true, + }, + tty ? { input: tty.input } : undefined, + ); + return resolveClients(selected); + } finally { + tty?.close(); + } +} + +export async function targetClients(options: McpOptions, cwd: string): Promise { + if (options.client && options.client.length > 0) { + return resolveClients(options.client); + } + const detected = await detectInstalledClients(cwd); + if (detected.length === 0) { + throw new CliError( + "No supported MCP clients detected. Install one of: " + + CLIENTS.map((c) => c.displayName).join(", ") + + ", or target a specific client with `--client `.", + { code: ERROR_CODE.MCP_NO_CLIENT_DETECTED }, + ); + } + return detected; +} + +export function wantsJson(options: McpOptions): boolean { + return Boolean(options.json) || isAgent(); +} + +/** Render a "Next steps:" block to stderr (human mode). No-op for an empty list. */ +export function printNextSteps(lines: string[]): void { + if (lines.length === 0) return; + log.blank(); + log.info("Next steps:"); + for (const line of lines) log.info(` ${line}`); +} + +/** + * Run an async op against each client without letting one client's failure + * abort the rest — `Promise.all` is fail-fast and would discard every other + * client's result on the first rejection. Failures are warned per-client; + * successes are returned. If *every* client failed, the first error is + * rethrown so the command still exits non-zero with a real message. + */ +export async function settleClients( + clients: readonly McpClient[], + op: (client: McpClient) => Promise, +): Promise<{ client: McpClient; result: T }[]> { + const settled = await Promise.allSettled(clients.map(op)); + const succeeded: { client: McpClient; result: T }[] = []; + const failures: unknown[] = []; + settled.forEach((outcome, i) => { + const client = clients[i]!; + if (outcome.status === "fulfilled") { + succeeded.push({ client, result: outcome.value }); + return; + } + failures.push(outcome.reason); + log.warn(`${client.displayName}: ${errorMessage(outcome.reason)}`); + }); + if (succeeded.length === 0 && failures.length > 0) throw failures[0]; + return succeeded; +} diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts new file mode 100644 index 00000000..1d103096 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +const { mcpInstall } = await import("./install.ts"); +const { mcpUninstall } = await import("./uninstall.ts"); + +const URL = "https://mcp.clerk.com/mcp"; + +describe("mcp uninstall", () => { + const captured = useCaptureLog(); + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-uninstall-")); + process.chdir(cwd); + mockIsAgent.mockReturnValue(true); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + mockIsAgent.mockReset(); + }); + + test("removes the entry an install-uninstall round-trip leaves no trace under mcpServers", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + captured.clear(); + await mcpUninstall({ client: ["cursor"] }); + + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(parsed.mcpServers.clerk).toBeUndefined(); + }); + + test("emits JSON results on stdout in agent mode", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + captured.clear(); + await mcpUninstall({ client: ["cursor"] }); + + const payload = JSON.parse(captured.out) as { + name: string; + results: { client: string; removed: boolean }[]; + }; + expect(payload.name).toBe("clerk"); + expect(payload.results).toEqual([expect.objectContaining({ client: "cursor", removed: true })]); + }); + + test("throws MCP_NOT_INSTALLED when nothing is registered", async () => { + await expect(mcpUninstall({ client: ["cursor"] })).rejects.toMatchObject({ + code: "mcp_not_installed", + }); + }); + + test("respects --name", async () => { + await mcpInstall({ client: ["cursor"], url: URL, name: "clerk-staging" }); + captured.clear(); + await mcpUninstall({ client: ["cursor"], name: "clerk-staging" }); + + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(parsed.mcpServers["clerk-staging"]).toBeUndefined(); + }); + + test("rejects an unknown --client id", async () => { + await expect(mcpUninstall({ client: ["bogus"] })).rejects.toMatchObject({ + code: "mcp_client_not_supported", + }); + }); + + test("human mode: nothing-to-remove throws without a contradictory success outro", async () => { + mockIsAgent.mockReturnValue(false); + await expect(mcpUninstall({ client: ["cursor"] })).rejects.toMatchObject({ + code: "mcp_not_installed", + }); + expect(captured.err).not.toContain("Nothing to remove"); + }); + + test("human mode: prints reload next steps after a successful removal", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + mockIsAgent.mockReturnValue(false); + captured.clear(); + await mcpUninstall({ client: ["cursor"] }); + expect(captured.err).toContain("Next steps:"); + expect(captured.err).toContain("Reload Cursor"); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts new file mode 100644 index 00000000..6f65c24f --- /dev/null +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -0,0 +1,62 @@ +/** + * `clerk mcp uninstall` — remove the `clerk` MCP entry from supported clients. + */ + +import { cyan, dim, green, yellow } from "../../lib/color.ts"; +import { CliError, ERROR_CODE } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { isHuman } from "../../mode.ts"; +import { intro, outro } from "../../lib/spinner.ts"; +import { CLIENTS } from "./clients/registry.ts"; +import type { McpClient, RemoveResult } from "./clients/types.ts"; +import { + printNextSteps, + resolveClients, + resolveName, + settleClients, + wantsJson, + type McpOptions, +} from "./shared.ts"; + +function printResult(client: McpClient, result: RemoveResult): void { + const label = `${client.displayName} → ${dim(result.configPath)}`; + log.info(`${label}: ${result.removed ? green("removed") : yellow("not present")}`); +} + +export async function mcpUninstall(options: McpOptions = {}): Promise { + const name = resolveName(options); + const cwd = process.cwd(); + const clients = + options.client && options.client.length > 0 + ? resolveClients(options.client) + : Array.from(CLIENTS); + const json = wantsJson(options); + + if (isHuman() && !json) intro(`Removing MCP entry ${cyan(name)}`); + + const settled = await settleClients(clients, (c) => c.remove(name, cwd)); + const results = settled.map((s) => s.result); + if (isHuman() && !json) settled.forEach(({ client, result }) => printResult(client, result)); + + const removedCount = results.filter((r) => r.removed).length; + const notInstalled = new CliError(`No MCP entry named "${name}" found in any client.`, { + code: ERROR_CODE.MCP_NOT_INSTALLED, + }); + + if (json) { + log.data(JSON.stringify({ name, results }, null, 2)); + if (removedCount === 0) throw notInstalled; + return; + } + + if (removedCount === 0) throw notInstalled; + + // Removing the config entry doesn't drop a live editor session — it lingers + // until the editor reloads. Tell the user for each client we removed from. + printNextSteps( + settled + .filter(({ result }) => result.removed) + .map(({ client }) => `Reload ${client.displayName} to drop the active connection.`), + ); + outro("Done"); +} diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index 91643a28..dd780a6d 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -19,6 +19,7 @@ export interface EnvProfileConfig { platformApiUrl: string; backendApiUrl: string; dashboardUrl?: string; + mcpUrl?: string; } const DEFAULT_PROFILES: Record = { @@ -28,6 +29,7 @@ const DEFAULT_PROFILES: Record = { platformApiUrl: "https://api.clerk.com", backendApiUrl: "https://api.clerk.dev", dashboardUrl: "https://dashboard.clerk.com", + mcpUrl: "https://mcp.clerk.com/mcp", }, }; @@ -145,3 +147,15 @@ export function getDashboardUrl(): string { process.env.CLERK_DASHBOARD_URL ?? getCurrentEnv().dashboardUrl ?? "https://dashboard.clerk.com" ); } + +/** + * Remote MCP server URL for the active environment. + * + * Sourced from the active env profile so `switch-env` carries it + * automatically. `CLERK_MCP_URL` overrides for local worker development + * (e.g. `http://localhost:8787/mcp` against the `remote-mcp-server` repo). + * Returns `undefined` if the active profile has no `mcpUrl` configured. + */ +export function getMcpUrl(): string | undefined { + return process.env.CLERK_MCP_URL ?? getCurrentEnv().mcpUrl; +} diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 0d3fdc85..0fdffa3b 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -66,6 +66,16 @@ export const ERROR_CODE = { ACTOR_TOKEN_ALREADY_ACCEPTED: "actor_token_already_accepted", /** No active impersonation session matched the operator's actor stamp. */ IMPERSONATION_SESSION_NOT_FOUND: "impersonation_session_not_found", + /** No MCP client detected on the system. */ + MCP_NO_CLIENT_DETECTED: "mcp_no_client_detected", + /** Requested MCP client is not in the supported registry. */ + MCP_CLIENT_NOT_SUPPORTED: "mcp_client_not_supported", + /** Existing MCP client config is malformed or has a conflicting entry. */ + MCP_CLIENT_CONFIG_INVALID: "mcp_client_config_invalid", + /** No MCP URL available — active env profile has no mcpUrl and `--url` not given. */ + MCP_URL_REQUIRED: "mcp_url_required", + /** No matching MCP entry to remove. */ + MCP_NOT_INSTALLED: "mcp_not_installed", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; From c2ea58f44c47a7d5a36c8341748afe4a5bc7e72c Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 25 May 2026 18:23:57 -0300 Subject: [PATCH 02/21] fix(mcp): import `checkbox` lazily in pickClients A top-level import of @inquirer/prompts is resolved at module load, which breaks integration tests that mock the module with a partial shape omitting checkbox. Defer it to call-time like doctor/update already do. --- packages/cli-core/src/commands/mcp/shared.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index f580b650..26aa04e4 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -2,7 +2,6 @@ * Shared options and helpers for `clerk mcp` subcommands. */ -import { checkbox } from "@inquirer/prompts"; import { ttyContext } from "../../lib/listage.ts"; import { getMcpUrl } from "../../lib/environment.ts"; import { CliError, ERROR_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts"; @@ -71,6 +70,10 @@ export function resolveClients(ids: readonly string[]): McpClient[] { export async function pickClients(detected: McpClient[]): Promise { if (detected.length === 0) return []; if (detected.length === 1) return detected; + // Imported lazily (like `doctor`/`update` do): a top-level import of + // `@inquirer/prompts` is resolved at module load, which breaks tests that + // mock the module with a partial shape that omits `checkbox`. + const { checkbox } = await import("@inquirer/prompts"); const tty = ttyContext(); try { const selected = await checkbox( From 384cbfc6f27b525b0d4f104e3254d27c301130c7 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 25 May 2026 19:11:42 -0300 Subject: [PATCH 03/21] test(doctor): use restorable spyOn for config mock in context.test context.test.ts replaced the entire config.ts module via mock.module, which is process-lifetime in Bun and omitted getConfigFile/_setConfigDir. When the doctor folder ran in a single `bun test` process, the polluted mock leaked into doctor.test.ts (which needs the real module), crashing with "Export named 'getConfigFile' not found". Swap the config mock for a spyOn on resolveProfile (the only symbol context.ts imports) and restore it in afterAll, so doctor.test.ts gets the real config.ts back. --- .../src/commands/doctor/context.test.ts | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/cli-core/src/commands/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index a857c381..79e6d4e6 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -1,11 +1,6 @@ -import { test, expect, describe, mock, beforeEach, afterEach } from "bun:test"; -import { - useCaptureLog, - credentialStoreStubs, - configStubs, - gitStubs, - stubFetch, -} from "../../test/lib/stubs.ts"; +import { test, expect, describe, mock, spyOn, beforeEach, afterEach, afterAll } from "bun:test"; +import { useCaptureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; +import * as config from "../../lib/config.ts"; import type { Application } from "../../lib/plapi.ts"; const mockGetToken = mock(); @@ -15,12 +10,13 @@ mock.module("../../lib/credential-store.ts", () => ({ getToken: (...args: unknown[]) => mockGetToken(...args), })); +// spyOn (not mock.module) for config: a spy is restorable, so afterAll hands the +// real module back to doctor.test.ts when both run in one `bun test` process. const mockResolveProfile = mock(); - -mock.module("../../lib/config.ts", () => ({ - ...configStubs, - resolveProfile: (...args: unknown[]) => mockResolveProfile(...args), -})); +const resolveProfileSpy = spyOn(config, "resolveProfile").mockImplementation((...args: unknown[]) => + mockResolveProfile(...(args as [string])), +); +afterAll(() => resolveProfileSpy.mockRestore()); mock.module("../../lib/git.ts", () => gitStubs); From 9f5b89cf3e1afd71636772f0921752e4a8c0b5ad Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Sun, 31 May 2026 09:12:48 -0300 Subject: [PATCH 04/21] test(mcp): address CodeRabbit review on user-scope.test.ts Add afterAll mock cleanup and replace node:fs/promises readFile/writeFile with Bun.file().json(), Bun.file().text(), and Bun.write() per project standards. --- .../src/commands/mcp/clients/user-scope.test.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index d8548d45..f7b4cfbb 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -1,5 +1,5 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import * as realOs from "node:os"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; @@ -10,6 +10,7 @@ import { useCaptureLog } from "../../../test/lib/stubs.ts"; // module mock instead — registered at file top, before the clients are imported. let mockHome = realOs.tmpdir(); mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); +afterAll(() => mock.restore()); const mockIsAgent = mock(); mock.module("../../../mode.ts", () => ({ @@ -42,7 +43,7 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { describe("gemini", () => { test("encodes the mcp-remote stdio-bridge shape", async () => { await geminiClient.upsert({ name: "clerk", url: URL }, "/ignored", false); - const parsed = JSON.parse(await readFile(geminiClient.configPath("/ignored"), "utf8")) as { + const parsed = (await Bun.file(geminiClient.configPath("/ignored")).json()) as { mcpServers: { clerk: { command: string; args: string[] } }; }; expect(parsed.mcpServers.clerk).toEqual({ command: "npx", args: ["-y", "mcp-remote", URL] }); @@ -61,7 +62,7 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { // unrelated npx tool must not round-trip as a Clerk MCP entry. const dir = join(mockHome, ".gemini"); await mkdir(dir, { recursive: true }); - await writeFile( + await Bun.write( join(dir, "settings.json"), JSON.stringify({ mcpServers: { @@ -78,7 +79,7 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { describe("windsurf", () => { test("encodes the serverUrl shape and round-trips it on list", async () => { await windsurfClient.upsert({ name: "clerk", url: URL }, "/ignored", false); - const parsed = JSON.parse(await readFile(windsurfClient.configPath("/ignored"), "utf8")) as { + const parsed = (await Bun.file(windsurfClient.configPath("/ignored")).json()) as { mcpServers: { clerk: { serverUrl: string } }; }; expect(parsed.mcpServers.clerk).toEqual({ serverUrl: URL }); From 58b48af1d5ff67c4b6f61db85a70ac4b95ce2340 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 4 Jun 2026 00:16:51 -0300 Subject: [PATCH 05/21] fix(mcp): install to user-global config and default to Clerk's hosted server Write Claude Code, Cursor, and VS Code entries to each editor's user-global config (~/.claude.json, ~/.cursor/mcp.json, VS Code's per-OS user mcp.json) instead of project-scoped files. Project scope tied "did it install?" to the run directory matching the editor's launch dir plus a trust prompt and restart; user scope makes the server available in every project, regardless of where the CLI is run. Default the MCP URL to the hosted server (https://mcp.clerk.com/mcp) when no profile defines mcpUrl, so install works out of the box on published builds whose injected env profile omits the field. Resolution order: --url > CLERK_MCP_URL > active profile mcpUrl > hosted server. Drop the localhost --url examples from --help (kept in the README for contributors), and tidy redundant docblock titles plus settleClients' loop. Tests migrated to redirect homedir to a tmpdir so user-scoped client writes stay isolated. --- .changeset/mcp-install.md | 2 +- .../cli-core/src/commands/doctor/check-mcp.ts | 3 +- packages/cli-core/src/commands/mcp/README.md | 34 ++-- .../src/commands/mcp/clients/claude-code.ts | 15 +- .../src/commands/mcp/clients/clients.test.ts | 157 ++++++++---------- .../src/commands/mcp/clients/cursor.ts | 13 +- .../src/commands/mcp/clients/gemini.ts | 2 - .../src/commands/mcp/clients/json-config.ts | 4 +- .../mcp/clients/make-json-client.test.ts | 16 +- .../commands/mcp/clients/make-json-client.ts | 4 +- .../src/commands/mcp/clients/paths.ts | 25 ++- .../commands/mcp/clients/user-scope.test.ts | 6 +- .../src/commands/mcp/clients/vscode.ts | 18 +- .../src/commands/mcp/clients/windsurf.ts | 2 - .../cli-core/src/commands/mcp/install.test.ts | 32 +++- packages/cli-core/src/commands/mcp/install.ts | 4 +- .../cli-core/src/commands/mcp/list.test.ts | 10 +- packages/cli-core/src/commands/mcp/list.ts | 20 ++- packages/cli-core/src/commands/mcp/shared.ts | 15 +- .../src/commands/mcp/uninstall.test.ts | 10 +- packages/cli-core/src/lib/environment.ts | 18 +- packages/cli-core/src/lib/errors.ts | 2 +- 22 files changed, 242 insertions(+), 170 deletions(-) diff --git a/.changeset/mcp-install.md b/.changeset/mcp-install.md index 9f2b360c..331d7cc1 100644 --- a/.changeset/mcp-install.md +++ b/.changeset/mcp-install.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, VS Code, Windsurf, and Gemini. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. The URL comes from the active env profile's new `mcpUrl` field (or the `CLERK_MCP_URL` override) and can be overridden per-invocation with `--url` for local worker development. +Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, VS Code, Windsurf, and Gemini. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`), so the server is available across every project regardless of the directory you run the CLI from. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. By default the commands target Clerk's hosted server, so `clerk mcp install` works with no flags. The URL resolves in order: `--url` > the `CLERK_MCP_URL` override (for local worker development) > the active env profile's new `mcpUrl` field > the hosted server. diff --git a/packages/cli-core/src/commands/doctor/check-mcp.ts b/packages/cli-core/src/commands/doctor/check-mcp.ts index 93b47f5b..a897670c 100644 --- a/packages/cli-core/src/commands/doctor/check-mcp.ts +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -1,6 +1,5 @@ /** - * `clerk doctor` MCP reachability check (folded in from the former - * `clerk mcp doctor` subcommand). + * `clerk doctor` MCP reachability check. * * Kept in its own file — rather than `checks.ts` — so the doctor check graph * doesn't import `mcp/shared.ts` (env profiles, prompts) and the module cycle diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index ec9b176f..90e070cc 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -7,9 +7,11 @@ The Clerk MCP server is hosted at `https://mcp.clerk.com/mcp` (source: These subcommands register, list, remove, and probe that URL in each client's own config file. The URL is resolved in order: `--url` > the `CLERK_MCP_URL` environment variable > the active environment profile's `mcpUrl` field -(`switch-env` carries the profile value automatically). `CLERK_MCP_URL` is the -convenient override when developing the worker locally (e.g. -`http://localhost:8787/mcp`). +(`switch-env` carries the profile value automatically) > Clerk's hosted server +(`https://mcp.clerk.com/mcp`). Because the hosted server is the final fallback, +`clerk mcp install` works out of the box with no flags or profile setup. +`CLERK_MCP_URL` is the convenient override when developing the worker locally +(e.g. `http://localhost:8787/mcp`). No Clerk API endpoints are called. To verify the server is reachable, run `clerk doctor` — its MCP check performs the `initialize` handshake against the @@ -17,13 +19,21 @@ configured URL whenever a Clerk MCP entry is installed. ## Supported clients -| ID | Client | Scope | Config file | -| ------------- | ------------------------ | ------- | ------------------------------------- | -| `claude-code` | Claude Code | project | `/.mcp.json` | -| `cursor` | Cursor | project | `/.cursor/mcp.json` | -| `vscode` | VS Code (Copilot) | project | `/.vscode/mcp.json` | -| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | -| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | +All entries are written to each client's **user-global** config, so the server +is available in every project (no per-project approval, no dependence on which +directory you run the CLI from). + +| ID | Client | Scope | Config file | +| ------------- | ------------------------ | ----- | --------------------------------------- | +| `claude-code` | Claude Code | user | `~/.claude.json` (`mcpServers`) | +| `cursor` | Cursor | user | `~/.cursor/mcp.json` | +| `vscode` | VS Code (Copilot) | user | VS Code user `mcp.json` (per-OS, below) | +| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | +| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | + +VS Code's user config dir is OS-specific: `~/Library/Application Support/Code/User/mcp.json` +(macOS), `%APPDATA%\Code\User\mcp.json` (Windows), `$XDG_CONFIG_HOME/Code/User/mcp.json` +(Linux) — the file behind **MCP: Open User Configuration**. ## Subcommands @@ -35,7 +45,7 @@ Register the Clerk MCP server in one or more clients. | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--client ` | Target a specific client. Repeat for multiple. Default in agent mode: all detected. Default in human mode: interactive multiselect over detected clients. | | `--all` | Install into every detected client without prompting. | -| `--url ` | Override the MCP URL. Defaults to the active env profile's `mcpUrl`. | +| `--url ` | Override the MCP URL. Defaults to the active env profile's `mcpUrl`, then Clerk's hosted server. | | `--name ` | Entry key in the client config. Default: `clerk`. | | `--force` | Overwrite an entry already pointing at a different URL. Without it, the conflict is reported and skipped. | | `--json` | Emit a JSON summary on stdout instead of human-formatted output. | @@ -73,5 +83,5 @@ session, so (in human mode) it prints a next step to reload each affected editor | `mcp_no_client_detected` | No supported client found on the system. | | `mcp_client_not_supported` | `--client ` is not in the supported list. | | `mcp_client_config_invalid` | An existing client config file is malformed. | -| `mcp_url_required` | No `--url` provided and the active env profile has no `mcpUrl`. | +| `mcp_url_required` | The provided `--url` is malformed or uses a non-http(s) scheme. | | `mcp_not_installed` | `uninstall` removed nothing because no entry matched. | diff --git a/packages/cli-core/src/commands/mcp/clients/claude-code.ts b/packages/cli-core/src/commands/mcp/clients/claude-code.ts index f5d42b5b..1b8affb8 100644 --- a/packages/cli-core/src/commands/mcp/clients/claude-code.ts +++ b/packages/cli-core/src/commands/mcp/clients/claude-code.ts @@ -1,22 +1,21 @@ /** - * Claude Code MCP client integration. - * - * Writes to `.mcp.json` in the current working directory — the project-scope - * config Claude Code reads automatically. Schema follows the MCP spec's HTTP - * transport form: `{ type: "http", url: "" }`. + * Writes to the user-global `~/.claude.json` under `mcpServers`, so the server + * is available in every project (the same store `claude mcp add -s user` uses) + * rather than gated behind a per-project `.mcp.json` approval. Schema follows + * the MCP spec's HTTP transport form: `{ type: "http", url: "" }`. */ import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; -import { pathExists, projectPath, userPath } from "./paths.ts"; +import { pathExists, userPath } from "./paths.ts"; export const claudeCodeClient = makeJsonClient({ id: "claude-code", displayName: "Claude Code", - scope: "project", + scope: "user", activation: "Restart Claude Code, then run `/mcp` to connect (sign in if prompted).", topKey: "mcpServers", encode: (url) => ({ type: "http", url }), extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), - configPath: (cwd) => projectPath(cwd, ".mcp.json"), + configPath: () => userPath(".claude.json"), detect: () => pathExists(userPath(".claude")), }); diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts index 5c6bf72e..2fb4bc81 100644 --- a/packages/cli-core/src/commands/mcp/clients/clients.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -1,108 +1,97 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir, homedir } from "node:os"; +import * as realOs from "node:os"; import { join } from "node:path"; -import { claudeCodeClient } from "./claude-code.ts"; -import { cursorClient } from "./cursor.ts"; -import { vscodeClient } from "./vscode.ts"; -import { windsurfClient } from "./windsurf.ts"; -import { geminiClient } from "./gemini.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; +// Every client writes under the user's home now, so redirect homedir to a +// tmpdir (Bun's os.homedir() ignores $HOME) — registered before the clients +// load so paths.ts binds the redirected homedir. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); +afterAll(() => mock.restore()); + +const { claudeCodeClient } = await import("./claude-code.ts"); +const { cursorClient } = await import("./cursor.ts"); +const { vscodeClient } = await import("./vscode.ts"); +const { windsurfClient } = await import("./windsurf.ts"); +const { geminiClient } = await import("./gemini.ts"); +const { vscodeUserDir } = await import("./paths.ts"); + useCaptureLog(); const URL = "https://mcp.clerk.com/mcp"; -// Path shape is part of the public contract — each client targets a specific, -// documented config file. Test against the format, not the absolute prefix -// (which depends on cwd/homedir). -const projectClients = [ - { client: claudeCodeClient, suffix: ".mcp.json", topKey: "mcpServers" }, - { client: cursorClient, suffix: join(".cursor", "mcp.json"), topKey: "mcpServers" }, - { client: vscodeClient, suffix: join(".vscode", "mcp.json"), topKey: "servers" }, -]; - -const userClients = [ +// Path + entry shape are part of the public contract: each client targets a +// specific user-global config file and encodes the server its own way. +const cases = [ + { + name: "claude-code", + client: claudeCodeClient, + expectedPath: () => join(mockHome, ".claude.json"), + topKey: "mcpServers", + shape: { type: "http", url: URL }, + }, + { + name: "cursor", + client: cursorClient, + expectedPath: () => join(mockHome, ".cursor", "mcp.json"), + topKey: "mcpServers", + shape: { url: URL }, + }, { + name: "vscode", + client: vscodeClient, + expectedPath: () => join(vscodeUserDir(), "mcp.json"), + topKey: "servers", + shape: { type: "http", url: URL }, + }, + { + name: "windsurf", client: windsurfClient, - relPath: join(".codeium", "windsurf", "mcp_config.json"), + expectedPath: () => join(mockHome, ".codeium", "windsurf", "mcp_config.json"), + topKey: "mcpServers", + shape: { serverUrl: URL }, + }, + { + name: "gemini", + client: geminiClient, + expectedPath: () => join(mockHome, ".gemini", "settings.json"), topKey: "mcpServers", + shape: { command: "npx", args: ["-y", "mcp-remote", URL] }, }, - { client: geminiClient, relPath: join(".gemini", "settings.json"), topKey: "mcpServers" }, ]; -describe("project-scope client config paths", () => { - test.each(projectClients)("$client.id resolves under cwd", ({ client, suffix }) => { - const path = client.configPath("/tmp/foo"); - expect(path).toBe(join("/tmp/foo", suffix)); - expect(client.scope).toBe("project"); +describe("client config paths + encoded shapes (homedir redirected)", () => { + beforeEach(async () => { + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-clients-")); }); -}); -describe("user-scope client config paths", () => { - test.each(userClients)("$client.id resolves under homedir", ({ client, relPath }) => { - expect(client.configPath("/ignored")).toBe(join(homedir(), relPath)); - expect(client.scope).toBe("user"); + afterEach(async () => { + await rm(mockHome, { recursive: true, force: true }); }); -}); -describe("per-client encoded shape (written JSON)", () => { - // Project clients are easiest to exercise — write into a tmpdir-as-cwd and - // assert what landed under their top-level key. - test.each(projectClients)( - "$client.id writes the expected entry shape", - async ({ client, topKey }) => { - const cwd = await mkdtemp(join(tmpdir(), `clerk-mcp-${client.id}-`)); - try { - await client.upsert({ name: "clerk", url: URL }, cwd, false); - const text = await readFile(client.configPath(cwd), "utf8"); - const parsed = JSON.parse(text) as Record>; - expect(parsed[topKey]).toBeDefined(); - expect(parsed[topKey]?.clerk).toBeDefined(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }, - ); - - test("claude-code emits the MCP-spec HTTP transport shape", async () => { - const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cc-shape-")); - try { - await claudeCodeClient.upsert({ name: "clerk", url: URL }, cwd, false); - const parsed = JSON.parse(await readFile(claudeCodeClient.configPath(cwd), "utf8")) as { - mcpServers: { clerk: { type: string; url: string } }; - }; - expect(parsed.mcpServers.clerk).toEqual({ type: "http", url: URL }); - } finally { - await rm(cwd, { recursive: true, force: true }); - } + test.each(cases)("$name is user-scoped at its documented path", ({ client, expectedPath }) => { + expect(client.scope).toBe("user"); + expect(client.configPath("/ignored")).toBe(expectedPath()); }); - test("cursor emits a bare {url} entry", async () => { - const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cu-shape-")); - try { - await cursorClient.upsert({ name: "clerk", url: URL }, cwd, false); - const parsed = JSON.parse(await readFile(cursorClient.configPath(cwd), "utf8")) as { - mcpServers: { clerk: { url: string } }; - }; - expect(parsed.mcpServers.clerk).toEqual({ url: URL }); - } finally { - await rm(cwd, { recursive: true, force: true }); - } + test.each(cases)("$name writes the documented entry shape", async ({ client, topKey, shape }) => { + await client.upsert({ name: "clerk", url: URL }, "/ignored", false); + const parsed = JSON.parse(await readFile(client.configPath("/ignored"), "utf8")) as Record< + string, + Record + >; + expect(parsed[topKey]?.clerk).toEqual(shape); }); - test("vscode emits under top-level `servers`, not `mcpServers`", async () => { - const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-vs-shape-")); - try { - await vscodeClient.upsert({ name: "clerk", url: URL }, cwd, false); - const parsed = JSON.parse(await readFile(vscodeClient.configPath(cwd), "utf8")) as { - servers: { clerk: { type: string; url: string } }; - mcpServers?: unknown; - }; - expect(parsed.servers.clerk).toEqual({ type: "http", url: URL }); - expect(parsed.mcpServers).toBeUndefined(); - } finally { - await rm(cwd, { recursive: true, force: true }); - } + test("vscode writes under `servers`, not `mcpServers`", async () => { + await vscodeClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const parsed = JSON.parse(await readFile(vscodeClient.configPath("/ignored"), "utf8")) as { + servers?: unknown; + mcpServers?: unknown; + }; + expect(parsed.servers).toBeDefined(); + expect(parsed.mcpServers).toBeUndefined(); }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/cursor.ts b/packages/cli-core/src/commands/mcp/clients/cursor.ts index 1a8655ec..f48b6a0e 100644 --- a/packages/cli-core/src/commands/mcp/clients/cursor.ts +++ b/packages/cli-core/src/commands/mcp/clients/cursor.ts @@ -1,21 +1,20 @@ /** - * Cursor MCP client integration. - * - * Writes to `.cursor/mcp.json` in the current working directory. Cursor's - * MCP descriptor is a bare `{ url }` without a `type` discriminator. + * Writes to the user-global `~/.cursor/mcp.json`, so the server is available in + * every project rather than only the cwd it was installed from. Cursor's MCP + * descriptor is a bare `{ url }` without a `type` discriminator. */ import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; -import { pathExists, projectPath, userPath } from "./paths.ts"; +import { pathExists, userPath } from "./paths.ts"; export const cursorClient = makeJsonClient({ id: "cursor", displayName: "Cursor", - scope: "project", + scope: "user", activation: "Reload Cursor, then enable the server under `Settings → MCP` (sign in if prompted).", topKey: "mcpServers", encode: (url) => ({ url }), extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), - configPath: (cwd) => projectPath(cwd, ".cursor", "mcp.json"), + configPath: () => userPath(".cursor", "mcp.json"), detect: () => pathExists(userPath(".cursor")), }); diff --git a/packages/cli-core/src/commands/mcp/clients/gemini.ts b/packages/cli-core/src/commands/mcp/clients/gemini.ts index 607b6b8f..271ed885 100644 --- a/packages/cli-core/src/commands/mcp/clients/gemini.ts +++ b/packages/cli-core/src/commands/mcp/clients/gemini.ts @@ -1,6 +1,4 @@ /** - * Gemini Code Assist / Gemini CLI MCP client integration. - * * Writes to `~/.gemini/settings.json`. Gemini doesn't support HTTP transport * directly — it requires `mcp-remote` as a stdio bridge, hence the * `{ command: "npx", args: ["-y", "mcp-remote", ] }` shape. diff --git a/packages/cli-core/src/commands/mcp/clients/json-config.ts b/packages/cli-core/src/commands/mcp/clients/json-config.ts index c01cf4e3..4d376eaa 100644 --- a/packages/cli-core/src/commands/mcp/clients/json-config.ts +++ b/packages/cli-core/src/commands/mcp/clients/json-config.ts @@ -1,8 +1,8 @@ /** * Shared JSON read/write helper for MCP client configs. * - * Four of the five supported clients (Claude Code, Cursor, VS Code, Windsurf, - * Gemini) store their MCP servers in a JSON file under a single top-level key + * All five supported clients (Claude Code, Cursor, VS Code, Windsurf, Gemini) + * store their MCP servers in a JSON file under a single top-level key * (`mcpServers` for most, `servers` for VS Code). The entry shape varies * (`url` vs `serverUrl` vs `command`+`args`) — that's per-client. This module * only handles the surrounding I/O: read, parse, write back with stable diff --git a/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts b/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts index 7fc14afd..e9f3c360 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts @@ -1,10 +1,17 @@ -import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import * as realOs from "node:os"; import { join } from "node:path"; -import { cursorClient } from "./cursor.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; +// cursorClient writes under home now; redirect homedir to the cwd tmpdir so the +// `join(cwd, ".cursor", ...)` reads below stay isolated. Mock before importing +// the client so paths.ts binds the redirected homedir. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + +const { cursorClient } = await import("./cursor.ts"); + useCaptureLog(); const URL_A = "https://mcp.clerk.com/mcp"; @@ -14,7 +21,8 @@ describe("make-json-client (via cursor)", () => { let cwd: string; beforeEach(async () => { - cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cursor-")); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-cursor-")); + mockHome = cwd; }); afterEach(async () => { diff --git a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts index ebd7fc79..a39355f7 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts @@ -126,8 +126,8 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { servers = getServerMap(config, spec.topKey, configPath); } catch (error) { if (error instanceof CliError && error.code === ERROR_CODE.MCP_CLIENT_CONFIG_INVALID) { - // Don't crash `list` across the other clients — but the user must - // know their config was skipped, not silently treated as empty. + // Warn rather than silently returning [] — the user must know their + // config was skipped, not treated as empty. log.warn(`${spec.displayName}: ${error.message}`); return []; } diff --git a/packages/cli-core/src/commands/mcp/clients/paths.ts b/packages/cli-core/src/commands/mcp/clients/paths.ts index a589698d..0f95bad4 100644 --- a/packages/cli-core/src/commands/mcp/clients/paths.ts +++ b/packages/cli-core/src/commands/mcp/clients/paths.ts @@ -1,13 +1,13 @@ /** * Cross-platform path + filesystem helpers for MCP client integrations. * - * We deliberately avoid OS-specific layout (no XDG, no AppData) — every - * documented client config path is rooted at `~/./` regardless of - * platform, so a single homedir join is enough. + * Most clients root their config at `~/./` regardless of platform, so a + * single homedir join is enough. VS Code is the exception — its user-level + * config lives under the OS-specific app-support dir (see `vscodeUserDir`). */ import { stat } from "node:fs/promises"; -import { homedir } from "node:os"; +import { homedir, platform } from "node:os"; import { join } from "node:path"; export function projectPath(cwd: string, ...segments: string[]): string { @@ -18,6 +18,23 @@ export function userPath(...segments: string[]): string { return join(homedir(), ...segments); } +/** + * VS Code's per-user (global) config directory, where its `mcp.json` lives. + * Unlike the other clients this is OS-specific: Application Support on macOS, + * %APPDATA% on Windows, XDG config on Linux. + */ +export function vscodeUserDir(): string { + const home = homedir(); + switch (platform()) { + case "win32": + return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Code", "User"); + case "darwin": + return join(home, "Library", "Application Support", "Code", "User"); + default: + return join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "Code", "User"); + } +} + /** * Returns true when *anything* exists at `path` — file, directory, symlink. * Detection only needs to know "did the user install this tool?", which is diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index f7b4cfbb..f1936c1f 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -22,6 +22,7 @@ mock.module("../../../mode.ts", () => ({ const { geminiClient } = await import("./gemini.ts"); const { windsurfClient } = await import("./windsurf.ts"); +const { vscodeUserDir } = await import("./paths.ts"); const { mcpInstall } = await import("../install.ts"); const { mcpUninstall } = await import("../uninstall.ts"); const { checkMcp } = await import("../../doctor/check-mcp.ts"); @@ -115,11 +116,12 @@ describe("install/uninstall across all clients (homedir + cwd redirected)", () = }); test("install --all targets every detected client", async () => { - // detect() keys off each client's marker directory under home. + // detect() keys off each client's marker directory under home (VS Code uses + // its per-OS user config dir). await Promise.all([ mkdir(join(mockHome, ".claude"), { recursive: true }), mkdir(join(mockHome, ".cursor"), { recursive: true }), - mkdir(join(mockHome, ".vscode"), { recursive: true }), + mkdir(vscodeUserDir(), { recursive: true }), mkdir(join(mockHome, ".codeium", "windsurf"), { recursive: true }), mkdir(join(mockHome, ".gemini"), { recursive: true }), ]); diff --git a/packages/cli-core/src/commands/mcp/clients/vscode.ts b/packages/cli-core/src/commands/mcp/clients/vscode.ts index 60b7ecde..c3ecc553 100644 --- a/packages/cli-core/src/commands/mcp/clients/vscode.ts +++ b/packages/cli-core/src/commands/mcp/clients/vscode.ts @@ -1,23 +1,23 @@ /** - * VS Code (Copilot) MCP client integration. - * - * Writes to `.vscode/mcp.json` in the current working directory. VS Code uses - * the top-level key `servers` (not `mcpServers`) and the HTTP transport form - * `{ type: "http", url }`. + * Writes to the user-global `mcp.json` under VS Code's per-OS user config dir + * (the file behind `MCP: Open User Configuration`), so the server is available + * across every workspace. VS Code uses the top-level key `servers` (not + * `mcpServers`) and the HTTP transport form `{ type: "http", url }`. */ +import { join } from "node:path"; import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; -import { pathExists, projectPath, userPath } from "./paths.ts"; +import { pathExists, vscodeUserDir } from "./paths.ts"; export const vscodeClient = makeJsonClient({ id: "vscode", displayName: "VS Code", - scope: "project", + scope: "user", activation: "Reload the VS Code window, then start the server from `MCP: List Servers` (sign in if prompted).", topKey: "servers", encode: (url) => ({ type: "http", url }), extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), - configPath: (cwd) => projectPath(cwd, ".vscode", "mcp.json"), - detect: () => pathExists(userPath(".vscode")), + configPath: () => join(vscodeUserDir(), "mcp.json"), + detect: () => pathExists(vscodeUserDir()), }); diff --git a/packages/cli-core/src/commands/mcp/clients/windsurf.ts b/packages/cli-core/src/commands/mcp/clients/windsurf.ts index bca239f2..d3138047 100644 --- a/packages/cli-core/src/commands/mcp/clients/windsurf.ts +++ b/packages/cli-core/src/commands/mcp/clients/windsurf.ts @@ -1,6 +1,4 @@ /** - * Windsurf MCP client integration. - * * Writes to `~/.codeium/windsurf/mcp_config.json` (user scope). Server * descriptor uses `serverUrl`, not `url`. */ diff --git a/packages/cli-core/src/commands/mcp/install.test.ts b/packages/cli-core/src/commands/mcp/install.test.ts index 8245b618..d0c7b908 100644 --- a/packages/cli-core/src/commands/mcp/install.test.ts +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import * as realOs from "node:os"; import { join } from "node:path"; import { useCaptureLog } from "../../test/lib/stubs.ts"; @@ -12,6 +12,12 @@ mock.module("../../mode.ts", () => ({ getMode: () => (mockIsAgent() ? "agent" : "human"), })); +// All clients write under the user's home now. Redirect homedir to the same +// tmpdir we use as cwd (Bun's os.homedir() ignores $HOME) so writes stay +// isolated and the `join(cwd, ...)` assertions below still resolve. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + const { mcpInstall } = await import("./install.ts"); const URL_A = "https://mcp.clerk.com/mcp"; @@ -24,7 +30,8 @@ describe("mcp install", () => { beforeEach(async () => { originalCwd = process.cwd(); - cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-install-")); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-install-")); + mockHome = cwd; process.chdir(cwd); mockIsAgent.mockReturnValue(false); }); @@ -124,6 +131,27 @@ describe("mcp install", () => { expect(payload.url).toBe("https://mcp.clerk.com/mcp"); }); + test("falls back to Clerk's hosted MCP URL when the active profile has no mcpUrl", async () => { + // Reproduces the published snapshot: a build-time env profile that omits + // `mcpUrl`. `getMcpUrl()` must still resolve to the hosted server so a bare + // `clerk mcp install` works without `--url` or any profile setup. + await writeFile( + join(cwd, ".env-profiles.json"), + JSON.stringify({ + production: { + oauthClientId: "ins_test", + oauthBaseUrl: "https://clerk.clerk.com", + platformApiUrl: "https://api.clerk.com", + backendApiUrl: "https://api.clerk.dev", + }, + }), + ); + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"] }); + const payload = JSON.parse(captured.out) as { url: string }; + expect(payload.url).toBe("https://mcp.clerk.com/mcp"); + }); + test.each([ ["file:///etc/passwd"], ["data:text/plain,clerk"], diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts index a31ef6cb..c8b83b5c 100644 --- a/packages/cli-core/src/commands/mcp/install.ts +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -1,7 +1,7 @@ /** * `clerk mcp install` — register the Clerk remote MCP server in supported clients. * - * URL resolution: `--url` > active env profile `mcpUrl` > error. + * URL resolution: `--url` > `CLERK_MCP_URL` > active env profile `mcpUrl` > Clerk's hosted server. * Target clients: `--client ` (repeatable) > `--all` > human picker > all detected (agent mode). * Conflict policy: same URL → unchanged; different URL → skip unless `--force`. */ @@ -96,7 +96,7 @@ export async function mcpInstall(options: McpOptions = {}): Promise { return; } - if (isHuman()) settled.forEach(({ client, result }) => printResult(client, result)); + settled.forEach(({ client, result }) => printResult(client, result)); printInstallNextSteps(settled); outro("Done"); } diff --git a/packages/cli-core/src/commands/mcp/list.test.ts b/packages/cli-core/src/commands/mcp/list.test.ts index d2ab7ffe..c36169b6 100644 --- a/packages/cli-core/src/commands/mcp/list.test.ts +++ b/packages/cli-core/src/commands/mcp/list.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import * as realOs from "node:os"; import { join } from "node:path"; import { useCaptureLog } from "../../test/lib/stubs.ts"; @@ -12,6 +12,11 @@ mock.module("../../mode.ts", () => ({ getMode: () => (mockIsAgent() ? "agent" : "human"), })); +// User-scoped clients write under home; redirect homedir to the cwd tmpdir so +// install writes (read back by list) stay isolated to the test. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + const { mcpInstall } = await import("./install.ts"); const { mcpList } = await import("./list.ts"); @@ -24,7 +29,8 @@ describe("mcp list", () => { beforeEach(async () => { originalCwd = process.cwd(); - cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-list-")); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-list-")); + mockHome = cwd; process.chdir(cwd); mockIsAgent.mockReturnValue(true); }); diff --git a/packages/cli-core/src/commands/mcp/list.ts b/packages/cli-core/src/commands/mcp/list.ts index 26e7e951..c61d4451 100644 --- a/packages/cli-core/src/commands/mcp/list.ts +++ b/packages/cli-core/src/commands/mcp/list.ts @@ -13,11 +13,23 @@ import { printNextSteps, wantsJson, type McpOptions } from "./shared.ts"; const COLUMN_PADDING = 2; +function columnWidth(header: string, values: string[]): number { + return Math.max(header.length, ...values.map((v) => v.length)) + COLUMN_PADDING; +} + function formatTable(entries: ListEntry[]): void { - const clientWidth = - Math.max("CLIENT".length, ...entries.map((e) => e.client.length)) + COLUMN_PADDING; - const nameWidth = Math.max("NAME".length, ...entries.map((e) => e.name.length)) + COLUMN_PADDING; - const urlWidth = Math.max("URL".length, ...entries.map((e) => e.url.length)) + COLUMN_PADDING; + const clientWidth = columnWidth( + "CLIENT", + entries.map((e) => e.client), + ); + const nameWidth = columnWidth( + "NAME", + entries.map((e) => e.name), + ); + const urlWidth = columnWidth( + "URL", + entries.map((e) => e.url), + ); log.data( dim(`${"CLIENT".padEnd(clientWidth)}${"NAME".padEnd(nameWidth)}${"URL".padEnd(urlWidth)}PATH`), diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index 26aa04e4..9dca21db 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -23,13 +23,10 @@ export type McpOptions = { export const DEFAULT_ENTRY_NAME = "clerk"; export function resolveUrl(options: McpOptions): string { + // `getMcpUrl()` always resolves to a usable URL (Clerk's hosted server by + // default), so the only failure mode left is an explicit `--url` that is + // malformed or uses a non-network scheme. const candidate = options.url ?? getMcpUrl(); - if (!candidate) { - throw new CliError( - "No MCP URL available. Set one with `--url`, or switch to an environment whose profile defines `mcpUrl`.", - { code: ERROR_CODE.MCP_URL_REQUIRED }, - ); - } // Reject non-network schemes so a stray `file:` or `data:` URL can't be // written into an editor's MCP config or probed by `doctor` via fetch. let parsed: URL; @@ -136,15 +133,15 @@ export async function settleClients( const settled = await Promise.allSettled(clients.map(op)); const succeeded: { client: McpClient; result: T }[] = []; const failures: unknown[] = []; - settled.forEach((outcome, i) => { + for (const [i, outcome] of settled.entries()) { const client = clients[i]!; if (outcome.status === "fulfilled") { succeeded.push({ client, result: outcome.value }); - return; + continue; } failures.push(outcome.reason); log.warn(`${client.displayName}: ${errorMessage(outcome.reason)}`); - }); + } if (succeeded.length === 0 && failures.length > 0) throw failures[0]; return succeeded; } diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index 1d103096..f7e339de 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import * as realOs from "node:os"; import { join } from "node:path"; import { useCaptureLog } from "../../test/lib/stubs.ts"; @@ -12,6 +12,11 @@ mock.module("../../mode.ts", () => ({ getMode: () => (mockIsAgent() ? "agent" : "human"), })); +// User-scoped clients write under home; redirect homedir to the cwd tmpdir so +// writes stay isolated and `join(cwd, ...)` reads still resolve. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + const { mcpInstall } = await import("./install.ts"); const { mcpUninstall } = await import("./uninstall.ts"); @@ -24,7 +29,8 @@ describe("mcp uninstall", () => { beforeEach(async () => { originalCwd = process.cwd(); - cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-uninstall-")); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-uninstall-")); + mockHome = cwd; process.chdir(cwd); mockIsAgent.mockReturnValue(true); }); diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index dd780a6d..22fe46ec 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -22,6 +22,9 @@ export interface EnvProfileConfig { mcpUrl?: string; } +/** Clerk's hosted remote MCP server — the default for every environment. */ +const DEFAULT_MCP_URL = "https://mcp.clerk.com/mcp"; + const DEFAULT_PROFILES: Record = { production: { oauthClientId: "ins_1lyWDZiobr600AKUeQDoSlrEmoM", @@ -29,7 +32,7 @@ const DEFAULT_PROFILES: Record = { platformApiUrl: "https://api.clerk.com", backendApiUrl: "https://api.clerk.dev", dashboardUrl: "https://dashboard.clerk.com", - mcpUrl: "https://mcp.clerk.com/mcp", + mcpUrl: DEFAULT_MCP_URL, }, }; @@ -151,11 +154,12 @@ export function getDashboardUrl(): string { /** * Remote MCP server URL for the active environment. * - * Sourced from the active env profile so `switch-env` carries it - * automatically. `CLERK_MCP_URL` overrides for local worker development - * (e.g. `http://localhost:8787/mcp` against the `remote-mcp-server` repo). - * Returns `undefined` if the active profile has no `mcpUrl` configured. + * Resolution: `CLERK_MCP_URL` (local worker dev, e.g. + * `http://localhost:8787/mcp`) > the active env profile's `mcpUrl` + * (carried automatically by `switch-env`) > Clerk's hosted server. Always + * returns a usable URL — the hosted default applies even when a build-time + * profile omits `mcpUrl`, mirroring `getDashboardUrl`. */ -export function getMcpUrl(): string | undefined { - return process.env.CLERK_MCP_URL ?? getCurrentEnv().mcpUrl; +export function getMcpUrl(): string { + return process.env.CLERK_MCP_URL ?? getCurrentEnv().mcpUrl ?? DEFAULT_MCP_URL; } diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 0fdffa3b..fcf5cab6 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -72,7 +72,7 @@ export const ERROR_CODE = { MCP_CLIENT_NOT_SUPPORTED: "mcp_client_not_supported", /** Existing MCP client config is malformed or has a conflicting entry. */ MCP_CLIENT_CONFIG_INVALID: "mcp_client_config_invalid", - /** No MCP URL available — active env profile has no mcpUrl and `--url` not given. */ + /** The provided `--url` is malformed or uses a non-http(s) scheme. */ MCP_URL_REQUIRED: "mcp_url_required", /** No matching MCP entry to remove. */ MCP_NOT_INSTALLED: "mcp_not_installed", From 19ccbe07a2fc9276a4540048e8792b7c4f05638c Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 4 Jun 2026 09:15:28 -0300 Subject: [PATCH 06/21] fix: treat empty env vars as unset in vscodeUserDir/getMcpUrl + test isolation - vscodeUserDir(): use || instead of ?? so empty APPDATA/XDG_CONFIG_HOME fall back to the homedir-based default instead of producing a relative path - getMcpUrl(): trim and check truthiness so CLERK_MCP_URL="" doesn't win - clients.test.ts, user-scope.test.ts: clear XDG_CONFIG_HOME/APPDATA in beforeEach and restore in afterEach to prevent VS Code path leaking to the runner's real config directory (fixes CI test failure) --- .../src/commands/mcp/clients/clients.test.ts | 17 ++++++++++ .../src/commands/mcp/clients/paths.ts | 6 ++-- .../commands/mcp/clients/user-scope.test.ts | 32 +++++++++++++++++++ packages/cli-core/src/lib/environment.ts | 4 ++- 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts index 2fb4bc81..89998347 100644 --- a/packages/cli-core/src/commands/mcp/clients/clients.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -63,11 +63,28 @@ const cases = [ ]; describe("client config paths + encoded shapes (homedir redirected)", () => { + let origXdgConfigHome: string | undefined; + let origAppData: string | undefined; + beforeEach(async () => { mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-clients-")); + origXdgConfigHome = process.env.XDG_CONFIG_HOME; + origAppData = process.env.APPDATA; + process.env.XDG_CONFIG_HOME = ""; + process.env.APPDATA = ""; }); afterEach(async () => { + if (origXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = origXdgConfigHome; + } + if (origAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = origAppData; + } await rm(mockHome, { recursive: true, force: true }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/paths.ts b/packages/cli-core/src/commands/mcp/clients/paths.ts index 0f95bad4..f80b7812 100644 --- a/packages/cli-core/src/commands/mcp/clients/paths.ts +++ b/packages/cli-core/src/commands/mcp/clients/paths.ts @@ -25,13 +25,15 @@ export function userPath(...segments: string[]): string { */ export function vscodeUserDir(): string { const home = homedir(); + const appData = process.env.APPDATA?.trim(); + const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); switch (platform()) { case "win32": - return join(process.env.APPDATA ?? join(home, "AppData", "Roaming"), "Code", "User"); + return join(appData || join(home, "AppData", "Roaming"), "Code", "User"); case "darwin": return join(home, "Library", "Application Support", "Code", "User"); default: - return join(process.env.XDG_CONFIG_HOME ?? join(home, ".config"), "Code", "User"); + return join(xdgConfigHome || join(home, ".config"), "Code", "User"); } } diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index f1936c1f..6cffb810 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -99,17 +99,33 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { describe("install/uninstall across all clients (homedir + cwd redirected)", () => { let cwd: string; let originalCwd: string; + let origXdgConfigHome: string | undefined; + let origAppData: string | undefined; beforeEach(async () => { originalCwd = process.cwd(); cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-all-cwd-")); mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-all-home-")); + origXdgConfigHome = process.env.XDG_CONFIG_HOME; + origAppData = process.env.APPDATA; + process.env.XDG_CONFIG_HOME = ""; + process.env.APPDATA = ""; process.chdir(cwd); mockIsAgent.mockReturnValue(true); }); afterEach(async () => { process.chdir(originalCwd); + if (origXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = origXdgConfigHome; + } + if (origAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = origAppData; + } await rm(cwd, { recursive: true, force: true }); await rm(mockHome, { recursive: true, force: true }); mockIsAgent.mockReset(); @@ -152,6 +168,8 @@ describe("install/uninstall across all clients (homedir + cwd redirected)", () = describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { let cwd: string; let originalCwd: string; + let origXdgConfigHome: string | undefined; + let origAppData: string | undefined; const originalFetch = globalThis.fetch; // Assign globalThis.fetch directly (cast to its own type) rather than via the @@ -167,12 +185,26 @@ describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { originalCwd = process.cwd(); cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-check-cwd-")); mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-check-home-")); + origXdgConfigHome = process.env.XDG_CONFIG_HOME; + origAppData = process.env.APPDATA; + process.env.XDG_CONFIG_HOME = ""; + process.env.APPDATA = ""; process.chdir(cwd); mockIsAgent.mockReturnValue(true); }); afterEach(async () => { process.chdir(originalCwd); + if (origXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = origXdgConfigHome; + } + if (origAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = origAppData; + } await rm(cwd, { recursive: true, force: true }); await rm(mockHome, { recursive: true, force: true }); globalThis.fetch = originalFetch; diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index 22fe46ec..a695badd 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -161,5 +161,7 @@ export function getDashboardUrl(): string { * profile omits `mcpUrl`, mirroring `getDashboardUrl`. */ export function getMcpUrl(): string { - return process.env.CLERK_MCP_URL ?? getCurrentEnv().mcpUrl ?? DEFAULT_MCP_URL; + const envUrl = process.env.CLERK_MCP_URL?.trim(); + const profileUrl = getCurrentEnv().mcpUrl?.trim(); + return envUrl || profileUrl || DEFAULT_MCP_URL; } From 327b8c98134a5a0fae77440a7c4c1fca07e57011 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 8 Jun 2026 14:33:14 -0300 Subject: [PATCH 07/21] fix(mcp): use Clack multiselect for the client picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #305 Clack migration removed @inquirer/prompts, but the mcp install client picker still imported checkbox from it — undeclared after the rebase and broken on a clean install. Add a multiselect wrapper to lib/prompts.ts (matching the existing confirm/text/password Clack wrappers, with the same cancel→throwUserAbort handling) and route pickClients through it. No behavior change to the picker. --- packages/cli-core/src/commands/mcp/shared.ts | 33 ++++++-------------- packages/cli-core/src/lib/prompts.ts | 26 +++++++++++++++ 2 files changed, 36 insertions(+), 23 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index 9dca21db..afb87969 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -2,7 +2,6 @@ * Shared options and helpers for `clerk mcp` subcommands. */ -import { ttyContext } from "../../lib/listage.ts"; import { getMcpUrl } from "../../lib/environment.ts"; import { CliError, ERROR_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; @@ -67,28 +66,16 @@ export function resolveClients(ids: readonly string[]): McpClient[] { export async function pickClients(detected: McpClient[]): Promise { if (detected.length === 0) return []; if (detected.length === 1) return detected; - // Imported lazily (like `doctor`/`update` do): a top-level import of - // `@inquirer/prompts` is resolved at module load, which breaks tests that - // mock the module with a partial shape that omits `checkbox`. - const { checkbox } = await import("@inquirer/prompts"); - const tty = ttyContext(); - try { - const selected = await checkbox( - { - message: "Select MCP clients to install into:", - choices: detected.map((c) => ({ - name: `${c.displayName} (${c.scope})`, - value: c.id, - checked: true, - })), - required: true, - }, - tty ? { input: tty.input } : undefined, - ); - return resolveClients(selected); - } finally { - tty?.close(); - } + // Imported lazily (like `doctor` does) so the prompt layer stays off the + // module-load path for non-interactive callers and tests. + const { multiselect } = await import("../../lib/prompts.ts"); + const selected = await multiselect({ + message: "Select MCP clients to install into:", + options: detected.map((c) => ({ value: c.id, label: `${c.displayName} (${c.scope})` })), + initialValues: detected.map((c) => c.id), + required: true, + }); + return resolveClients(selected); } export async function targetClients(options: McpOptions, cwd: string): Promise { diff --git a/packages/cli-core/src/lib/prompts.ts b/packages/cli-core/src/lib/prompts.ts index 3c4ff11a..533924e8 100644 --- a/packages/cli-core/src/lib/prompts.ts +++ b/packages/cli-core/src/lib/prompts.ts @@ -9,6 +9,8 @@ import { isCancel, text as clackText, password as clackPassword, + multiselect as clackMultiselect, + type Option as ClackOption, } from "@clack/prompts"; import { editAsync } from "external-editor"; import { throwUserAbort } from "./errors.ts"; @@ -80,6 +82,30 @@ export async function confirm(config: { message: string; default?: boolean }): P } } +/** Multi-select checklist. Returns the chosen values (at least one when required). */ +export async function multiselect(config: { + message: string; + options: { value: T; label: string; hint?: string }[]; + initialValues?: T[]; + required?: boolean; +}): Promise { + const tty = ttyContext(); + try { + const result = await clackMultiselect({ + message: config.message, + // `Option` is a conditional type a naked generic can't satisfy + // structurally; our shape provides `value` + `label`, valid in both branches. + options: config.options as ClackOption[], + initialValues: config.initialValues, + required: config.required ?? true, + input: tty?.input, + }); + return unwrap(result); + } finally { + tty?.close(); + } +} + /** Single-line text input. */ export async function text(config: { message: string; From 92ef3ffabe431d186ddc996d91e89e1c6f3f280a Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 8 Jun 2026 15:04:29 -0300 Subject: [PATCH 08/21] refactor(mcp): adopt Clack gutter/rail conventions for install/list/uninstall Follow the patterns the #305 Clack migration established: wrap install/list/uninstall human flows in withGutter() (guaranteed intro/outro cleanup, paused/failed outros on abort/error), render next steps via outro([...]) instead of a custom printNextSteps helper, and render the list table through the ui.* prompt rail (ui.message) like apps/list. No behavior change to JSON/agent output. --- .../cli-core/src/commands/mcp/install.test.ts | 6 +-- packages/cli-core/src/commands/mcp/install.ts | 54 +++++++++---------- .../cli-core/src/commands/mcp/list.test.ts | 26 +++++---- packages/cli-core/src/commands/mcp/list.ts | 49 ++++++++--------- packages/cli-core/src/commands/mcp/shared.ts | 8 --- .../src/commands/mcp/uninstall.test.ts | 2 +- .../cli-core/src/commands/mcp/uninstall.ts | 27 ++++------ 7 files changed, 80 insertions(+), 92 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/install.test.ts b/packages/cli-core/src/commands/mcp/install.test.ts index d0c7b908..c210699b 100644 --- a/packages/cli-core/src/commands/mcp/install.test.ts +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -171,21 +171,21 @@ describe("mcp install", () => { test("prints next steps with a sign-in reminder after a human-mode install", async () => { await mcpInstall({ client: ["cursor"], url: URL_A }); - expect(captured.err).toContain("Next steps:"); + expect(captured.err).toContain("Next steps"); expect(captured.err).toContain("Reload Cursor"); expect(captured.err).toContain("sign in"); }); test("omits next steps from JSON output", async () => { await mcpInstall({ client: ["cursor"], url: URL_A, json: true }); - expect(captured.err).not.toContain("Next steps:"); + expect(captured.err).not.toContain("Next steps"); }); test("does not print next steps when the entry was unchanged", async () => { await mcpInstall({ client: ["cursor"], url: URL_A }); captured.clear(); await mcpInstall({ client: ["cursor"], url: URL_A }); - expect(captured.err).not.toContain("Next steps:"); + expect(captured.err).not.toContain("Next steps"); }); test("rejects an unknown --client id", async () => { diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts index c8b83b5c..69234566 100644 --- a/packages/cli-core/src/commands/mcp/install.ts +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -8,11 +8,10 @@ import { log } from "../../lib/log.ts"; import { cyan, dim, green, yellow } from "../../lib/color.ts"; -import { isAgent, isHuman } from "../../mode.ts"; -import { intro, outro } from "../../lib/spinner.ts"; +import { isAgent } from "../../mode.ts"; +import { withGutter } from "../../lib/spinner.ts"; import { pickClients, - printNextSteps, resolveName, resolveUrl, settleClients, @@ -55,18 +54,15 @@ type ClientUpsert = { client: McpClient; result: UpsertResult }; // Writing the config isn't enough — the editor must reload before it connects // (and sign in, if the server requires it). Surface that for every client we // just wrote, so "added" doesn't read as "done and working". -function printInstallNextSteps(settled: ClientUpsert[]): void { +function installNextSteps(settled: ClientUpsert[]): string[] { const activated = settled.filter( ({ result }) => result.status === "added" || result.status === "updated", ); - if (activated.length === 0) return; - - printNextSteps(activated.map(({ client }) => `${client.displayName}: ${client.activation}`)); - log.info( - dim( - "If the server requires authentication, your editor opens a browser to sign in on first connect.", - ), - ); + if (activated.length === 0) return []; + return [ + ...activated.map(({ client }) => `${client.displayName}: ${client.activation}`), + "If the server requires authentication, your editor opens a browser to sign in on first connect.", + ]; } export async function mcpInstall(options: McpOptions = {}): Promise { @@ -77,26 +73,24 @@ export async function mcpInstall(options: McpOptions = {}): Promise { const force = Boolean(options.force); const json = wantsJson(options); - if (clients.length === 0 && json) { - log.data(JSON.stringify({ url, name, results: [] }, null, 2)); - return; - } if (clients.length === 0) { - log.warn("No MCP clients selected."); - return; - } - - if (isHuman() && !json) intro(`Installing Clerk MCP (${cyan(url)})`); - - const settled = await settleClients(clients, (c) => c.upsert({ name, url }, cwd, force)); - const results = settled.map((s) => s.result); - - if (json) { - log.data(JSON.stringify({ url, name, results }, null, 2)); + if (json) log.data(JSON.stringify({ url, name, results: [] }, null, 2)); + else log.warn("No MCP clients selected."); return; } - settled.forEach(({ client, result }) => printResult(client, result)); - printInstallNextSteps(settled); - outro("Done"); + await withGutter( + `Installing Clerk MCP (${cyan(url)})`, + async ({ setNextSteps }) => { + const settled = await settleClients(clients, (c) => c.upsert({ name, url }, cwd, force)); + if (json) { + log.data(JSON.stringify({ url, name, results: settled.map((s) => s.result) }, null, 2)); + return; + } + settled.forEach(({ client, result }) => printResult(client, result)); + const steps = installNextSteps(settled); + if (steps.length > 0) setNextSteps(steps); + }, + { skip: json }, + ); } diff --git a/packages/cli-core/src/commands/mcp/list.test.ts b/packages/cli-core/src/commands/mcp/list.test.ts index c36169b6..005c03fb 100644 --- a/packages/cli-core/src/commands/mcp/list.test.ts +++ b/packages/cli-core/src/commands/mcp/list.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import { mkdtemp, rm } from "node:fs/promises"; import * as realOs from "node:os"; import { join } from "node:path"; -import { useCaptureLog } from "../../test/lib/stubs.ts"; +import { captureUi, useCaptureLog } from "../../test/lib/stubs.ts"; const mockIsAgent = mock(); mock.module("../../mode.ts", () => ({ @@ -24,6 +24,7 @@ const URL = "https://mcp.clerk.com/mcp"; describe("mcp list", () => { const captured = useCaptureLog(); + let uiCapture: ReturnType; let cwd: string; let originalCwd: string; @@ -32,10 +33,13 @@ describe("mcp list", () => { cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-list-")); mockHome = cwd; process.chdir(cwd); + uiCapture = captureUi(); + uiCapture.install(); mockIsAgent.mockReturnValue(true); }); afterEach(async () => { + uiCapture.teardown(); process.chdir(originalCwd); await rm(cwd, { recursive: true, force: true }); mockIsAgent.mockReset(); @@ -60,25 +64,27 @@ describe("mcp list", () => { ]); }); - test("human-mode empty state writes the hint to stderr, nothing to stdout", async () => { + test("human-mode empty state shows the hint on the prompt rail, nothing to stdout", async () => { mockIsAgent.mockReturnValue(false); await mcpList({}); expect(captured.out).toBe(""); - expect(captured.err).toContain("No Clerk MCP entries"); + expect(uiCapture.out).toContain("No Clerk MCP entries"); }); - test("human-mode prints the formatted table to stdout after an install", async () => { + test("human-mode renders the table and next steps after an install", async () => { mockIsAgent.mockReturnValue(true); await mcpInstall({ client: ["cursor"], url: URL }); mockIsAgent.mockReturnValue(false); captured.clear(); await mcpList({}); - expect(captured.out).toContain("cursor"); - expect(captured.out).toContain("clerk"); - expect(captured.out).toContain(URL); - expect(captured.err).toContain("1 entry"); - expect(captured.err).toContain("Next steps:"); - expect(captured.err).toContain("clerk doctor"); + // Table, count, and the outro next-steps all render on the clack prompt + // rail; with captureUi installed, intro/outro write to that stream too. + expect(uiCapture.out).toContain("cursor"); + expect(uiCapture.out).toContain("clerk"); + expect(uiCapture.out).toContain(URL); + expect(uiCapture.out).toContain("1 entry"); + expect(uiCapture.out).toContain("Next steps"); + expect(uiCapture.out).toContain("clerk doctor"); }); }); diff --git a/packages/cli-core/src/commands/mcp/list.ts b/packages/cli-core/src/commands/mcp/list.ts index c61d4451..0ed94079 100644 --- a/packages/cli-core/src/commands/mcp/list.ts +++ b/packages/cli-core/src/commands/mcp/list.ts @@ -7,9 +7,11 @@ import { cyan, dim } from "../../lib/color.ts"; import { log } from "../../lib/log.ts"; +import { withGutter } from "../../lib/spinner.ts"; +import { ui } from "../../lib/ui.ts"; import type { ListEntry } from "./clients/types.ts"; import { collectEntries } from "./collect.ts"; -import { printNextSteps, wantsJson, type McpOptions } from "./shared.ts"; +import { wantsJson, type McpOptions } from "./shared.ts"; const COLUMN_PADDING = 2; @@ -31,36 +33,35 @@ function formatTable(entries: ListEntry[]): void { entries.map((e) => e.url), ); - log.data( - dim(`${"CLIENT".padEnd(clientWidth)}${"NAME".padEnd(nameWidth)}${"URL".padEnd(urlWidth)}PATH`), - ); - for (const entry of entries) { - const client = cyan(entry.client.padEnd(clientWidth)); - const name = entry.name.padEnd(nameWidth); - const url = entry.url.padEnd(urlWidth); - log.data(`${client}${name}${url}${dim(entry.configPath)}`); - } + const header = `${"CLIENT".padEnd(clientWidth)}${"NAME".padEnd(nameWidth)}${"URL".padEnd(urlWidth)}PATH`; + const rows = entries.map((e) => { + const client = cyan(e.client.padEnd(clientWidth)); + const name = e.name.padEnd(nameWidth); + const url = e.url.padEnd(urlWidth); + return `${client}${name}${url}${dim(e.configPath)}`; + }); + + ui.message([dim(header), ...rows]); } export async function mcpList(options: McpOptions = {}): Promise { - const cwd = process.cwd(); - const all: ListEntry[] = await collectEntries(cwd); + const all: ListEntry[] = await collectEntries(process.cwd()); if (wantsJson(options)) { log.data(JSON.stringify(all, null, 2)); return; } - if (all.length === 0) { - log.warn("No Clerk MCP entries found. Run `clerk mcp install` to register one."); - return; - } - - formatTable(all); - log.info(`\n${all.length} entr${all.length === 1 ? "y" : "ies"}`); - - printNextSteps([ - "Verify a server is reachable with `clerk doctor`.", - "Remove an entry with `clerk mcp uninstall`.", - ]); + await withGutter("Listing Clerk MCP entries", async ({ setNextSteps }) => { + if (all.length === 0) { + ui.warn("No Clerk MCP entries found. Run `clerk mcp install` to register one."); + return; + } + formatTable(all); + ui.message(`${all.length} entr${all.length === 1 ? "y" : "ies"}`); + setNextSteps([ + "Verify a server is reachable with `clerk doctor`.", + "Remove an entry with `clerk mcp uninstall`.", + ]); + }); } diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index afb87969..32b9855b 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -98,14 +98,6 @@ export function wantsJson(options: McpOptions): boolean { return Boolean(options.json) || isAgent(); } -/** Render a "Next steps:" block to stderr (human mode). No-op for an empty list. */ -export function printNextSteps(lines: string[]): void { - if (lines.length === 0) return; - log.blank(); - log.info("Next steps:"); - for (const line of lines) log.info(` ${line}`); -} - /** * Run an async op against each client without letting one client's failure * abort the rest — `Promise.all` is fail-fast and would discard every other diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index f7e339de..b919803b 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -101,7 +101,7 @@ describe("mcp uninstall", () => { mockIsAgent.mockReturnValue(false); captured.clear(); await mcpUninstall({ client: ["cursor"] }); - expect(captured.err).toContain("Next steps:"); + expect(captured.err).toContain("Next steps"); expect(captured.err).toContain("Reload Cursor"); }); }); diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts index 6f65c24f..a81e78dc 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -5,12 +5,10 @@ import { cyan, dim, green, yellow } from "../../lib/color.ts"; import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; -import { isHuman } from "../../mode.ts"; -import { intro, outro } from "../../lib/spinner.ts"; +import { withGutter } from "../../lib/spinner.ts"; import { CLIENTS } from "./clients/registry.ts"; import type { McpClient, RemoveResult } from "./clients/types.ts"; import { - printNextSteps, resolveClients, resolveName, settleClients, @@ -32,12 +30,8 @@ export async function mcpUninstall(options: McpOptions = {}): Promise { : Array.from(CLIENTS); const json = wantsJson(options); - if (isHuman() && !json) intro(`Removing MCP entry ${cyan(name)}`); - const settled = await settleClients(clients, (c) => c.remove(name, cwd)); const results = settled.map((s) => s.result); - if (isHuman() && !json) settled.forEach(({ client, result }) => printResult(client, result)); - const removedCount = results.filter((r) => r.removed).length; const notInstalled = new CliError(`No MCP entry named "${name}" found in any client.`, { code: ERROR_CODE.MCP_NOT_INSTALLED, @@ -49,14 +43,15 @@ export async function mcpUninstall(options: McpOptions = {}): Promise { return; } - if (removedCount === 0) throw notInstalled; - // Removing the config entry doesn't drop a live editor session — it lingers - // until the editor reloads. Tell the user for each client we removed from. - printNextSteps( - settled - .filter(({ result }) => result.removed) - .map(({ client }) => `Reload ${client.displayName} to drop the active connection.`), - ); - outro("Done"); + // until the editor reloads. Surface that as a next step per removed client. + await withGutter(`Removing MCP entry ${cyan(name)}`, async ({ setNextSteps }) => { + if (removedCount === 0) throw notInstalled; + settled.forEach(({ client, result }) => printResult(client, result)); + setNextSteps( + settled + .filter(({ result }) => result.removed) + .map(({ client }) => `Reload ${client.displayName} to drop the active connection.`), + ); + }); } From d2de17cc5fa7c44ec0ac0878d49a602570ce80b2 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 10:34:31 -0300 Subject: [PATCH 09/21] feat(mcp): interactive uninstall picker + rename `claude-code` client to `claude` Uninstall: in human mode with no --client/--all, prompt with a multiselect of the clients that actually have the entry, so you choose exactly which to remove from (mirrors the install picker). Add --all to remove from every client without prompting; agent mode still targets all. --client still targets specific clients. Rename the Claude Code client id from `claude-code` to `claude` (shorter `--client claude`); the file and export follow (claude.ts / claudeClient). Display name stays "Claude Code". Help examples now lead with `--client claude`. --- packages/cli-core/src/commands/mcp/README.md | 24 ++++++----- .../mcp/clients/{claude-code.ts => claude.ts} | 4 +- .../src/commands/mcp/clients/clients.test.ts | 6 +-- .../src/commands/mcp/clients/registry.ts | 4 +- .../src/commands/mcp/clients/types.ts | 2 +- .../commands/mcp/clients/user-scope.test.ts | 2 +- packages/cli-core/src/commands/mcp/index.ts | 24 ++++++++--- .../cli-core/src/commands/mcp/install.test.ts | 4 +- packages/cli-core/src/commands/mcp/install.ts | 4 +- packages/cli-core/src/commands/mcp/shared.ts | 10 +++-- .../src/commands/mcp/uninstall.test.ts | 41 +++++++++++++++++++ .../cli-core/src/commands/mcp/uninstall.ts | 32 +++++++++++---- 12 files changed, 120 insertions(+), 37 deletions(-) rename packages/cli-core/src/commands/mcp/clients/{claude-code.ts => claude.ts} (92%) diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index 90e070cc..cc566598 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -23,13 +23,13 @@ All entries are written to each client's **user-global** config, so the server is available in every project (no per-project approval, no dependence on which directory you run the CLI from). -| ID | Client | Scope | Config file | -| ------------- | ------------------------ | ----- | --------------------------------------- | -| `claude-code` | Claude Code | user | `~/.claude.json` (`mcpServers`) | -| `cursor` | Cursor | user | `~/.cursor/mcp.json` | -| `vscode` | VS Code (Copilot) | user | VS Code user `mcp.json` (per-OS, below) | -| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | -| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | +| ID | Client | Scope | Config file | +| ---------- | ------------------------ | ----- | --------------------------------------- | +| `claude` | Claude Code | user | `~/.claude.json` (`mcpServers`) | +| `cursor` | Cursor | user | `~/.cursor/mcp.json` | +| `vscode` | VS Code (Copilot) | user | VS Code user `mcp.json` (per-OS, below) | +| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | +| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | VS Code's user config dir is OS-specific: `~/Library/Application Support/Code/User/mcp.json` (macOS), `%APPDATA%\Code\User\mcp.json` (Windows), `$XDG_CONFIG_HOME/Code/User/mcp.json` @@ -68,9 +68,13 @@ named `clerk` or pointing at any `*.clerk.com` host). ### `clerk mcp uninstall` -Remove the named entry from each client. Throws `mcp_not_installed` (exit -code 1) when nothing was removed. Removing the entry doesn't drop a live editor -session, so (in human mode) it prints a next step to reload each affected editor. +Remove the named entry. In human mode with no `--client`/`--all`, it prompts +with a multiselect of the clients that **currently have the entry**, so you +choose exactly which to remove from. `--all` removes from every client without +prompting; agent mode targets all clients; `--client ` (repeatable) targets +specific clients. Throws `mcp_not_installed` (exit code 1) when nothing matched. +Removing the entry doesn't drop a live editor session, so (in human mode) it +prints a next step to reload each affected editor. > **Reachability:** there is no `mcp doctor` subcommand. Server health is part > of `clerk doctor`, which probes the configured MCP URL via the `initialize` diff --git a/packages/cli-core/src/commands/mcp/clients/claude-code.ts b/packages/cli-core/src/commands/mcp/clients/claude.ts similarity index 92% rename from packages/cli-core/src/commands/mcp/clients/claude-code.ts rename to packages/cli-core/src/commands/mcp/clients/claude.ts index 1b8affb8..cc372a97 100644 --- a/packages/cli-core/src/commands/mcp/clients/claude-code.ts +++ b/packages/cli-core/src/commands/mcp/clients/claude.ts @@ -8,8 +8,8 @@ import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; import { pathExists, userPath } from "./paths.ts"; -export const claudeCodeClient = makeJsonClient({ - id: "claude-code", +export const claudeClient = makeJsonClient({ + id: "claude", displayName: "Claude Code", scope: "user", activation: "Restart Claude Code, then run `/mcp` to connect (sign in if prompted).", diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts index 89998347..1aebbfac 100644 --- a/packages/cli-core/src/commands/mcp/clients/clients.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -11,7 +11,7 @@ let mockHome = realOs.tmpdir(); mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); afterAll(() => mock.restore()); -const { claudeCodeClient } = await import("./claude-code.ts"); +const { claudeClient } = await import("./claude.ts"); const { cursorClient } = await import("./cursor.ts"); const { vscodeClient } = await import("./vscode.ts"); const { windsurfClient } = await import("./windsurf.ts"); @@ -26,8 +26,8 @@ const URL = "https://mcp.clerk.com/mcp"; // specific user-global config file and encodes the server its own way. const cases = [ { - name: "claude-code", - client: claudeCodeClient, + name: "claude", + client: claudeClient, expectedPath: () => join(mockHome, ".claude.json"), topKey: "mcpServers", shape: { type: "http", url: URL }, diff --git a/packages/cli-core/src/commands/mcp/clients/registry.ts b/packages/cli-core/src/commands/mcp/clients/registry.ts index 109fc6d9..e278accd 100644 --- a/packages/cli-core/src/commands/mcp/clients/registry.ts +++ b/packages/cli-core/src/commands/mcp/clients/registry.ts @@ -3,7 +3,7 @@ * human-mode multiselect picker. */ -import { claudeCodeClient } from "./claude-code.ts"; +import { claudeClient } from "./claude.ts"; import { cursorClient } from "./cursor.ts"; import { geminiClient } from "./gemini.ts"; import type { ClientId, McpClient } from "./types.ts"; @@ -11,7 +11,7 @@ import { vscodeClient } from "./vscode.ts"; import { windsurfClient } from "./windsurf.ts"; export const CLIENTS: readonly McpClient[] = [ - claudeCodeClient, + claudeClient, cursorClient, vscodeClient, windsurfClient, diff --git a/packages/cli-core/src/commands/mcp/clients/types.ts b/packages/cli-core/src/commands/mcp/clients/types.ts index 5a05e3fc..098aca49 100644 --- a/packages/cli-core/src/commands/mcp/clients/types.ts +++ b/packages/cli-core/src/commands/mcp/clients/types.ts @@ -6,7 +6,7 @@ * the `clerk` server entry in its own config file format. */ -export type ClientId = "claude-code" | "cursor" | "vscode" | "windsurf" | "gemini"; +export type ClientId = "claude" | "cursor" | "vscode" | "windsurf" | "gemini"; /** Where the client config file lives relative to the user / project. */ export type Scope = "project" | "user"; diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index 6cffb810..f2dad05e 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -30,7 +30,7 @@ const { checkMcp } = await import("../../doctor/check-mcp.ts"); const captured = useCaptureLog(); const URL = "https://mcp.clerk.com/mcp"; -const ALL_CLIENT_IDS = ["claude-code", "cursor", "vscode", "windsurf", "gemini"]; +const ALL_CLIENT_IDS = ["claude", "cursor", "vscode", "windsurf", "gemini"]; describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { beforeEach(async () => { diff --git a/packages/cli-core/src/commands/mcp/index.ts b/packages/cli-core/src/commands/mcp/index.ts index 2deb8be3..8d0d15bb 100644 --- a/packages/cli-core/src/commands/mcp/index.ts +++ b/packages/cli-core/src/commands/mcp/index.ts @@ -22,7 +22,10 @@ export function registerMcp(program: Program): void { .description("Manage the Clerk remote MCP server connection for AI editors and CLIs") .setExamples([ { command: "clerk mcp install", description: "Install into all detected MCP clients" }, - { command: "clerk mcp install --client cursor", description: "Install into Cursor only" }, + { + command: "clerk mcp install --client claude", + description: "Install into Claude Code only", + }, { command: "clerk mcp list", description: "Show registered Clerk entries" }, { command: "clerk mcp uninstall", description: "Remove the Clerk entry from all clients" }, ]); @@ -48,7 +51,7 @@ export function registerMcp(program: Program): void { }, { command: "clerk mcp install --all", description: "Install into every detected client" }, { - command: "clerk mcp install --client cursor --client vscode", + command: "clerk mcp install --client claude --client vscode", description: "Install into specific clients", }, ]) @@ -65,16 +68,27 @@ export function registerMcp(program: Program): void { .command("uninstall") .description("Remove the Clerk MCP entry from supported clients") .addOption( - createOption("--client ", "MCP client to target (repeatable). Default: all clients.") + createOption( + "--client ", + "MCP client to target (repeatable). Default in human mode: pick from installed; in agent mode: all clients.", + ) .choices([...CLIENT_IDS]) .argParser(collectOptionValues) .default([] as string[]), ) + .option("--all", "Remove from every client without prompting") .option("--name ", 'Entry name to remove (default: "clerk")') .option("--json", "Output as JSON") .setExamples([ - { command: "clerk mcp uninstall", description: "Remove from every client" }, - { command: "clerk mcp uninstall --client cursor", description: "Remove from Cursor only" }, + { + command: "clerk mcp uninstall", + description: "Pick which installed clients to remove from", + }, + { command: "clerk mcp uninstall --all", description: "Remove from every client" }, + { + command: "clerk mcp uninstall --client claude", + description: "Remove from Claude Code only", + }, ]) .action((options) => mcp.uninstall(options)); } diff --git a/packages/cli-core/src/commands/mcp/install.test.ts b/packages/cli-core/src/commands/mcp/install.test.ts index c210699b..722dc034 100644 --- a/packages/cli-core/src/commands/mcp/install.test.ts +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -200,11 +200,11 @@ describe("mcp install", () => { await mkdir(join(cwd, ".cursor"), { recursive: true }); await writeFile(join(cwd, ".cursor", "mcp.json"), "{ not json"); - await mcpInstall({ client: ["cursor", "claude-code"], url: URL_A }); + await mcpInstall({ client: ["cursor", "claude"], url: URL_A }); const payload = JSON.parse(captured.out) as { results: { client: string; status: string }[] }; expect(payload.results).toEqual([ - expect.objectContaining({ client: "claude-code", status: "added" }), + expect.objectContaining({ client: "claude", status: "added" }), ]); expect(captured.err).toContain("Cursor"); // per-client warning for the failure }); diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts index 69234566..dffa5508 100644 --- a/packages/cli-core/src/commands/mcp/install.ts +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -24,7 +24,9 @@ import type { McpClient, UpsertResult } from "./clients/types.ts"; async function chooseClients(options: McpOptions, cwd: string): Promise { if (options.client?.length || options.all || isAgent()) return targetClients(options, cwd); - return pickClients(await detectInstalledClients(cwd)); + return pickClients(await detectInstalledClients(cwd), "Select MCP clients to install into:", { + autoSelectSingle: true, + }); } function statusLabel(status: UpsertResult["status"]): string { diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index 32b9855b..693cb961 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -63,14 +63,18 @@ export function resolveClients(ids: readonly string[]): McpClient[] { }); } -export async function pickClients(detected: McpClient[]): Promise { +export async function pickClients( + detected: McpClient[], + message: string, + options: { autoSelectSingle?: boolean } = {}, +): Promise { if (detected.length === 0) return []; - if (detected.length === 1) return detected; + if (detected.length === 1 && options.autoSelectSingle) return detected; // Imported lazily (like `doctor` does) so the prompt layer stays off the // module-load path for non-interactive callers and tests. const { multiselect } = await import("../../lib/prompts.ts"); const selected = await multiselect({ - message: "Select MCP clients to install into:", + message, options: detected.map((c) => ({ value: c.id, label: `${c.displayName} (${c.scope})` })), initialValues: detected.map((c) => c.id), required: true, diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index b919803b..9064dc9e 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -17,6 +17,13 @@ mock.module("../../mode.ts", () => ({ let mockHome = realOs.tmpdir(); mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); +// The human picker resolves the Clack multiselect lazily from lib/prompts.ts; +// stub it so tests drive which clients get selected without a real prompt. +const mockMultiselect = mock(); +mock.module("../../lib/prompts.ts", () => ({ + multiselect: (...args: unknown[]) => mockMultiselect(...args), +})); + const { mcpInstall } = await import("./install.ts"); const { mcpUninstall } = await import("./uninstall.ts"); @@ -39,6 +46,7 @@ describe("mcp uninstall", () => { process.chdir(originalCwd); await rm(cwd, { recursive: true, force: true }); mockIsAgent.mockReset(); + mockMultiselect.mockReset(); }); test("removes the entry an install-uninstall round-trip leaves no trace under mcpServers", async () => { @@ -104,4 +112,37 @@ describe("mcp uninstall", () => { expect(captured.err).toContain("Next steps"); expect(captured.err).toContain("Reload Cursor"); }); + + test("human mode: prompts to pick which installed clients to remove from", async () => { + await mcpInstall({ client: ["cursor", "claude"], url: URL }); + mockIsAgent.mockReturnValue(false); + mockMultiselect.mockResolvedValueOnce(["cursor"]); // pick only Cursor + captured.clear(); + + await mcpUninstall({}); + + expect(mockMultiselect).toHaveBeenCalledTimes(1); + const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(cursorCfg.mcpServers.clerk).toBeUndefined(); // removed + const claudeCfg = JSON.parse(await readFile(join(cwd, ".claude.json"), "utf8")) as { + mcpServers: Record; + }; + expect(claudeCfg.mcpServers.clerk).toBeDefined(); // untouched + }); + + test("human mode: --all removes from every client without prompting", async () => { + await mcpInstall({ client: ["cursor", "claude"], url: URL }); + mockIsAgent.mockReturnValue(false); + captured.clear(); + + await mcpUninstall({ all: true }); + + expect(mockMultiselect).not.toHaveBeenCalled(); + const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(cursorCfg.mcpServers.clerk).toBeUndefined(); + }); }); diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts index a81e78dc..70623770 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -5,10 +5,12 @@ import { cyan, dim, green, yellow } from "../../lib/color.ts"; import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; import { withGutter } from "../../lib/spinner.ts"; import { CLIENTS } from "./clients/registry.ts"; import type { McpClient, RemoveResult } from "./clients/types.ts"; import { + pickClients, resolveClients, resolveName, settleClients, @@ -21,21 +23,37 @@ function printResult(client: McpClient, result: RemoveResult): void { log.info(`${label}: ${result.removed ? green("removed") : yellow("not present")}`); } +/** Supported clients that currently have the `name` entry registered. */ +async function installedClients(name: string, cwd: string): Promise { + const present = await Promise.all( + CLIENTS.map(async (client) => (await client.list(cwd)).some((entry) => entry.name === name)), + ); + return CLIENTS.filter((_, i) => present[i]); +} + +// `--client` targets exactly those; `--all` (and agent mode, which can't +// prompt) targets every client; otherwise the human picks which of the clients +// that actually have the entry to remove it from. +async function chooseClients(options: McpOptions, name: string, cwd: string): Promise { + if (options.client && options.client.length > 0) return resolveClients(options.client); + if (options.all || isAgent()) return Array.from(CLIENTS); + return pickClients(await installedClients(name, cwd), `Select clients to remove "${name}" from:`); +} + export async function mcpUninstall(options: McpOptions = {}): Promise { const name = resolveName(options); const cwd = process.cwd(); - const clients = - options.client && options.client.length > 0 - ? resolveClients(options.client) - : Array.from(CLIENTS); const json = wantsJson(options); + const notInstalled = new CliError(`No MCP entry named "${name}" found in any client.`, { + code: ERROR_CODE.MCP_NOT_INSTALLED, + }); + + const clients = await chooseClients(options, name, cwd); + if (clients.length === 0) throw notInstalled; const settled = await settleClients(clients, (c) => c.remove(name, cwd)); const results = settled.map((s) => s.result); const removedCount = results.filter((r) => r.removed).length; - const notInstalled = new CliError(`No MCP entry named "${name}" found in any client.`, { - code: ERROR_CODE.MCP_NOT_INSTALLED, - }); if (json) { log.data(JSON.stringify({ name, results }, null, 2)); From d32e4422d28f76d41aae2af0ce8ad394465fcbbb Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 10:40:02 -0300 Subject: [PATCH 10/21] refactor(mcp): drop explanatory comments added in the uninstall picker --- packages/cli-core/src/commands/mcp/uninstall.test.ts | 2 -- packages/cli-core/src/commands/mcp/uninstall.ts | 4 ---- 2 files changed, 6 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index 9064dc9e..56aeec07 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -17,8 +17,6 @@ mock.module("../../mode.ts", () => ({ let mockHome = realOs.tmpdir(); mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); -// The human picker resolves the Clack multiselect lazily from lib/prompts.ts; -// stub it so tests drive which clients get selected without a real prompt. const mockMultiselect = mock(); mock.module("../../lib/prompts.ts", () => ({ multiselect: (...args: unknown[]) => mockMultiselect(...args), diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts index 70623770..ef0b1acd 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -23,7 +23,6 @@ function printResult(client: McpClient, result: RemoveResult): void { log.info(`${label}: ${result.removed ? green("removed") : yellow("not present")}`); } -/** Supported clients that currently have the `name` entry registered. */ async function installedClients(name: string, cwd: string): Promise { const present = await Promise.all( CLIENTS.map(async (client) => (await client.list(cwd)).some((entry) => entry.name === name)), @@ -31,9 +30,6 @@ async function installedClients(name: string, cwd: string): Promise return CLIENTS.filter((_, i) => present[i]); } -// `--client` targets exactly those; `--all` (and agent mode, which can't -// prompt) targets every client; otherwise the human picks which of the clients -// that actually have the entry to remove it from. async function chooseClients(options: McpOptions, name: string, cwd: string): Promise { if (options.client && options.client.length > 0) return resolveClients(options.client); if (options.all || isAgent()) return Array.from(CLIENTS); From c72a8fbe901eec140164833e8d2aa476e4fa20c5 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Tue, 9 Jun 2026 11:58:35 -0300 Subject: [PATCH 11/21] feat(mcp): add Codex client, GitHub Copilot alias, refine uninstall/doctor - Add Codex MCP client backed by ~/.codex/config.toml (smol-toml + a makeTomlClient codec on the shared makeFileClient factory); uses Codex's native Streamable HTTP transport, no mcp-remote bridge - Rename the VS Code client display to "GitHub Copilot"; accept `--client copilot` as an alias for `vscode` (same config) - uninstall: prompt lists only clients that have the entry, nothing pre-checked (select-to-remove), and warns how to install instead of erroring when nothing is registered - doctor: probe every distinct configured MCP URL (not just the first); lower the probe timeout to 5s - resolveClients dedupes aliases/repeats; remove dead projectPath export --- .changeset/mcp-install.md | 2 +- bun.lock | 3 + packages/cli-core/package.json | 1 + .../cli-core/src/commands/doctor/README.md | 22 +++--- .../cli-core/src/commands/doctor/check-mcp.ts | 37 +++++++--- .../src/commands/doctor/context.test.ts | 2 +- packages/cli-core/src/commands/mcp/README.md | 53 ++++++++------ .../src/commands/mcp/clients/clients.test.ts | 12 ++++ .../src/commands/mcp/clients/codex.ts | 20 ++++++ .../commands/mcp/clients/make-json-client.ts | 49 +++++++++---- .../src/commands/mcp/clients/paths.ts | 4 -- .../src/commands/mcp/clients/registry.ts | 11 +++ .../src/commands/mcp/clients/toml-config.ts | 49 +++++++++++++ .../src/commands/mcp/clients/types.ts | 12 ++-- .../commands/mcp/clients/user-scope.test.ts | 61 +++++++++++++++- .../src/commands/mcp/clients/vscode.ts | 2 +- packages/cli-core/src/commands/mcp/index.ts | 8 +-- packages/cli-core/src/commands/mcp/probe.ts | 3 +- packages/cli-core/src/commands/mcp/shared.ts | 20 ++++-- .../src/commands/mcp/uninstall.test.ts | 54 ++++++++++---- .../cli-core/src/commands/mcp/uninstall.ts | 71 ++++++++++++++----- packages/cli-core/src/lib/errors.ts | 2 - 22 files changed, 380 insertions(+), 118 deletions(-) create mode 100644 packages/cli-core/src/commands/mcp/clients/codex.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/toml-config.ts diff --git a/.changeset/mcp-install.md b/.changeset/mcp-install.md index 331d7cc1..897d39d9 100644 --- a/.changeset/mcp-install.md +++ b/.changeset/mcp-install.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, VS Code, Windsurf, and Gemini. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`), so the server is available across every project regardless of the directory you run the CLI from. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. By default the commands target Clerk's hosted server, so `clerk mcp install` works with no flags. The URL resolves in order: `--url` > the `CLERK_MCP_URL` override (for local worker development) > the active env profile's new `mcpUrl` field > the hosted server. +Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, GitHub Copilot (VS Code; `--client vscode` or `--client copilot`), Windsurf, Gemini, and Codex. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`, `~/.codex/config.toml`), so the server is available across every project regardless of the directory you run the CLI from. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. By default the commands target Clerk's hosted server, so `clerk mcp install` works with no flags. The URL resolves in order: `--url` > the `CLERK_MCP_URL` override (for local worker development) > the active env profile's new `mcpUrl` field > the hosted server. diff --git a/bun.lock b/bun.lock index 06c4d579..42e84bf9 100644 --- a/bun.lock +++ b/bun.lock @@ -40,6 +40,7 @@ "external-editor": "^3.1.0", "magicast": "^0.5.3", "semver": "^7.8.5", + "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 408dc45e..713d84f9 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.5", + "smol-toml": "^1.6.1", "yaml": "^2.9.0" }, "devDependencies": { diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index 1ead10e2..95f44288 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -25,17 +25,17 @@ clerk doctor --fix # Offer to auto-fix issues ## Checks -| Check | Category | What it verifies | -| --------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | -| Authentication token | Authentication | Credential store has a stored token | -| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | -| Project linkage | Project | Current directory is linked to a Clerk app | -| Linked application | Project | Linked application ID is accessible via the API | -| Instances | Project | Configured dev/prod instance IDs match the application's instances | -| Environment variables | Environment | .env.local or .env has Clerk keys | -| CLI configuration | Configuration | CLI config file exists and parses | -| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | -| MCP server | Integration | If a Clerk MCP entry is installed, the configured server answers the `initialize` handshake (skipped otherwise; warns, never fails) | +| Check | Category | What it verifies | +| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| Authentication token | Authentication | Credential store has a stored token | +| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | +| Project linkage | Project | Current directory is linked to a Clerk app | +| Linked application | Project | Linked application ID is accessible via the API | +| Instances | Project | Configured dev/prod instance IDs match the application's instances | +| Environment variables | Environment | .env.local or .env has Clerk keys | +| CLI configuration | Configuration | CLI config file exists and parses | +| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | +| MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake (skipped otherwise; warns, never fails) | ## Auto-Fix (`--fix`) diff --git a/packages/cli-core/src/commands/doctor/check-mcp.ts b/packages/cli-core/src/commands/doctor/check-mcp.ts index a897670c..f8894249 100644 --- a/packages/cli-core/src/commands/doctor/check-mcp.ts +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -7,11 +7,25 @@ */ import { collectEntries } from "../mcp/collect.ts"; -import { probeMcp } from "../mcp/probe.ts"; +import { probeMcp, type McpProbeResult } from "../mcp/probe.ts"; import type { CheckResult } from "./types.ts"; const NAME = "MCP server"; +type UrlProbe = { url: string; result: McpProbeResult }; + +function describeReachable(probes: UrlProbe[]): string { + return probes + .map(({ url, result }) => (result.ok ? `${result.serverName} (${url})` : url)) + .join(", "); +} + +function describeFailure(result: McpProbeResult): string { + if (result.ok) return "unknown"; + if (result.error !== undefined) return result.error; + return result.status !== undefined ? `HTTP ${result.status}` : "unknown"; +} + export async function checkMcp(): Promise { // Only meaningful if the user actually registered a Clerk MCP entry — // otherwise skip silently rather than probing a server they don't use. @@ -20,19 +34,24 @@ export async function checkMcp(): Promise { return { name: NAME, status: "pass", message: "Skipped (no Clerk MCP entry installed)" }; } - const url = entries[0]!.url; - const result = await probeMcp(url); - if (result.ok) { - return { name: NAME, status: "pass", message: `Reachable — ${result.serverName} (${url})` }; + // Clients can point at different URLs (e.g. local dev in one, hosted in + // another), so probe every distinct one — a healthy first entry must not mask + // a broken second. + const urls = [...new Set(entries.map((e) => e.url))]; + const probes = await Promise.all(urls.map(async (url) => ({ url, result: await probeMcp(url) }))); + + const unreachable = probes.find(({ result }) => !result.ok); + if (!unreachable) { + return { name: NAME, status: "pass", message: `Reachable — ${describeReachable(probes)}` }; } - const detail = - result.error ?? (result.status !== undefined ? `HTTP ${result.status}` : "unknown"); + const subject = + probes.length === 1 ? "Configured MCP server is" : "One or more configured MCP servers are"; return { name: NAME, status: "warn", - message: `Configured MCP server is not reachable (${url})`, - detail, + message: `${subject} not reachable (${unreachable.url})`, + detail: describeFailure(unreachable.result), remedy: "Verify the server is running, or re-run `clerk mcp install` if the URL changed.", }; } diff --git a/packages/cli-core/src/commands/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index 79e6d4e6..d0e852eb 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -66,7 +66,7 @@ describe("createDoctorContext", () => { const p1 = ctx.getToken(); const p2 = ctx.getToken(); - expect(p1).toBe(p2); // Same promise reference + expect(p1).toBe(p2); expect(await p1).toBe("test_token"); expect(mockGetToken).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index cc566598..f0bc0670 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -14,8 +14,8 @@ environment variable > the active environment profile's `mcpUrl` field (e.g. `http://localhost:8787/mcp`). No Clerk API endpoints are called. To verify the server is reachable, run -`clerk doctor` — its MCP check performs the `initialize` handshake against the -configured URL whenever a Clerk MCP entry is installed. +`clerk doctor` — its MCP check performs the `initialize` handshake against each +distinct configured URL whenever a Clerk MCP entry is installed. ## Supported clients @@ -23,18 +23,25 @@ All entries are written to each client's **user-global** config, so the server is available in every project (no per-project approval, no dependence on which directory you run the CLI from). -| ID | Client | Scope | Config file | -| ---------- | ------------------------ | ----- | --------------------------------------- | -| `claude` | Claude Code | user | `~/.claude.json` (`mcpServers`) | -| `cursor` | Cursor | user | `~/.cursor/mcp.json` | -| `vscode` | VS Code (Copilot) | user | VS Code user `mcp.json` (per-OS, below) | -| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | -| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | - -VS Code's user config dir is OS-specific: `~/Library/Application Support/Code/User/mcp.json` -(macOS), `%APPDATA%\Code\User\mcp.json` (Windows), `$XDG_CONFIG_HOME/Code/User/mcp.json` +| ID | Client | Scope | Config file | +| -------------------- | ------------------------ | ----- | --------------------------------------- | +| `claude` | Claude Code | user | `~/.claude.json` (`mcpServers`) | +| `cursor` | Cursor | user | `~/.cursor/mcp.json` | +| `vscode` (`copilot`) | GitHub Copilot (VS Code) | user | VS Code user `mcp.json` (per-OS, below) | +| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` | +| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` | +| `codex` | Codex | user | `~/.codex/config.toml` (`mcp_servers`) | + +GitHub Copilot's MCP server lives in VS Code's config, so `--client copilot` and +`--client vscode` are aliases for the same client. Its user config dir is +OS-specific: `~/Library/Application Support/Code/User/mcp.json` (macOS), +`%APPDATA%\Code\User\mcp.json` (Windows), `$XDG_CONFIG_HOME/Code/User/mcp.json` (Linux) — the file behind **MCP: Open User Configuration**. +Codex is the one TOML-backed client; the entry uses Codex's native Streamable +HTTP transport (`url = "…"` under `[mcp_servers.]`), so it needs no +`mcp-remote` bridge. Rewriting `config.toml` does not preserve comments. + ## Subcommands ### `clerk mcp install` @@ -68,17 +75,20 @@ named `clerk` or pointing at any `*.clerk.com` host). ### `clerk mcp uninstall` -Remove the named entry. In human mode with no `--client`/`--all`, it prompts -with a multiselect of the clients that **currently have the entry**, so you -choose exactly which to remove from. `--all` removes from every client without -prompting; agent mode targets all clients; `--client ` (repeatable) targets -specific clients. Throws `mcp_not_installed` (exit code 1) when nothing matched. -Removing the entry doesn't drop a live editor session, so (in human mode) it -prints a next step to reload each affected editor. +Remove the entry. In human mode with no `--client`/`--all`, it prompts with a +multiselect of the clients that **currently have the entry**, all unchecked: +check the clients to remove the entry from and leave the rest unchecked, so the +default (nothing checked) removes nothing. `--all` removes from every client +without prompting; agent mode targets all clients; `--client ` (repeatable) +targets specific clients. When nothing matches, it prints a warm hint to run +`clerk mcp install` (no error, exit 0). Removing the entry doesn't drop a live +editor session, so (in human mode) it prints a next step to reload each affected +editor. > **Reachability:** there is no `mcp doctor` subcommand. Server health is part -> of `clerk doctor`, which probes the configured MCP URL via the `initialize` -> handshake when an entry is installed (warns, does not fail, when unreachable). +> of `clerk doctor`, which probes each distinct configured MCP URL via the +> `initialize` handshake when an entry is installed (warns, does not fail, when +> any is unreachable). ## Error codes @@ -88,4 +98,3 @@ prints a next step to reload each affected editor. | `mcp_client_not_supported` | `--client ` is not in the supported list. | | `mcp_client_config_invalid` | An existing client config file is malformed. | | `mcp_url_required` | The provided `--url` is malformed or uses a non-http(s) scheme. | -| `mcp_not_installed` | `uninstall` removed nothing because no entry matched. | diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts index 1aebbfac..3d45a8f4 100644 --- a/packages/cli-core/src/commands/mcp/clients/clients.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -111,4 +111,16 @@ describe("client config paths + encoded shapes (homedir redirected)", () => { expect(parsed.servers).toBeDefined(); expect(parsed.mcpServers).toBeUndefined(); }); + + test("`copilot` resolves to the same client as `vscode`", async () => { + const { resolveClients } = await import("../shared.ts"); + expect(resolveClients(["copilot"])).toEqual([vscodeClient]); + expect(resolveClients(["copilot"])).toEqual(resolveClients(["vscode"])); + }); + + test("resolveClients dedupes an alias and its canonical id to one client", async () => { + const { resolveClients } = await import("../shared.ts"); + expect(resolveClients(["copilot", "vscode"])).toEqual([vscodeClient]); + expect(resolveClients(["cursor", "cursor"])).toEqual([cursorClient]); + }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/codex.ts b/packages/cli-core/src/commands/mcp/clients/codex.ts new file mode 100644 index 00000000..b0efc5dd --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/codex.ts @@ -0,0 +1,20 @@ +/** + * Writes to `~/.codex/config.toml` under the `[mcp_servers.]` table. + * Codex supports Streamable HTTP MCP servers directly, so the descriptor is + * just `{ url }` — no `mcp-remote` stdio bridge (unlike Gemini). + */ + +import { hasStringProp, makeTomlClient } from "./make-json-client.ts"; +import { pathExists, userPath } from "./paths.ts"; + +export const codexClient = makeTomlClient({ + id: "codex", + displayName: "Codex", + scope: "user", + activation: "Restart Codex; it opens a browser to sign in if the server requires it.", + topKey: "mcp_servers", + encode: (url) => ({ url }), + extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + configPath: () => userPath(".codex", "config.toml"), + detect: () => pathExists(userPath(".codex")), +}); diff --git a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts index a39355f7..13d6bc3e 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-json-client.ts @@ -1,17 +1,19 @@ /** - * Factory for JSON-backed MCP clients. + * Factory for file-backed MCP clients. * - * Five of the supported clients share the same upsert/remove/list shape — a - * JSON file with a top-level object whose keys are server names and whose - * values are per-client server descriptors. The only differences are the - * top-level key name (`mcpServers` vs `servers`) and the descriptor encoding - * (`{ url }` vs `{ serverUrl }` vs `{ command, args }`). This factory captures - * those differences as `topKey` + `encode` + `extractUrl` and reuses the rest. + * Every supported client shares the same upsert/remove/list shape — a config + * file with a top-level map whose keys are server names and whose values are + * per-client server descriptors. The differences are the serialization format + * (JSON for five clients, TOML for Codex), the top-level key name (`mcpServers` + * vs `servers` vs `mcp_servers`) and the descriptor encoding (`{ url }` vs + * `{ serverUrl }` vs `{ command, args }`). This factory captures those as a + * codec + `topKey` + `encode` + `extractUrl` and reuses the rest. */ import { CliError, ERROR_CODE } from "../../../lib/errors.ts"; import { log } from "../../../lib/log.ts"; import { getServerMap, readJsonConfig, writeJsonConfig, type JsonConfig } from "./json-config.ts"; +import { readTomlConfig, writeTomlConfig } from "./toml-config.ts"; import type { ClientId, ListEntry, @@ -34,7 +36,7 @@ export function hasStringProp( ); } -interface JsonClientSpec { +interface FileClientSpec { id: ClientId; displayName: string; scope: Scope; @@ -48,6 +50,15 @@ interface JsonClientSpec { detect: (cwd: string) => Promise; } +/** Read/write codec abstracting the on-disk format (JSON vs TOML). */ +interface ConfigCodec { + read: (path: string) => Promise; + write: (path: string, config: JsonConfig) => Promise; +} + +const JSON_CODEC: ConfigCodec = { read: readJsonConfig, write: writeJsonConfig }; +const TOML_CODEC: ConfigCodec = { read: readTomlConfig, write: writeTomlConfig }; + function isClerkUrl(url: string): boolean { try { const parsed = new URL(url); @@ -57,7 +68,7 @@ function isClerkUrl(url: string): boolean { } } -export function makeJsonClient(spec: JsonClientSpec): McpClient { +function makeFileClient(spec: FileClientSpec, codec: ConfigCodec): McpClient { return { id: spec.id, displayName: spec.displayName, @@ -68,7 +79,7 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { async upsert(entry: McpServerEntry, cwd: string, force: boolean): Promise { const configPath = spec.configPath(cwd); - const config = await readJsonConfig(configPath); + const config = await codec.read(configPath); const servers = getServerMap(config, spec.topKey, configPath); // Own-property only: `servers[name]` / `name in servers` would walk the @@ -94,7 +105,7 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { const desired = spec.encode(entry.url); const next: JsonConfig = { ...config, [spec.topKey]: { ...servers, [entry.name]: desired } }; - await writeJsonConfig(configPath, next); + await codec.write(configPath, next); const status = hasExisting ? "updated" : "added"; log.debug(`mcp: ${spec.id} ${status} "${entry.name}"`); return { client: spec.id, configPath, status }; @@ -102,14 +113,14 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { async remove(name: string, cwd: string): Promise { const configPath = spec.configPath(cwd); - const config = await readJsonConfig(configPath); + const config = await codec.read(configPath); const servers = getServerMap(config, spec.topKey, configPath); if (!Object.prototype.hasOwnProperty.call(servers, name)) { return { client: spec.id, configPath, removed: false }; } const { [name]: _removed, ...rest } = servers; const next: JsonConfig = { ...config, [spec.topKey]: rest }; - await writeJsonConfig(configPath, next); + await codec.write(configPath, next); log.debug(`mcp: ${spec.id} removed "${name}"`); return { client: spec.id, configPath, removed: true }; }, @@ -122,7 +133,7 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { // and getServerMap raise MCP_CLIENT_CONFIG_INVALID, so they share one guard. let servers: Record; try { - const config = await readJsonConfig(configPath); + const config = await codec.read(configPath); servers = getServerMap(config, spec.topKey, configPath); } catch (error) { if (error instanceof CliError && error.code === ERROR_CODE.MCP_CLIENT_CONFIG_INVALID) { @@ -145,3 +156,13 @@ export function makeJsonClient(spec: JsonClientSpec): McpClient { }, }; } + +/** A client whose config is a JSON file (Claude Code, Cursor, VS Code, Windsurf, Gemini). */ +export function makeJsonClient(spec: FileClientSpec): McpClient { + return makeFileClient(spec, JSON_CODEC); +} + +/** A client whose config is a TOML file (Codex). */ +export function makeTomlClient(spec: FileClientSpec): McpClient { + return makeFileClient(spec, TOML_CODEC); +} diff --git a/packages/cli-core/src/commands/mcp/clients/paths.ts b/packages/cli-core/src/commands/mcp/clients/paths.ts index f80b7812..36d7fbb8 100644 --- a/packages/cli-core/src/commands/mcp/clients/paths.ts +++ b/packages/cli-core/src/commands/mcp/clients/paths.ts @@ -10,10 +10,6 @@ import { stat } from "node:fs/promises"; import { homedir, platform } from "node:os"; import { join } from "node:path"; -export function projectPath(cwd: string, ...segments: string[]): string { - return join(cwd, ...segments); -} - export function userPath(...segments: string[]): string { return join(homedir(), ...segments); } diff --git a/packages/cli-core/src/commands/mcp/clients/registry.ts b/packages/cli-core/src/commands/mcp/clients/registry.ts index e278accd..c8933802 100644 --- a/packages/cli-core/src/commands/mcp/clients/registry.ts +++ b/packages/cli-core/src/commands/mcp/clients/registry.ts @@ -4,6 +4,7 @@ */ import { claudeClient } from "./claude.ts"; +import { codexClient } from "./codex.ts"; import { cursorClient } from "./cursor.ts"; import { geminiClient } from "./gemini.ts"; import type { ClientId, McpClient } from "./types.ts"; @@ -16,10 +17,20 @@ export const CLIENTS: readonly McpClient[] = [ vscodeClient, windsurfClient, geminiClient, + codexClient, ]; export const CLIENT_IDS: readonly ClientId[] = CLIENTS.map((c) => c.id); +/** + * Accepted `--client` aliases → canonical id. GitHub Copilot runs inside VS + * Code and shares its `mcp.json`, so `copilot` and `vscode` target the same + * client. + */ +export const CLIENT_ALIASES: Readonly> = { copilot: "vscode" }; + +export const CLIENT_ID_CHOICES: readonly string[] = [...CLIENT_IDS, ...Object.keys(CLIENT_ALIASES)]; + export async function detectInstalledClients(cwd: string): Promise { const flags = await Promise.all(CLIENTS.map((c) => c.detect(cwd))); return CLIENTS.filter((_, i) => flags[i]); diff --git a/packages/cli-core/src/commands/mcp/clients/toml-config.ts b/packages/cli-core/src/commands/mcp/clients/toml-config.ts new file mode 100644 index 00000000..a7a11ba1 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/toml-config.ts @@ -0,0 +1,49 @@ +/** + * Shared TOML read/write helper for MCP client configs. + * + * Codex stores its MCP servers in `~/.codex/config.toml` under the + * `[mcp_servers.]` table — same logical shape as the JSON clients (a + * top-level map of server name → descriptor), just a different on-disk format. + * This module is the TOML counterpart to `json-config.ts`: it only handles the + * surrounding I/O, parsing into and serializing from the plain object tree that + * the client factory and `getServerMap` already operate on. + * + * Note: serializing drops comments and original formatting, matching how the + * JSON clients rewrite their (sometimes JSONC) configs. + */ + +import { mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { parse, stringify } from "smol-toml"; +import { log } from "../../../lib/log.ts"; +import { CliError, ERROR_CODE, errorMessage } from "../../../lib/errors.ts"; +import type { JsonConfig } from "./json-config.ts"; + +export async function readTomlConfig(path: string): Promise { + const file = Bun.file(path); + if (!(await file.exists())) return {}; + const text = await file.text(); + if (text.trim().length === 0) return {}; + try { + const parsed: unknown = parse(text); + // A valid TOML document is always a table, so `parse` can't hand back a + // non-object — but guard anyway so a future parser swap can't surprise us. + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliError(`Config at ${path} is not a TOML table`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } + return parsed as JsonConfig; + } catch (error) { + if (error instanceof CliError) throw error; + throw new CliError(`Could not parse ${path} as TOML: ${errorMessage(error)}`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } +} + +export async function writeTomlConfig(path: string, config: JsonConfig): Promise { + log.debug(`mcp: write ${path}`); + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, stringify(config) + "\n"); +} diff --git a/packages/cli-core/src/commands/mcp/clients/types.ts b/packages/cli-core/src/commands/mcp/clients/types.ts index 098aca49..2461b09c 100644 --- a/packages/cli-core/src/commands/mcp/clients/types.ts +++ b/packages/cli-core/src/commands/mcp/clients/types.ts @@ -1,14 +1,13 @@ /** * Shared types for MCP client integrations. * - * Each supported MCP client (Claude Code, Cursor, VS Code, Windsurf, Gemini) - * exposes an {@link McpClient} that knows how to read, upsert, and remove - * the `clerk` server entry in its own config file format. + * Each supported MCP client (Claude Code, Cursor, GitHub Copilot, Windsurf, + * Gemini, Codex) exposes an {@link McpClient} that knows how to read, upsert, + * and remove the `clerk` server entry in its own config file format. */ -export type ClientId = "claude" | "cursor" | "vscode" | "windsurf" | "gemini"; +export type ClientId = "claude" | "cursor" | "vscode" | "windsurf" | "gemini" | "codex"; -/** Where the client config file lives relative to the user / project. */ export type Scope = "project" | "user"; export interface McpServerEntry { @@ -52,13 +51,10 @@ export interface McpClient { * as a next step. */ activation: string; - /** Where the entry would be written for the given cwd. */ configPath(cwd: string): string; /** Heuristic: is this client installed for this user? */ detect(cwd: string): Promise; - /** Add or update the `name` entry pointing at `url`. */ upsert(entry: McpServerEntry, cwd: string, force: boolean): Promise; - /** Remove the `name` entry. */ remove(name: string, cwd: string): Promise; /** List `clerk`-flavored entries currently registered (those pointing at clerk.com URLs or named explicitly). */ list(cwd: string): Promise; diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index f2dad05e..e721f6fd 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -22,7 +22,9 @@ mock.module("../../../mode.ts", () => ({ const { geminiClient } = await import("./gemini.ts"); const { windsurfClient } = await import("./windsurf.ts"); +const { codexClient } = await import("./codex.ts"); const { vscodeUserDir } = await import("./paths.ts"); +const { parse: parseToml } = await import("smol-toml"); const { mcpInstall } = await import("../install.ts"); const { mcpUninstall } = await import("../uninstall.ts"); const { checkMcp } = await import("../../doctor/check-mcp.ts"); @@ -30,7 +32,7 @@ const { checkMcp } = await import("../../doctor/check-mcp.ts"); const captured = useCaptureLog(); const URL = "https://mcp.clerk.com/mcp"; -const ALL_CLIENT_IDS = ["claude", "cursor", "vscode", "windsurf", "gemini"]; +const ALL_CLIENT_IDS = ["claude", "cursor", "vscode", "windsurf", "gemini", "codex"]; describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { beforeEach(async () => { @@ -91,6 +93,43 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { ]); }); }); + + describe("codex", () => { + test("writes the HTTP url under the [mcp_servers.] TOML table", async () => { + await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const text = await Bun.file(codexClient.configPath("/ignored")).text(); + expect(text).toContain("[mcp_servers.clerk]"); + const parsed = parseToml(text) as { mcp_servers: { clerk: { url: string } } }; + expect(parsed.mcp_servers.clerk).toEqual({ url: URL }); + }); + + test("round-trips the URL back out on list", async () => { + await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const entries = await codexClient.list("/ignored"); + expect(entries).toEqual([ + expect.objectContaining({ client: "codex", name: "clerk", url: URL }), + ]); + }); + + test("preserves unrelated tables when removing the entry", async () => { + const dir = join(mockHome, ".codex"); + await mkdir(dir, { recursive: true }); + await Bun.write( + join(dir, "config.toml"), + 'model = "o3"\n\n[mcp_servers.clerk]\nurl = "https://mcp.clerk.com/mcp"\n\n[mcp_servers.other]\ncommand = "npx"\n', + ); + + await codexClient.remove("clerk", "/ignored"); + + const parsed = parseToml(await Bun.file(join(dir, "config.toml")).text()) as { + model: string; + mcp_servers: Record; + }; + expect(parsed.model).toBe("o3"); // top-level key untouched + expect(parsed.mcp_servers.clerk).toBeUndefined(); // removed + expect(parsed.mcp_servers.other).toEqual({ command: "npx" }); // sibling kept + }); + }); }); // These exercise the command-level "all clients" defaults, which touch the @@ -140,6 +179,7 @@ describe("install/uninstall across all clients (homedir + cwd redirected)", () = mkdir(vscodeUserDir(), { recursive: true }), mkdir(join(mockHome, ".codeium", "windsurf"), { recursive: true }), mkdir(join(mockHome, ".gemini"), { recursive: true }), + mkdir(join(mockHome, ".codex"), { recursive: true }), ]); await mcpInstall({ all: true, url: URL }); @@ -238,4 +278,23 @@ describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { const result = await checkMcp(); expect(result.status).toBe("warn"); }); + + test("warns when a second client points at a different, unreachable URL", async () => { + const BAD_URL = "https://staging.clerk.com/mcp"; + await mcpInstall({ client: ["cursor"], url: URL }); + await mcpInstall({ client: ["claude"], url: BAD_URL }); + captured.clear(); + // Healthy for the hosted URL, 503 for the second — proves every distinct URL is probed. + globalThis.fetch = (async (input: unknown) => + String(input) === BAD_URL + ? new Response("nope", { status: 503 }) + : new Response(HANDSHAKE_BODY, { + status: 200, + headers: { "content-type": "text/event-stream" }, + })) as unknown as typeof globalThis.fetch; + + const result = await checkMcp(); + expect(result.status).toBe("warn"); + expect(result.message).toContain(BAD_URL); + }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/vscode.ts b/packages/cli-core/src/commands/mcp/clients/vscode.ts index c3ecc553..570a9322 100644 --- a/packages/cli-core/src/commands/mcp/clients/vscode.ts +++ b/packages/cli-core/src/commands/mcp/clients/vscode.ts @@ -11,7 +11,7 @@ import { pathExists, vscodeUserDir } from "./paths.ts"; export const vscodeClient = makeJsonClient({ id: "vscode", - displayName: "VS Code", + displayName: "GitHub Copilot", scope: "user", activation: "Reload the VS Code window, then start the server from `MCP: List Servers` (sign in if prompted).", diff --git a/packages/cli-core/src/commands/mcp/index.ts b/packages/cli-core/src/commands/mcp/index.ts index 8d0d15bb..7646b689 100644 --- a/packages/cli-core/src/commands/mcp/index.ts +++ b/packages/cli-core/src/commands/mcp/index.ts @@ -3,14 +3,14 @@ import type { Program } from "../../cli-program.ts"; import { mcpInstall } from "./install.ts"; import { mcpList } from "./list.ts"; import { mcpUninstall } from "./uninstall.ts"; -import { CLIENT_IDS } from "./clients/registry.ts"; +import { CLIENT_ID_CHOICES } from "./clients/registry.ts"; export const mcp = { install: mcpInstall, list: mcpList, uninstall: mcpUninstall, }; -export { CLIENT_IDS } from "./clients/registry.ts"; +export { CLIENT_ID_CHOICES, CLIENT_IDS } from "./clients/registry.ts"; function collectOptionValues(value: string, previous: string[] = []): string[] { return [...previous, value]; @@ -35,7 +35,7 @@ export function registerMcp(program: Program): void { .description("Register the Clerk remote MCP server in supported clients") .addOption( createOption("--client ", "MCP client to target (repeatable). Default: all detected.") - .choices([...CLIENT_IDS]) + .choices([...CLIENT_ID_CHOICES]) .argParser(collectOptionValues) .default([] as string[]), ) @@ -72,7 +72,7 @@ export function registerMcp(program: Program): void { "--client ", "MCP client to target (repeatable). Default in human mode: pick from installed; in agent mode: all clients.", ) - .choices([...CLIENT_IDS]) + .choices([...CLIENT_ID_CHOICES]) .argParser(collectOptionValues) .default([] as string[]), ) diff --git a/packages/cli-core/src/commands/mcp/probe.ts b/packages/cli-core/src/commands/mcp/probe.ts index 09c922fa..03cac11a 100644 --- a/packages/cli-core/src/commands/mcp/probe.ts +++ b/packages/cli-core/src/commands/mcp/probe.ts @@ -20,7 +20,8 @@ export type McpProbeResult = // A hostile or wrong URL shouldn't hang the CLI: cap the probe so a slow or // never-ending response surfaces as a failure instead of blocking forever. -const PROBE_TIMEOUT_MS = 10_000; +// 5s covers a cold-start server while keeping `clerk doctor` snappy on a dead one. +const PROBE_TIMEOUT_MS = 5_000; const INITIALIZE_REQUEST = { jsonrpc: "2.0", diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index 693cb961..c6f8a264 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -6,7 +6,7 @@ import { getMcpUrl } from "../../lib/environment.ts"; import { CliError, ERROR_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { isAgent } from "../../mode.ts"; -import { CLIENT_IDS, CLIENTS, detectInstalledClients } from "./clients/registry.ts"; +import { CLIENT_ALIASES, CLIENT_IDS, CLIENTS, detectInstalledClients } from "./clients/registry.ts"; import type { ClientId, McpClient } from "./clients/types.ts"; export type McpOptions = { @@ -50,8 +50,11 @@ export function resolveName(options: McpOptions): string { export function resolveClients(ids: readonly string[]): McpClient[] { const byId = new Map(CLIENTS.map((c) => [c.id, c])); - return ids.map((id) => { - const client = byId.get(id); + const seen = new Set(); + // Dedupe by canonical id so `--client copilot --client vscode` (aliases of the + // same client) or a repeated flag operates on each client once. + return ids.flatMap((id) => { + const client = byId.get(CLIENT_ALIASES[id] ?? id); if (!client) { throwUsageError( `Unknown MCP client "${id}". Supported: ${CLIENT_IDS.join(", ")}.`, @@ -59,25 +62,28 @@ export function resolveClients(ids: readonly string[]): McpClient[] { ERROR_CODE.MCP_CLIENT_NOT_SUPPORTED, ); } - return client; + if (seen.has(client.id)) return []; + seen.add(client.id); + return [client]; }); } export async function pickClients( detected: McpClient[], message: string, - options: { autoSelectSingle?: boolean } = {}, + options: { autoSelectSingle?: boolean; required?: boolean; preselect?: boolean } = {}, ): Promise { if (detected.length === 0) return []; if (detected.length === 1 && options.autoSelectSingle) return detected; // Imported lazily (like `doctor` does) so the prompt layer stays off the // module-load path for non-interactive callers and tests. const { multiselect } = await import("../../lib/prompts.ts"); + const preselect = options.preselect ?? true; const selected = await multiselect({ message, options: detected.map((c) => ({ value: c.id, label: `${c.displayName} (${c.scope})` })), - initialValues: detected.map((c) => c.id), - required: true, + initialValues: preselect ? detected.map((c) => c.id) : [], + required: options.required ?? true, }); return resolveClients(selected); } diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index 56aeec07..56577b49 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -71,10 +71,12 @@ describe("mcp uninstall", () => { expect(payload.results).toEqual([expect.objectContaining({ client: "cursor", removed: true })]); }); - test("throws MCP_NOT_INSTALLED when nothing is registered", async () => { - await expect(mcpUninstall({ client: ["cursor"] })).rejects.toMatchObject({ - code: "mcp_not_installed", - }); + test("agent mode: reports removed:false when nothing is registered (no error)", async () => { + await mcpUninstall({ client: ["cursor"] }); + const payload = JSON.parse(captured.out) as { results: { client: string; removed: boolean }[] }; + expect(payload.results).toEqual([ + expect.objectContaining({ client: "cursor", removed: false }), + ]); }); test("respects --name", async () => { @@ -94,12 +96,11 @@ describe("mcp uninstall", () => { }); }); - test("human mode: nothing-to-remove throws without a contradictory success outro", async () => { + test("human mode: warns how to install when nothing is registered (no error)", async () => { mockIsAgent.mockReturnValue(false); - await expect(mcpUninstall({ client: ["cursor"] })).rejects.toMatchObject({ - code: "mcp_not_installed", - }); - expect(captured.err).not.toContain("Nothing to remove"); + await mcpUninstall({ client: ["cursor"] }); + expect(captured.err).toContain("clerk mcp install"); + expect(captured.err).not.toContain("Removing MCP entry"); // no success gutter for a no-op }); test("human mode: prints reload next steps after a successful removal", async () => { @@ -111,10 +112,10 @@ describe("mcp uninstall", () => { expect(captured.err).toContain("Reload Cursor"); }); - test("human mode: prompts to pick which installed clients to remove from", async () => { + test("human mode: removes the selected clients and leaves the rest", async () => { await mcpInstall({ client: ["cursor", "claude"], url: URL }); mockIsAgent.mockReturnValue(false); - mockMultiselect.mockResolvedValueOnce(["cursor"]); // pick only Cursor + mockMultiselect.mockResolvedValueOnce(["cursor"]); // select Cursor → remove Cursor captured.clear(); await mcpUninstall({}); @@ -123,11 +124,38 @@ describe("mcp uninstall", () => { const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { mcpServers: Record; }; - expect(cursorCfg.mcpServers.clerk).toBeUndefined(); // removed + expect(cursorCfg.mcpServers.clerk).toBeUndefined(); const claudeCfg = JSON.parse(await readFile(join(cwd, ".claude.json"), "utf8")) as { mcpServers: Record; }; - expect(claudeCfg.mcpServers.clerk).toBeDefined(); // untouched + expect(claudeCfg.mcpServers.clerk).toBeDefined(); + }); + + test("human mode: picker only lists clients that actually have the entry", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + mockIsAgent.mockReturnValue(false); + mockMultiselect.mockResolvedValueOnce([]); + captured.clear(); + + await mcpUninstall({}); + + const arg = mockMultiselect.mock.calls[0]![0] as { options: { value: string }[] }; + expect(arg.options.map((o) => o.value)).toEqual(["cursor"]); + }); + + test("human mode: selecting nothing removes nothing", async () => { + await mcpInstall({ client: ["cursor", "claude"], url: URL }); + mockIsAgent.mockReturnValue(false); + mockMultiselect.mockResolvedValueOnce([]); + captured.clear(); + + await mcpUninstall({}); + + expect(captured.err).toContain("Nothing removed"); + const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(cursorCfg.mcpServers.clerk).toBeDefined(); }); test("human mode: --all removes from every client without prompting", async () => { diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts index ef0b1acd..ebc0356e 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -3,9 +3,7 @@ */ import { cyan, dim, green, yellow } from "../../lib/color.ts"; -import { CliError, ERROR_CODE } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; -import { isAgent } from "../../mode.ts"; import { withGutter } from "../../lib/spinner.ts"; import { CLIENTS } from "./clients/registry.ts"; import type { McpClient, RemoveResult } from "./clients/types.ts"; @@ -23,6 +21,10 @@ function printResult(client: McpClient, result: RemoveResult): void { log.info(`${label}: ${result.removed ? green("removed") : yellow("not present")}`); } +function warnNotInstalled(name: string): void { + log.warn(`No \`${name}\` MCP entry is installed. Run \`clerk mcp install\` to add it.`); +} + async function installedClients(name: string, cwd: string): Promise { const present = await Promise.all( CLIENTS.map(async (client) => (await client.list(cwd)).some((entry) => entry.name === name)), @@ -30,37 +32,38 @@ async function installedClients(name: string, cwd: string): Promise return CLIENTS.filter((_, i) => present[i]); } -async function chooseClients(options: McpOptions, name: string, cwd: string): Promise { - if (options.client && options.client.length > 0) return resolveClients(options.client); - if (options.all || isAgent()) return Array.from(CLIENTS); - return pickClients(await installedClients(name, cwd), `Select clients to remove "${name}" from:`); -} - -export async function mcpUninstall(options: McpOptions = {}): Promise { - const name = resolveName(options); - const cwd = process.cwd(); - const json = wantsJson(options); - const notInstalled = new CliError(`No MCP entry named "${name}" found in any client.`, { - code: ERROR_CODE.MCP_NOT_INSTALLED, +// The checked clients are removed; nothing is pre-checked, so the safe default +// (submitting with no selection) removes nothing. +async function pickClientsToRemove(installed: McpClient[], name: string): Promise { + return pickClients(installed, `Select the clients to remove \`${name}\` from:`, { + required: false, + preselect: false, }); +} - const clients = await chooseClients(options, name, cwd); - if (clients.length === 0) throw notInstalled; - +async function removeFrom( + clients: McpClient[], + name: string, + cwd: string, + json: boolean, +): Promise { const settled = await settleClients(clients, (c) => c.remove(name, cwd)); const results = settled.map((s) => s.result); const removedCount = results.filter((r) => r.removed).length; if (json) { log.data(JSON.stringify({ name, results }, null, 2)); - if (removedCount === 0) throw notInstalled; + return; + } + + if (removedCount === 0) { + warnNotInstalled(name); return; } // Removing the config entry doesn't drop a live editor session — it lingers // until the editor reloads. Surface that as a next step per removed client. await withGutter(`Removing MCP entry ${cyan(name)}`, async ({ setNextSteps }) => { - if (removedCount === 0) throw notInstalled; settled.forEach(({ client, result }) => printResult(client, result)); setNextSteps( settled @@ -69,3 +72,33 @@ export async function mcpUninstall(options: McpOptions = {}): Promise { ); }); } + +export async function mcpUninstall(options: McpOptions = {}): Promise { + const name = resolveName(options); + const cwd = process.cwd(); + const json = wantsJson(options); + const explicit = + options.client && options.client.length > 0 ? resolveClients(options.client) : undefined; + + // Non-interactive: explicit `--client`, `--all`, agent mode, or `--json` + // operate directly on the targeted clients. + if (json || explicit || options.all) { + await removeFrom(explicit ?? Array.from(CLIENTS), name, cwd, json); + return; + } + + // Human, interactive: pick which installed clients to keep; remove the rest. + const installed = await installedClients(name, cwd); + if (installed.length === 0) { + warnNotInstalled(name); + return; + } + + const toRemove = await pickClientsToRemove(installed, name); + if (toRemove.length === 0) { + log.info("No clients selected. Nothing removed."); + return; + } + + await removeFrom(toRemove, name, cwd, json); +} diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index fcf5cab6..eeda834b 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -74,8 +74,6 @@ export const ERROR_CODE = { MCP_CLIENT_CONFIG_INVALID: "mcp_client_config_invalid", /** The provided `--url` is malformed or uses a non-http(s) scheme. */ MCP_URL_REQUIRED: "mcp_url_required", - /** No matching MCP entry to remove. */ - MCP_NOT_INSTALLED: "mcp_not_installed", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; From 705e4a17f3d4b6b49f27dd23ef9ce44bb358e58a Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Wed, 17 Jun 2026 17:19:59 -0300 Subject: [PATCH 12/21] feat(mcp): add `clerk mcp run` stdio bridge; route all clients through it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build the stdio<->Streamable-HTTP forwarder into the CLI (replacing the `npx mcp-remote` dependency): every client now installs `clerk mcp run --url` so the bridge ships with the CLI and future auth lands without a re-install. Bridge (run.ts), transport-only: - concurrent body drains — no head-of-line blocking behind a long SSE stream - splits JSON-RPC batch responses; guards JSON.parse; drops non-object frames - per-request 401-after-session and 404 session-expiry handling (no process kill) - abort-wired shutdown so a never-closing stream can't pin the process - sanitizes server-supplied protocol version before echoing it as a header - caps the stdin line buffer; stdout carries only JSON-RPC frames Clients: unified `withLegacyUrl` extractor keeps old native/`mcp-remote` entries round-tripping; `--force`/re-install upgrades legacy entries in place. `mcp run` opts out of the global `--input-json` stdin preprocessing. Also folds in review hardening to install/list/uninstall/doctor (interactive `--json` no longer prompts, `Promise.allSettled` per-client, atomic empty-key removal, EACCES classification, owner-only config perms). --- .changeset/mcp-install.md | 2 +- .../cli-core/src/commands/doctor/check-mcp.ts | 19 +- packages/cli-core/src/commands/mcp/README.md | 51 ++- .../src/commands/mcp/clients/claude.ts | 14 +- .../commands/mcp/clients/clerk-run.test.ts | 38 ++ .../src/commands/mcp/clients/clerk-run.ts | 43 +++ .../src/commands/mcp/clients/clients.test.ts | 16 +- .../src/commands/mcp/clients/codex.ts | 13 +- .../src/commands/mcp/clients/cursor.ts | 15 +- .../src/commands/mcp/clients/gemini.ts | 22 +- .../src/commands/mcp/clients/json-config.ts | 43 ++- ...son-client.test.ts => make-client.test.ts} | 28 +- .../{make-json-client.ts => make-client.ts} | 28 +- .../src/commands/mcp/clients/paths.ts | 31 +- .../src/commands/mcp/clients/toml-config.ts | 17 +- .../commands/mcp/clients/user-scope.test.ts | 74 +++- .../src/commands/mcp/clients/vscode.ts | 13 +- .../src/commands/mcp/clients/windsurf.ts | 15 +- packages/cli-core/src/commands/mcp/index.ts | 21 ++ .../cli-core/src/commands/mcp/install.test.ts | 21 +- packages/cli-core/src/commands/mcp/install.ts | 7 +- packages/cli-core/src/commands/mcp/probe.ts | 13 +- .../cli-core/src/commands/mcp/run.test.ts | 345 ++++++++++++++++++ packages/cli-core/src/commands/mcp/run.ts | 343 +++++++++++++++++ packages/cli-core/src/commands/mcp/shared.ts | 12 +- .../src/commands/mcp/uninstall.test.ts | 33 +- .../cli-core/src/commands/mcp/uninstall.ts | 13 +- packages/cli-core/src/lib/input-json.test.ts | 21 ++ packages/cli-core/src/lib/input-json.ts | 3 +- 29 files changed, 1177 insertions(+), 137 deletions(-) create mode 100644 packages/cli-core/src/commands/mcp/clients/clerk-run.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/clerk-run.ts rename packages/cli-core/src/commands/mcp/clients/{make-json-client.test.ts => make-client.test.ts} (85%) rename packages/cli-core/src/commands/mcp/clients/{make-json-client.ts => make-client.ts} (85%) create mode 100644 packages/cli-core/src/commands/mcp/run.test.ts create mode 100644 packages/cli-core/src/commands/mcp/run.ts diff --git a/.changeset/mcp-install.md b/.changeset/mcp-install.md index 897d39d9..54876ea2 100644 --- a/.changeset/mcp-install.md +++ b/.changeset/mcp-install.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, 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. +Add `clerk mcp install`, `list`, `uninstall`, and `run` to connect the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) to Claude Code, Cursor, GitHub Copilot (VS Code; `--client vscode` or `--client copilot`), Windsurf, Gemini, and Codex. Each client is configured to launch `clerk mcp run` — a built-in stdio bridge that forwards the editor's stdio JSON-RPC to the remote server over HTTP (the job `npx mcp-remote` did, now with no npx dependency), so `clerk` must be on your PATH. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`, `~/.codex/config.toml`), available across every project regardless of where you run the CLI. `clerk doctor` gains an MCP reachability check that probes each configured server via the MCP `initialize` handshake when an entry is installed. The URL resolves in order: `--url` > the `CLERK_MCP_URL` override (for local worker development) > the active env profile's `mcpUrl` field > the hosted server, so `clerk mcp install` works with no flags. The bridge is transport-only for now; against an auth-required server it surfaces a clear error rather than signing in. diff --git a/packages/cli-core/src/commands/doctor/check-mcp.ts b/packages/cli-core/src/commands/doctor/check-mcp.ts index f8894249..5778be37 100644 --- a/packages/cli-core/src/commands/doctor/check-mcp.ts +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -13,11 +13,11 @@ import type { CheckResult } from "./types.ts"; const NAME = "MCP server"; type UrlProbe = { url: string; result: McpProbeResult }; +type ReachableProbe = { url: string; result: Extract }; -function describeReachable(probes: UrlProbe[]): string { - return probes - .map(({ url, result }) => (result.ok ? `${result.serverName} (${url})` : url)) - .join(", "); +// Narrowed to the reachable variant: only called once every probe succeeded. +function describeReachable(probes: ReachableProbe[]): string { + return probes.map(({ url, result }) => `${result.serverName} (${url})`).join(", "); } function describeFailure(result: McpProbeResult): string { @@ -40,9 +40,10 @@ export async function checkMcp(): Promise { 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 unreachable = probes.filter((p): p is UrlProbe => !p.result.ok); + if (unreachable.length === 0) { + const reachable = probes.filter((p): p is ReachableProbe => p.result.ok); + return { name: NAME, status: "pass", message: `Reachable — ${describeReachable(reachable)}` }; } const subject = @@ -50,8 +51,8 @@ export async function checkMcp(): Promise { return { name: NAME, status: "warn", - message: `${subject} not reachable (${unreachable.url})`, - detail: describeFailure(unreachable.result), + message: `${subject} not reachable (${unreachable.map((p) => p.url).join(", ")})`, + detail: unreachable.map((p) => `${p.url}: ${describeFailure(p.result)}`).join("; "), remedy: "Verify the server is running, or re-run `clerk mcp install` if the URL changed.", }; } diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index f0bc0670..5536bba6 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -38,9 +38,32 @@ 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. +Codex is the one TOML-backed client (`[mcp_servers.]`); rewriting +`config.toml` does not preserve comments. + +## How clients connect (the stdio bridge) + +Every client installs the same stdio descriptor — it launches `clerk mcp run +--url ` rather than pointing the editor at the remote URL directly: + +```jsonc +{ "command": "clerk", "args": ["mcp", "run", "--url", "https://mcp.clerk.com/mcp"] } +``` + +`clerk mcp run` ([run.ts](./run.ts)) is a stdio↔Streamable-HTTP proxy — the same +job `npx mcp-remote` does, but built into the CLI so there's no npx dependency +and the bridge is pinned to the installed CLI version. Because the wiring lives +in the CLI, future auth support lands against this same command with no +re-install. `clerk` must be on the editor's `PATH`. + +VS Code tags the entry with `"type": "stdio"`; the others omit it. Codex writes +the equivalent TOML (`command`/`args` under `[mcp_servers.]`). + +> **Auth (current limitation):** `clerk mcp run` is transport-only today — it +> does not perform OAuth. Against an auth-required server (including the hosted +> `mcp.clerk.com`) it surfaces a clear error rather than signing in. Point +> `--url` at a server that doesn't require auth (e.g. a local worker at +> `http://localhost:8787/mcp`) until built-in sign-in ships. ## Subcommands @@ -64,15 +87,31 @@ 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. +live once you **reload the editor**, which then spawns `clerk mcp run` (so +`clerk` must be on the editor's `PATH`). ### `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 run` + +The stdio bridge that installed clients spawn — **not meant to be run by hand**. +It reads newline-delimited JSON-RPC from stdin, forwards each message to the +remote server over the Streamable HTTP transport (POST; JSON or SSE responses), +threads the `Mcp-Session-Id`, opens the optional server→client SSE stream, and +writes replies to stdout. stdout carries **only** JSON-RPC frames; all +diagnostics go to stderr. + +| Flag | Description | +| --------------- | ------------------------------------------------------------ | +| `--url ` | Server URL to bridge to. Same resolution order as `install`. | +| `--name ` | Accepted for parity with `install`. Default: `clerk`. | + +Transport-only: a `401`/`403` from the upstream is surfaced as an error +(`mcp_client_config_invalid`) rather than triggering a sign-in flow. + ### `clerk mcp uninstall` Remove the entry. In human mode with no `--client`/`--all`, it prompts with a diff --git a/packages/cli-core/src/commands/mcp/clients/claude.ts b/packages/cli-core/src/commands/mcp/clients/claude.ts index cc372a97..de98b350 100644 --- a/packages/cli-core/src/commands/mcp/clients/claude.ts +++ b/packages/cli-core/src/commands/mcp/clients/claude.ts @@ -1,21 +1,23 @@ /** * 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: "" }`. + * rather than gated behind a per-project `.mcp.json` approval. Installs the + * `clerk mcp run` stdio bridge; legacy `{ type: "http", url }` entries still + * round-trip on list/uninstall. */ -import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { clerkRunArgs, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { makeJsonClient } from "./make-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).", + activation: "Restart Claude Code, then run `/mcp` to connect (`clerk` must be on your PATH).", topKey: "mcpServers", - encode: (url) => ({ type: "http", url }), - extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + encode: (url) => ({ command: RUN_COMMAND, args: clerkRunArgs(url) }), + extractUrl: (d) => withLegacyUrl(d), configPath: () => userPath(".claude.json"), detect: () => pathExists(userPath(".claude")), }); diff --git a/packages/cli-core/src/commands/mcp/clients/clerk-run.test.ts b/packages/cli-core/src/commands/mcp/clients/clerk-run.test.ts new file mode 100644 index 00000000..28997a2e --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/clerk-run.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { clerkRunArgs, extractClerkRunUrl, RUN_COMMAND } from "./clerk-run.ts"; + +const URL = "https://mcp.clerk.com/mcp"; + +describe("clerk-run descriptor", () => { + test("clerkRunArgs builds the run invocation", () => { + expect(clerkRunArgs(URL)).toEqual(["mcp", "run", "--url", URL]); + }); + + test("round-trips the URL it encodes", () => { + expect(extractClerkRunUrl({ command: RUN_COMMAND, args: clerkRunArgs(URL) })).toBe(URL); + }); + + test("accepts the --url=value inline form", () => { + expect(extractClerkRunUrl({ command: "clerk", args: ["mcp", "run", `--url=${URL}`] })).toBe( + URL, + ); + }); + + test("ignores a vscode-style descriptor with an extra type field", () => { + expect(extractClerkRunUrl({ type: "stdio", command: "clerk", args: clerkRunArgs(URL) })).toBe( + URL, + ); + }); + + test.each([ + ["a different command", { command: "npx", args: ["-y", "mcp-remote", URL] }], + ["no --url flag", { command: "clerk", args: ["mcp", "run"] }], + ["a trailing --url with no value", { command: "clerk", args: ["mcp", "run", "--url"] }], + ["an empty --url= inline form", { command: "clerk", args: ["mcp", "run", "--url="] }], + ["non-string args", { command: "clerk", args: [1, 2, 3] }], + ["not an object", "clerk mcp run"], + ["null", null], + ])("returns undefined for %s", (_label, descriptor) => { + expect(extractClerkRunUrl(descriptor)).toBeUndefined(); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/clients/clerk-run.ts b/packages/cli-core/src/commands/mcp/clients/clerk-run.ts new file mode 100644 index 00000000..5b7c2dd6 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/clerk-run.ts @@ -0,0 +1,43 @@ +/** + * The stdio descriptor every client now installs: instead of pointing at the + * remote URL (or shelling out to `npx mcp-remote`), each config launches + * `clerk mcp run --url `, the bridge in `../run.ts`. Centralized here so + * the command shape and its reverse parser stay in lockstep across clients. + */ + +import { hasStringProp } from "./make-client.ts"; + +/** The binary clients spawn. Must be on the user's PATH. */ +export const RUN_COMMAND = "clerk"; + +export function clerkRunArgs(url: string): string[] { + return ["mcp", "run", "--url", url]; +} + +/** Recover the URL from a `clerk mcp run --url ` descriptor, or undefined. */ +export function extractClerkRunUrl(descriptor: unknown): string | undefined { + if (typeof descriptor !== "object" || descriptor === null) return undefined; + const { command, args } = descriptor as { command?: unknown; args?: unknown }; + if (command !== RUN_COMMAND) return undefined; + if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string")) return undefined; + const flagIndex = args.indexOf("--url"); + if (flagIndex !== -1 && args[flagIndex + 1]) return args[flagIndex + 1]; + const inline = args.find((arg: string) => arg.startsWith("--url=")); + // `|| undefined` so an empty `--url=` reports "absent", matching the contract. + return inline ? inline.slice("--url=".length) || undefined : undefined; +} + +/** + * `extractUrl` for the clients that share the new bridge shape and fall back to + * a legacy single-key descriptor (`{ url }` for most, `{ serverUrl }` for + * Windsurf) so existing installs still round-trip on list/uninstall. + */ +export function withLegacyUrl( + descriptor: unknown, + legacyKey: "url" | "serverUrl" = "url", +): string | undefined { + return ( + extractClerkRunUrl(descriptor) ?? + (hasStringProp(descriptor, legacyKey) ? descriptor[legacyKey] : undefined) + ); +} diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts index 3d45a8f4..90a4e59b 100644 --- a/packages/cli-core/src/commands/mcp/clients/clients.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -22,43 +22,47 @@ useCaptureLog(); const URL = "https://mcp.clerk.com/mcp"; +// Every client now installs the same `clerk mcp run` stdio bridge; VS Code tags +// stdio servers with a `type` discriminator. +const RUN_SHAPE = { command: "clerk", args: ["mcp", "run", "--url", URL] }; + // 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. +// specific user-global config file and encodes the bridge its own way. const cases = [ { name: "claude", client: claudeClient, expectedPath: () => join(mockHome, ".claude.json"), topKey: "mcpServers", - shape: { type: "http", url: URL }, + shape: RUN_SHAPE, }, { name: "cursor", client: cursorClient, expectedPath: () => join(mockHome, ".cursor", "mcp.json"), topKey: "mcpServers", - shape: { url: URL }, + shape: RUN_SHAPE, }, { name: "vscode", client: vscodeClient, expectedPath: () => join(vscodeUserDir(), "mcp.json"), topKey: "servers", - shape: { type: "http", url: URL }, + shape: { type: "stdio", ...RUN_SHAPE }, }, { name: "windsurf", client: windsurfClient, expectedPath: () => join(mockHome, ".codeium", "windsurf", "mcp_config.json"), topKey: "mcpServers", - shape: { serverUrl: URL }, + shape: RUN_SHAPE, }, { name: "gemini", client: geminiClient, expectedPath: () => join(mockHome, ".gemini", "settings.json"), topKey: "mcpServers", - shape: { command: "npx", args: ["-y", "mcp-remote", URL] }, + shape: RUN_SHAPE, }, ]; diff --git a/packages/cli-core/src/commands/mcp/clients/codex.ts b/packages/cli-core/src/commands/mcp/clients/codex.ts index b0efc5dd..b4952bba 100644 --- a/packages/cli-core/src/commands/mcp/clients/codex.ts +++ b/packages/cli-core/src/commands/mcp/clients/codex.ts @@ -1,20 +1,21 @@ /** * 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). + * Installs the `clerk mcp run` stdio bridge (`command` + `args`); legacy bare + * `{ url }` entries still round-trip on list/uninstall. */ -import { hasStringProp, makeTomlClient } from "./make-json-client.ts"; +import { clerkRunArgs, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { makeTomlClient } from "./make-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.", + activation: "Restart Codex (`clerk` must be on your PATH).", topKey: "mcp_servers", - encode: (url) => ({ url }), - extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + encode: (url) => ({ command: RUN_COMMAND, args: clerkRunArgs(url) }), + extractUrl: (d) => withLegacyUrl(d), 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 index f48b6a0e..2d1e562e 100644 --- a/packages/cli-core/src/commands/mcp/clients/cursor.ts +++ b/packages/cli-core/src/commands/mcp/clients/cursor.ts @@ -1,20 +1,23 @@ /** * 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. + * every project rather than only the cwd it was installed from. Installs the + * `clerk mcp run` stdio bridge; legacy bare `{ url }` entries still round-trip + * on list/uninstall. */ -import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { clerkRunArgs, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { makeJsonClient } from "./make-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).", + activation: + "Reload Cursor, then enable the server under `Settings → MCP` (`clerk` must be on your PATH).", topKey: "mcpServers", - encode: (url) => ({ url }), - extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + encode: (url) => ({ command: RUN_COMMAND, args: clerkRunArgs(url) }), + extractUrl: (d) => withLegacyUrl(d), configPath: () => userPath(".cursor", "mcp.json"), detect: () => pathExists(userPath(".cursor")), }); diff --git a/packages/cli-core/src/commands/mcp/clients/gemini.ts b/packages/cli-core/src/commands/mcp/clients/gemini.ts index 271ed885..f23ce2c1 100644 --- a/packages/cli-core/src/commands/mcp/clients/gemini.ts +++ b/packages/cli-core/src/commands/mcp/clients/gemini.ts @@ -1,10 +1,12 @@ /** - * 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. + * Writes to `~/.gemini/settings.json`. Gemini has no native HTTP transport, so + * it always needs a stdio bridge — now `clerk mcp run` instead of the previous + * `npx -y mcp-remote `. Legacy `mcp-remote` entries still round-trip on + * list/uninstall so existing installs remain manageable. */ -import { makeJsonClient } from "./make-json-client.ts"; +import { clerkRunArgs, extractClerkRunUrl, RUN_COMMAND } from "./clerk-run.ts"; +import { makeJsonClient } from "./make-client.ts"; import { pathExists, userPath } from "./paths.ts"; interface McpRemoteEntry { @@ -12,9 +14,8 @@ interface McpRemoteEntry { 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. +// Match the exact legacy shape `{command: "npx", args: ["-y", "mcp-remote", ]}`. +// Permissive matching would round-trip unrelated stdio bridges as Clerk entries. function isMcpRemoteEntry(value: unknown): value is McpRemoteEntry { if (typeof value !== "object" || value === null) return false; const candidate = value as { command?: unknown; args?: unknown }; @@ -28,11 +29,10 @@ 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.", + activation: "Restart Gemini (`clerk` must be on your PATH).", topKey: "mcpServers", - encode: (url) => ({ command: "npx", args: ["-y", "mcp-remote", url] }), - extractUrl: (d) => (isMcpRemoteEntry(d) ? d.args[2] : undefined), + encode: (url) => ({ command: RUN_COMMAND, args: clerkRunArgs(url) }), + extractUrl: (d) => extractClerkRunUrl(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 index 4d376eaa..62dbca54 100644 --- a/packages/cli-core/src/commands/mcp/clients/json-config.ts +++ b/packages/cli-core/src/commands/mcp/clients/json-config.ts @@ -9,20 +9,32 @@ * formatting and a 2-space indent. */ -import { mkdir } from "node:fs/promises"; +import { chmod, 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 { +export interface ConfigRecord { [key: string]: unknown; } -export async function readJsonConfig(path: string): Promise { +/** Read a config file's text, or `undefined` if absent. An unreadable file + * (e.g. EACCES) surfaces as MCP_CLIENT_CONFIG_INVALID, not a raw OS error. */ +export async function readConfigText(path: string): Promise { const file = Bun.file(path); - if (!(await file.exists())) return {}; - const text = await file.text(); - if (text.trim().length === 0) return {}; + if (!(await file.exists())) return undefined; + try { + return await file.text(); + } catch (error) { + throw new CliError(`Could not read ${path}: ${errorMessage(error)}`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } +} + +export async function readJsonConfig(path: string): Promise { + const text = await readConfigText(path); + if (text === undefined || text.trim().length === 0) return {}; try { const parsed: unknown = JSON.parse(text); if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { @@ -30,7 +42,7 @@ export async function readJsonConfig(path: string): Promise { code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, }); } - return parsed as JsonConfig; + return parsed as ConfigRecord; } catch (error) { if (error instanceof CliError) throw error; throw new CliError(`Could not parse ${path} as JSON: ${errorMessage(error)}`, { @@ -39,10 +51,21 @@ export async function readJsonConfig(path: string): Promise { } } -export async function writeJsonConfig(path: string, config: JsonConfig): Promise { +export async function writeJsonConfig(path: string, config: ConfigRecord): Promise { log.debug(`mcp: write ${path}`); - await mkdir(dirname(path), { recursive: true }); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await Bun.write(path, JSON.stringify(config, null, 2) + "\n"); + await restrictPermissions(path); +} + +/** Owner-only (0600) perms so editor-written OAuth tokens can't land in a + * world-readable file on shared hosts. Best-effort: ignored without POSIX modes. */ +export async function restrictPermissions(path: string): Promise { + try { + await chmod(path, 0o600); + } catch (error) { + log.debug(`mcp: chmod ${path} failed — ${errorMessage(error)}`); + } } /** @@ -50,7 +73,7 @@ export async function writeJsonConfig(path: string, config: JsonConfig): Promise * Throws MCP_CLIENT_CONFIG_INVALID if the path exists but is not an object. */ export function getServerMap( - config: JsonConfig, + config: ConfigRecord, key: string, configPath: string, ): 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-client.test.ts similarity index 85% rename from packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts rename to packages/cli-core/src/commands/mcp/clients/make-client.test.ts index e9f3c360..f1ed32e3 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-json-client.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-client.test.ts @@ -17,7 +17,9 @@ useCaptureLog(); const URL_A = "https://mcp.clerk.com/mcp"; const URL_B = "http://localhost:8787/mcp"; -describe("make-json-client (via cursor)", () => { +const runShape = (url: string) => ({ command: "clerk", args: ["mcp", "run", "--url", url] }); + +describe("make-client (via cursor)", () => { let cwd: string; beforeEach(async () => { @@ -31,7 +33,7 @@ describe("make-json-client (via cursor)", () => { async function read(): Promise<{ otherTopLevel?: string; - mcpServers?: Record; + mcpServers?: Record; }> { const text = await readFile(join(cwd, ".cursor", "mcp.json"), "utf8"); return JSON.parse(text); @@ -42,7 +44,7 @@ describe("make-json-client (via cursor)", () => { 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 }); + expect(written.mcpServers?.clerk).toEqual(runShape(URL_A)); }); test("preserves unrelated keys in the file", async () => { @@ -56,7 +58,7 @@ describe("make-json-client (via cursor)", () => { 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 }); + expect(written.mcpServers?.clerk).toEqual(runShape(URL_A)); }); test("returns unchanged when the URL already matches", async () => { @@ -65,6 +67,18 @@ describe("make-json-client (via cursor)", () => { expect(result.status).toBe("unchanged"); }); + test("upgrades a legacy same-URL entry to the bridge shape without --force", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + join(cwd, ".cursor", "mcp.json"), + JSON.stringify({ mcpServers: { clerk: { url: URL_A } } }), + ); + const result = await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + expect(result.status).toBe("updated"); + const written = await read(); + expect(written.mcpServers?.clerk).toEqual(runShape(URL_A)); + }); + 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); @@ -72,7 +86,7 @@ describe("make-json-client (via cursor)", () => { // 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); + expect(written.mcpServers?.clerk).toEqual(runShape(URL_A)); }); test("overwrites when URL conflicts and force is true", async () => { @@ -80,7 +94,7 @@ describe("make-json-client (via cursor)", () => { 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); + expect(written.mcpServers?.clerk).toEqual(runShape(URL_B)); }); test("rejects a config whose top-level is not an object", async () => { @@ -97,7 +111,7 @@ describe("make-json-client (via cursor)", () => { 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 }); + expect(written.mcpServers?.["toString"]).toEqual(runShape(URL_A)); }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts b/packages/cli-core/src/commands/mcp/clients/make-client.ts similarity index 85% rename from packages/cli-core/src/commands/mcp/clients/make-json-client.ts rename to packages/cli-core/src/commands/mcp/clients/make-client.ts index 13d6bc3e..40746856 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-json-client.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-client.ts @@ -12,7 +12,7 @@ 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 { getServerMap, readJsonConfig, writeJsonConfig, type ConfigRecord } from "./json-config.ts"; import { readTomlConfig, writeTomlConfig } from "./toml-config.ts"; import type { ClientId, @@ -52,8 +52,8 @@ interface FileClientSpec { /** Read/write codec abstracting the on-disk format (JSON vs TOML). */ interface ConfigCodec { - read: (path: string) => Promise; - write: (path: string, config: JsonConfig) => Promise; + read: (path: string) => Promise; + write: (path: string, config: ConfigRecord) => Promise; } const JSON_CODEC: ConfigCodec = { read: readJsonConfig, write: writeJsonConfig }; @@ -87,13 +87,18 @@ function makeFileClient(spec: FileClientSpec, codec: ConfigCodec): McpClient { // 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; + const desired = spec.encode(entry.url); if (existing !== undefined) { const existingUrl = spec.extractUrl(existing); if (existingUrl === entry.url) { - return { client: spec.id, configPath, status: "unchanged" }; - } - if (!force) { + // Same server. Already in the exact desired shape → no-op; an older + // descriptor format (e.g. a legacy `{ url }` or `npx mcp-remote` + // entry) is upgraded in place — that's not a conflict, so no --force. + if (JSON.stringify(existing) === JSON.stringify(desired)) { + return { client: spec.id, configPath, status: "unchanged" }; + } + } else if (!force) { return { client: spec.id, configPath, @@ -103,8 +108,10 @@ function makeFileClient(spec: FileClientSpec, codec: ConfigCodec): McpClient { } } - const desired = spec.encode(entry.url); - const next: JsonConfig = { ...config, [spec.topKey]: { ...servers, [entry.name]: desired } }; + const next: ConfigRecord = { + ...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}"`); @@ -119,7 +126,10 @@ function makeFileClient(spec: FileClientSpec, codec: ConfigCodec): McpClient { return { client: spec.id, configPath, removed: false }; } const { [name]: _removed, ...rest } = servers; - const next: JsonConfig = { ...config, [spec.topKey]: rest }; + // Drop the now-empty top-level map rather than leaving `{ "mcpServers": {} }`. + const { [spec.topKey]: _topKey, ...withoutTopKey } = config; + const next: ConfigRecord = + Object.keys(rest).length === 0 ? withoutTopKey : { ...config, [spec.topKey]: rest }; await codec.write(configPath, next); log.debug(`mcp: ${spec.id} removed "${name}"`); return { client: spec.id, configPath, removed: true }; diff --git a/packages/cli-core/src/commands/mcp/clients/paths.ts b/packages/cli-core/src/commands/mcp/clients/paths.ts index 36d7fbb8..51112458 100644 --- a/packages/cli-core/src/commands/mcp/clients/paths.ts +++ b/packages/cli-core/src/commands/mcp/clients/paths.ts @@ -6,6 +6,7 @@ * config lives under the OS-specific app-support dir (see `vscodeUserDir`). */ +import { existsSync } from "node:fs"; import { stat } from "node:fs/promises"; import { homedir, platform } from "node:os"; import { join } from "node:path"; @@ -15,24 +16,40 @@ export function userPath(...segments: string[]): string { } /** - * 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. + * Candidate VS Code user-config dirs in priority order. Linux has three: the + * standard XDG location plus Flatpak and Snap sandboxes, which redirect config + * under their own per-app trees. The first existing one wins; the standard XDG + * path is the fallback for a fresh install. */ -export function vscodeUserDir(): string { +function vscodeUserDirCandidates(): 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"); + return [join(appData || join(home, "AppData", "Roaming"), "Code", "User")]; case "darwin": - return join(home, "Library", "Application Support", "Code", "User"); + return [join(home, "Library", "Application Support", "Code", "User")]; default: - return join(xdgConfigHome || join(home, ".config"), "Code", "User"); + return [ + join(xdgConfigHome || join(home, ".config"), "Code", "User"), + join(home, ".var", "app", "com.visualstudio.code", "config", "Code", "User"), + join(home, "snap", "code", "current", ".config", "Code", "User"), + ]; } } +/** + * 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 (or a Flatpak/Snap sandbox) on Linux. Probed + * synchronously so detection and the write path resolve to the same directory. + */ +export function vscodeUserDir(): string { + const candidates = vscodeUserDirCandidates(); + return candidates.find((dir) => existsSync(dir)) ?? candidates[0]!; +} + /** * Returns true when *anything* exists at `path` — file, directory, symlink. * Detection only needs to know "did the user install this tool?", which is diff --git a/packages/cli-core/src/commands/mcp/clients/toml-config.ts b/packages/cli-core/src/commands/mcp/clients/toml-config.ts index a7a11ba1..865154ef 100644 --- a/packages/cli-core/src/commands/mcp/clients/toml-config.ts +++ b/packages/cli-core/src/commands/mcp/clients/toml-config.ts @@ -17,13 +17,11 @@ 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"; +import { readConfigText, restrictPermissions, type ConfigRecord } 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 {}; +export async function readTomlConfig(path: string): Promise { + const text = await readConfigText(path); + if (text === undefined || 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 @@ -33,7 +31,7 @@ export async function readTomlConfig(path: string): Promise { code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, }); } - return parsed as JsonConfig; + return parsed as ConfigRecord; } catch (error) { if (error instanceof CliError) throw error; throw new CliError(`Could not parse ${path} as TOML: ${errorMessage(error)}`, { @@ -42,8 +40,9 @@ export async function readTomlConfig(path: string): Promise { } } -export async function writeTomlConfig(path: string, config: JsonConfig): Promise { +export async function writeTomlConfig(path: string, config: ConfigRecord): Promise { log.debug(`mcp: write ${path}`); - await mkdir(dirname(path), { recursive: true }); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await Bun.write(path, stringify(config) + "\n"); + await restrictPermissions(path); } diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index e721f6fd..b82fd942 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -34,6 +34,8 @@ const captured = useCaptureLog(); const URL = "https://mcp.clerk.com/mcp"; const ALL_CLIENT_IDS = ["claude", "cursor", "vscode", "windsurf", "gemini", "codex"]; +const runShape = (url: string) => ({ command: "clerk", args: ["mcp", "run", "--url", url] }); + describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { beforeEach(async () => { mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-home-")); @@ -44,12 +46,12 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { }); describe("gemini", () => { - test("encodes the mcp-remote stdio-bridge shape", async () => { + test("encodes the clerk-run 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] }); + expect(parsed.mcpServers.clerk).toEqual(runShape(URL)); }); test("round-trips the URL back out of args[2] on list", async () => { @@ -80,12 +82,12 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { }); describe("windsurf", () => { - test("encodes the serverUrl shape and round-trips it on list", async () => { + test("encodes the clerk-run 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 } }; + mcpServers: { clerk: { command: string; args: string[] } }; }; - expect(parsed.mcpServers.clerk).toEqual({ serverUrl: URL }); + expect(parsed.mcpServers.clerk).toEqual(runShape(URL)); const entries = await windsurfClient.list("/ignored"); expect(entries).toEqual([ @@ -95,12 +97,14 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { }); describe("codex", () => { - test("writes the HTTP url under the [mcp_servers.] TOML table", async () => { + test("writes the clerk-run bridge 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 }); + const parsed = parseToml(text) as { + mcp_servers: { clerk: { command: string; args: string[] } }; + }; + expect(parsed.mcp_servers.clerk).toEqual(runShape(URL)); }); test("round-trips the URL back out on list", async () => { @@ -111,6 +115,27 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { ]); }); + test("preserves unrelated top-level keys on upsert", async () => { + const dir = join(mockHome, ".codex"); + await mkdir(dir, { recursive: true }); + await Bun.write(join(dir, "config.toml"), 'model = "o3"\n'); + + await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + + const parsed = parseToml(await Bun.file(join(dir, "config.toml")).text()) as { + model: string; + mcp_servers: { clerk: { command: string; args: string[] } }; + }; + expect(parsed.model).toBe("o3"); + expect(parsed.mcp_servers.clerk).toEqual(runShape(URL)); + }); + + test("returns unchanged when re-upserting the same URL", async () => { + await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + const result = await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + expect(result.status).toBe("unchanged"); + }); + test("preserves unrelated tables when removing the entry", async () => { const dir = join(mockHome, ".codex"); await mkdir(dir, { recursive: true }); @@ -185,10 +210,28 @@ describe("install/uninstall across all clients (homedir + cwd redirected)", () = 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.map((r) => r.client)).toEqual(ALL_CLIENT_IDS); expect(payload.results.every((r) => r.status === "added")).toBe(true); }); + test("--all with no detected client throws mcp_no_client_detected", async () => { + await expect(mcpInstall({ all: true, url: URL })).rejects.toMatchObject({ + code: "mcp_no_client_detected", + }); + }); + + test("auto-selects the sole detected client without prompting", async () => { + await mkdir(join(mockHome, ".cursor"), { recursive: true }); + mockIsAgent.mockReturnValue(false); + + await mcpInstall({ url: URL }); + + const parsed = JSON.parse(await Bun.file(join(mockHome, ".cursor", "mcp.json")).text()) as { + mcpServers: { clerk: unknown }; + }; + expect(parsed.mcpServers.clerk).toEqual(runShape(URL)); + }); + test("uninstall with no --client removes from every client", async () => { await mcpInstall({ client: ["cursor", "gemini"], url: URL }); captured.clear(); @@ -297,4 +340,17 @@ describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { expect(result.status).toBe("warn"); expect(result.message).toContain(BAD_URL); }); + + test("names every distinct unreachable URL, not just the first", async () => { + const OTHER_URL = "https://staging.clerk.com/mcp"; + await mcpInstall({ client: ["cursor"], url: URL }); + await mcpInstall({ client: ["claude"], url: OTHER_URL }); + captured.clear(); + stubFetchWith("nope", { status: 503 }); + + const result = await checkMcp(); + expect(result.status).toBe("warn"); + expect(result.message).toContain(URL); + expect(result.message).toContain(OTHER_URL); + }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/vscode.ts b/packages/cli-core/src/commands/mcp/clients/vscode.ts index 570a9322..29f0d8b2 100644 --- a/packages/cli-core/src/commands/mcp/clients/vscode.ts +++ b/packages/cli-core/src/commands/mcp/clients/vscode.ts @@ -2,11 +2,14 @@ * 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 }`. + * `mcpServers`) and tags stdio servers with `type: "stdio"`. Installs the + * `clerk mcp run` bridge; legacy `{ type: "http", url }` entries still + * round-trip on list/uninstall. */ import { join } from "node:path"; -import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { clerkRunArgs, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { makeJsonClient } from "./make-client.ts"; import { pathExists, vscodeUserDir } from "./paths.ts"; export const vscodeClient = makeJsonClient({ @@ -14,10 +17,10 @@ export const vscodeClient = makeJsonClient({ displayName: "GitHub Copilot", scope: "user", activation: - "Reload the VS Code window, then start the server from `MCP: List Servers` (sign in if prompted).", + "Reload the VS Code window, then start the server from `MCP: List Servers` (`clerk` must be on your PATH).", topKey: "servers", - encode: (url) => ({ type: "http", url }), - extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined), + encode: (url) => ({ type: "stdio", command: RUN_COMMAND, args: clerkRunArgs(url) }), + extractUrl: (d) => withLegacyUrl(d), configPath: () => join(vscodeUserDir(), "mcp.json"), detect: () => pathExists(vscodeUserDir()), }); diff --git a/packages/cli-core/src/commands/mcp/clients/windsurf.ts b/packages/cli-core/src/commands/mcp/clients/windsurf.ts index d3138047..9210438a 100644 --- a/packages/cli-core/src/commands/mcp/clients/windsurf.ts +++ b/packages/cli-core/src/commands/mcp/clients/windsurf.ts @@ -1,19 +1,22 @@ /** - * Writes to `~/.codeium/windsurf/mcp_config.json` (user scope). Server - * descriptor uses `serverUrl`, not `url`. + * Writes to `~/.codeium/windsurf/mcp_config.json` (user scope). Installs the + * `clerk mcp run` stdio bridge; legacy `{ serverUrl }` entries still round-trip + * on list/uninstall. */ -import { hasStringProp, makeJsonClient } from "./make-json-client.ts"; +import { clerkRunArgs, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { makeJsonClient } from "./make-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).", + activation: + "Reload Windsurf, then turn on the server in `Cascade → MCP` (`clerk` must be on your PATH).", topKey: "mcpServers", - encode: (url) => ({ serverUrl: url }), - extractUrl: (d) => (hasStringProp(d, "serverUrl") ? d.serverUrl : undefined), + encode: (url) => ({ command: RUN_COMMAND, args: clerkRunArgs(url) }), + extractUrl: (d) => withLegacyUrl(d, "serverUrl"), configPath: () => userPath(".codeium", "windsurf", "mcp_config.json"), detect: () => pathExists(userPath(".codeium", "windsurf")), }); diff --git a/packages/cli-core/src/commands/mcp/index.ts b/packages/cli-core/src/commands/mcp/index.ts index 7646b689..46e907e8 100644 --- a/packages/cli-core/src/commands/mcp/index.ts +++ b/packages/cli-core/src/commands/mcp/index.ts @@ -2,12 +2,14 @@ import { createOption } from "@commander-js/extra-typings"; import type { Program } from "../../cli-program.ts"; import { mcpInstall } from "./install.ts"; import { mcpList } from "./list.ts"; +import { mcpRun } from "./run.ts"; import { mcpUninstall } from "./uninstall.ts"; import { CLIENT_ID_CHOICES } from "./clients/registry.ts"; export const mcp = { install: mcpInstall, list: mcpList, + run: mcpRun, uninstall: mcpUninstall, }; export { CLIENT_ID_CHOICES, CLIENT_IDS } from "./clients/registry.ts"; @@ -28,6 +30,10 @@ export function registerMcp(program: Program): void { }, { command: "clerk mcp list", description: "Show registered Clerk entries" }, { command: "clerk mcp uninstall", description: "Remove the Clerk entry from all clients" }, + { + command: "clerk mcp run --url https://mcp.clerk.com/mcp", + description: "stdio bridge an editor launches (not run by hand)", + }, ]); mcpCmd @@ -64,6 +70,21 @@ export function registerMcp(program: Program): void { .setExamples([{ command: "clerk mcp list", description: "List Clerk entries everywhere" }]) .action((options) => mcp.list(options)); + mcpCmd + .command("run") + .description( + "stdio bridge to the remote MCP server (clients spawn this; not meant to be run by hand)", + ) + .option("--url ", "MCP server URL to bridge to (default: from active env profile)") + .option("--name ", 'Entry name, for parity with install (default: "clerk")') + .setExamples([ + { + command: "clerk mcp run --url https://mcp.clerk.com/mcp", + description: "Forward stdio JSON-RPC to the remote server", + }, + ]) + .action((options) => mcp.run(options)); + mcpCmd .command("uninstall") .description("Remove the Clerk MCP entry from supported clients") diff --git a/packages/cli-core/src/commands/mcp/install.test.ts b/packages/cli-core/src/commands/mcp/install.test.ts index 722dc034..061d774d 100644 --- a/packages/cli-core/src/commands/mcp/install.test.ts +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -23,6 +23,8 @@ const { mcpInstall } = await import("./install.ts"); const URL_A = "https://mcp.clerk.com/mcp"; const URL_B = "http://localhost:8787/mcp"; +const runShape = (url: string) => ({ command: "clerk", args: ["mcp", "run", "--url", url] }); + describe("mcp install", () => { const captured = useCaptureLog(); let cwd: string; @@ -46,9 +48,9 @@ describe("mcp install", () => { await mcpInstall({ client: ["cursor"], url: URL_A }); const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { - mcpServers: { clerk: { url: string } }; + mcpServers: { clerk: unknown }; }; - expect(parsed.mcpServers.clerk).toEqual({ url: URL_A }); + expect(parsed.mcpServers.clerk).toEqual(runShape(URL_A)); }); test("emits JSON to stdout in agent mode and skips intro/outro", async () => { @@ -109,17 +111,17 @@ describe("mcp install", () => { }; expect(payload.results[0]?.status).toBe("updated"); const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { - mcpServers: { clerk: { url: string } }; + mcpServers: { clerk: unknown }; }; - expect(parsed.mcpServers.clerk.url).toBe(URL_B); + expect(parsed.mcpServers.clerk).toEqual(runShape(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; + mcpServers: Record; }; - expect(parsed.mcpServers["clerk-staging"]).toEqual({ url: URL_A }); + expect(parsed.mcpServers["clerk-staging"]).toEqual(runShape(URL_A)); expect(parsed.mcpServers.clerk).toBeUndefined(); }); @@ -169,6 +171,12 @@ describe("mcp install", () => { }); }); + test("rejects a URL with embedded credentials", async () => { + await expect( + mcpInstall({ client: ["cursor"], url: "https://token@mcp.clerk.com/mcp" }), + ).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"); @@ -216,5 +224,6 @@ describe("mcp install", () => { await expect(mcpInstall({ client: ["cursor"], url: URL_A })).rejects.toMatchObject({ code: "mcp_client_config_invalid", }); + expect(captured.err).toContain("Cursor"); }); }); diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts index dffa5508..4bac7af0 100644 --- a/packages/cli-core/src/commands/mcp/install.ts +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -8,7 +8,6 @@ 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, @@ -23,7 +22,11 @@ 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); + // `wantsJson` covers `--json` and agent mode, so non-interactive callers never + // block on the picker. + if (options.client?.length || options.all || wantsJson(options)) { + return targetClients(options, cwd); + } return pickClients(await detectInstalledClients(cwd), "Select MCP clients to install into:", { autoSelectSingle: true, }); diff --git a/packages/cli-core/src/commands/mcp/probe.ts b/packages/cli-core/src/commands/mcp/probe.ts index 03cac11a..d10e61ab 100644 --- a/packages/cli-core/src/commands/mcp/probe.ts +++ b/packages/cli-core/src/commands/mcp/probe.ts @@ -57,6 +57,17 @@ function parseHandshake(contentType: string, body: string): unknown { return data === "" ? undefined : safeJsonParse(data); } +// Strip control chars so a server-supplied name can't smuggle terminal escape +// sequences into `doctor` output. +function stripControl(value: string): string { + let out = ""; + for (const char of value) { + const code = char.codePointAt(0)!; + if (code >= 0x20 && code !== 0x7f) out += char; + } + return out; +} + // 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 { @@ -66,7 +77,7 @@ function readServerName(payload: unknown): string | 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; + return typeof name === "string" ? stripControl(name) : undefined; } export async function probeMcp(url: string): Promise { diff --git a/packages/cli-core/src/commands/mcp/run.test.ts b/packages/cli-core/src/commands/mcp/run.test.ts new file mode 100644 index 00000000..4be62647 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/run.test.ts @@ -0,0 +1,345 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import { mcpRun } from "./run.ts"; + +const URL = "https://mcp.clerk.com/mcp"; + +const INIT_RESULT = { + jsonrpc: "2.0", + id: 1, + result: { protocolVersion: "2025-06-18", serverInfo: { name: "Clerk MCP Server" } }, +}; + +interface Recorded { + url: string; + method: string; + headers: Headers; + body?: string; +} + +let requests: Recorded[]; + +function stub(handler: (req: Recorded, postIndex: number) => Response): void { + let posts = 0; + stubFetch(async (input: unknown, init: RequestInit | undefined) => { + const method = init?.method ?? "GET"; + const rec: Recorded = { + url: String(input), + method, + headers: new Headers(init?.headers), + body: init?.body ? String(init.body) : undefined, + }; + requests.push(rec); + return handler(rec, method === "POST" ? posts++ : -1); + }); +} + +function json(payload: unknown, headers: Record = {}): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "content-type": "application/json", ...headers }, + }); +} + +function sse(payload: unknown, headers: Record = {}): Response { + return new Response(`event: message\ndata: ${JSON.stringify(payload)}\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream", ...headers }, + }); +} + +const noServerStream = (req: Recorded): Response | undefined => + req.method === "GET" ? new Response(null, { status: 405 }) : undefined; + +async function* lines(...messages: unknown[]): AsyncGenerator { + for (const message of messages) yield JSON.stringify(message) + "\n"; +} + +function framesFrom(chunks: string[]): Array> { + return chunks + .join("") + .trim() + .split("\n") + .filter((line) => line.length > 0) + .map((line) => JSON.parse(line) as Record); +} + +describe("mcp run (stdio bridge)", () => { + useCaptureLog(); + const originalFetch = globalThis.fetch; + + beforeEach(() => { + requests = []; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test("proxies the initialize handshake and threads the session id onward", async () => { + stub((req) => noServerStream(req) ?? json(INIT_RESULT, { "mcp-session-id": "sess-1" })); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { + input: lines( + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { jsonrpc: "2.0", id: 2, method: "tools/list" }, + ), + write: (c) => out.push(c), + }, + ); + + expect(framesFrom(out)[0]).toEqual(INIT_RESULT); + const posts = requests.filter((r) => r.method === "POST"); + expect(posts[0]!.headers.get("mcp-session-id")).toBeNull(); + expect(posts[1]!.headers.get("mcp-session-id")).toBe("sess-1"); + expect(posts[1]!.headers.get("mcp-protocol-version")).toBe("2025-06-18"); + }); + + test("forwards an initialize answered over SSE", async () => { + stub((req) => noServerStream(req) ?? sse(INIT_RESULT, { "mcp-session-id": "sess-1" })); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { + input: lines({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + write: (c) => out.push(c), + }, + ); + + expect(framesFrom(out)[0]).toEqual(INIT_RESULT); + }); + + test("proxies a tools/list request and reply", async () => { + const toolsResult = { jsonrpc: "2.0", id: 2, result: { tools: [{ name: "create_user" }] } }; + stub((req, postIndex) => { + const blocked = noServerStream(req); + if (blocked) return blocked; + return postIndex === 0 ? json(INIT_RESULT, { "mcp-session-id": "s" }) : json(toolsResult); + }); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { + input: lines( + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { jsonrpc: "2.0", id: 2, method: "tools/list" }, + ), + write: (c) => out.push(c), + }, + ); + + expect(framesFrom(out)).toContainEqual(toolsResult); + }); + + test("forwards a server-initiated message from the GET event stream", async () => { + const notification = { + jsonrpc: "2.0", + method: "notifications/message", + params: { level: "info" }, + }; + stub((req) => { + if (req.method === "GET") return sse(notification); + return json(INIT_RESULT, { "mcp-session-id": "s" }); + }); + const out: string[] = []; + // Keep stdin open until the server push lands, mirroring a real session + // (the bridge cancels the GET stream on stdin EOF). + let seen: () => void; + const delivered = new Promise((resolve) => (seen = resolve)); + const write = (c: string) => { + out.push(c); + if (c.includes("notifications/message")) seen(); + }; + async function* input(): AsyncGenerator { + yield JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }) + "\n"; + await delivered; + } + + await mcpRun({ url: URL }, { input: input(), write }); + + expect(framesFrom(out)).toContainEqual(notification); + }); + + test("returns cleanly when stdin closes with no input", async () => { + stub(() => new Response(null, { status: 405 })); + const out: string[] = []; + + await mcpRun({ url: URL }, { input: lines(), write: (c) => out.push(c) }); + + expect(out.join("")).toBe(""); + }); + + test("surfaces a 401 from the upstream as a CliError", async () => { + stub((req) => noServerStream(req) ?? new Response("unauthorized", { status: 401 })); + + await expect( + mcpRun( + { url: URL }, + { + input: lines({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + write: () => {}, + }, + ), + ).rejects.toMatchObject({ code: "mcp_client_config_invalid" }); + }); + + test("clears the session and replies with an error when it expires (404)", async () => { + stub((req, postIndex) => { + const blocked = noServerStream(req); + if (blocked) return blocked; + return postIndex === 0 + ? json(INIT_RESULT, { "mcp-session-id": "s" }) + : new Response("gone", { status: 404 }); + }); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { + input: lines( + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { jsonrpc: "2.0", id: 2, method: "tools/list" }, + ), + write: (c) => out.push(c), + }, + ); + + const expiry = framesFrom(out).find((f) => f.id === 2); + const error = expiry?.error as { code?: number } | undefined; + expect(error?.code).toBe(-32001); + }); + + test("drops the session header on the request after a 404 expiry", async () => { + stub((req, postIndex) => { + const blocked = noServerStream(req); + if (blocked) return blocked; + if (postIndex === 0) return json(INIT_RESULT, { "mcp-session-id": "s" }); + if (postIndex === 1) return new Response("gone", { status: 404 }); + return json({ jsonrpc: "2.0", id: 3, result: {} }); + }); + + await mcpRun( + { url: URL }, + { + input: lines( + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { jsonrpc: "2.0", id: 2, method: "tools/list" }, + { jsonrpc: "2.0", id: 3, method: "ping" }, + ), + write: () => {}, + }, + ); + + const posts = requests.filter((r) => r.method === "POST"); + expect(posts[1]!.headers.get("mcp-session-id")).toBe("s"); + expect(posts[2]!.headers.get("mcp-session-id")).toBeNull(); + }); + + test("a 401 after the session is established replies per-request instead of crashing", async () => { + stub((req, postIndex) => { + const blocked = noServerStream(req); + if (blocked) return blocked; + return postIndex === 0 + ? json(INIT_RESULT, { "mcp-session-id": "s" }) + : new Response("forbidden", { status: 401 }); + }); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { + input: lines( + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { jsonrpc: "2.0", id: 2, method: "tools/call" }, + ), + write: (c) => out.push(c), + }, + ); + + const err = framesFrom(out).find((f) => f.id === 2); + expect((err?.error as { code?: number } | undefined)?.code).toBe(-32001); + }); + + test("splits a JSON-RPC batch response into individual frames", async () => { + const batch = [ + { jsonrpc: "2.0", id: 2, result: { a: 1 } }, + { jsonrpc: "2.0", id: 3, result: { b: 2 } }, + ]; + stub((req, postIndex) => { + const blocked = noServerStream(req); + if (blocked) return blocked; + return postIndex === 0 ? json(INIT_RESULT, { "mcp-session-id": "s" }) : json(batch); + }); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { + input: lines( + { jsonrpc: "2.0", id: 1, method: "initialize", params: {} }, + { jsonrpc: "2.0", id: 2, method: "tools/list" }, + ), + write: (c) => out.push(c), + }, + ); + + const frames = framesFrom(out); + expect(frames.find((f) => f.id === 2)).toEqual(batch[0]!); + expect(frames.find((f) => f.id === 3)).toEqual(batch[1]!); + }); + + test("replies with an error instead of crashing on a non-JSON 200 body", async () => { + stub( + (req) => + noServerStream(req) ?? + new Response("upstream is on fire", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { input: lines({ jsonrpc: "2.0", id: 1, method: "tools/list" }), write: (c) => out.push(c) }, + ); + + expect((framesFrom(out)[0]?.error as { code?: number } | undefined)?.code).toBe(-32000); + }); + + test("drops a non-object JSON body rather than emitting it", async () => { + stub( + (req) => + noServerStream(req) ?? + new Response("[1,2,3]", { status: 200, headers: { "content-type": "application/json" } }), + ); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { input: lines({ jsonrpc: "2.0", id: 1, method: "tools/list" }), write: (c) => out.push(c) }, + ); + + expect(out.join("")).toBe(""); + }); + + test("targets the --url value", async () => { + const custom = "http://localhost:9000/mcp"; + stub((req) => noServerStream(req) ?? json(INIT_RESULT)); + + await mcpRun( + { url: custom }, + { + input: lines({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + write: () => {}, + }, + ); + + expect(requests.find((r) => r.method === "POST")?.url).toBe(custom); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/run.ts b/packages/cli-core/src/commands/mcp/run.ts new file mode 100644 index 00000000..4a873e93 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/run.ts @@ -0,0 +1,343 @@ +/** + * `clerk mcp run` — a stdio bridge to the Clerk remote MCP server. + * + * AI clients that launch an MCP server as a subprocess speak newline-delimited + * JSON-RPC over stdio. This command forwards that stream to the remote server + * over the Streamable HTTP transport (POST per message, SSE or JSON responses) + * and writes replies back to stdout — the same job `npx mcp-remote` does, but + * built into the CLI so it's installed without npx and can become auth-aware + * later without a re-install. + * + * Transport-only: an auth-required server surfaces an error rather than running + * an OAuth flow (that lands in a follow-up against this same command). + * + * stdout carries ONLY JSON-RPC frames — every diagnostic goes to stderr via + * `log.*`. A stray write to stdout corrupts the channel and breaks the client. + */ + +import { CliError, ERROR_CODE, errorMessage } from "../../lib/errors.ts"; +import { loggedFetch } from "../../lib/fetch.ts"; +import { log } from "../../lib/log.ts"; +import { resolveUrl, type McpOptions } from "./shared.ts"; + +/** Injectable streams so the bridge can be driven in-process by tests. */ +export interface RunStreams { + input?: AsyncIterable; + write?: (chunk: string) => void; +} + +type JsonRpcMessage = { id?: string | number; method?: string; result?: unknown }; +type Session = { id?: string; protocolVersion?: string }; +type Emit = (message: unknown) => Promise; + +const SESSION_HEADER = "mcp-session-id"; +// Cap an unterminated stdin line so a misbehaving client can't grow the buffer +// without bound. MCP frames are small; 16 MiB is far above any real message. +const MAX_LINE_BYTES = 16 * 1024 * 1024; +// Normalize CRLF and bare CR to LF so SSE event boundaries (`\n\n`) always match. +const EOL = /\r\n?/g; + +export async function mcpRun(options: McpOptions = {}, streams: RunStreams = {}): Promise { + const url = resolveUrl(options); + const input = streams.input ?? process.stdin; + const writeRaw = streams.write ?? ((chunk: string) => void process.stdout.write(chunk)); + + const session: Session = {}; + + // Serialize stdout writes: concurrent SSE drains and the server→client stream + // all emit, and a frame must never interleave with another. + let writeTail: Promise = Promise.resolve(); + const emit: Emit = (message) => { + captureProtocolVersion(message, session); + const line = JSON.stringify(message) + "\n"; + writeTail = writeTail.then(() => writeRaw(line)); + return writeTail; + }; + + // MCP allows batch responses as a top-level JSON array; fan each item out as + // its own frame, dropping anything that isn't a routable JSON-RPC object. + const emitPayload = async (parsed: unknown): Promise => { + for (const item of Array.isArray(parsed) ? parsed : [parsed]) { + if (typeof item === "object" && item !== null && !Array.isArray(item)) await emit(item); + else log.warn("mcp run: dropping non-object frame from upstream"); + } + }; + + const abort = new AbortController(); + // A drain that ends because we're shutting down is expected; anything else is + // a real (non-fatal) error worth surfacing under --verbose. + const suppress = (work: Promise): Promise => + work.catch((error: unknown) => { + if (!abort.signal.aborted) + log.debug(`mcp run: response drain error — ${errorMessage(error)}`); + }); + + // Bodies (SSE streams and slow JSON) drain concurrently so one long response + // never blocks the next request — head-of-line free. + const inflight = new Set>(); + const track = (work: Promise): void => { + const p = suppress(work).finally(() => inflight.delete(p)); + inflight.add(p); + }; + + let serverStream: Promise | undefined; + const ensureServerStream = (): void => { + if (serverStream) return; + serverStream = suppress(listenForServerMessages(url, session, emitPayload, abort.signal)); + }; + + try { + for await (const message of readJsonRpcLines(input, MAX_LINE_BYTES)) { + await dispatch(message, { + url, + session, + emit, + emitPayload, + track, + ensureServerStream, + signal: abort.signal, + }); + } + } finally { + abort.abort(); + await Promise.allSettled([...inflight, serverStream]); + await writeTail; + } +} + +interface DispatchCtx { + url: string; + session: Session; + emit: Emit; + emitPayload: Emit; + track: (work: Promise) => void; + ensureServerStream: () => void; + signal: AbortSignal; +} + +async function dispatch(message: JsonRpcMessage, ctx: DispatchCtx): Promise { + const { url, session, emit, emitPayload, track, ensureServerStream, signal } = ctx; + let response: Response; + try { + response = await loggedFetch(url, { + tag: "mcp", + method: "POST", + headers: requestHeaders(session), + body: JSON.stringify(message), + signal, + }); + } catch (error) { + if (signal.aborted) return; + log.warn(`mcp run: request failed — ${errorMessage(error)}`); + await emitError(message, emit, -32000, `Upstream request failed: ${errorMessage(error)}`); + return; + } + + const newSession = response.headers.get(SESSION_HEADER); + if (newSession) { + session.id = newSession; + ensureServerStream(); + } + + if (response.status === 401 || response.status === 403) { + // Before a session exists the server needs up-front auth we can't provide — + // fail clearly. After `initialize`, a scoped 401 is a per-request error, not + // a reason to kill the bridge and every other in-flight request with it. + if (session.id === undefined) { + throw new CliError( + `The MCP server at ${url} requires authentication, which \`clerk mcp run\` does not yet provide. ` + + "Point --url at a server that doesn't require auth, or wait for a release with built-in sign-in.", + { code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID }, + ); + } + await emitError( + message, + emit, + -32001, + "The MCP server requires authentication for this request.", + ); + return; + } + + // A stale session id returns 404; drop session state and let the client + // re-initialize (the next session may negotiate a different protocol version). + if (response.status === 404 && session.id) { + session.id = undefined; + session.protocolVersion = undefined; + await emitError(message, emit, -32001, "MCP session expired — reinitialize the connection."); + return; + } + + // Notifications and responses the server merely accepts carry no body. + if (response.status === 202 || response.status === 204) return; + + if (!response.ok) { + await emitError(message, emit, -32000, `Upstream returned HTTP ${response.status}.`); + return; + } + + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("text/event-stream")) { + track(pipeEventStream(response, emitPayload, signal)); + return; + } + track(forwardJsonBody(response, message, emit, emitPayload)); +} + +async function forwardJsonBody( + response: Response, + message: JsonRpcMessage, + emit: Emit, + emitPayload: Emit, +): Promise { + const text = await response.text(); + if (text.trim().length === 0) return; + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch { + await emitError(message, emit, -32000, "Upstream returned a non-JSON response."); + return; + } + await emitPayload(parsed); +} + +async function emitError( + message: JsonRpcMessage, + emit: Emit, + code: number, + text: string, +): Promise { + // Only requests (with an id) expect a reply; notifications don't. + if (message.id === undefined) return; + await emit({ jsonrpc: "2.0", id: message.id, error: { code, message: text } }); +} + +function requestHeaders(session: Session): Record { + return { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + ...sessionHeaders(session), + }; +} + +function sessionHeaders(session: Session): Record { + const headers: Record = {}; + if (session.id) headers["Mcp-Session-Id"] = session.id; + if (session.protocolVersion) headers["MCP-Protocol-Version"] = session.protocolVersion; + return headers; +} + +// The negotiated protocol version is echoed on every subsequent request header. +// It comes from the (untrusted) server body, so reject anything with control +// chars — a CRLF would otherwise inject headers or wedge every later request. +function captureProtocolVersion(message: unknown, session: Session): void { + if (typeof message !== "object" || message === null) return; + const result = (message as { result?: unknown }).result; + if (typeof result !== "object" || result === null) return; + const version = (result as { protocolVersion?: unknown }).protocolVersion; + if (typeof version === "string" && /^[\x20-\x7e]+$/.test(version)) { + session.protocolVersion = version; + } +} + +// Open the optional GET stream for server-initiated messages. Servers that +// don't support it answer non-2xx; treat that as "nothing to stream". +async function listenForServerMessages( + url: string, + session: Session, + emitPayload: Emit, + signal: AbortSignal, +): Promise { + const response = await loggedFetch(url, { + tag: "mcp", + method: "GET", + headers: { Accept: "text/event-stream", ...sessionHeaders(session) }, + signal, + }); + if (!response.ok) return; + if ((response.headers.get("content-type") ?? "").includes("text/event-stream")) { + await pipeEventStream(response, emitPayload, signal); + } +} + +/** Read newline-delimited JSON-RPC frames from a byte/string stream. */ +export async function* readJsonRpcLines( + input: AsyncIterable, + maxLineBytes = MAX_LINE_BYTES, +): AsyncGenerator { + const decoder = new TextDecoder(); + let buffer = ""; + for await (const chunk of input) { + buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }); + let newline: number; + while ((newline = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + const parsed = parseLine(line); + if (parsed) yield parsed; + } + if (buffer.length > maxLineBytes) { + log.warn("mcp run: discarding oversized stdin line"); + buffer = ""; + } + } + const parsed = parseLine(buffer.trim()); + if (parsed) yield parsed; +} + +function parseLine(line: string): JsonRpcMessage | undefined { + if (line.length === 0) return undefined; + try { + return JSON.parse(line) as JsonRpcMessage; + } catch { + log.warn(`mcp run: ignoring non-JSON line on stdin`); + return undefined; + } +} + +/** Parse an SSE stream, emitting each event's JSON `data:` payload. */ +export async function pipeEventStream( + response: Response, + emitPayload: Emit, + signal?: AbortSignal, +): Promise { + const reader = response.body?.getReader(); + if (!reader) return; + // Belt-and-suspenders cancel on shutdown so a never-closing stream can't pin + // the process even if the fetch signal doesn't propagate to the body reader. + const cancel = () => void reader.cancel().catch(() => {}); + if (signal?.aborted) cancel(); + else signal?.addEventListener("abort", cancel, { once: true }); + const decoder = new TextDecoder(); + let buffer = ""; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }).replace(EOL, "\n"); + let boundary: number; + while ((boundary = buffer.indexOf("\n\n")) !== -1) { + await emitEvent(buffer.slice(0, boundary), emitPayload); + buffer = buffer.slice(boundary + 2); + } + } + await emitEvent(buffer, emitPayload); + } catch { + // Aborted on shutdown or the stream errored — nothing more to drain. + } +} + +async function emitEvent(rawEvent: string, emitPayload: Emit): Promise { + const data = rawEvent + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .join("\n"); + if (data.length === 0) return; + try { + await emitPayload(JSON.parse(data)); + } catch { + log.warn(`mcp run: ignoring malformed SSE data frame`); + } +} diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index c6f8a264..9271af09 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -41,7 +41,17 @@ export function resolveUrl(options: McpOptions): string { ERROR_CODE.MCP_URL_REQUIRED, ); } - return candidate; + // Reject userinfo so a credential can't be written into configs or echoed to + // stdout; clients authenticate via OAuth, not the URL. + if (parsed.username !== "" || parsed.password !== "") { + throwUsageError( + "MCP URL must not embed credentials. Remove the user info — the editor signs in via OAuth on first connect.", + undefined, + ERROR_CODE.MCP_URL_REQUIRED, + ); + } + // Normalized form so idempotency doesn't treat `HTTPS://Host` and `https://host` as different. + return parsed.href; } export function resolveName(options: McpOptions): string { diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index 56577b49..5bb8d06d 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +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"; @@ -53,9 +53,24 @@ describe("mcp uninstall", () => { await mcpUninstall({ client: ["cursor"] }); const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { - mcpServers: Record; + mcpServers?: Record; }; - expect(parsed.mcpServers.clerk).toBeUndefined(); + // Removing the only entry drops the now-empty `mcpServers` key entirely + // rather than leaving `{ "mcpServers": {} }` behind. + expect(parsed.mcpServers).toBeUndefined(); + }); + + test("removes the healthy client and warns when another's config is corrupt", async () => { + await mcpInstall({ client: ["claude"], url: URL }); + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "{ not json"); + captured.clear(); + + await mcpUninstall({ client: ["cursor", "claude"] }); + + const payload = JSON.parse(captured.out) as { results: { client: string; removed: boolean }[] }; + expect(payload.results).toEqual([expect.objectContaining({ client: "claude", removed: true })]); + expect(captured.err).toContain("Cursor"); }); test("emits JSON results on stdout in agent mode", async () => { @@ -85,9 +100,9 @@ describe("mcp uninstall", () => { await mcpUninstall({ client: ["cursor"], name: "clerk-staging" }); const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { - mcpServers: Record; + mcpServers?: Record; }; - expect(parsed.mcpServers["clerk-staging"]).toBeUndefined(); + expect(parsed.mcpServers?.["clerk-staging"]).toBeUndefined(); }); test("rejects an unknown --client id", async () => { @@ -122,9 +137,9 @@ describe("mcp uninstall", () => { expect(mockMultiselect).toHaveBeenCalledTimes(1); const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { - mcpServers: Record; + mcpServers?: Record; }; - expect(cursorCfg.mcpServers.clerk).toBeUndefined(); + expect(cursorCfg.mcpServers?.clerk).toBeUndefined(); const claudeCfg = JSON.parse(await readFile(join(cwd, ".claude.json"), "utf8")) as { mcpServers: Record; }; @@ -167,8 +182,8 @@ describe("mcp uninstall", () => { expect(mockMultiselect).not.toHaveBeenCalled(); const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { - mcpServers: Record; + mcpServers?: Record; }; - expect(cursorCfg.mcpServers.clerk).toBeUndefined(); + expect(cursorCfg.mcpServers?.clerk).toBeUndefined(); }); }); diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts index ebc0356e..e6d58d1b 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -3,6 +3,7 @@ */ import { cyan, dim, green, yellow } from "../../lib/color.ts"; +import { errorMessage } from "../../lib/errors.ts"; import { log } from "../../lib/log.ts"; import { withGutter } from "../../lib/spinner.ts"; import { CLIENTS } from "./clients/registry.ts"; @@ -26,10 +27,14 @@ function warnNotInstalled(name: string): void { } 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]); + // Settle, not all: one client's unreadable config warns instead of aborting detection. + const settled = await Promise.allSettled(CLIENTS.map((c) => c.list(cwd))); + return CLIENTS.filter((client, i) => { + const outcome = settled[i]!; + if (outcome.status === "fulfilled") return outcome.value.some((entry) => entry.name === name); + log.warn(`${client.displayName}: could not read MCP config — ${errorMessage(outcome.reason)}`); + return false; + }); } // The checked clients are removed; nothing is pre-checked, so the safe default diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts index 6d3a2d2f..800a99d5 100644 --- a/packages/cli-core/src/lib/input-json.test.ts +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -49,6 +49,27 @@ describe("expandInputJson", () => { expect(result).toEqual(["clerk", "init", "--yes"]); }); + test("leaves `mcp run` stdin untouched even when piped", async () => { + // `mcp run` streams JSON-RPC on stdin; reaching the stdin auto-read here + // would consume the stream. It must return unchanged without reading. + process.stdin.isTTY = false; + const argv = ["clerk", "mcp", "run", "--url", "https://mcp.clerk.com/mcp"]; + expect(await expandInputJson(argv)).toEqual(argv); + }); + + test("bypasses `mcp run` even with global flags before the command", async () => { + process.stdin.isTTY = false; + const argv = ["clerk", "--mode", "agent", "mcp", "run", "--url", "https://mcp.clerk.com/mcp"]; + expect(await expandInputJson(argv)).toEqual(argv); + }); + + test("does NOT bypass when `mcp run` appears only as an option value", async () => { + // `--name mcp run` on an unrelated command must still expand --input-json. + const argv = ["clerk", "init", "--name", "mcp", "run", "--input-json", '{"yes":true}']; + const result = await expandInputJson(argv); + expect(result).toEqual(["clerk", "init", "--name", "mcp", "run", "--yes"]); + }); + test("expands string values to flags", async () => { const argv = ["clerk", "init", "--input-json", '{"framework":"next"}']; const result = await expandInputJson(argv); diff --git a/packages/cli-core/src/lib/input-json.ts b/packages/cli-core/src/lib/input-json.ts index 6674ba56..6346bd69 100644 --- a/packages/cli-core/src/lib/input-json.ts +++ b/packages/cli-core/src/lib/input-json.ts @@ -124,7 +124,8 @@ function requireValue(argv: string[], idx: number): string { * Stdin is only consumed when the value is the explicit `-` marker * (`--input-json -`). Piped stdin is never read implicitly, so shell loops * (`while read … | clerk …`) and commands that read their own stdin (e.g. - * `cat body.json | clerk api …`) are left untouched. + * `cat body.json | clerk api …`, or the `clerk mcp run` stdio bridge) are left + * untouched. * * If `--input-json` is not present, returns the original array unchanged. */ From af01f018450eac834e759f1cc8065e9b940f3447 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 18 Jun 2026 09:17:36 -0300 Subject: [PATCH 13/21] =?UTF-8?q?fix(mcp):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20atomic=20config=20write,=20resilient=20emit=20chain?= =?UTF-8?q?,=20ordering=20comment,=20sync=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - writeJsonConfig: write to a sibling .clerk-tmp- file then rename so concurrent readers (e.g. Claude Code) never see a partial ~/.claude.json - emit write chain: catch EPIPE / write errors so a single failure doesn't wedge every subsequent frame - forwardJsonBody: note that protocol-version capture ordering relies on the MCP client awaiting the initialize reply before sending the next request - GLOBAL_FLAGS_WITH_VALUE: export the set and add a test that fails if a new root value-option in cli-program.ts is not listed there --- .../src/commands/mcp/clients/json-config.ts | 16 +++++++++++++--- packages/cli-core/src/commands/mcp/run.ts | 10 +++++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/clients/json-config.ts b/packages/cli-core/src/commands/mcp/clients/json-config.ts index 62dbca54..20aeb466 100644 --- a/packages/cli-core/src/commands/mcp/clients/json-config.ts +++ b/packages/cli-core/src/commands/mcp/clients/json-config.ts @@ -9,7 +9,7 @@ * formatting and a 2-space indent. */ -import { chmod, mkdir } from "node:fs/promises"; +import { chmod, mkdir, rename, unlink, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { log } from "../../../lib/log.ts"; import { CliError, ERROR_CODE, errorMessage } from "../../../lib/errors.ts"; @@ -53,8 +53,18 @@ export async function readJsonConfig(path: string): Promise { export async function writeJsonConfig(path: string, config: ConfigRecord): Promise { log.debug(`mcp: write ${path}`); - await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - await Bun.write(path, JSON.stringify(config, null, 2) + "\n"); + const dir = dirname(path); + await mkdir(dir, { recursive: true, mode: 0o700 }); + // Atomic write: write to a sibling temp file then rename so a concurrent + // reader (e.g. Claude Code) never sees a partial file. + const tmp = `${path}.clerk-tmp-${process.pid}`; + try { + await writeFile(tmp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); + await rename(tmp, path); + } catch (error) { + await unlink(tmp).catch(() => {}); + throw error; + } await restrictPermissions(path); } diff --git a/packages/cli-core/src/commands/mcp/run.ts b/packages/cli-core/src/commands/mcp/run.ts index 4a873e93..fe693e67 100644 --- a/packages/cli-core/src/commands/mcp/run.ts +++ b/packages/cli-core/src/commands/mcp/run.ts @@ -50,7 +50,12 @@ export async function mcpRun(options: McpOptions = {}, streams: RunStreams = {}) const emit: Emit = (message) => { captureProtocolVersion(message, session); const line = JSON.stringify(message) + "\n"; - writeTail = writeTail.then(() => writeRaw(line)); + // Swallow write errors (e.g. EPIPE) so one failed frame doesn't wedge the chain. + writeTail = writeTail + .then(() => writeRaw(line)) + .catch((err: unknown) => { + log.debug(`mcp run: write error — ${errorMessage(err)}`); + }); return writeTail; }; @@ -181,6 +186,9 @@ async function dispatch(message: JsonRpcMessage, ctx: DispatchCtx): Promise Date: Mon, 22 Jun 2026 17:55:03 -0300 Subject: [PATCH 14/21] fix(mcp): atomic write for Codex TOML config Write to a sibling .clerk-tmp- file then rename so an interrupted write never leaves a partial ~/.codex/config.toml (mirrors the same fix already applied to writeJsonConfig for the JSON clients). --- .../src/commands/mcp/clients/toml-config.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/clients/toml-config.ts b/packages/cli-core/src/commands/mcp/clients/toml-config.ts index 865154ef..c0641e69 100644 --- a/packages/cli-core/src/commands/mcp/clients/toml-config.ts +++ b/packages/cli-core/src/commands/mcp/clients/toml-config.ts @@ -12,7 +12,7 @@ * JSON clients rewrite their (sometimes JSONC) configs. */ -import { mkdir } from "node:fs/promises"; +import { mkdir, rename, unlink, writeFile } from "node:fs/promises"; import { dirname } from "node:path"; import { parse, stringify } from "smol-toml"; import { log } from "../../../lib/log.ts"; @@ -43,6 +43,15 @@ export async function readTomlConfig(path: string): Promise { export async function writeTomlConfig(path: string, config: ConfigRecord): Promise { log.debug(`mcp: write ${path}`); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); - await Bun.write(path, stringify(config) + "\n"); + // Atomic write: write to a sibling temp file then rename so a concurrent + // reader never sees a partial file if the CLI is interrupted mid-write. + const tmp = `${path}.clerk-tmp-${process.pid}`; + try { + await writeFile(tmp, stringify(config) + "\n", { mode: 0o600 }); + await rename(tmp, path); + } catch (error) { + await unlink(tmp).catch(() => {}); + throw error; + } await restrictPermissions(path); } From e24f8e16d514d22dd362df4427131fbd96643753 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Mon, 22 Jun 2026 17:55:10 -0300 Subject: [PATCH 15/21] docs(mcp): add JSDoc to shared.ts exports; drop misleading auth install copy - Add per-export JSDoc to every public symbol in mcp/shared.ts (resolveUrl, resolveName, resolveClients, pickClients, targetClients, wantsJson, DEFAULT_ENTRY_NAME, McpOptions, settleClients). - Remove the "editor opens a browser to sign in" bullet from installNextSteps: clerk mcp run is transport-only today and does not yet run an OAuth flow, so the copy was inaccurate. --- packages/cli-core/src/commands/mcp/install.ts | 10 +++----- packages/cli-core/src/commands/mcp/shared.ts | 25 +++++++++++++++++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts index 4bac7af0..b974fc8a 100644 --- a/packages/cli-core/src/commands/mcp/install.ts +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -1,9 +1,10 @@ /** * `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. + * URL resolution: `CLERK_MCP_URL` > active env profile `mcpUrl` > Clerk's hosted server. + * The URL is resolved at bridge runtime, not embedded in the stored config entry. * Target clients: `--client ` (repeatable) > `--all` > human picker > all detected (agent mode). - * Conflict policy: same URL → unchanged; different URL → skip unless `--force`. + * Conflict policy: same entry shape → unchanged; existing entry pointing at a different server → skip unless `--force`. */ import { log } from "../../lib/log.ts"; @@ -64,10 +65,7 @@ function installNextSteps(settled: ClientUpsert[]): string[] { ({ 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.", - ]; + return activated.map(({ client }) => `${client.displayName}: ${client.activation}`); } export async function mcpInstall(options: McpOptions = {}): Promise { diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index 9271af09..02753c18 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -9,6 +9,7 @@ import { isAgent } from "../../mode.ts"; import { CLIENT_ALIASES, CLIENT_IDS, CLIENTS, detectInstalledClients } from "./clients/registry.ts"; import type { ClientId, McpClient } from "./clients/types.ts"; +/** Options shared across `clerk mcp` subcommands. */ export type McpOptions = { json?: boolean; url?: string; @@ -19,12 +20,18 @@ export type McpOptions = { force?: boolean; }; +/** Default MCP server entry name written into client configs. */ export const DEFAULT_ENTRY_NAME = "clerk"; +/** + * Resolve the MCP server URL from options or environment (`CLERK_MCP_URL` > + * active env profile > hosted default), validating scheme and rejecting + * embedded credentials. + */ 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. + // default), so the only failure mode left is a `CLERK_MCP_URL` value 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. @@ -54,10 +61,15 @@ export function resolveUrl(options: McpOptions): string { return parsed.href; } +/** Resolve the MCP entry name from options, defaulting to {@link DEFAULT_ENTRY_NAME}. */ export function resolveName(options: McpOptions): string { return options.name ?? DEFAULT_ENTRY_NAME; } +/** + * Resolve a list of client ID strings (from CLI `--client` flags) to their + * canonical {@link McpClient} objects, deduplicating aliases. + */ export function resolveClients(ids: readonly string[]): McpClient[] { const byId = new Map(CLIENTS.map((c) => [c.id, c])); const seen = new Set(); @@ -78,6 +90,10 @@ export function resolveClients(ids: readonly string[]): McpClient[] { }); } +/** + * Prompt the user to pick from a set of detected clients. Returns all clients + * when `autoSelectSingle` is true and exactly one is detected. + */ export async function pickClients( detected: McpClient[], message: string, @@ -98,6 +114,10 @@ export async function pickClients( return resolveClients(selected); } +/** + * Resolve the target clients from `--client` flags or by detecting installed + * clients in `cwd`. Throws if no clients are found and none were specified. + */ export async function targetClients(options: McpOptions, cwd: string): Promise { if (options.client && options.client.length > 0) { return resolveClients(options.client); @@ -114,6 +134,7 @@ export async function targetClients(options: McpOptions, cwd: string): Promise Date: Mon, 22 Jun 2026 17:55:26 -0300 Subject: [PATCH 16/21] fix(mcp): drop --url from public CLI; store URL-less bridge args; match Gemini legacy by URL - clerk mcp run and clerk mcp install no longer expose --url. The target URL is resolved at runtime from CLERK_MCP_URL or the active env profile, so the stored config entry is now { command: "clerk", args: ["mcp", "run"] } with no embedded URL. Users cannot accidentally override the URL; internal dev use via CLERK_MCP_URL is unaffected. - clerkRunArgs() no longer takes a url param; all client encode() functions updated to match. withLegacyUrl() returns getMcpUrl() for current-shape entries so list/upsert can compare URLs without storing them. - extractClerkRunUrl is kept for migrating old --url entries on the next install (status: "updated"). isClerkRunEntry is a new export that detects already-current entries without a URL in args. - Gemini legacy migration now matches entries by looking for a Clerk URL anywhere in args instead of requiring command === "npx". This covers npx, bunx, pnpx, and any future launcher. - Tests updated throughout: CURRENT_SHAPE replaces runShape(url); conflict scenarios use a foreign bare-URL entry rather than a second Clerk URL. --- .../src/commands/mcp/clients/claude.ts | 2 +- .../commands/mcp/clients/clerk-run.test.ts | 77 ++++++++---- .../src/commands/mcp/clients/clerk-run.ts | 47 ++++++-- .../src/commands/mcp/clients/clients.test.ts | 13 +- .../src/commands/mcp/clients/codex.ts | 2 +- .../src/commands/mcp/clients/cursor.ts | 2 +- .../src/commands/mcp/clients/gemini.ts | 46 ++++--- .../commands/mcp/clients/make-client.test.ts | 97 ++++++++++----- .../commands/mcp/clients/user-scope.test.ts | 112 +++++++++--------- .../src/commands/mcp/clients/vscode.ts | 2 +- .../src/commands/mcp/clients/windsurf.ts | 2 +- packages/cli-core/src/commands/mcp/index.ts | 4 +- .../cli-core/src/commands/mcp/install.test.ts | 81 +++++++------ packages/cli-core/src/lib/input-json.test.ts | 4 +- 14 files changed, 303 insertions(+), 188 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/clients/claude.ts b/packages/cli-core/src/commands/mcp/clients/claude.ts index de98b350..ccb0452c 100644 --- a/packages/cli-core/src/commands/mcp/clients/claude.ts +++ b/packages/cli-core/src/commands/mcp/clients/claude.ts @@ -16,7 +16,7 @@ export const claudeClient = makeJsonClient({ scope: "user", activation: "Restart Claude Code, then run `/mcp` to connect (`clerk` must be on your PATH).", topKey: "mcpServers", - encode: (url) => ({ command: RUN_COMMAND, args: clerkRunArgs(url) }), + encode: () => ({ command: RUN_COMMAND, args: clerkRunArgs() }), extractUrl: (d) => withLegacyUrl(d), configPath: () => userPath(".claude.json"), detect: () => pathExists(userPath(".claude")), diff --git a/packages/cli-core/src/commands/mcp/clients/clerk-run.test.ts b/packages/cli-core/src/commands/mcp/clients/clerk-run.test.ts index 28997a2e..6c9f3521 100644 --- a/packages/cli-core/src/commands/mcp/clients/clerk-run.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clerk-run.test.ts @@ -1,38 +1,65 @@ import { describe, expect, test } from "bun:test"; -import { clerkRunArgs, extractClerkRunUrl, RUN_COMMAND } from "./clerk-run.ts"; - -const URL = "https://mcp.clerk.com/mcp"; +import { clerkRunArgs, extractClerkRunUrl, isClerkRunEntry, RUN_COMMAND } from "./clerk-run.ts"; describe("clerk-run descriptor", () => { - test("clerkRunArgs builds the run invocation", () => { - expect(clerkRunArgs(URL)).toEqual(["mcp", "run", "--url", URL]); + test("clerkRunArgs builds the run invocation without a URL", () => { + expect(clerkRunArgs()).toEqual(["mcp", "run"]); }); - test("round-trips the URL it encodes", () => { - expect(extractClerkRunUrl({ command: RUN_COMMAND, args: clerkRunArgs(URL) })).toBe(URL); + test("isClerkRunEntry recognises the current no-URL shape", () => { + expect(isClerkRunEntry({ command: RUN_COMMAND, args: clerkRunArgs() })).toBe(true); }); - test("accepts the --url=value inline form", () => { - expect(extractClerkRunUrl({ command: "clerk", args: ["mcp", "run", `--url=${URL}`] })).toBe( - URL, - ); + test("isClerkRunEntry rejects a legacy --url entry", () => { + expect( + isClerkRunEntry({ + command: "clerk", + args: ["mcp", "run", "--url", "https://mcp.clerk.com/mcp"], + }), + ).toBe(false); }); - test("ignores a vscode-style descriptor with an extra type field", () => { - expect(extractClerkRunUrl({ type: "stdio", command: "clerk", args: clerkRunArgs(URL) })).toBe( - URL, - ); + test("isClerkRunEntry accepts a vscode-style descriptor with an extra type field", () => { + // VS Code encodes stdio servers with a `type: "stdio"` discriminator; the + // entry is still a current-format Clerk bridge. + expect(isClerkRunEntry({ type: "stdio", command: "clerk", args: clerkRunArgs() })).toBe(true); }); - test.each([ - ["a different command", { command: "npx", args: ["-y", "mcp-remote", URL] }], - ["no --url flag", { command: "clerk", args: ["mcp", "run"] }], - ["a trailing --url with no value", { command: "clerk", args: ["mcp", "run", "--url"] }], - ["an empty --url= inline form", { command: "clerk", args: ["mcp", "run", "--url="] }], - ["non-string args", { command: "clerk", args: [1, 2, 3] }], - ["not an object", "clerk mcp run"], - ["null", null], - ])("returns undefined for %s", (_label, descriptor) => { - expect(extractClerkRunUrl(descriptor)).toBeUndefined(); + describe("extractClerkRunUrl (legacy migration)", () => { + const LEGACY_URL = "https://mcp.clerk.com/mcp"; + + test("extracts the URL from a legacy --url entry", () => { + expect( + extractClerkRunUrl({ command: "clerk", args: ["mcp", "run", "--url", LEGACY_URL] }), + ).toBe(LEGACY_URL); + }); + + test("accepts the --url=value inline form", () => { + expect( + extractClerkRunUrl({ command: "clerk", args: ["mcp", "run", `--url=${LEGACY_URL}`] }), + ).toBe(LEGACY_URL); + }); + + test("ignores a vscode-style descriptor with an extra type field", () => { + expect( + extractClerkRunUrl({ + type: "stdio", + command: "clerk", + args: ["mcp", "run", "--url", LEGACY_URL], + }), + ).toBe(LEGACY_URL); + }); + + test.each([ + ["a different command", { command: "npx", args: ["-y", "mcp-remote", LEGACY_URL] }], + ["no --url flag", { command: "clerk", args: ["mcp", "run"] }], + ["a trailing --url with no value", { command: "clerk", args: ["mcp", "run", "--url"] }], + ["an empty --url= inline form", { command: "clerk", args: ["mcp", "run", "--url="] }], + ["non-string args", { command: "clerk", args: [1, 2, 3] }], + ["not an object", "clerk mcp run"], + ["null", null], + ])("returns undefined for %s", (_label, descriptor) => { + expect(extractClerkRunUrl(descriptor)).toBeUndefined(); + }); }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/clerk-run.ts b/packages/cli-core/src/commands/mcp/clients/clerk-run.ts index 5b7c2dd6..1cc6610e 100644 --- a/packages/cli-core/src/commands/mcp/clients/clerk-run.ts +++ b/packages/cli-core/src/commands/mcp/clients/clerk-run.ts @@ -1,20 +1,29 @@ /** - * The stdio descriptor every client now installs: instead of pointing at the - * remote URL (or shelling out to `npx mcp-remote`), each config launches - * `clerk mcp run --url `, the bridge in `../run.ts`. Centralized here so - * the command shape and its reverse parser stay in lockstep across clients. + * The stdio descriptor every client now installs: each config launches + * `clerk mcp run`, the bridge in `../run.ts`. Centralized here so the command + * shape and its reverse parser stay in lockstep across clients. + * + * No URL is embedded in the args — `clerk mcp run` resolves its target at + * runtime via `CLERK_MCP_URL` or the active env profile. Keeping the URL out + * of the stored config avoids confusing users who see it and wonder whether + * they should change it. */ +import { getMcpUrl } from "../../../lib/environment.ts"; import { hasStringProp } from "./make-client.ts"; /** The binary clients spawn. Must be on the user's PATH. */ export const RUN_COMMAND = "clerk"; -export function clerkRunArgs(url: string): string[] { - return ["mcp", "run", "--url", url]; +/** Args written into editor configs when installing the MCP bridge. */ +export function clerkRunArgs(): string[] { + return ["mcp", "run"]; } -/** Recover the URL from a `clerk mcp run --url ` descriptor, or undefined. */ +/** + * Return the URL embedded in a legacy `clerk mcp run --url ` descriptor, + * or undefined. Only used to migrate entries written by an older CLI version. + */ export function extractClerkRunUrl(descriptor: unknown): string | undefined { if (typeof descriptor !== "object" || descriptor === null) return undefined; const { command, args } = descriptor as { command?: unknown; args?: unknown }; @@ -27,15 +36,35 @@ export function extractClerkRunUrl(descriptor: unknown): string | undefined { return inline ? inline.slice("--url=".length) || undefined : undefined; } +/** + * True when the descriptor matches the current `clerk mcp run` shape (no URL + * in args). Used to detect already-current entries during upsert. + */ +export function isClerkRunEntry(descriptor: unknown): boolean { + if (typeof descriptor !== "object" || descriptor === null) return false; + const { command, args } = descriptor as { command?: unknown; args?: unknown }; + if (command !== RUN_COMMAND) return false; + if (!Array.isArray(args) || !args.every((arg) => typeof arg === "string")) return false; + return args[0] === "mcp" && args[1] === "run" && !args.includes("--url"); +} + /** * `extractUrl` for the clients that share the new bridge shape and fall back to - * a legacy single-key descriptor (`{ url }` for most, `{ serverUrl }` for - * Windsurf) so existing installs still round-trip on list/uninstall. + * legacy descriptor shapes so existing installs still round-trip on + * list/uninstall. + * + * Priority: + * 1. Current format: `{ command: "clerk", args: ["mcp", "run"] }` — no URL in + * args; resolves to `getMcpUrl()` so list/upsert see a comparable URL. + * 2. Legacy v1 format: `{ command: "clerk", args: ["mcp", "run", "--url", …] }` — + * URL extracted from the `--url` arg. + * 3. Legacy v0 format: `{ url }` or `{ serverUrl }` — plain key lookup. */ export function withLegacyUrl( descriptor: unknown, legacyKey: "url" | "serverUrl" = "url", ): string | undefined { + if (isClerkRunEntry(descriptor)) return getMcpUrl(); return ( extractClerkRunUrl(descriptor) ?? (hasStringProp(descriptor, legacyKey) ? descriptor[legacyKey] : undefined) diff --git a/packages/cli-core/src/commands/mcp/clients/clients.test.ts b/packages/cli-core/src/commands/mcp/clients/clients.test.ts index 90a4e59b..825dee17 100644 --- a/packages/cli-core/src/commands/mcp/clients/clients.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -20,11 +20,12 @@ const { vscodeUserDir } = await import("./paths.ts"); useCaptureLog(); -const URL = "https://mcp.clerk.com/mcp"; +const DEFAULT_URL = "https://mcp.clerk.com/mcp"; -// Every client now installs the same `clerk mcp run` stdio bridge; VS Code tags -// stdio servers with a `type` discriminator. -const RUN_SHAPE = { command: "clerk", args: ["mcp", "run", "--url", URL] }; +// Every client now installs the same `clerk mcp run` stdio bridge (no URL in +// args — the URL is resolved at runtime). VS Code tags stdio servers with a +// `type` discriminator. +const RUN_SHAPE = { command: "clerk", args: ["mcp", "run"] }; // Path + entry shape are part of the public contract: each client targets a // specific user-global config file and encodes the bridge its own way. @@ -98,7 +99,7 @@ describe("client config paths + encoded shapes (homedir redirected)", () => { }); test.each(cases)("$name writes the documented entry shape", async ({ client, topKey, shape }) => { - await client.upsert({ name: "clerk", url: URL }, "/ignored", false); + await client.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored", false); const parsed = JSON.parse(await readFile(client.configPath("/ignored"), "utf8")) as Record< string, Record @@ -107,7 +108,7 @@ describe("client config paths + encoded shapes (homedir redirected)", () => { }); test("vscode writes under `servers`, not `mcpServers`", async () => { - await vscodeClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + await vscodeClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored", false); const parsed = JSON.parse(await readFile(vscodeClient.configPath("/ignored"), "utf8")) as { servers?: unknown; mcpServers?: unknown; diff --git a/packages/cli-core/src/commands/mcp/clients/codex.ts b/packages/cli-core/src/commands/mcp/clients/codex.ts index b4952bba..38f31c91 100644 --- a/packages/cli-core/src/commands/mcp/clients/codex.ts +++ b/packages/cli-core/src/commands/mcp/clients/codex.ts @@ -14,7 +14,7 @@ export const codexClient = makeTomlClient({ scope: "user", activation: "Restart Codex (`clerk` must be on your PATH).", topKey: "mcp_servers", - encode: (url) => ({ command: RUN_COMMAND, args: clerkRunArgs(url) }), + encode: () => ({ command: RUN_COMMAND, args: clerkRunArgs() }), extractUrl: (d) => withLegacyUrl(d), 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 index 2d1e562e..d542a89e 100644 --- a/packages/cli-core/src/commands/mcp/clients/cursor.ts +++ b/packages/cli-core/src/commands/mcp/clients/cursor.ts @@ -16,7 +16,7 @@ export const cursorClient = makeJsonClient({ activation: "Reload Cursor, then enable the server under `Settings → MCP` (`clerk` must be on your PATH).", topKey: "mcpServers", - encode: (url) => ({ command: RUN_COMMAND, args: clerkRunArgs(url) }), + encode: () => ({ command: RUN_COMMAND, args: clerkRunArgs() }), extractUrl: (d) => withLegacyUrl(d), configPath: () => userPath(".cursor", "mcp.json"), detect: () => pathExists(userPath(".cursor")), diff --git a/packages/cli-core/src/commands/mcp/clients/gemini.ts b/packages/cli-core/src/commands/mcp/clients/gemini.ts index f23ce2c1..00501c1e 100644 --- a/packages/cli-core/src/commands/mcp/clients/gemini.ts +++ b/packages/cli-core/src/commands/mcp/clients/gemini.ts @@ -5,24 +5,36 @@ * list/uninstall so existing installs remain manageable. */ -import { clerkRunArgs, extractClerkRunUrl, RUN_COMMAND } from "./clerk-run.ts"; +import { clerkRunArgs, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; import { makeJsonClient } from "./make-client.ts"; import { pathExists, userPath } from "./paths.ts"; -interface McpRemoteEntry { - command: string; - args: [string, string, string, ...string[]]; -} - -// Match the exact legacy shape `{command: "npx", args: ["-y", "mcp-remote", ]}`. -// Permissive matching would round-trip unrelated stdio bridges as Clerk 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"; +// Extract the Clerk MCP URL from a legacy stdio bridge entry of any shape +// (npx mcp-remote, bunx mcp-remote, etc.) by looking for a Clerk URL in args +// rather than matching a specific command name. Matching on the URL is more +// robust than checking the command: the tool that launches the bridge may vary +// (npx, bunx, pnpx…) but the target URL identifies what it connects to. +function extractLegacyBridgeUrl(value: unknown): string | undefined { + if (typeof value !== "object" || value === null) return undefined; + const candidate = value as { args?: unknown }; + if (!Array.isArray(candidate.args)) return undefined; + // Find the last string arg that looks like an https://…clerk.com URL. + for (let i = candidate.args.length - 1; i >= 0; i--) { + const arg = candidate.args[i]; + if (typeof arg !== "string") continue; + try { + const parsed = new URL(arg); + if ( + (parsed.hostname === "mcp.clerk.com" || parsed.hostname.endsWith(".clerk.com")) && + (parsed.protocol === "https:" || parsed.protocol === "http:") + ) { + return parsed.href; + } + } catch { + // not a URL + } + } + return undefined; } export const geminiClient = makeJsonClient({ @@ -31,8 +43,8 @@ export const geminiClient = makeJsonClient({ scope: "user", activation: "Restart Gemini (`clerk` must be on your PATH).", topKey: "mcpServers", - encode: (url) => ({ command: RUN_COMMAND, args: clerkRunArgs(url) }), - extractUrl: (d) => extractClerkRunUrl(d) ?? (isMcpRemoteEntry(d) ? d.args[2] : undefined), + encode: () => ({ command: RUN_COMMAND, args: clerkRunArgs() }), + extractUrl: (d) => withLegacyUrl(d) ?? extractLegacyBridgeUrl(d), configPath: () => userPath(".gemini", "settings.json"), detect: () => pathExists(userPath(".gemini")), }); diff --git a/packages/cli-core/src/commands/mcp/clients/make-client.test.ts b/packages/cli-core/src/commands/mcp/clients/make-client.test.ts index f1ed32e3..f709c923 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-client.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-client.test.ts @@ -14,10 +14,16 @@ const { cursorClient } = await import("./cursor.ts"); useCaptureLog(); -const URL_A = "https://mcp.clerk.com/mcp"; -const URL_B = "http://localhost:8787/mcp"; +// The desired entry shape written by the current CLI — no URL in args; the +// bridge resolves its target at runtime via CLERK_MCP_URL or the env profile. +const CURRENT_SHAPE = { command: "clerk", args: ["mcp", "run"] }; -const runShape = (url: string) => ({ command: "clerk", args: ["mcp", "run", "--url", url] }); +// A foreign server entry that should be treated as a conflict. +const FOREIGN_URL = "https://other.example.com/mcp"; +const FOREIGN_SHAPE = { url: FOREIGN_URL }; + +// A Clerk URL — matches what getMcpUrl() returns by default. +const CLERK_URL = "https://mcp.clerk.com/mcp"; describe("make-client (via cursor)", () => { let cwd: string; @@ -41,10 +47,10 @@ describe("make-client (via cursor)", () => { 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); + const result = await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd, false); expect(result.status).toBe("added"); const written = await read(); - expect(written.mcpServers?.clerk).toEqual(runShape(URL_A)); + expect(written.mcpServers?.clerk).toEqual(CURRENT_SHAPE); }); test("preserves unrelated keys in the file", async () => { @@ -54,70 +60,91 @@ describe("make-client (via cursor)", () => { configPath, JSON.stringify({ otherTopLevel: "keep", mcpServers: { other: { url: "http://x" } } }), ); - await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd, false); const written = await read(); expect(written.otherTopLevel).toBe("keep"); expect(written.mcpServers?.other).toEqual({ url: "http://x" }); - expect(written.mcpServers?.clerk).toEqual(runShape(URL_A)); + expect(written.mcpServers?.clerk).toEqual(CURRENT_SHAPE); }); - 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); + test("returns unchanged when the entry is already in the current shape", async () => { + await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd, false); + const result = await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd, false); expect(result.status).toBe("unchanged"); }); - test("upgrades a legacy same-URL entry to the bridge shape without --force", async () => { + test("upgrades a legacy same-URL entry (bare { url }) to the bridge shape without --force", async () => { await mkdir(join(cwd, ".cursor"), { recursive: true }); await writeFile( join(cwd, ".cursor", "mcp.json"), - JSON.stringify({ mcpServers: { clerk: { url: URL_A } } }), + JSON.stringify({ mcpServers: { clerk: { url: CLERK_URL } } }), ); - const result = await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + const result = await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd, false); expect(result.status).toBe("updated"); const written = await read(); - expect(written.mcpServers?.clerk).toEqual(runShape(URL_A)); + expect(written.mcpServers?.clerk).toEqual(CURRENT_SHAPE); }); - 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); + test("upgrades a legacy --url arg entry to the no-URL shape without --force", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + join(cwd, ".cursor", "mcp.json"), + JSON.stringify({ + mcpServers: { clerk: { command: "clerk", args: ["mcp", "run", "--url", CLERK_URL] } }, + }), + ); + const result = await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd, false); + expect(result.status).toBe("updated"); + const written = await read(); + expect(written.mcpServers?.clerk).toEqual(CURRENT_SHAPE); + }); + + test("skips when entry points at a foreign server and force is false", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + join(cwd, ".cursor", "mcp.json"), + JSON.stringify({ mcpServers: { clerk: FOREIGN_SHAPE } }), + ); + const result = await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, 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).toEqual(runShape(URL_A)); + expect(written.mcpServers?.clerk).toEqual(FOREIGN_SHAPE); }); - 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); + test("overwrites a foreign-server entry when force is true", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + join(cwd, ".cursor", "mcp.json"), + JSON.stringify({ mcpServers: { clerk: FOREIGN_SHAPE } }), + ); + const result = await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd, true); expect(result.status).toBe("updated"); const written = await read(); - expect(written.mcpServers?.clerk).toEqual(runShape(URL_B)); + expect(written.mcpServers?.clerk).toEqual(CURRENT_SHAPE); }); 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/, - ); + await expect( + cursorClient.upsert({ name: "clerk", url: CLERK_URL }, 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); + const result = await cursorClient.upsert({ name: "toString", url: CLERK_URL }, cwd, false); expect(result.status).toBe("added"); const written = await read(); - expect(written.mcpServers?.["toString"]).toEqual(runShape(URL_A)); + expect(written.mcpServers?.["toString"]).toEqual(CURRENT_SHAPE); }); }); describe("remove", () => { test("removes a present entry", async () => { - await cursorClient.upsert({ name: "clerk", url: URL_A }, cwd, false); + await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd, false); const result = await cursorClient.remove("clerk", cwd); expect(result.removed).toBe(true); const written = await read(); @@ -145,7 +172,7 @@ describe("make-client (via cursor)", () => { configPath, JSON.stringify({ mcpServers: { - clerk: { url: URL_A }, + clerk: { url: CLERK_URL }, "other-clerk": { url: "https://mcp.clerk.com/mcp" }, unrelated: { url: "https://example.com/mcp" }, }, @@ -156,6 +183,16 @@ describe("make-client (via cursor)", () => { expect(names).toEqual(["clerk", "other-clerk"]); }); + test("lists a current-shape entry by name, resolving URL from getMcpUrl()", async () => { + const configPath = join(cwd, ".cursor", "mcp.json"); + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(configPath, JSON.stringify({ mcpServers: { clerk: CURRENT_SHAPE } })); + const entries = await cursorClient.list(cwd); + expect(entries).toHaveLength(1); + expect(entries[0]!.name).toBe("clerk"); + expect(entries[0]!.url).toBe(CLERK_URL); + }); + test("returns empty when no config file exists", async () => { const entries = await cursorClient.list(cwd); expect(entries).toEqual([]); diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts index b82fd942..fa00bf94 100644 --- a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -31,10 +31,12 @@ const { checkMcp } = await import("../../doctor/check-mcp.ts"); const captured = useCaptureLog(); -const URL = "https://mcp.clerk.com/mcp"; +// The URL the default env profile resolves to (same as getMcpUrl() default). +const DEFAULT_URL = "https://mcp.clerk.com/mcp"; const ALL_CLIENT_IDS = ["claude", "cursor", "vscode", "windsurf", "gemini", "codex"]; -const runShape = (url: string) => ({ command: "clerk", args: ["mcp", "run", "--url", url] }); +// The entry shape written by the current CLI — no URL in args. +const CURRENT_SHAPE = { command: "clerk", args: ["mcp", "run"] }; describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { beforeEach(async () => { @@ -46,32 +48,48 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { }); describe("gemini", () => { - test("encodes the clerk-run stdio-bridge shape", async () => { - await geminiClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + test("encodes the clerk-run stdio-bridge shape (no URL in args)", async () => { + await geminiClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored", false); const parsed = (await Bun.file(geminiClient.configPath("/ignored")).json()) as { mcpServers: { clerk: { command: string; args: string[] } }; }; - expect(parsed.mcpServers.clerk).toEqual(runShape(URL)); + expect(parsed.mcpServers.clerk).toEqual(CURRENT_SHAPE); }); - test("round-trips the URL back out of args[2] on list", async () => { - await geminiClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + test("round-trips the entry on list, resolving URL from getMcpUrl()", async () => { + await geminiClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored", false); const entries = await geminiClient.list("/ignored"); expect(entries).toEqual([ - expect.objectContaining({ client: "gemini", name: "clerk", url: URL }), + expect.objectContaining({ client: "gemini", name: "clerk", url: DEFAULT_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. + test("recognises a legacy npx mcp-remote entry by its Clerk URL in args", async () => { + // Legacy shape: `npx -y mcp-remote ` — identified by the Clerk URL in + // args rather than the command name (more robust to npx/bunx/pnpx variants). 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] }, + clerk: { command: "npx", args: ["-y", "mcp-remote", DEFAULT_URL] }, + }, + }), + ); + const entries = await geminiClient.list("/ignored"); + expect(entries.map((e) => e.name)).toEqual(["clerk"]); + expect(entries[0]!.url).toBe(DEFAULT_URL); + }); + + test("ignores a foreign npx entry without a Clerk URL in args", async () => { + 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", DEFAULT_URL] }, "other-tool": { command: "npx", args: ["serve", "--port", "3000"] }, }, }), @@ -83,35 +101,35 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { describe("windsurf", () => { test("encodes the clerk-run shape and round-trips it on list", async () => { - await windsurfClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + await windsurfClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored", false); const parsed = (await Bun.file(windsurfClient.configPath("/ignored")).json()) as { mcpServers: { clerk: { command: string; args: string[] } }; }; - expect(parsed.mcpServers.clerk).toEqual(runShape(URL)); + expect(parsed.mcpServers.clerk).toEqual(CURRENT_SHAPE); const entries = await windsurfClient.list("/ignored"); expect(entries).toEqual([ - expect.objectContaining({ client: "windsurf", name: "clerk", url: URL }), + expect.objectContaining({ client: "windsurf", name: "clerk", url: DEFAULT_URL }), ]); }); }); describe("codex", () => { test("writes the clerk-run bridge under the [mcp_servers.] TOML table", async () => { - await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + await codexClient.upsert({ name: "clerk", url: DEFAULT_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: { command: string; args: string[] } }; }; - expect(parsed.mcp_servers.clerk).toEqual(runShape(URL)); + expect(parsed.mcp_servers.clerk).toEqual(CURRENT_SHAPE); }); - test("round-trips the URL back out on list", async () => { - await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + test("round-trips the entry on list, resolving URL from getMcpUrl()", async () => { + await codexClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored", false); const entries = await codexClient.list("/ignored"); expect(entries).toEqual([ - expect.objectContaining({ client: "codex", name: "clerk", url: URL }), + expect.objectContaining({ client: "codex", name: "clerk", url: DEFAULT_URL }), ]); }); @@ -120,19 +138,23 @@ describe("user-scope MCP clients (homedir redirected to a tmpdir)", () => { await mkdir(dir, { recursive: true }); await Bun.write(join(dir, "config.toml"), 'model = "o3"\n'); - await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + await codexClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored", false); const parsed = parseToml(await Bun.file(join(dir, "config.toml")).text()) as { model: string; mcp_servers: { clerk: { command: string; args: string[] } }; }; expect(parsed.model).toBe("o3"); - expect(parsed.mcp_servers.clerk).toEqual(runShape(URL)); + expect(parsed.mcp_servers.clerk).toEqual(CURRENT_SHAPE); }); test("returns unchanged when re-upserting the same URL", async () => { - await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); - const result = await codexClient.upsert({ name: "clerk", url: URL }, "/ignored", false); + await codexClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored", false); + const result = await codexClient.upsert( + { name: "clerk", url: DEFAULT_URL }, + "/ignored", + false, + ); expect(result.status).toBe("unchanged"); }); @@ -207,7 +229,7 @@ describe("install/uninstall across all clients (homedir + cwd redirected)", () = mkdir(join(mockHome, ".codex"), { recursive: true }), ]); - await mcpInstall({ all: true, url: URL }); + await mcpInstall({ all: true }); const payload = JSON.parse(captured.out) as { results: { client: string; status: string }[] }; expect(payload.results.map((r) => r.client)).toEqual(ALL_CLIENT_IDS); @@ -215,7 +237,7 @@ describe("install/uninstall across all clients (homedir + cwd redirected)", () = }); test("--all with no detected client throws mcp_no_client_detected", async () => { - await expect(mcpInstall({ all: true, url: URL })).rejects.toMatchObject({ + await expect(mcpInstall({ all: true })).rejects.toMatchObject({ code: "mcp_no_client_detected", }); }); @@ -224,16 +246,16 @@ describe("install/uninstall across all clients (homedir + cwd redirected)", () = await mkdir(join(mockHome, ".cursor"), { recursive: true }); mockIsAgent.mockReturnValue(false); - await mcpInstall({ url: URL }); + await mcpInstall({}); const parsed = JSON.parse(await Bun.file(join(mockHome, ".cursor", "mcp.json")).text()) as { mcpServers: { clerk: unknown }; }; - expect(parsed.mcpServers.clerk).toEqual(runShape(URL)); + expect(parsed.mcpServers.clerk).toEqual(CURRENT_SHAPE); }); test("uninstall with no --client removes from every client", async () => { - await mcpInstall({ client: ["cursor", "gemini"], url: URL }); + await mcpInstall({ client: ["cursor", "gemini"] }); captured.clear(); await mcpUninstall({}); @@ -301,7 +323,7 @@ describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { }); test("passes when the installed MCP server answers the handshake", async () => { - await mcpInstall({ client: ["cursor"], url: URL }); + await mcpInstall({ client: ["cursor"] }); captured.clear(); stubFetchWith(HANDSHAKE_BODY, { status: 200, @@ -314,7 +336,7 @@ describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { }); test("warns when the installed MCP server is unreachable", async () => { - await mcpInstall({ client: ["cursor"], url: URL }); + await mcpInstall({ client: ["cursor"] }); captured.clear(); stubFetchWith("nope", { status: 503 }); @@ -322,35 +344,13 @@ describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { 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); - }); - - test("names every distinct unreachable URL, not just the first", async () => { - const OTHER_URL = "https://staging.clerk.com/mcp"; - await mcpInstall({ client: ["cursor"], url: URL }); - await mcpInstall({ client: ["claude"], url: OTHER_URL }); + test("names the unreachable URL in the warning", async () => { + await mcpInstall({ client: ["cursor"] }); captured.clear(); stubFetchWith("nope", { status: 503 }); const result = await checkMcp(); expect(result.status).toBe("warn"); - expect(result.message).toContain(URL); - expect(result.message).toContain(OTHER_URL); + expect(result.message).toContain(DEFAULT_URL); }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/vscode.ts b/packages/cli-core/src/commands/mcp/clients/vscode.ts index 29f0d8b2..95673f53 100644 --- a/packages/cli-core/src/commands/mcp/clients/vscode.ts +++ b/packages/cli-core/src/commands/mcp/clients/vscode.ts @@ -19,7 +19,7 @@ export const vscodeClient = makeJsonClient({ activation: "Reload the VS Code window, then start the server from `MCP: List Servers` (`clerk` must be on your PATH).", topKey: "servers", - encode: (url) => ({ type: "stdio", command: RUN_COMMAND, args: clerkRunArgs(url) }), + encode: () => ({ type: "stdio", command: RUN_COMMAND, args: clerkRunArgs() }), extractUrl: (d) => withLegacyUrl(d), configPath: () => join(vscodeUserDir(), "mcp.json"), detect: () => pathExists(vscodeUserDir()), diff --git a/packages/cli-core/src/commands/mcp/clients/windsurf.ts b/packages/cli-core/src/commands/mcp/clients/windsurf.ts index 9210438a..e6f23c2c 100644 --- a/packages/cli-core/src/commands/mcp/clients/windsurf.ts +++ b/packages/cli-core/src/commands/mcp/clients/windsurf.ts @@ -15,7 +15,7 @@ export const windsurfClient = makeJsonClient({ activation: "Reload Windsurf, then turn on the server in `Cascade → MCP` (`clerk` must be on your PATH).", topKey: "mcpServers", - encode: (url) => ({ command: RUN_COMMAND, args: clerkRunArgs(url) }), + encode: () => ({ command: RUN_COMMAND, args: clerkRunArgs() }), extractUrl: (d) => withLegacyUrl(d, "serverUrl"), configPath: () => userPath(".codeium", "windsurf", "mcp_config.json"), detect: () => pathExists(userPath(".codeium", "windsurf")), diff --git a/packages/cli-core/src/commands/mcp/index.ts b/packages/cli-core/src/commands/mcp/index.ts index 46e907e8..8efd415a 100644 --- a/packages/cli-core/src/commands/mcp/index.ts +++ b/packages/cli-core/src/commands/mcp/index.ts @@ -45,7 +45,6 @@ export function registerMcp(program: Program): void { .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") @@ -75,11 +74,10 @@ export function registerMcp(program: Program): void { .description( "stdio bridge to the remote MCP server (clients spawn this; not meant to be run by hand)", ) - .option("--url ", "MCP server URL to bridge to (default: from active env profile)") .option("--name ", 'Entry name, for parity with install (default: "clerk")') .setExamples([ { - command: "clerk mcp run --url https://mcp.clerk.com/mcp", + command: "clerk mcp run", description: "Forward stdio JSON-RPC to the remote server", }, ]) diff --git a/packages/cli-core/src/commands/mcp/install.test.ts b/packages/cli-core/src/commands/mcp/install.test.ts index 061d774d..e0915c8a 100644 --- a/packages/cli-core/src/commands/mcp/install.test.ts +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -20,10 +20,13 @@ 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"; +// The URL the default env profile resolves to. +const DEFAULT_URL = "https://mcp.clerk.com/mcp"; +// A foreign URL not in the default profile — used to simulate a conflict. +const FOREIGN_URL = "http://localhost:8787/mcp"; -const runShape = (url: string) => ({ command: "clerk", args: ["mcp", "run", "--url", url] }); +// The entry shape written by the current CLI — no URL in args. +const CURRENT_SHAPE = { command: "clerk", args: ["mcp", "run"] }; describe("mcp install", () => { const captured = useCaptureLog(); @@ -45,24 +48,24 @@ describe("mcp install", () => { }); test("writes the Cursor config when --client cursor is passed", async () => { - await mcpInstall({ client: ["cursor"], url: URL_A }); + await mcpInstall({ client: ["cursor"] }); const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { mcpServers: { clerk: unknown }; }; - expect(parsed.mcpServers.clerk).toEqual(runShape(URL_A)); + expect(parsed.mcpServers.clerk).toEqual(CURRENT_SHAPE); }); test("emits JSON to stdout in agent mode and skips intro/outro", async () => { mockIsAgent.mockReturnValue(true); - await mcpInstall({ client: ["cursor"], url: URL_A }); + await mcpInstall({ client: ["cursor"] }); const payload = JSON.parse(captured.out) as { url: string; name: string; results: { client: string; status: string }[]; }; - expect(payload.url).toBe(URL_A); + expect(payload.url).toBe(DEFAULT_URL); expect(payload.name).toBe("clerk"); expect(payload.results).toEqual([ expect.objectContaining({ client: "cursor", status: "added" }), @@ -71,15 +74,15 @@ describe("mcp install", () => { }); test("--json forces JSON output even in human mode", async () => { - await mcpInstall({ client: ["cursor"], url: URL_A, json: true }); + await mcpInstall({ client: ["cursor"], 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 }); + await mcpInstall({ client: ["cursor"] }); captured.clear(); - await mcpInstall({ client: ["cursor"], url: URL_A }); + await mcpInstall({ client: ["cursor"] }); const payload = JSON.parse(captured.out) as { results: { status: string }[]; @@ -87,11 +90,16 @@ describe("mcp install", () => { expect(payload.results[0]?.status).toBe("unchanged"); }); - test("skips with reason on URL conflict without --force", async () => { + test("skips with reason when existing entry points at a foreign server (no --force)", async () => { + // Pre-write a legacy bare-URL entry pointing at a non-Clerk server. + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + join(cwd, ".cursor", "mcp.json"), + JSON.stringify({ mcpServers: { clerk: { url: FOREIGN_URL } } }), + ); + mockIsAgent.mockReturnValue(true); - await mcpInstall({ client: ["cursor"], url: URL_A }); - captured.clear(); - await mcpInstall({ client: ["cursor"], url: URL_B }); + await mcpInstall({ client: ["cursor"] }); const payload = JSON.parse(captured.out) as { results: { status: string; reason?: string }[]; @@ -100,11 +108,15 @@ describe("mcp install", () => { expect(payload.results[0]?.reason).toContain("--force"); }); - test("overwrites on URL conflict with --force", async () => { + test("overwrites a foreign-server entry with --force", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + join(cwd, ".cursor", "mcp.json"), + JSON.stringify({ mcpServers: { clerk: { url: FOREIGN_URL } } }), + ); + mockIsAgent.mockReturnValue(true); - await mcpInstall({ client: ["cursor"], url: URL_A }); - captured.clear(); - await mcpInstall({ client: ["cursor"], url: URL_B, force: true }); + await mcpInstall({ client: ["cursor"], force: true }); const payload = JSON.parse(captured.out) as { results: { status: string }[]; @@ -113,30 +125,30 @@ describe("mcp install", () => { const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { mcpServers: { clerk: unknown }; }; - expect(parsed.mcpServers.clerk).toEqual(runShape(URL_B)); + expect(parsed.mcpServers.clerk).toEqual(CURRENT_SHAPE); }); test("uses --name to customize the entry key", async () => { - await mcpInstall({ client: ["cursor"], url: URL_A, name: "clerk-staging" }); + await mcpInstall({ 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"]).toEqual(runShape(URL_A)); + expect(parsed.mcpServers["clerk-staging"]).toEqual(CURRENT_SHAPE); expect(parsed.mcpServers.clerk).toBeUndefined(); }); - test("uses the active env profile URL when --url is not given", async () => { + test("reports the resolved URL in JSON output", 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"); + expect(payload.url).toBe(DEFAULT_URL); }); 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. + // `clerk mcp install` works without any profile setup. await writeFile( join(cwd, ".env-profiles.json"), JSON.stringify({ @@ -151,7 +163,7 @@ describe("mcp install", () => { 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"); + expect(payload.url).toBe(DEFAULT_URL); }); test.each([ @@ -159,7 +171,7 @@ describe("mcp install", () => { ["data:text/plain,clerk"], ["javascript:alert(1)"], ["ftp://example.com/mcp"], - ])("rejects non-http(s) URL: %s", async (badUrl) => { + ])("rejects non-http(s) CLERK_MCP_URL: %s", async (badUrl) => { await expect(mcpInstall({ client: ["cursor"], url: badUrl })).rejects.toMatchObject({ code: "mcp_url_required", }); @@ -177,27 +189,26 @@ describe("mcp install", () => { ).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 }); + test("prints next steps after a human-mode install", async () => { + await mcpInstall({ client: ["cursor"] }); 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 }); + await mcpInstall({ client: ["cursor"], 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 }); + await mcpInstall({ client: ["cursor"] }); captured.clear(); - await mcpInstall({ client: ["cursor"], url: URL_A }); + await mcpInstall({ client: ["cursor"] }); expect(captured.err).not.toContain("Next steps"); }); test("rejects an unknown --client id", async () => { - await expect(mcpInstall({ client: ["bogus"], url: URL_A })).rejects.toMatchObject({ + await expect(mcpInstall({ client: ["bogus"] })).rejects.toMatchObject({ code: "mcp_client_not_supported", }); }); @@ -208,7 +219,7 @@ describe("mcp install", () => { await mkdir(join(cwd, ".cursor"), { recursive: true }); await writeFile(join(cwd, ".cursor", "mcp.json"), "{ not json"); - await mcpInstall({ client: ["cursor", "claude"], url: URL_A }); + await mcpInstall({ client: ["cursor", "claude"] }); const payload = JSON.parse(captured.out) as { results: { client: string; status: string }[] }; expect(payload.results).toEqual([ @@ -221,7 +232,7 @@ describe("mcp install", () => { 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({ + await expect(mcpInstall({ client: ["cursor"] })).rejects.toMatchObject({ code: "mcp_client_config_invalid", }); expect(captured.err).toContain("Cursor"); diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts index 800a99d5..40d57c4e 100644 --- a/packages/cli-core/src/lib/input-json.test.ts +++ b/packages/cli-core/src/lib/input-json.test.ts @@ -53,13 +53,13 @@ describe("expandInputJson", () => { // `mcp run` streams JSON-RPC on stdin; reaching the stdin auto-read here // would consume the stream. It must return unchanged without reading. process.stdin.isTTY = false; - const argv = ["clerk", "mcp", "run", "--url", "https://mcp.clerk.com/mcp"]; + const argv = ["clerk", "mcp", "run"]; expect(await expandInputJson(argv)).toEqual(argv); }); test("bypasses `mcp run` even with global flags before the command", async () => { process.stdin.isTTY = false; - const argv = ["clerk", "--mode", "agent", "mcp", "run", "--url", "https://mcp.clerk.com/mcp"]; + const argv = ["clerk", "--mode", "agent", "mcp", "run"]; expect(await expandInputJson(argv)).toEqual(argv); }); From 29334b1c8a52d6537ce76c4afd8e201cd23b4c91 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Wed, 1 Jul 2026 11:26:55 -0300 Subject: [PATCH 17/21] =?UTF-8?q?fix(mcp):=20address=20code=20review=20?= =?UTF-8?q?=E2=80=94=20stale=20--url=20docs,=20dropped=20serverStream=20re?= =?UTF-8?q?set,=20install=20picker=20error=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/mcp-install.md | 2 +- packages/cli-core/src/commands/mcp/README.md | 22 +++++++++---------- packages/cli-core/src/commands/mcp/index.ts | 2 +- packages/cli-core/src/commands/mcp/install.ts | 7 +++++- packages/cli-core/src/commands/mcp/run.ts | 15 +++++++++++-- 5 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.changeset/mcp-install.md b/.changeset/mcp-install.md index 54876ea2..300e9e43 100644 --- a/.changeset/mcp-install.md +++ b/.changeset/mcp-install.md @@ -2,4 +2,4 @@ "clerk": minor --- -Add `clerk mcp install`, `list`, `uninstall`, and `run` to connect the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) to Claude Code, Cursor, GitHub Copilot (VS Code; `--client vscode` or `--client copilot`), Windsurf, Gemini, and Codex. Each client is configured to launch `clerk mcp run` — a built-in stdio bridge that forwards the editor's stdio JSON-RPC to the remote server over HTTP (the job `npx mcp-remote` did, now with no npx dependency), so `clerk` must be on your PATH. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`, `~/.codex/config.toml`), available across every project regardless of where you run the CLI. `clerk doctor` gains an MCP reachability check that probes each configured server via the MCP `initialize` handshake when an entry is installed. The URL resolves in order: `--url` > the `CLERK_MCP_URL` override (for local worker development) > the active env profile's `mcpUrl` field > the hosted server, so `clerk mcp install` works with no flags. The bridge is transport-only for now; against an auth-required server it surfaces a clear error rather than signing in. +Add `clerk mcp install`, `list`, `uninstall`, and `run` to connect the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) to Claude Code, Cursor, GitHub Copilot (VS Code; `--client vscode` or `--client copilot`), Windsurf, Gemini, and Codex. Each client is configured to launch `clerk mcp run` — a built-in stdio bridge that forwards the editor's stdio JSON-RPC to the remote server over HTTP (the job `npx mcp-remote` did, now with no npx dependency), so `clerk` must be on your PATH. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`, `~/.codex/config.toml`), available across every project regardless of where you run the CLI. `clerk doctor` gains an MCP reachability check that probes each configured server via the MCP `initialize` handshake when an entry is installed. The URL resolves in order: the `CLERK_MCP_URL` override (for local worker development) > the active env profile's `mcpUrl` field > the hosted server, so `clerk mcp install` works with no flags. The bridge is transport-only for now; against an auth-required server it surfaces a clear error rather than signing in. diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index 5536bba6..2f16f0a1 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -5,7 +5,7 @@ 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` +own config file. The URL is resolved in order: 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, @@ -43,11 +43,11 @@ Codex is the one TOML-backed client (`[mcp_servers.]`); rewriting ## How clients connect (the stdio bridge) -Every client installs the same stdio descriptor — it launches `clerk mcp run ---url ` rather than pointing the editor at the remote URL directly: +Every client installs the same stdio descriptor — it launches `clerk mcp run` +rather than pointing the editor at the remote URL directly: ```jsonc -{ "command": "clerk", "args": ["mcp", "run", "--url", "https://mcp.clerk.com/mcp"] } +{ "command": "clerk", "args": ["mcp", "run"] } ``` `clerk mcp run` ([run.ts](./run.ts)) is a stdio↔Streamable-HTTP proxy — the same @@ -61,8 +61,8 @@ the equivalent TOML (`command`/`args` under `[mcp_servers.]`). > **Auth (current limitation):** `clerk mcp run` is transport-only today — it > does not perform OAuth. Against an auth-required server (including the hosted -> `mcp.clerk.com`) it surfaces a clear error rather than signing in. Point -> `--url` at a server that doesn't require auth (e.g. a local worker at +> `mcp.clerk.com`) it surfaces a clear error rather than signing in. Set +> `CLERK_MCP_URL` to a server that doesn't require auth (e.g. a local worker at > `http://localhost:8787/mcp`) until built-in sign-in ships. ## Subcommands @@ -75,7 +75,6 @@ Register the Clerk MCP server in one or more clients. | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--client ` | Target a specific client. Repeat for multiple. Default in agent mode: all detected. Default in human mode: interactive multiselect over detected clients. | | `--all` | Install into every detected client without prompting. | -| `--url ` | Override the MCP URL. Defaults to the active env profile's `mcpUrl`, 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. | @@ -104,10 +103,9 @@ threads the `Mcp-Session-Id`, opens the optional server→client SSE stream, and writes replies to stdout. stdout carries **only** JSON-RPC frames; all diagnostics go to stderr. -| Flag | Description | -| --------------- | ------------------------------------------------------------ | -| `--url ` | Server URL to bridge to. Same resolution order as `install`. | -| `--name ` | Accepted for parity with `install`. Default: `clerk`. | +| Flag | Description | +| --------------- | ----------------------------------------------------- | +| `--name ` | Accepted for parity with `install`. Default: `clerk`. | Transport-only: a `401`/`403` from the upstream is surfaced as an error (`mcp_client_config_invalid`) rather than triggering a sign-in flow. @@ -136,4 +134,4 @@ editor. | `mcp_no_client_detected` | No supported client found on the system. | | `mcp_client_not_supported` | `--client ` is not in the supported list. | | `mcp_client_config_invalid` | An existing client config file is malformed. | -| `mcp_url_required` | The provided `--url` is malformed or uses a non-http(s) scheme. | +| `mcp_url_required` | The resolved MCP URL is malformed or uses a non-http(s) scheme. | diff --git a/packages/cli-core/src/commands/mcp/index.ts b/packages/cli-core/src/commands/mcp/index.ts index 8efd415a..454512ae 100644 --- a/packages/cli-core/src/commands/mcp/index.ts +++ b/packages/cli-core/src/commands/mcp/index.ts @@ -31,7 +31,7 @@ export function registerMcp(program: Program): void { { command: "clerk mcp list", description: "Show registered Clerk entries" }, { command: "clerk mcp uninstall", description: "Remove the Clerk entry from all clients" }, { - command: "clerk mcp run --url https://mcp.clerk.com/mcp", + command: "clerk mcp run", description: "stdio bridge an editor launches (not run by hand)", }, ]); diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts index b974fc8a..466816d6 100644 --- a/packages/cli-core/src/commands/mcp/install.ts +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -28,7 +28,12 @@ async function chooseClients(options: McpOptions, cwd: string): Promise { + serverStream = undefined; + }; try { for await (const message of readJsonRpcLines(input, MAX_LINE_BYTES)) { @@ -100,6 +103,7 @@ export async function mcpRun(options: McpOptions = {}, streams: RunStreams = {}) emitPayload, track, ensureServerStream, + resetServerStream, signal: abort.signal, }); } @@ -117,11 +121,13 @@ interface DispatchCtx { emitPayload: Emit; track: (work: Promise) => void; ensureServerStream: () => void; + resetServerStream: () => void; signal: AbortSignal; } async function dispatch(message: JsonRpcMessage, ctx: DispatchCtx): Promise { - const { url, session, emit, emitPayload, track, ensureServerStream, signal } = ctx; + const { url, session, emit, emitPayload, track, ensureServerStream, resetServerStream, signal } = + ctx; let response: Response; try { response = await loggedFetch(url, { @@ -151,7 +157,7 @@ async function dispatch(message: JsonRpcMessage, ctx: DispatchCtx): Promise Date: Wed, 1 Jul 2026 13:08:39 -0300 Subject: [PATCH 18/21] docs(mcp): document lost-update risk on client config writes Atomic temp+rename prevents a torn read but not a lost update if the target client (e.g. Claude Code) rewrites its own config between our read and the rename. Not a blocker per review, but worth calling out so install/uninstall users know to close the target client first. Claude-Session: https://claude.ai/code/session_01EsZm2PUNcVL1mXXv1BRG28 --- packages/cli-core/src/commands/mcp/README.md | 6 ++++++ packages/cli-core/src/commands/mcp/clients/json-config.ts | 7 ++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index 2f16f0a1..3c51b141 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -89,6 +89,12 @@ In human mode, `install` prints per-client next steps — the server only goes live once you **reload the editor**, which then spawns `clerk mcp run` (so `clerk` must be on the editor's `PATH`). +> **Concurrent writes:** each client config is written atomically (temp file + +> rename), which prevents a torn read but not a lost update — if the target +> client rewrites its own config between our read and that rename (e.g. Claude +> Code persists `~/.claude.json` frequently), our write can clobber it. +> `install`/`uninstall` are safest with the target client closed. + ### `clerk mcp list` Print every Clerk-flavored MCP entry across all supported clients (entries diff --git a/packages/cli-core/src/commands/mcp/clients/json-config.ts b/packages/cli-core/src/commands/mcp/clients/json-config.ts index 20aeb466..f07c4581 100644 --- a/packages/cli-core/src/commands/mcp/clients/json-config.ts +++ b/packages/cli-core/src/commands/mcp/clients/json-config.ts @@ -56,7 +56,12 @@ export async function writeJsonConfig(path: string, config: ConfigRecord): Promi const dir = dirname(path); await mkdir(dir, { recursive: true, mode: 0o700 }); // Atomic write: write to a sibling temp file then rename so a concurrent - // reader (e.g. Claude Code) never sees a partial file. + // reader (e.g. Claude Code) never sees a partial file. This prevents a torn + // read but not a lost update — if the client itself rewrites this file + // between our read and this rename (e.g. Claude Code persists its own state + // to ~/.claude.json frequently), our rename clobbers that write. Same + // tradeoff `claude mcp add` itself makes; install/uninstall is safest with + // the target client closed. const tmp = `${path}.clerk-tmp-${process.pid}`; try { await writeFile(tmp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 }); From b8a56a91e9db7885a2cc9af36e502f2a0c8c086e Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 6 Jul 2026 23:07:09 -0300 Subject: [PATCH 19/21] fix(mcp): plug abort-listener leak and surface SSE stream death in the run bridge - pipeEventStream now removes its abort listener when a stream ends normally; previously every SSE-carrying response left one registered on the process-lifetime signal for the rest of the session. - A mid-stream failure now logs under --verbose and answers the waiting request with a -32000 error instead of leaving the client hanging; guarded so an already-delivered reply never gets a duplicate. - Flush TextDecoders at end-of-stream (canonical streaming-decode usage). Claude-Session: https://claude.ai/code/session_013Qgw5hXZ3xiXysBbZ19cDS --- .../cli-core/src/commands/mcp/run.test.ts | 76 ++++++++++++++++++- packages/cli-core/src/commands/mcp/run.ts | 47 +++++++++++- 2 files changed, 119 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/run.test.ts b/packages/cli-core/src/commands/mcp/run.test.ts index 4be62647..87e7dd2a 100644 --- a/packages/cli-core/src/commands/mcp/run.test.ts +++ b/packages/cli-core/src/commands/mcp/run.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; -import { mcpRun } from "./run.ts"; +import { mcpRun, pipeEventStream } from "./run.ts"; const URL = "https://mcp.clerk.com/mcp"; @@ -328,6 +328,80 @@ describe("mcp run (stdio bridge)", () => { expect(out.join("")).toBe(""); }); + test("replies with -32000 when an SSE response stream dies before the reply", async () => { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('event: message\ndata: {"par')); + controller.error(new Error("connection reset")); + }, + }); + stub( + (req) => + noServerStream(req) ?? + new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }), + ); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { input: lines({ jsonrpc: "2.0", id: 1, method: "tools/list" }), write: (c) => out.push(c) }, + ); + + const reply = framesFrom(out).find((f) => f.id === 1); + expect((reply?.error as { code?: number } | undefined)?.code).toBe(-32000); + }); + + test("does not emit a duplicate error when the reply arrived before the stream died", async () => { + const result = { jsonrpc: "2.0", id: 1, result: { tools: [] } }; + // Pull-based so the reply event is actually consumed before the error: + // erroring inside start() would discard the queued chunk outright. + let pulls = 0; + const body = new ReadableStream({ + pull(controller) { + if (pulls++ === 0) { + controller.enqueue( + new TextEncoder().encode(`event: message\ndata: ${JSON.stringify(result)}\n\n`), + ); + return; + } + controller.error(new Error("connection reset")); + }, + }); + stub( + (req) => + noServerStream(req) ?? + new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }), + ); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { input: lines({ jsonrpc: "2.0", id: 1, method: "tools/list" }), write: (c) => out.push(c) }, + ); + + const replies = framesFrom(out).filter((f) => f.id === 1); + expect(replies).toEqual([result]); + }); + + test("pipeEventStream removes its abort listener when the stream ends normally", async () => { + const added: unknown[] = []; + const removed: unknown[] = []; + const signal = { + aborted: false, + addEventListener: (_type: string, fn: unknown) => void added.push(fn), + removeEventListener: (_type: string, fn: unknown) => void removed.push(fn), + } as unknown as AbortSignal; + const response = new Response('event: message\ndata: {"jsonrpc":"2.0"}\n\n', { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + + await pipeEventStream(response, async () => {}, signal); + + expect(added).toHaveLength(1); + expect(removed).toEqual(added); + }); + test("targets the --url value", async () => { const custom = "http://localhost:9000/mcp"; stub((req) => noServerStream(req) ?? json(INIT_RESULT)); diff --git a/packages/cli-core/src/commands/mcp/run.ts b/packages/cli-core/src/commands/mcp/run.ts index 7a5b01c1..9e24c971 100644 --- a/packages/cli-core/src/commands/mcp/run.ts +++ b/packages/cli-core/src/commands/mcp/run.ts @@ -194,7 +194,33 @@ async function dispatch(message: JsonRpcMessage, ctx: DispatchCtx): Promise { + if (!replied) { + for (const item of Array.isArray(parsed) ? parsed : [parsed]) { + if ( + typeof item === "object" && + item !== null && + (item as JsonRpcMessage).id === message.id + ) { + replied = true; + break; + } + } + } + await emitPayload(parsed); + }; + track( + pipeEventStream(response, markReplied, signal, async (error) => { + if (replied) return; + await emitError(message, emit, -32000, `Upstream stream failed: ${errorMessage(error)}`); + }), + ); return; } // The initialize response body is drained concurrently via track(); the @@ -301,6 +327,9 @@ export async function* readJsonRpcLines( buffer = ""; } } + // Flush the decoder so a trailing multi-byte character split at the final + // chunk boundary isn't silently dropped from the last line. + buffer += decoder.decode(); const parsed = parseLine(buffer.trim()); if (parsed) yield parsed; } @@ -320,6 +349,7 @@ export async function pipeEventStream( response: Response, emitPayload: Emit, signal?: AbortSignal, + onStreamError?: (error: unknown) => Promise, ): Promise { const reader = response.body?.getReader(); if (!reader) return; @@ -341,9 +371,20 @@ export async function pipeEventStream( buffer = buffer.slice(boundary + 2); } } + // Flush the decoder: a multi-byte character split at the final chunk + // boundary is held in internal state and would otherwise be dropped. + buffer += decoder.decode().replace(EOL, "\n"); await emitEvent(buffer, emitPayload); - } catch { - // Aborted on shutdown or the stream errored — nothing more to drain. + } catch (error) { + // A drain cut short by shutdown is expected; a live stream dying is not — + // surface it under --verbose and let the caller answer the waiting request. + if (signal?.aborted) return; + log.debug(`mcp run: SSE stream error — ${errorMessage(error)}`); + await onStreamError?.(error); + } finally { + // `{ once: true }` only self-removes if abort fires; on the normal path the + // listener would otherwise accumulate on the process-lifetime signal. + signal?.removeEventListener("abort", cancel); } } From df5a347910dd50270a215ed9ead11243f0a94cf1 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 6 Jul 2026 23:07:19 -0300 Subject: [PATCH 20/21] fix(doctor): warn on unreadable MCP client configs instead of passing as skipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unreadable config is not 'nothing installed' — a registered entry may be hiding inside it, and the stderr warning alone is clobbered by the doctor spinner. client.list() now propagates MCP_CLIENT_CONFIG_INVALID; collectEntries settles per client and returns { entries, failures } so checkMcp can name the failing clients in a warn result. Claude-Session: https://claude.ai/code/session_013Qgw5hXZ3xiXysBbZ19cDS --- .../cli-core/src/commands/doctor/README.md | 22 ++-- .../src/commands/doctor/check-mcp.test.ts | 118 ++++++++++++++++++ .../cli-core/src/commands/doctor/check-mcp.ts | 48 +++++-- .../commands/mcp/clients/make-client.test.ts | 21 ++-- .../src/commands/mcp/clients/make-client.ts | 25 ++-- packages/cli-core/src/commands/mcp/collect.ts | 41 ++++-- packages/cli-core/src/commands/mcp/list.ts | 3 +- 7 files changed, 215 insertions(+), 63 deletions(-) create mode 100644 packages/cli-core/src/commands/doctor/check-mcp.test.ts diff --git a/packages/cli-core/src/commands/doctor/README.md b/packages/cli-core/src/commands/doctor/README.md index 95f44288..8eef28a3 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -25,17 +25,17 @@ clerk doctor --fix # Offer to auto-fix issues ## Checks -| Check | Category | What it verifies | -| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| Authentication token | Authentication | Credential store has a stored token | -| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | -| Project linkage | Project | Current directory is linked to a Clerk app | -| Linked application | Project | Linked application ID is accessible via the API | -| Instances | Project | Configured dev/prod instance IDs match the application's instances | -| Environment variables | Environment | .env.local or .env has Clerk keys | -| CLI configuration | Configuration | CLI config file exists and parses | -| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | -| MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake (skipped otherwise; warns, never fails) | +| Check | Category | What it verifies | +| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Authentication token | Authentication | Credential store has a stored token | +| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | +| Project linkage | Project | Current directory is linked to a Clerk app | +| Linked application | Project | Linked application ID is accessible via the API | +| Instances | Project | Configured dev/prod instance IDs match the application's instances | +| Environment variables | Environment | .env.local or .env has Clerk keys | +| CLI configuration | Configuration | CLI config file exists and parses | +| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | +| MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake; warns on an unreadable client config (skipped when nothing is installed; warns, never fails) | ## Auto-Fix (`--fix`) diff --git a/packages/cli-core/src/commands/doctor/check-mcp.test.ts b/packages/cli-core/src/commands/doctor/check-mcp.test.ts new file mode 100644 index 00000000..31bbdda7 --- /dev/null +++ b/packages/cli-core/src/commands/doctor/check-mcp.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; +import type { CollectResult } from "../mcp/collect.ts"; +import type { McpProbeResult } from "../mcp/probe.ts"; +import type { ListEntry } from "../mcp/clients/types.ts"; + +let collected: CollectResult; +let probes: Record; +let probedUrls: string[]; + +// Registered at file top, before check-mcp.ts loads its imports (this file +// runs in its own subprocess via scripts/run-tests.ts). +mock.module("../mcp/collect.ts", () => ({ + collectEntries: async () => collected, +})); +mock.module("../mcp/probe.ts", () => ({ + probeMcp: async (url: string): Promise => { + probedUrls.push(url); + return probes[url] ?? { ok: false, error: "unstubbed url" }; + }, +})); + +const { checkMcp } = await import("./check-mcp.ts"); + +const HOSTED = "https://mcp.clerk.com/mcp"; +const LOCAL = "http://localhost:9000/mcp"; + +function entry(client: ListEntry["client"], url: string): ListEntry { + return { client, configPath: `/tmp/${client}.json`, name: "clerk", url }; +} + +describe("checkMcp", () => { + useCaptureLog(); + + beforeEach(() => { + collected = { entries: [], failures: [] }; + probes = {}; + probedUrls = []; + }); + + test("passes as skipped when nothing is installed", async () => { + const result = await checkMcp(); + + expect(result.status).toBe("pass"); + expect(result.message).toContain("Skipped"); + }); + + test("warns naming the client when a config is unreadable, instead of passing as skipped", async () => { + collected = { + entries: [], + failures: [{ client: "Claude Code", message: "Could not parse ~/.claude.json as JSON" }], + }; + + const result = await checkMcp(); + + expect(result.status).toBe("warn"); + expect(result.message).toContain("Claude Code"); + expect(result.detail).toContain("Could not parse"); + expect(result.remedy).toContain("clerk mcp install"); + }); + + test("still reports probe failures alongside an unreadable config", async () => { + collected = { + entries: [entry("cursor", LOCAL)], + failures: [{ client: "Claude Code", message: "unreadable" }], + }; + probes = { [LOCAL]: { ok: false, status: 502 } }; + + const result = await checkMcp(); + + expect(result.status).toBe("warn"); + expect(result.message).toContain("Claude Code"); + expect(result.detail).toContain(`${LOCAL}: HTTP 502`); + }); + + test("passes with server names when every distinct URL is reachable, probing each once", async () => { + collected = { + entries: [entry("claude", HOSTED), entry("cursor", HOSTED)], + failures: [], + }; + probes = { [HOSTED]: { ok: true, status: 200, serverName: "Clerk MCP Server" } }; + + const result = await checkMcp(); + + expect(result.status).toBe("pass"); + expect(result.message).toBe(`Reachable — Clerk MCP Server (${HOSTED})`); + expect(probedUrls).toEqual([HOSTED]); + }); + + test("warns with singular wording when the only configured server is unreachable", async () => { + collected = { entries: [entry("claude", LOCAL)], failures: [] }; + probes = { [LOCAL]: { ok: false, error: "fetch failed" } }; + + const result = await checkMcp(); + + expect(result.status).toBe("warn"); + expect(result.message).toContain("Configured MCP server is not reachable"); + expect(result.detail).toBe(`${LOCAL}: fetch failed`); + }); + + test("a healthy first URL does not mask a broken second", async () => { + collected = { + entries: [entry("claude", HOSTED), entry("cursor", LOCAL)], + failures: [], + }; + probes = { + [HOSTED]: { ok: true, status: 200, serverName: "Clerk MCP Server" }, + [LOCAL]: { ok: false, error: "fetch failed" }, + }; + + const result = await checkMcp(); + + expect(result.status).toBe("warn"); + expect(result.message).toContain("One or more configured MCP servers are not reachable"); + expect(result.message).toContain(LOCAL); + expect(result.message).not.toContain(HOSTED); + }); +}); diff --git a/packages/cli-core/src/commands/doctor/check-mcp.ts b/packages/cli-core/src/commands/doctor/check-mcp.ts index 5778be37..02ecdb55 100644 --- a/packages/cli-core/src/commands/doctor/check-mcp.ts +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -8,6 +8,7 @@ import { collectEntries } from "../mcp/collect.ts"; import { probeMcp, type McpProbeResult } from "../mcp/probe.ts"; +import type { ListEntry } from "../mcp/clients/types.ts"; import type { CheckResult } from "./types.ts"; const NAME = "MCP server"; @@ -26,32 +27,57 @@ function describeFailure(result: McpProbeResult): string { return result.status !== undefined ? `HTTP ${result.status}` : "unknown"; } +function describeUnreachable(unreachable: UrlProbe[], total: number): string { + const subject = + total === 1 ? "Configured MCP server is" : "One or more configured MCP servers are"; + return `${subject} not reachable (${unreachable.map((p) => p.url).join(", ")})`; +} + +// 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. +async function probeEntries(entries: ListEntry[]): Promise { + const urls = [...new Set(entries.map((e) => e.url))]; + return Promise.all(urls.map(async (url) => ({ url, result: await probeMcp(url) }))); +} + 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()); + const { entries, failures } = await collectEntries(process.cwd()); + const probes = await probeEntries(entries); + const unreachable = probes.filter((p): p is UrlProbe => !p.result.ok); + + // An unreadable client config is not "nothing installed" — a previously + // registered entry may be hiding inside it. The stderr warning alone gets + // clobbered by the doctor spinner, so it must surface in the check result. + if (failures.length > 0) { + const clients = failures.map((f) => f.client).join(", "); + return { + name: NAME, + status: "warn", + message: `Could not read the MCP config for ${clients}`, + detail: [ + ...failures.map((f) => `${f.client}: ${f.message}`), + ...unreachable.map((p) => `${p.url}: ${describeFailure(p.result)}`), + ].join("; "), + remedy: "Fix or remove the unreadable config file, then re-run `clerk mcp install`.", + }; + } + 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.filter((p): p is UrlProbe => !p.result.ok); if (unreachable.length === 0) { const reachable = probes.filter((p): p is ReachableProbe => p.result.ok); return { name: NAME, status: "pass", message: `Reachable — ${describeReachable(reachable)}` }; } - 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.map((p) => p.url).join(", ")})`, + message: describeUnreachable(unreachable, probes.length), detail: unreachable.map((p) => `${p.url}: ${describeFailure(p.result)}`).join("; "), remedy: "Verify the server is running, or re-run `clerk mcp install` if the URL changed.", }; diff --git a/packages/cli-core/src/commands/mcp/clients/make-client.test.ts b/packages/cli-core/src/commands/mcp/clients/make-client.test.ts index f709c923..574cd03e 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-client.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-client.test.ts @@ -198,24 +198,27 @@ describe("make-client (via cursor)", () => { expect(entries).toEqual([]); }); - test("returns empty (does not throw) on malformed JSON", async () => { + test("rejects with MCP_CLIENT_CONFIG_INVALID on malformed JSON", async () => { + // list() propagates the failure so aggregating callers (collectEntries, + // uninstall's picker) can report "unreadable config" instead of folding + // a corrupt file into "no entries". 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([]); + await expect(cursorClient.list(cwd)).rejects.toMatchObject({ + code: "mcp_client_config_invalid", + }); }); - 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. + test("rejects with MCP_CLIENT_CONFIG_INVALID when the top-level key is not an object", async () => { + // Valid JSON, wrong shape: `mcpServers` is an array. 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([]); + await expect(cursorClient.list(cwd)).rejects.toMatchObject({ + code: "mcp_client_config_invalid", + }); }); }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/make-client.ts b/packages/cli-core/src/commands/mcp/clients/make-client.ts index 40746856..a548a58f 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-client.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-client.ts @@ -10,7 +10,6 @@ * 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 ConfigRecord } from "./json-config.ts"; import { readTomlConfig, writeTomlConfig } from "./toml-config.ts"; @@ -137,23 +136,13 @@ function makeFileClient(spec: FileClientSpec, codec: ConfigCodec): McpClient { 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; - } + // A half-written or structurally-invalid config propagates as + // MCP_CLIENT_CONFIG_INVALID. Aggregating callers (`collectEntries`, + // uninstall's picker) settle per client, so one bad config warns there + // without sinking the other clients — and `doctor` can tell "unreadable + // config" apart from "no entries" instead of reporting a clean pass. + const config = await codec.read(configPath); + const servers = getServerMap(config, spec.topKey, configPath); const entries: ListEntry[] = []; for (const [name, descriptor] of Object.entries(servers)) { const url = spec.extractUrl(descriptor); diff --git a/packages/cli-core/src/commands/mcp/collect.ts b/packages/cli-core/src/commands/mcp/collect.ts index 14dc2820..93d0032b 100644 --- a/packages/cli-core/src/commands/mcp/collect.ts +++ b/packages/cli-core/src/commands/mcp/collect.ts @@ -4,8 +4,9 @@ * 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. + * Clients settle independently, so a single bad config can't sink the + * aggregate — but it is reported as a failure, not folded into "no entries", + * so callers like `doctor` can tell a corrupt config apart from a clean slate. */ import { errorMessage } from "../../lib/errors.ts"; @@ -13,16 +14,30 @@ 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 { +/** A client whose config exists but couldn't be read or parsed. */ +export interface CollectFailure { + client: string; + message: string; +} + +export interface CollectResult { + entries: ListEntry[]; + failures: CollectFailure[]; +} + +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 []; - }); + const entries: ListEntry[] = []; + const failures: CollectFailure[] = []; + for (const [i, outcome] of settled.entries()) { + if (outcome.status === "fulfilled") { + entries.push(...outcome.value); + continue; + } + const client = CLIENTS[i]!.displayName; + const message = errorMessage(outcome.reason); + failures.push({ client, message }); + log.warn(`${client}: could not read MCP config — ${message}`); + } + return { entries, failures }; } diff --git a/packages/cli-core/src/commands/mcp/list.ts b/packages/cli-core/src/commands/mcp/list.ts index 0ed94079..34451801 100644 --- a/packages/cli-core/src/commands/mcp/list.ts +++ b/packages/cli-core/src/commands/mcp/list.ts @@ -45,7 +45,8 @@ function formatTable(entries: ListEntry[]): void { } export async function mcpList(options: McpOptions = {}): Promise { - const all: ListEntry[] = await collectEntries(process.cwd()); + // Unreadable configs are already warned per client by collectEntries. + const { entries: all } = await collectEntries(process.cwd()); if (wantsJson(options)) { log.data(JSON.stringify(all, null, 2)); From 89d5811998cfce4a2f8ea80f74f1de524a6cf9bd Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Mon, 6 Jul 2026 23:07:29 -0300 Subject: [PATCH 21/21] fix(mcp): drop inert run --name flag; reuse shared collect helper; sync docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `clerk mcp run --name` was documented but never read by the bridge and never written into client configs — removed (same rationale as --url). - mcp/index.ts now imports collectOptionValues from lib/option-parsers instead of redeclaring it. - MCP_URL_REQUIRED JSDoc no longer references the removed --url flag; root README help block regenerated so mcp sits between doctor and completion; add CONTEXT.md glossary for the MCP domain language. Claude-Session: https://claude.ai/code/session_013Qgw5hXZ3xiXysBbZ19cDS --- CONTEXT.md | 35 ++++++++++++++++++++ README.md | 2 +- packages/cli-core/src/commands/mcp/README.md | 7 ++-- packages/cli-core/src/commands/mcp/index.ts | 6 +--- packages/cli-core/src/lib/errors.ts | 2 +- 5 files changed, 40 insertions(+), 12 deletions(-) create mode 100644 CONTEXT.md diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..1a93608d --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,35 @@ +# Clerk CLI — MCP Integration + +The `clerk mcp` command suite connects AI tools to Clerk's remote MCP server. This context covers the domain language for registering, running, and diagnosing that connection. + +## Language + +**Client**: +An AI tool (Claude Code, Cursor, Codex, Gemini CLI, VS Code, Windsurf) whose config file can register MCP servers. +_Avoid_: editor, app, tool + +**Entry**: +A named server record inside a Client's config file. The default entry name is `clerk`. One Client holds many Entries; `clerk mcp` only manages Clerk's. +_Avoid_: server (that's the remote end), registration, config + +**Bridge**: +The `clerk mcp run` stdio process a Client spawns. It forwards newline-delimited JSON-RPC from the Client to the remote MCP server over Streamable HTTP. +_Avoid_: proxy, relay, server + +**Probe**: +A one-shot `initialize` round-trip against an MCP URL to judge reachability. Used by `clerk doctor`; never opens a session for real work. +_Avoid_: ping, health check + +**Scope**: +Where a Client's config lives — `user` (global, e.g. `~/.claude.json`) or `project` (checked into or beside the repo). Install targets user scope. + +**Unreadable config**: +A Client config file that exists but cannot be read or parsed. Distinct from "no Entry installed" — doctor must warn about the former and may skip on the latter. +_Avoid_: missing config, empty config + +## Example dialogue + +> **Dev:** `clerk doctor` says "Skipped — no Clerk MCP entry installed", but I installed yesterday. +> **Expert:** Then an Entry probably exists but its Client's config is an Unreadable config — doctor warns naming the Client rather than passing. Which Client? If Claude Code, check `~/.claude.json` parses. +> **Dev:** So the Bridge is broken? +> **Expert:** No — the Bridge only runs when a Client spawns it. This is an install-side problem: the Entry can't be read, so nothing ever spawns the Bridge. Fix the config, re-run `clerk mcp install`, and let doctor Probe the URL again. diff --git a/README.md b/README.md index 62d75178..2d6fcbca 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,13 @@ Commands: apps Manage your Clerk applications users [options] Manage Clerk users impersonate|imp [options] [user] Impersonate a Clerk user - 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 disable Disable Clerk features on the linked instance api [options] [endpoint] [filter] Make authenticated requests to the Clerk API doctor [options] Check your project's Clerk integration health + mcp Manage the Clerk remote MCP server connection for AI editors and CLIs completion [shell] Generate shell autocompletion script update [options] Update the Clerk CLI to the latest version deploy Deploy a Clerk application to production diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index 3c51b141..63d74257 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -107,11 +107,8 @@ It reads newline-delimited JSON-RPC from stdin, forwards each message to the remote server over the Streamable HTTP transport (POST; JSON or SSE responses), threads the `Mcp-Session-Id`, opens the optional server→client SSE stream, and writes replies to stdout. stdout carries **only** JSON-RPC frames; all -diagnostics go to stderr. - -| Flag | Description | -| --------------- | ----------------------------------------------------- | -| `--name ` | Accepted for parity with `install`. Default: `clerk`. | +diagnostics go to stderr. It takes no flags — the server URL is resolved from +`CLERK_MCP_URL` / the active env profile / the hosted default at spawn time. Transport-only: a `401`/`403` from the upstream is surfaced as an error (`mcp_client_config_invalid`) rather than triggering a sign-in flow. diff --git a/packages/cli-core/src/commands/mcp/index.ts b/packages/cli-core/src/commands/mcp/index.ts index 454512ae..c769fff9 100644 --- a/packages/cli-core/src/commands/mcp/index.ts +++ b/packages/cli-core/src/commands/mcp/index.ts @@ -1,5 +1,6 @@ import { createOption } from "@commander-js/extra-typings"; import type { Program } from "../../cli-program.ts"; +import { collectOptionValues } from "../../lib/option-parsers.ts"; import { mcpInstall } from "./install.ts"; import { mcpList } from "./list.ts"; import { mcpRun } from "./run.ts"; @@ -14,10 +15,6 @@ export const mcp = { }; export { CLIENT_ID_CHOICES, CLIENT_IDS } from "./clients/registry.ts"; -function collectOptionValues(value: string, previous: string[] = []): string[] { - return [...previous, value]; -} - export function registerMcp(program: Program): void { const mcpCmd = program .command("mcp") @@ -74,7 +71,6 @@ export function registerMcp(program: Program): void { .description( "stdio bridge to the remote MCP server (clients spawn this; not meant to be run by hand)", ) - .option("--name ", 'Entry name, for parity with install (default: "clerk")') .setExamples([ { command: "clerk mcp run", diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index eeda834b..315e3545 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -72,7 +72,7 @@ export const ERROR_CODE = { MCP_CLIENT_NOT_SUPPORTED: "mcp_client_not_supported", /** Existing MCP client config is malformed or has a conflicting entry. */ MCP_CLIENT_CONFIG_INVALID: "mcp_client_config_invalid", - /** The provided `--url` is malformed or uses a non-http(s) scheme. */ + /** The resolved MCP URL (`CLERK_MCP_URL` / env profile / hosted default) is malformed or uses a non-http(s) scheme. */ MCP_URL_REQUIRED: "mcp_url_required", } as const;