From 42ad04df8ae39b97c46207f8990e5338f938a9c1 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Tue, 21 Jul 2026 17:58:57 -0300 Subject: [PATCH 1/4] feat(mcp): add clerk mcp install/list/uninstall/run across 10 clients + doctor MCP check Registers the Clerk remote MCP server (https://mcp.clerk.com/mcp) in AI editors and agent CLIs, with an MCP reachability check in `clerk doctor`. Supported clients (all user-global): Claude Code, Cursor, GitHub Copilot / VS Code (`--client vscode` or `copilot`), Windsurf, Gemini, Codex, opencode, OpenClaw, Warp, and Hermes Agent. Registration is delegated to each client's own CLI where a non-interactive one exists (`claude`/`gemini`/`codex`/`openclaw`/`hermes` `mcp add`, `code --add-mcp`), with PATH-based detection and no file-write fallback; Cursor, Windsurf, Warp, and opencode (interactive-only CLI) get atomic file writes. Install always converges via remove-then-add. Client CLIs are spawned with stdin closed and a 15s timeout; Windows `.cmd` shims run through `cmd.exe /c`; Hermes gets its confirm prompt answered via stdin and a post-add verification because its cancel path exits 0 without saving. Every client stores the same URL-less bridge descriptor `{command: "clerk", args: ["mcp", "run"]}`. `clerk mcp run` is a built-in stdio<->Streamable-HTTP bridge (replaces npx mcp-remote) that resolves its target at spawn time (CLERK_MCP_URL > env profile mcpUrl > hosted default), serializes stdout frames, caps both stdin lines and upstream SSE/JSON bodies at 16 MiB, and surfaces pre-session 401/403 as a clear error while answering in-session auth failures per-request. Reads stay file-based (JSON/TOML/YAML codecs; TOML and YAML are read-only since those clients' CLIs own both mutations). `--name` is allowlisted ([A-Za-z0-9_-], no leading dash) because it is spliced into client CLI argv and used as a config key. `list --json` reports { entries, failures } so an unreadable config surfaces structurally; install/uninstall carry the same failures array and exit non-zero only when every targeted client fails. Agent mode never prompts and always emits JSON with machine-readable error codes plus a docs URL (clerk.com/docs/guides/ai/mcp/clerk-mcp-server). Verified live against the real client CLIs on macOS: full uninstall/install round-trips with zero failures; opencode reports "clerk connected" and Hermes "clerk enabled" through their own CLIs; OpenClaw's dialect verified in a sandboxed OPENCLAW_STATE_DIR; bridge handshake against the hosted server returns "Clerk MCP Server". --- .changeset/mcp-install.md | 5 + README.md | 2 + bun.lock | 2 +- packages/cli-core/src/cli-program.ts | 2 + .../cli-core/src/commands/doctor/README.md | 21 +- .../src/commands/doctor/check-mcp.test.ts | 124 +++++ .../cli-core/src/commands/doctor/check-mcp.ts | 84 ++++ .../src/commands/doctor/context.test.ts | 24 +- .../cli-core/src/commands/doctor/index.ts | 2 + packages/cli-core/src/commands/mcp/README.md | 223 +++++++++ .../src/commands/mcp/clients/claude.ts | 43 ++ .../commands/mcp/clients/clerk-run.test.ts | 65 +++ .../src/commands/mcp/clients/clerk-run.ts | 82 +++ .../src/commands/mcp/clients/cli-exec.test.ts | 111 +++++ .../src/commands/mcp/clients/cli-exec.ts | 90 ++++ .../src/commands/mcp/clients/clients.test.ts | 357 +++++++++++++ .../src/commands/mcp/clients/codex.ts | 31 ++ .../src/commands/mcp/clients/cursor.ts | 23 + .../src/commands/mcp/clients/gemini.ts | 71 +++ .../src/commands/mcp/clients/hermes.ts | 38 ++ .../src/commands/mcp/clients/json-config.ts | 117 +++++ .../mcp/clients/make-cli-client.test.ts | 324 ++++++++++++ .../commands/mcp/clients/make-cli-client.ts | 142 ++++++ .../commands/mcp/clients/make-client.test.ts | 322 ++++++++++++ .../src/commands/mcp/clients/make-client.ts | 223 +++++++++ .../src/commands/mcp/clients/openclaw.ts | 42 ++ .../src/commands/mcp/clients/opencode.ts | 47 ++ .../src/commands/mcp/clients/paths.ts | 80 +++ .../src/commands/mcp/clients/registry.ts | 45 ++ .../src/commands/mcp/clients/toml-config.ts | 40 ++ .../src/commands/mcp/clients/types.ts | 81 +++ .../commands/mcp/clients/user-scope.test.ts | 372 ++++++++++++++ .../src/commands/mcp/clients/vscode.ts | 39 ++ .../cli-core/src/commands/mcp/clients/warp.ts | 24 + .../src/commands/mcp/clients/windsurf.ts | 22 + .../commands/mcp/clients/yaml-config.test.ts | 55 ++ .../src/commands/mcp/clients/yaml-config.ts | 35 ++ packages/cli-core/src/commands/mcp/collect.ts | 44 ++ packages/cli-core/src/commands/mcp/index.ts | 108 ++++ .../cli-core/src/commands/mcp/install.test.ts | 313 ++++++++++++ packages/cli-core/src/commands/mcp/install.ts | 96 ++++ .../cli-core/src/commands/mcp/list.test.ts | 118 +++++ packages/cli-core/src/commands/mcp/list.ts | 84 ++++ .../cli-core/src/commands/mcp/probe.test.ts | 107 ++++ packages/cli-core/src/commands/mcp/probe.ts | 107 ++++ .../cli-core/src/commands/mcp/run.test.ts | 468 ++++++++++++++++++ packages/cli-core/src/commands/mcp/run.ts | 454 +++++++++++++++++ packages/cli-core/src/commands/mcp/shared.ts | 188 +++++++ .../src/commands/mcp/uninstall.test.ts | 231 +++++++++ .../cli-core/src/commands/mcp/uninstall.ts | 109 ++++ packages/cli-core/src/lib/environment.ts | 20 + packages/cli-core/src/lib/errors.ts | 12 + packages/cli-core/src/lib/input-json.test.ts | 21 + packages/cli-core/src/lib/input-json.ts | 3 +- packages/cli-core/src/lib/objects.ts | 8 + packages/cli-core/src/lib/prompts.ts | 26 + 56 files changed, 5901 insertions(+), 26 deletions(-) create mode 100644 .changeset/mcp-install.md create mode 100644 packages/cli-core/src/commands/doctor/check-mcp.test.ts 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.ts 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 create mode 100644 packages/cli-core/src/commands/mcp/clients/cli-exec.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/cli-exec.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/clients.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/codex.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/hermes.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-cli-client.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/make-cli-client.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/make-client.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/make-client.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/openclaw.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/opencode.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/toml-config.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/warp.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/windsurf.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/yaml-config.test.ts create mode 100644 packages/cli-core/src/commands/mcp/clients/yaml-config.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/run.test.ts create mode 100644 packages/cli-core/src/commands/mcp/run.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 create mode 100644 packages/cli-core/src/lib/objects.ts diff --git a/.changeset/mcp-install.md b/.changeset/mcp-install.md new file mode 100644 index 00000000..a60d3c7b --- /dev/null +++ b/.changeset/mcp-install.md @@ -0,0 +1,5 @@ +--- +"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, Codex, opencode, OpenClaw, Warp, and Hermes Agent. Clients that ship a non-interactive MCP registration CLI are registered by shelling out to it — `claude mcp add`, `gemini mcp add`, `codex mcp add`, `code --add-mcp`, `openclaw mcp add --no-probe`, `hermes mcp add` — so each client owns its config format and write safety; for those, the client's binary must be on PATH (detection is PATH-based, no file-write fallback). Cursor, Windsurf, Warp (no CLI), and opencode (interactive-only CLI) get their user-global config file written directly, and VS Code removal (add-only CLI) edits its `mcp.json` in place. `clerk mcp list --json` reports `{ entries, failures }` so an unreadable client config surfaces structurally instead of reading as "nothing installed". Install always converges: an existing entry under the same name is replaced. 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. `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/README.md b/README.md index b6eb6d61..ab88d248 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,11 @@ Commands: 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 + webhooks Stream webhook events to a local handler and verify their signatures help [command] Display help for command bird Play Clerk Bird, a Flappy Bird game in your terminal ``` diff --git a/bun.lock b/bun.lock index 06c4d579..d3cc3cf5 100644 --- a/bun.lock +++ b/bun.lock @@ -19,7 +19,7 @@ }, "packages/cli": { "name": "clerk", - "version": "2.0.0", + "version": "2.2.0", "bin": { "clerk": "./bin/clerk", }, 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..8eef28a3 100644 --- a/packages/cli-core/src/commands/doctor/README.md +++ b/packages/cli-core/src/commands/doctor/README.md @@ -25,16 +25,17 @@ clerk doctor --fix # Offer to auto-fix issues ## Checks -| Check | Category | What it verifies | -| --------------------- | -------------- | ------------------------------------------------------------------ | -| Authentication token | Authentication | Credential store has a stored token | -| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | -| Project linkage | Project | Current directory is linked to a Clerk app | -| Linked application | Project | Linked application ID is accessible via the API | -| Instances | Project | Configured dev/prod instance IDs match the application's instances | -| Environment variables | Environment | .env.local or .env has Clerk keys | -| CLI configuration | Configuration | CLI config file exists and parses | -| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | +| Check | Category | What it verifies | +| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Authentication token | Authentication | Credential store has a stored token | +| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) | +| Project linkage | Project | Current directory is linked to a Clerk app | +| Linked application | Project | Linked application ID is accessible via the API | +| Instances | Project | Configured dev/prod instance IDs match the application's instances | +| Environment variables | Environment | .env.local or .env has Clerk keys | +| CLI configuration | Configuration | CLI config file exists and parses | +| Shell completion | Configuration | Shell autocompletion is installed for the detected shell | +| MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake; 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..b9f3fb97 --- /dev/null +++ b/packages/cli-core/src/commands/doctor/check-mcp.test.ts @@ -0,0 +1,124 @@ +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", + displayName: "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", displayName: "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 new file mode 100644 index 00000000..b6aad7ea --- /dev/null +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -0,0 +1,84 @@ +/** + * `clerk doctor` MCP reachability check. + * + * Kept in its own file — rather than `checks.ts` — so the doctor check graph + * doesn't import `mcp/shared.ts` (env profiles, prompts) and the module cycle + * that comes with it. Imports only the light `collect`/`probe` helpers. + */ + +import { collectEntries } from "../mcp/collect.ts"; +import { probeMcp, type McpProbeResult } from "../mcp/probe.ts"; +import type { ListEntry } from "../mcp/clients/types.ts"; +import type { CheckResult } from "./types.ts"; + +const NAME = "MCP server"; + +type UrlProbe = { url: string; result: McpProbeResult }; +type ReachableProbe = { url: string; result: Extract }; + +// 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 { + if (result.ok) return "unknown"; + if (result.error !== undefined) return result.error; + 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, 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.displayName).join(", "); + return { + name: NAME, + status: "warn", + message: `Could not read the MCP config for ${clients}`, + detail: [ + ...failures.map((f) => `${f.displayName}: ${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)" }; + } + + if (unreachable.length === 0) { + const reachable = probes.filter((p): p is ReachableProbe => p.result.ok); + return { name: NAME, status: "pass", message: `Reachable — ${describeReachable(reachable)}` }; + } + + return { + name: NAME, + status: "warn", + 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/doctor/context.test.ts b/packages/cli-core/src/commands/doctor/context.test.ts index a857c381..d0e852eb 100644 --- a/packages/cli-core/src/commands/doctor/context.test.ts +++ b/packages/cli-core/src/commands/doctor/context.test.ts @@ -1,11 +1,6 @@ -import { test, expect, describe, mock, beforeEach, afterEach } from "bun:test"; -import { - useCaptureLog, - credentialStoreStubs, - configStubs, - gitStubs, - stubFetch, -} from "../../test/lib/stubs.ts"; +import { test, expect, describe, mock, spyOn, beforeEach, afterEach, afterAll } from "bun:test"; +import { useCaptureLog, credentialStoreStubs, gitStubs, stubFetch } from "../../test/lib/stubs.ts"; +import * as config from "../../lib/config.ts"; import type { Application } from "../../lib/plapi.ts"; const mockGetToken = mock(); @@ -15,12 +10,13 @@ mock.module("../../lib/credential-store.ts", () => ({ getToken: (...args: unknown[]) => mockGetToken(...args), })); +// spyOn (not mock.module) for config: a spy is restorable, so afterAll hands the +// real module back to doctor.test.ts when both run in one `bun test` process. const mockResolveProfile = mock(); - -mock.module("../../lib/config.ts", () => ({ - ...configStubs, - resolveProfile: (...args: unknown[]) => mockResolveProfile(...args), -})); +const resolveProfileSpy = spyOn(config, "resolveProfile").mockImplementation((...args: unknown[]) => + mockResolveProfile(...(args as [string])), +); +afterAll(() => resolveProfileSpy.mockRestore()); mock.module("../../lib/git.ts", () => gitStubs); @@ -70,7 +66,7 @@ describe("createDoctorContext", () => { const p1 = ctx.getToken(); const p2 = ctx.getToken(); - expect(p1).toBe(p2); // Same promise reference + expect(p1).toBe(p2); expect(await p1).toBe("test_token"); expect(mockGetToken).toHaveBeenCalledTimes(1); }); diff --git a/packages/cli-core/src/commands/doctor/index.ts b/packages/cli-core/src/commands/doctor/index.ts index 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..4814cf65 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/README.md @@ -0,0 +1,223 @@ +# `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, and remove the Clerk entry per client, and +probe the server via `clerk doctor`. Clients that ship a **non-interactive** MCP +registration CLI (Claude Code, Gemini, Codex, VS Code, OpenClaw, Hermes) are +registered by shelling out to it — the client owns its config format and write +safety; for the rest (Cursor, Windsurf, Warp, opencode) we write the config +file directly. opencode does ship an `mcp add` command, but it is an +interactive wizard with no flag-driven stdio path (and no remove command), so +it counts as file-backed. Reads (`list`, `doctor`, the uninstall picker) +always parse the config files directly. 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, +`clerk mcp install` works out of the box with no flags or profile setup. +`CLERK_MCP_URL` is the convenient override when developing the worker locally +(e.g. `http://localhost:8787/mcp`). + +No Clerk API endpoints are called. To verify the server is reachable, run +`clerk doctor` — its MCP check performs the `initialize` handshake against each +distinct configured URL whenever a Clerk MCP entry is installed. + +## Supported clients + +All entries are written to each client's **user-global** config, so the server +is available in every project (no per-project approval, no dependence on which +directory you run the CLI from). + +| ID | Client | Registered via | Removed via | Config file (read for `list`/`doctor`) | +| -------------------- | ------------------------ | ------------------------------------------- | -------------------- | --------------------------------------------- | +| `claude` | Claude Code | `claude mcp add --scope user` | `claude mcp remove` | `~/.claude.json` (`mcpServers`) | +| `cursor` | Cursor | direct file write (no CLI exists) | direct file write | `~/.cursor/mcp.json` | +| `vscode` (`copilot`) | GitHub Copilot (VS Code) | `code --add-mcp ''` | direct file write | VS Code user `mcp.json` (per-OS, below) | +| `windsurf` | Windsurf | direct file write (no CLI exists) | direct file write | `~/.codeium/windsurf/mcp_config.json` | +| `gemini` | Gemini Code Assist / CLI | `gemini mcp add --scope user` | `gemini mcp remove` | `~/.gemini/settings.json` | +| `codex` | Codex | `codex mcp add` | `codex mcp remove` | `~/.codex/config.toml` (`mcp_servers`) | +| `opencode` | opencode | direct file write (CLI is interactive-only) | direct file write | `opencode.json` in the XDG config dir (`mcp`) | +| `openclaw` | OpenClaw | `openclaw mcp add --no-probe` | `openclaw mcp unset` | `~/.openclaw/openclaw.json` (`mcp.servers`) | +| `warp` | Warp | direct file write (no CLI exists) | direct file write | `~/.warp/.mcp.json` | +| `hermes` | Hermes Agent | `hermes mcp add` | `hermes mcp remove` | `~/.hermes/config.yaml` (`mcp_servers`) | + +For CLI-registered clients there is **no file-write fallback**: if the client's +binary isn't on PATH (e.g. VS Code without the `code` shell command installed), +that client fails with an actionable error (`mcp_client_cli_not_found`), and +detection treats the client as absent — the picker and `--all` only offer +clients whose CLI can actually be driven. Client CLIs are spawned with stdin +closed and a 15s timeout, so a CLI that tries to prompt fails cleanly instead +of hanging agent-mode runs. + +GitHub Copilot's MCP server lives in VS Code's config, so `--client copilot` and +`--client vscode` are aliases for the same client. VS Code has an add CLI but no +removal counterpart, so `uninstall` (and the pre-clean before a re-install) +edits its `mcp.json` directly. 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**. + +**Configs owned by a client's CLI are read-only to us.** The file layer exists +for two different jobs: _reads_ (every client — `list`, `doctor`, and the +presence checks parse the config files, because no client CLI offers a stable +machine-readable listing) and _writes_ (only the clients with no usable +registration CLI: Cursor, Windsurf, Warp, opencode — plus VS Code's +removal, since its CLI is add-only). For every CLI-delegated client +(Claude Code, Gemini, Codex, OpenClaw, Hermes) the file base is built +read-only and a write reaching it throws — Codex is the one TOML-backed +client (`[mcp_servers.]`), Hermes the one YAML-backed client (`--args` +is passed last to its CLI because it swallows the rest of the argv). + +Per-client dialect notes: + +- **opencode** nests entries under top-level `mcp` and uses a single argv + array: `{ "type": "local", "command": ["clerk", "mcp", "run"] }`. Its config + root follows XDG on every platform: `$XDG_CONFIG_HOME/opencode/opencode.json` + (default `~/.config/opencode/opencode.json`) on macOS/Linux, + `%APPDATA%\opencode\opencode.json` on Windows. +- **OpenClaw** nests its server map at `mcp.servers.`. `add` is passed + `--no-probe` because OpenClaw test-connects new servers by default and the + hosted Clerk server requires OAuth — the probe would fail an otherwise valid + registration. Its `unset` errors on a missing name, so removal is skipped + when our read shows no entry. +- **Warp** ships no registration CLI (its `oz` CLI only attaches servers to + cloud-agent runs); `~/.warp/.mcp.json` is the documented file surface behind + `Settings → Agents → MCP servers`, standard `mcpServers` dialect. +- **Hermes** `mcp add` probes the server and then ends in a confirm prompt + ("Enable all tools?" on success, "Save config anyway?" on failure) — and + cancelling on EOF exits **0** without saving. The CLI is therefore driven + with the affirmative answer piped to stdin, and after add we re-read the + config and fail with `mcp_client_cli_failed` if the entry didn't land, since + the exit code alone can't be trusted. `hermes mcp remove` takes its default + (yes) on EOF, so removal needs no piped input. + +## How clients connect (the stdio bridge) + +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"] } +``` + +`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"`; opencode uses its +`{ "type": "local", "command": [argv…] }` dialect; the others use the plain +shape above. Codex writes the equivalent TOML (`command`/`args` under +`[mcp_servers.]`) and Hermes' CLI writes the equivalent YAML. + +> **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. 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 + +### `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. | +| `--name ` | Entry key in the client config. Default: `clerk`. | +| `--json` | Emit a JSON summary on stdout instead of human-formatted output. | + +**Install always converges:** whatever entry currently sits under `--name` +(a legacy shape, a stale URL, an unrelated server that happens to share the +name) is replaced with the current bridge entry. For CLI-registered clients +this is a best-effort `remove` followed by `add` through the client's own CLI +(so re-install works no matter how the CLI treats duplicate names); for +file-backed clients the entry is overwritten in place. Success reports +`status: installed` per client. Failures are warned per client on stderr and +listed in the `--json` output's `failures` array (`{ client, error }`); +`uninstall --json` reports the same shape. The command exits non-zero only +when every targeted client fails. + +**After install:** registering the entry 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**, which then spawns `clerk mcp run` +(so `clerk` must be on the editor's `PATH`). + +> **Concurrent writes:** for CLI-registered clients, write safety is the +> client's own responsibility — its CLI owns the config. The file-backed +> clients (Cursor, Windsurf, VS Code removal) are written atomically (temp +> file + rename), which prevents a torn read but not a lost update if the +> editor rewrites its own config concurrently — those writes are safest with +> the target client closed. + +### `clerk mcp list` + +Print every Clerk-flavored MCP entry across all supported clients (entries +named `clerk` or pointing at any `*.clerk.com` host). The `--json` (and +agent-mode) output is `{ entries, failures }`: a client whose config exists but +can't be read or parsed appears in `failures` (`{ client, error }`) rather than +being silently folded into "no entries" — the same structural-failure contract +as `install`/`uninstall`. In human mode, an unreadable config downgrades the +"nothing installed" hint to a "could not be read" warning. + +### `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. 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 before a session exists (the +initial handshake) throws `mcp_client_config_invalid` and kills the bridge; +once a session id exists, the same status is instead answered per-request as a +JSON-RPC error (`-32001`, "requires authentication") and the bridge keeps +running. + +### `clerk mcp uninstall` + +Remove the entry. For CLI-registered clients (claude, gemini, codex, openclaw, +hermes), removal runs the client's own remove command; when our read of the +config shows no entry, `removed: false` is reported without invoking any CLI, +and when the entry is present but the client's binary is missing, that client +fails with `mcp_client_cli_not_found`. Cursor, Windsurf, Warp, opencode, and +VS Code (add-only CLI) are removed by editing the config file directly. + +In human mode with no `--client`/`--all`, it prompts with a +multiselect of the clients that **currently have the entry**, all unchecked: +check the clients to remove the entry from and leave the rest unchecked, so the +default (nothing checked) removes nothing. `--all` removes from every client +without prompting; agent mode targets all clients; `--client ` (repeatable) +targets specific clients. When nothing matches, it prints a warm hint to run +`clerk mcp install` (no error, exit 0). Removing the entry doesn't drop a live +editor session, so (in human mode) it prints a next step to reload each affected +editor. + +> **Reachability:** there is no `mcp doctor` subcommand. Server health is part +> of `clerk doctor`, which probes each distinct configured MCP URL via the +> `initialize` handshake when an entry is installed (warns, does not fail, when +> any is unreachable). + +## Error codes + +Errors that block registration (`mcp_no_client_detected`, +`mcp_client_cli_not_found`, `mcp_client_cli_failed`) carry a `docsUrl` pointing +at the [Clerk MCP server docs](https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server), +which document per-client manual setup — the fallback path when the CLI can't +drive a client (agent mode receives the raw-markdown `.md` variant). + +| Code | Meaning | +| --------------------------- | ------------------------------------------------------------------------- | +| `mcp_no_client_detected` | No supported client found on the system. | +| `mcp_client_not_supported` | `--client ` is not in the supported list. | +| `mcp_client_config_invalid` | An existing client config file is malformed. | +| `mcp_url_required` | The resolved MCP URL is malformed or uses a non-http(s) scheme. | +| `mcp_client_cli_not_found` | The client's own CLI (e.g. `claude`, `code`) is not on PATH. | +| `mcp_client_cli_failed` | The client's own CLI exited non-zero or timed out during register/remove. | diff --git a/packages/cli-core/src/commands/mcp/clients/claude.ts b/packages/cli-core/src/commands/mcp/clients/claude.ts new file mode 100644 index 00000000..586c7d1a --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/claude.ts @@ -0,0 +1,43 @@ +/** + * Registration is delegated to Claude Code's own CLI: + * `claude mcp add --scope user … -- clerk mcp run`, so Claude Code owns its + * config format and write safety. The file-backed base still reads the + * user-global `~/.claude.json` (`mcpServers`) — the store `--scope user` + * writes to — for `list`/`doctor`; legacy `{ type: "http", url }` entries + * still round-trip there. + */ + +import { clerkRunArgs, clerkRunDescriptor, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { makeCliClient } from "./make-cli-client.ts"; +import { makeReadOnlyJsonClient } from "./make-client.ts"; +import { userPath } from "./paths.ts"; + +const claudeFileClient = makeReadOnlyJsonClient({ + id: "claude", + displayName: "Claude Code", + scope: "user", + activation: "Restart Claude Code, then run `/mcp` to connect (`clerk` must be on your PATH).", + topKey: "mcpServers", + encode: clerkRunDescriptor, + extractUrl: withLegacyUrl, + configPath: () => userPath(".claude.json"), +}); + +export const claudeClient = makeCliClient({ + base: claudeFileClient, + binary: "claude", + installHint: "Install Claude Code: https://claude.com/claude-code", + addArgs: (name) => [ + "mcp", + "add", + "--scope", + "user", + "--transport", + "stdio", + name, + "--", + RUN_COMMAND, + ...clerkRunArgs(), + ], + removeArgs: (name) => ["mcp", "remove", "--scope", "user", name], +}); 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..6c9f3521 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/clerk-run.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from "bun:test"; +import { clerkRunArgs, extractClerkRunUrl, isClerkRunEntry, RUN_COMMAND } from "./clerk-run.ts"; + +describe("clerk-run descriptor", () => { + test("clerkRunArgs builds the run invocation without a URL", () => { + expect(clerkRunArgs()).toEqual(["mcp", "run"]); + }); + + test("isClerkRunEntry recognises the current no-URL shape", () => { + expect(isClerkRunEntry({ command: RUN_COMMAND, args: clerkRunArgs() })).toBe(true); + }); + + test("isClerkRunEntry rejects a legacy --url entry", () => { + expect( + isClerkRunEntry({ + command: "clerk", + args: ["mcp", "run", "--url", "https://mcp.clerk.com/mcp"], + }), + ).toBe(false); + }); + + 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); + }); + + 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 new file mode 100644 index 00000000..b8061bcd --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/clerk-run.ts @@ -0,0 +1,82 @@ +/** + * 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 { isRecord } from "../../../lib/objects.ts"; +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"; + +/** Args written into editor configs when installing the MCP bridge. */ +export function clerkRunArgs(): string[] { + return ["mcp", "run"]; +} + +/** + * The standard `{ command, args }` bridge descriptor most clients store. + * Clients with a different dialect (VS Code's `type: "stdio"` tag, opencode's + * single argv array) build their own shape from `RUN_COMMAND`/`clerkRunArgs`. + */ +export function clerkRunDescriptor(): Record { + return { command: RUN_COMMAND, args: clerkRunArgs() }; +} + +/** + * 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 (!isRecord(descriptor)) 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; +} + +/** + * 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 (!isRecord(descriptor)) 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 + * 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/cli-exec.test.ts b/packages/cli-core/src/commands/mcp/clients/cli-exec.test.ts new file mode 100644 index 00000000..ea0579a8 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/cli-exec.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, test } from "bun:test"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import { findClientBinary, runClientCli, toSpawnArgv } from "./cli-exec.ts"; + +useCaptureLog(); + +// Spawn Bun itself (`process.execPath`) so the tests exercise real subprocesses +// without depending on any client CLI being installed. +const BUN = process.execPath; + +describe("runClientCli", () => { + test("captures exit code, stdout, and stderr", async () => { + const result = await runClientCli([ + BUN, + "-e", + 'console.log("to-stdout"); console.error("to-stderr"); process.exit(3);', + ]); + expect(result.exitCode).toBe(3); + expect(result.stdout).toContain("to-stdout"); + expect(result.stderr).toContain("to-stderr"); + }); + + test("returns exit 0 for a successful command", async () => { + const result = await runClientCli([BUN, "-e", "process.exit(0);"]); + expect(result.exitCode).toBe(0); + }); + + test("feeds provided stdin to the child, then EOF", async () => { + // Hermes' `mcp add` ends in an interactive y/N prompt and cancels on bare + // EOF — clients like it get their answers piped in instead. + const result = await runClientCli( + [ + BUN, + "-e", + 'let d = ""; process.stdin.on("data", (c) => (d += c)); process.stdin.on("end", () => { console.log(`got:${d.trim()}`); process.exit(0); });', + ], + { stdin: "y\n" }, + ); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("got:y"); + }); + + test("closes stdin so a CLI waiting for input sees EOF instead of blocking", async () => { + // The child exits with a marker code only when stdin reaches EOF. If stdin + // were an open pipe, this would hang until the timeout instead. + const result = await runClientCli([ + BUN, + "-e", + 'process.stdin.resume(); process.stdin.on("end", () => process.exit(7));', + ]); + expect(result.exitCode).toBe(7); + }); + + test("kills the process and rejects when the timeout elapses", async () => { + await expect( + runClientCli([BUN, "-e", "setTimeout(() => {}, 30_000);"], { timeoutMs: 250 }), + ).rejects.toMatchObject({ + code: "mcp_client_cli_failed", + docsUrl: expect.stringContaining("https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server"), + }); + }); + + test("names the command in the timeout error", async () => { + await expect( + runClientCli([BUN, "-e", "setTimeout(() => {}, 30_000);"], { timeoutMs: 250 }), + ).rejects.toThrow(/timed out/); + }); +}); + +describe("toSpawnArgv", () => { + // npm-installed client CLIs resolve to `.cmd`/`.bat` shims on Windows, which + // are cmd.exe scripts, not executables — they must be launched through + // `cmd.exe /c` (Node's child_process has the same constraint). + test.each([ + ["C:\\Users\\me\\AppData\\Roaming\\npm\\claude.CMD", "win32"], + ["C:\\nvm\\gemini.bat", "win32"], + ] as const)("wraps %s in cmd.exe /c on win32", (bin, platform) => { + expect(toSpawnArgv([bin, "mcp", "add"], platform)).toEqual([ + "cmd.exe", + "/c", + bin, + "mcp", + "add", + ]); + }); + + test("leaves a win32 .exe untouched", () => { + expect(toSpawnArgv(["C:\\bin\\claude.exe", "mcp"], "win32")).toEqual([ + "C:\\bin\\claude.exe", + "mcp", + ]); + }); + + test("leaves POSIX binaries untouched even with a .cmd-looking name", () => { + expect(toSpawnArgv(["/usr/local/bin/claude", "mcp"], "darwin")).toEqual([ + "/usr/local/bin/claude", + "mcp", + ]); + }); +}); + +describe("findClientBinary", () => { + test("resolves a binary that exists on PATH", () => { + // `bun` is guaranteed on PATH in this repo — the test suite runs under it. + expect(findClientBinary("bun")).toBeTruthy(); + }); + + test("returns null for a binary that does not exist", () => { + expect(findClientBinary("definitely-not-a-real-client-cli-xyz")).toBeNull(); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/clients/cli-exec.ts b/packages/cli-core/src/commands/mcp/clients/cli-exec.ts new file mode 100644 index 00000000..d8619008 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/cli-exec.ts @@ -0,0 +1,90 @@ +/** + * Subprocess runner for MCP client CLIs (`claude`, `gemini`, `codex`, `code`, + * `openclaw`, `hermes`). + * + * Registration is delegated to each client's own CLI, which is trusted to + * manage its config format. Two guardrails keep that delegation from breaking + * the agent-mode "never blocks" guarantee: by default stdin is closed so a CLI + * that tries to prompt sees EOF and errors instead of waiting (a client can + * opt into piped stdin instead, as Hermes does via `addStdin`), and a hard + * timeout kills a CLI that hangs anyway (e.g. polling for a TTY). + */ + +import { CliError, ERROR_CODE } from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { MCP_DOCS_URL } from "./types.ts"; + +/** Resolve a client CLI binary on PATH. Null when the CLI isn't installed. */ +export function findClientBinary(binary: string): string | null { + return Bun.which(binary); +} + +interface ClientCliResult { + exitCode: number; + stdout: string; + stderr: string; +} + +const CLIENT_CLI_TIMEOUT_MS = 15_000; + +/** + * Adapt an argv for the host platform. On Windows, npm-installed client CLIs + * (`claude`, `gemini`, …) resolve to `.cmd`/`.bat` shims — cmd.exe scripts that + * can't be spawned directly and must run through `cmd.exe /c`. + */ +export function toSpawnArgv( + argv: [string, ...string[]], + platform: NodeJS.Platform = process.platform, +): [string, ...string[]] { + if (platform === "win32" && /\.(cmd|bat)$/i.test(argv[0])) { + return ["cmd.exe", "/c", ...argv]; + } + return argv; +} + +/** + * Run a client CLI to completion and capture its output. Non-zero exits are + * returned, not thrown — interpreting failure (and naming the client in the + * message) is the caller's job. Only a timeout rejects, because there is no + * exit result to return once the process is killed. + * + * `stdin` pipes the given text to the child then closes (for CLIs whose + * command ends in a y/N prompt, e.g. Hermes' `mcp add`); by default stdin is + * closed immediately so a prompting CLI sees EOF instead of blocking. + */ +export async function runClientCli( + argv: [string, ...string[]], + options: { timeoutMs?: number; stdin?: string } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? CLIENT_CLI_TIMEOUT_MS; + const proc = Bun.spawn(toSpawnArgv(argv), { + stdin: options.stdin === undefined ? "ignore" : Buffer.from(options.stdin), + stdout: "pipe", + stderr: "pipe", + }); + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + proc.kill(); + }, timeoutMs); + try { + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + if (timedOut) { + throw new CliError( + `\`${argv.join(" ")}\` timed out after ${Math.round(timeoutMs / 1000)}s — the client CLI may be waiting for input.`, + { code: ERROR_CODE.MCP_CLIENT_CLI_FAILED, docsUrl: MCP_DOCS_URL }, + ); + } + // On failure, include the CLI's output so `--verbose` leaves a full trail + // even when the caller swallows the result (e.g. the best-effort pre-clean). + const detail = exitCode === 0 ? "" : ` — ${(stderr.trim() || stdout.trim()).slice(0, 500)}`; + log.debug(`mcp: exec \`${argv.join(" ")}\` → exit ${exitCode}${detail}`); + return { exitCode, stdout, stderr }; + } finally { + clearTimeout(timer); + } +} 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..b2640bf6 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -0,0 +1,357 @@ +import type { findClientBinary, runClientCli } from "./cli-exec.ts"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import * as realOs from "node:os"; +import { join } from "node:path"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; +import type { McpClient } from "./types.ts"; + +// Every client reads/writes under the user's home, 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 })); + +// CLI-backed clients spawn their client's binary; stub the subprocess layer so +// the argv contract is asserted without real CLIs installed. +const mockRun = mock(); +const mockWhich = mock(); +mock.module("./cli-exec.ts", () => ({ + findClientBinary: mockWhich, + runClientCli: mockRun, +})); +afterAll(() => mock.restore()); + +const { claudeClient } = await import("./claude.ts"); +const { cursorClient } = await import("./cursor.ts"); +const { vscodeClient } = await import("./vscode.ts"); +const { windsurfClient } = await import("./windsurf.ts"); +const { geminiClient } = await import("./gemini.ts"); +const { codexClient } = await import("./codex.ts"); +const { opencodeClient } = await import("./opencode.ts"); +const { openclawClient } = await import("./openclaw.ts"); +const { warpClient } = await import("./warp.ts"); +const { hermesClient } = await import("./hermes.ts"); +const { vscodeUserDir } = await import("./paths.ts"); + +useCaptureLog(); + +const DEFAULT_URL = "https://mcp.clerk.com/mcp"; + +// The stdio bridge every client registers: it launches `clerk mcp run` (no URL +// in args — the URL is resolved at runtime). +const RUN_SHAPE = { command: "clerk", args: ["mcp", "run"] }; + +// Config paths are part of the public contract: `list`/`doctor` read them, and +// for the file-backed clients they're also where installs land. +const pathCases = [ + { name: "claude", client: claudeClient, expectedPath: () => join(mockHome, ".claude.json") }, + { + name: "cursor", + client: cursorClient, + expectedPath: () => join(mockHome, ".cursor", "mcp.json"), + }, + { name: "vscode", client: vscodeClient, expectedPath: () => join(vscodeUserDir(), "mcp.json") }, + { + name: "windsurf", + client: windsurfClient, + expectedPath: () => join(mockHome, ".codeium", "windsurf", "mcp_config.json"), + }, + { + name: "gemini", + client: geminiClient, + expectedPath: () => join(mockHome, ".gemini", "settings.json"), + }, + { + name: "codex", + client: codexClient, + expectedPath: () => join(mockHome, ".codex", "config.toml"), + }, + { + name: "opencode", + client: opencodeClient, + // XDG_CONFIG_HOME is blanked in beforeEach, so the XDG fallback applies. + expectedPath: () => join(mockHome, ".config", "opencode", "opencode.json"), + }, + { + name: "openclaw", + client: openclawClient, + expectedPath: () => join(mockHome, ".openclaw", "openclaw.json"), + }, + { + name: "warp", + client: warpClient, + expectedPath: () => join(mockHome, ".warp", ".mcp.json"), + }, + { + name: "hermes", + client: hermesClient, + expectedPath: () => join(mockHome, ".hermes", "config.yaml"), + }, +]; + +// File-backed clients: we write the entry ourselves (no usable registration +// CLI exists — opencode's `mcp add` is an interactive wizard, Warp has none). +const fileCases = [ + { name: "cursor", client: cursorClient, topKey: "mcpServers", shape: RUN_SHAPE }, + { name: "windsurf", client: windsurfClient, topKey: "mcpServers", shape: RUN_SHAPE }, + { name: "warp", client: warpClient, topKey: "mcpServers", shape: RUN_SHAPE }, + { + name: "opencode", + client: opencodeClient, + topKey: "mcp", + // opencode's stdio dialect: `type: "local"` and a single command array. + shape: { type: "local", command: ["clerk", "mcp", "run"] }, + }, +]; + +// CLI-backed clients: registration is delegated to the client's own CLI. The +// argv below (after the resolved binary path) is the public contract. +// `addOptions` is the subprocess options contract (e.g. Hermes gets its +// confirm-prompt answers piped to stdin). +type CliCase = { + name: string; + client: McpClient; + binary: string; + addArgv: string[]; + removeArgv: string[]; + addOptions?: { stdin: string }; +}; +const cliCases: CliCase[] = [ + { + name: "claude", + client: claudeClient, + binary: "claude", + addArgv: [ + "mcp", + "add", + "--scope", + "user", + "--transport", + "stdio", + "clerk", + "--", + "clerk", + "mcp", + "run", + ], + removeArgv: ["mcp", "remove", "--scope", "user", "clerk"], + }, + { + name: "gemini", + client: geminiClient, + binary: "gemini", + addArgv: [ + "mcp", + "add", + "--scope", + "user", + "--transport", + "stdio", + "clerk", + "clerk", + "mcp", + "run", + ], + removeArgv: ["mcp", "remove", "--scope", "user", "clerk"], + }, + { + name: "codex", + client: codexClient, + binary: "codex", + addArgv: ["mcp", "add", "clerk", "--", "clerk", "mcp", "run"], + removeArgv: ["mcp", "remove", "clerk"], + }, + { + name: "openclaw", + client: openclawClient, + binary: "openclaw", + // `--no-probe`: skip OpenClaw's test-connect on add — the hosted server + // needs OAuth, so probing would fail an otherwise valid registration. + addArgv: [ + "mcp", + "add", + "clerk", + "--command", + "clerk", + "--arg", + "mcp", + "--arg", + "run", + "--no-probe", + ], + removeArgv: ["mcp", "unset", "clerk"], + }, + { + name: "hermes", + client: hermesClient, + binary: "hermes", + // `--args` must be last: it swallows the rest of the argv. + addArgv: ["mcp", "add", "clerk", "--command", "clerk", "--args", "mcp", "run"], + // Hermes' add ends in a confirm prompt and cancels (exit 0!) on EOF, so + // the answer is piped in. + addOptions: { stdin: "y\n" }, + removeArgv: ["mcp", "remove", "clerk"], + }, +]; + +const VSCODE_ADD_JSON = JSON.stringify({ name: "clerk", type: "stdio", ...RUN_SHAPE }); + +describe("client contracts (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 = ""; + mockWhich.mockImplementation((binary: string) => `/fake/bin/${binary}`); + mockRun.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + }); + + 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 }); + mockWhich.mockReset(); + mockRun.mockReset(); + }); + + test.each(pathCases)( + "$name is user-scoped at its documented path", + ({ client, expectedPath }) => { + expect(client.scope).toBe("user"); + expect(client.configPath("/ignored")).toBe(expectedPath()); + }, + ); + + test.each(fileCases)( + "$name writes the documented entry shape", + async ({ client, topKey, shape }) => { + await client.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored"); + const parsed = JSON.parse(await readFile(client.configPath("/ignored"), "utf8")) as Record< + string, + Record + >; + expect(parsed[topKey]?.clerk).toEqual(shape); + }, + ); + + test.each(cliCases)( + "$name registers through its own CLI", + async ({ client, binary, addArgv, addOptions }) => { + // Seed the entry so post-add verification (hermes) sees it saved; the + // pre-clean remove this triggers is asserted separately. + await writeClientEntry(client.configPath("/ignored")); + const result = await client.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored"); + expect(result.status).toBe("installed"); + if (addOptions) { + expect(mockRun).toHaveBeenCalledWith([`/fake/bin/${binary}`, ...addArgv], addOptions); + } else { + expect(mockRun).toHaveBeenCalledWith([`/fake/bin/${binary}`, ...addArgv]); + } + }, + ); + + test.each(cliCases)( + "$name removes through its own CLI", + async ({ client, binary, removeArgv }) => { + // Pre-write the entry (as the client's CLI would have) so presence checks pass. + await writeClientEntry(client.configPath("/ignored")); + const result = await client.remove("clerk", "/ignored"); + expect(result.removed).toBe(true); + expect(mockRun).toHaveBeenCalledWith([`/fake/bin/${binary}`, ...removeArgv]); + }, + ); + + test.each(cliCases)( + "$name detects via its binary on PATH, not the config dir", + async ({ client, binary }) => { + expect(await client.detect("/ignored")).toBe(true); + expect(mockWhich).toHaveBeenCalledWith(binary); + mockWhich.mockReturnValue(null); + expect(await client.detect("/ignored")).toBe(false); + }, + ); + + test("vscode registers through `code --add-mcp` with the entry JSON", async () => { + const result = await vscodeClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored"); + expect(result.status).toBe("installed"); + expect(mockRun).toHaveBeenCalledWith(["/fake/bin/code", "--add-mcp", VSCODE_ADD_JSON]); + }); + + test("vscode removes by editing its mcp.json (no removal CLI exists)", async () => { + const configPath = vscodeClient.configPath("/ignored"); + await mkdir(join(vscodeUserDir()), { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ servers: { clerk: { type: "stdio", ...RUN_SHAPE } } }), + ); + const result = await vscodeClient.remove("clerk", "/ignored"); + expect(result.removed).toBe(true); + expect(mockRun).not.toHaveBeenCalled(); + const parsed = JSON.parse(await readFile(configPath, "utf8")) as { servers?: unknown }; + expect(parsed.servers).toBeUndefined(); + }); + + test("opencode lists both its local (bridge) and remote (clerk-hosted) dialects", async () => { + const configPath = opencodeClient.configPath("/ignored"); + await mkdir(join(configPath, ".."), { recursive: true }); + await writeFile( + configPath, + JSON.stringify({ + mcp: { + clerk: { type: "local", command: ["clerk", "mcp", "run"] }, + hosted: { type: "remote", url: DEFAULT_URL }, + unrelated: { type: "remote", url: "https://example.com/mcp" }, + }, + }), + ); + const entries = await opencodeClient.list("/ignored"); + expect(entries.map((e) => e.name).sort()).toEqual(["clerk", "hosted"]); + expect(entries.every((e) => e.url === DEFAULT_URL)).toBe(true); + }); + + 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]); + }); +}); + +async function writeClientEntry(configPath: string): Promise { + const dir = join(configPath, ".."); + await mkdir(dir, { recursive: true }); + if (configPath.endsWith(".toml")) { + await writeFile(configPath, '[mcp_servers.clerk]\ncommand = "clerk"\nargs = ["mcp", "run"]\n'); + return; + } + if (configPath.endsWith("config.yaml")) { + await writeFile( + configPath, + "mcp_servers:\n clerk:\n command: clerk\n args: [mcp, run]\n", + ); + return; + } + if (configPath.endsWith("openclaw.json")) { + await writeFile(configPath, JSON.stringify({ mcp: { servers: { clerk: RUN_SHAPE } } })); + return; + } + await writeFile(configPath, JSON.stringify({ mcpServers: { clerk: RUN_SHAPE } })); +} 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..5a00ee72 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/codex.ts @@ -0,0 +1,31 @@ +/** + * Registration is delegated to Codex's own CLI: + * `codex mcp add … -- clerk mcp run` (Codex's config is global, no scope + * flag). The file-backed base still reads `~/.codex/config.toml` + * (`[mcp_servers.]`) for `list`/`doctor`; legacy bare `{ url }` entries + * still round-trip there. + */ + +import { clerkRunArgs, clerkRunDescriptor, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { makeCliClient } from "./make-cli-client.ts"; +import { makeTomlClient } from "./make-client.ts"; +import { userPath } from "./paths.ts"; + +const codexFileClient = makeTomlClient({ + id: "codex", + displayName: "Codex", + scope: "user", + activation: "Restart Codex (`clerk` must be on your PATH).", + topKey: "mcp_servers", + encode: clerkRunDescriptor, + extractUrl: withLegacyUrl, + configPath: () => userPath(".codex", "config.toml"), +}); + +export const codexClient = makeCliClient({ + base: codexFileClient, + binary: "codex", + installHint: "Install the Codex CLI: https://github.com/openai/codex", + addArgs: (name) => ["mcp", "add", name, "--", RUN_COMMAND, ...clerkRunArgs()], + removeArgs: (name) => ["mcp", "remove", name], +}); 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..0e3dd40e --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/cursor.ts @@ -0,0 +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. Installs the + * `clerk mcp run` stdio bridge; legacy bare `{ url }` entries still round-trip + * on list/uninstall. + */ + +import { clerkRunDescriptor, 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` (`clerk` must be on your PATH).", + topKey: "mcpServers", + encode: clerkRunDescriptor, + extractUrl: withLegacyUrl, + configPath: () => userPath(".cursor", "mcp.json"), + detect: () => pathExists(userPath(".cursor")), +}); diff --git a/packages/cli-core/src/commands/mcp/clients/gemini.ts b/packages/cli-core/src/commands/mcp/clients/gemini.ts new file mode 100644 index 00000000..d236b2e2 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/gemini.ts @@ -0,0 +1,71 @@ +/** + * Registration is delegated to Gemini's own CLI: + * `gemini mcp add --scope user --transport stdio … clerk mcp run`. The + * file-backed base still reads `~/.gemini/settings.json` for `list`/`doctor`. + * Gemini has no native HTTP transport, so it always needs the stdio bridge — + * `clerk mcp run` today, previously `npx -y mcp-remote `; legacy + * `mcp-remote` entries still round-trip on list/uninstall. + */ + +import { isRecord } from "../../../lib/objects.ts"; +import { clerkRunArgs, clerkRunDescriptor, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { makeCliClient } from "./make-cli-client.ts"; +import { isClerkHost, makeReadOnlyJsonClient } from "./make-client.ts"; +import { userPath } from "./paths.ts"; + +// 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 (!isRecord(value)) 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 ( + isClerkHost(parsed.hostname) && + (parsed.protocol === "https:" || parsed.protocol === "http:") + ) { + return parsed.href; + } + } catch { + // not a URL + } + } + return undefined; +} + +const geminiFileClient = makeReadOnlyJsonClient({ + id: "gemini", + displayName: "Gemini Code Assist / CLI", + scope: "user", + activation: "Restart Gemini (`clerk` must be on your PATH).", + topKey: "mcpServers", + encode: clerkRunDescriptor, + extractUrl: (d) => withLegacyUrl(d) ?? extractLegacyBridgeUrl(d), + configPath: () => userPath(".gemini", "settings.json"), +}); + +export const geminiClient = makeCliClient({ + base: geminiFileClient, + binary: "gemini", + installHint: "Install the Gemini CLI: https://github.com/google-gemini/gemini-cli", + addArgs: (name) => [ + "mcp", + "add", + "--scope", + "user", + "--transport", + "stdio", + name, + RUN_COMMAND, + ...clerkRunArgs(), + ], + removeArgs: (name) => ["mcp", "remove", "--scope", "user", name], +}); diff --git a/packages/cli-core/src/commands/mcp/clients/hermes.ts b/packages/cli-core/src/commands/mcp/clients/hermes.ts new file mode 100644 index 00000000..6a523927 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/hermes.ts @@ -0,0 +1,38 @@ +/** + * Registration is delegated to the Hermes Agent CLI: + * `hermes mcp add --command clerk --args mcp run` — `--args` must be + * last, it swallows the remaining argv — and `hermes mcp remove `. The + * YAML base reads `~/.hermes/config.yaml` (`mcp_servers.`) for + * `list`/`doctor` only; YAML writes are refused (Hermes' CLI owns both + * mutations, and rewriting the user's YAML would destroy comments). + */ + +import { clerkRunArgs, clerkRunDescriptor, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { makeCliClient } from "./make-cli-client.ts"; +import { makeYamlClient } from "./make-client.ts"; +import { userPath } from "./paths.ts"; + +const hermesFileClient = makeYamlClient({ + id: "hermes", + displayName: "Hermes Agent", + scope: "user", + activation: "Restart Hermes — or run `/reload-mcp` in a session (`clerk` must be on your PATH).", + topKey: "mcp_servers", + encode: clerkRunDescriptor, + extractUrl: withLegacyUrl, + configPath: () => userPath(".hermes", "config.yaml"), +}); + +export const hermesClient = makeCliClient({ + base: hermesFileClient, + binary: "hermes", + installHint: "Install Hermes Agent: https://hermes-agent.nousresearch.com", + addArgs: (name) => ["mcp", "add", name, "--command", RUN_COMMAND, "--args", ...clerkRunArgs()], + // Hermes' add probes the server, then ends in a confirm prompt ("Enable all + // tools?" on success, "Save config anyway?" on failure) — and cancelling on + // EOF exits 0 without saving. Pipe the affirmative answer, and verify the + // entry actually landed since the exit code alone can't be trusted. + addStdin: "y\n", + verifyAdd: true, + removeArgs: (name) => ["mcp", "remove", name], +}); 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..452598f9 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/json-config.ts @@ -0,0 +1,117 @@ +/** + * Shared JSON read/write helper for MCP client configs. + * + * Every JSON-backed client (Claude Code, Cursor, VS Code, Windsurf, Gemini, + * opencode, OpenClaw, Warp) stores its MCP servers in a JSON file under a + * 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 { isRecord } from "../../../lib/objects.ts"; +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"; + +export interface ConfigRecord { + [key: string]: unknown; +} + +/** 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 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 (!isRecord(parsed)) { + throw new CliError(`Config at ${path} is not a JSON object`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } + return parsed as ConfigRecord; + } 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: ConfigRecord): Promise { + log.debug(`mcp: write ${path}`); + 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. 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 }); + await rename(tmp, path); + } catch (error) { + await unlink(tmp).catch(() => {}); + throw error; + } + await restrictPermissions(path); +} + +/** + * The write half of every read-only codec: CLI-delegated clients own both of + * their config mutations, so a write reaching us is a wiring bug, not a user + * error — refuse loudly instead of silently rewriting a file the client owns. + */ +export async function refuseConfigWrite(path: string): Promise { + throw new CliError( + `Refusing to rewrite ${path} — config edits are delegated to the client's own CLI.`, + { code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID }, + ); +} + +/** 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)}`); + } +} + +/** + * 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: ConfigRecord, + key: string, + configPath: string, +): Record { + const existing = config[key]; + if (existing === undefined) return {}; + if (!isRecord(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-cli-client.test.ts b/packages/cli-core/src/commands/mcp/clients/make-cli-client.test.ts new file mode 100644 index 00000000..394da86d --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/make-cli-client.test.ts @@ -0,0 +1,324 @@ +import type { findClientBinary, runClientCli } from "./cli-exec.ts"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import * as realOs from "node:os"; +import { join } from "node:path"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; + +// Redirect homedir so the synthetic base client writes into a tmpdir. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + +// Stub the subprocess layer: no real client CLIs are spawned in unit tests. +const mockRun = mock(); +const mockWhich = mock(); +mock.module("./cli-exec.ts", () => ({ + findClientBinary: mockWhich, + runClientCli: mockRun, +})); +afterAll(() => mock.restore()); + +const { makeJsonClient } = await import("./make-client.ts"); +const { makeCliClient } = await import("./make-cli-client.ts"); + +useCaptureLog(); + +const CLERK_URL = "https://mcp.clerk.com/mcp"; +const BIN_PATH = "/fake/bin/fakecli"; + +function ok() { + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); +} + +function fail(stderr: string) { + return Promise.resolve({ exitCode: 1, stdout: "", stderr }); +} + +// A synthetic file-backed base standing in for the real clients, so the factory +// is tested in isolation from any specific client's path/encode details. +function makeBase() { + return makeJsonClient({ + id: "claude", + displayName: "Fake Client", + scope: "user", + activation: "Restart Fake Client.", + topKey: "mcpServers", + encode: () => ({ command: "clerk", args: ["mcp", "run"] }), + extractUrl: (d) => + typeof d === "object" && d !== null && "url" in d ? String(d.url) : CLERK_URL, + configPath: () => join(mockHome, ".fake", "config.json"), + detect: () => Promise.resolve(true), + }); +} + +function makeClient( + overrides: { + removeArgs?: (name: string) => string[]; + addStdin?: string; + verifyAdd?: boolean; + } = {}, +) { + const spec = { + base: makeBase(), + binary: "fakecli", + installHint: "Install it from https://example.com/fakecli.", + addArgs: (name: string) => ["mcp", "add", name], + ...(overrides.addStdin !== undefined ? { addStdin: overrides.addStdin } : {}), + ...(overrides.verifyAdd !== undefined ? { verifyAdd: overrides.verifyAdd } : {}), + }; + // An explicit `removeArgs: undefined` override models VS Code's add-only CLI. + if ("removeArgs" in overrides) { + return overrides.removeArgs + ? makeCliClient({ ...spec, removeArgs: overrides.removeArgs }) + : makeCliClient(spec); + } + return makeCliClient({ ...spec, removeArgs: (name: string) => ["mcp", "remove", name] }); +} + +async function writeBaseConfig(entryName = "clerk"): Promise { + const dir = join(mockHome, ".fake"); + await mkdir(dir, { recursive: true }); + const path = join(dir, "config.json"); + await writeFile( + path, + JSON.stringify({ mcpServers: { [entryName]: { command: "clerk", args: ["mcp", "run"] } } }), + ); + return path; +} + +describe("makeCliClient", () => { + beforeEach(async () => { + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-cli-client-")); + mockWhich.mockReturnValue(BIN_PATH); + mockRun.mockImplementation(() => ok()); + }); + + afterEach(async () => { + await rm(mockHome, { recursive: true, force: true }); + mockWhich.mockReset(); + mockRun.mockReset(); + }); + + test("delegates identity, configPath, and list to the base client", async () => { + const client = makeClient(); + expect(client.id).toBe("claude"); + expect(client.displayName).toBe("Fake Client"); + expect(client.scope).toBe("user"); + expect(client.configPath("/ignored")).toBe(join(mockHome, ".fake", "config.json")); + + await writeBaseConfig(); + const entries = await client.list("/ignored"); + expect(entries.map((e) => e.name)).toEqual(["clerk"]); + }); + + describe("detect", () => { + test("is true when the binary resolves on PATH", async () => { + const client = makeClient(); + expect(await client.detect("/ignored")).toBe(true); + expect(mockWhich).toHaveBeenCalledWith("fakecli"); + }); + + test("is false when the binary is missing, even if the config dir exists", async () => { + await writeBaseConfig(); + mockWhich.mockReturnValue(null); + const client = makeClient(); + expect(await client.detect("/ignored")).toBe(false); + }); + }); + + describe("upsert", () => { + test("rejects with mcp_client_cli_not_found when the binary is missing", async () => { + mockWhich.mockReturnValue(null); + const client = makeClient(); + const attempt = client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored"); + // The docs page carries per-client manual setup — the fallback when we + // can't drive the client's CLI. + await expect(attempt).rejects.toMatchObject({ + code: "mcp_client_cli_not_found", + docsUrl: expect.stringContaining("https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server"), + }); + await expect(client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored")).rejects.toThrow( + /fakecli/, + ); + expect(mockRun).not.toHaveBeenCalled(); + }); + + test("includes the install hint in the not-found error", async () => { + mockWhich.mockReturnValue(null); + const client = makeClient(); + await expect(client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored")).rejects.toThrow( + /https:\/\/example.com\/fakecli/, + ); + }); + + test("runs only the add command when no entry exists yet", async () => { + const client = makeClient(); + const result = await client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored"); + expect(result).toEqual({ + client: "claude", + configPath: join(mockHome, ".fake", "config.json"), + status: "installed", + }); + expect(mockRun).toHaveBeenCalledTimes(1); + expect(mockRun).toHaveBeenCalledWith([BIN_PATH, "mcp", "add", "clerk"]); + }); + + test("removes before adding when the entry already exists (remove-then-add)", async () => { + await writeBaseConfig(); + const client = makeClient(); + await client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored"); + expect(mockRun.mock.calls.map((c) => c[0])).toEqual([ + [BIN_PATH, "mcp", "remove", "clerk"], + [BIN_PATH, "mcp", "add", "clerk"], + ]); + }); + + test("ignores a failing best-effort remove and still adds", async () => { + await writeBaseConfig(); + mockRun + .mockImplementationOnce(() => fail("no such server")) + .mockImplementationOnce(() => ok()); + const client = makeClient(); + const result = await client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored"); + expect(result.status).toBe("installed"); + expect(mockRun).toHaveBeenCalledTimes(2); + }); + + test("rejects with mcp_client_cli_failed and surfaces stderr when add exits non-zero", async () => { + mockRun.mockImplementation(() => fail("boom: flag not recognized")); + const client = makeClient(); + const attempt = client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored"); + await expect(attempt).rejects.toMatchObject({ + code: "mcp_client_cli_failed", + docsUrl: expect.stringContaining("https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server"), + }); + await expect(client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored")).rejects.toThrow( + /boom: flag not recognized/, + ); + }); + + test("still runs add when the base config is unreadable (the CLI owns its format)", async () => { + const dir = join(mockHome, ".fake"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "config.json"), "{ not json"); + const client = makeClient(); + const result = await client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored"); + expect(result.status).toBe("installed"); + expect(mockRun).toHaveBeenCalledWith([BIN_PATH, "mcp", "add", "clerk"]); + }); + + test("pipes addStdin to the CLI add (for CLIs whose add ends in a prompt)", async () => { + const client = makeClient({ addStdin: "y\n" }); + await client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored"); + expect(mockRun).toHaveBeenCalledWith([BIN_PATH, "mcp", "add", "clerk"], { stdin: "y\n" }); + }); + + test("verifyAdd rejects when the CLI exits 0 without saving the entry", async () => { + // Hermes' `mcp add` cancels its final prompt on unexpected input/EOF and + // still exits 0 — a lying exit code. verifyAdd re-reads the config and + // turns that silent no-op into a real failure. + const client = makeClient({ verifyAdd: true }); + await expect( + client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored"), + ).rejects.toMatchObject({ + code: "mcp_client_cli_failed", + docsUrl: expect.stringContaining("https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server"), + }); + }); + + test("verifyAdd passes when the entry landed in the config", async () => { + mockRun.mockImplementation(async (argv: string[]) => { + // Simulate the client CLI writing its own config on add. + if (argv.includes("add")) await writeBaseConfig(); + return ok(); + }); + const client = makeClient({ verifyAdd: true }); + const result = await client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored"); + expect(result.status).toBe("installed"); + }); + + test("verifyAdd trusts the CLI when the config is unreadable", async () => { + const dir = join(mockHome, ".fake"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "config.json"), "{ not json"); + const client = makeClient({ verifyAdd: true }); + const result = await client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored"); + expect(result.status).toBe("installed"); + }); + + test("falls back to a file-based pre-clean when the CLI has no remove command", async () => { + // VS Code's case: `code --add-mcp` exists, but there is no removal CLI. + const path = await writeBaseConfig(); + const client = makeClient({ removeArgs: undefined }); + await client.upsert({ name: "clerk", url: CLERK_URL }, "/ignored"); + // Old entry cleaned from the file, then the CLI add ran. + const written = JSON.parse(await readFile(path, "utf8")) as { + mcpServers?: Record; + }; + expect(written.mcpServers?.clerk).toBeUndefined(); + expect(mockRun).toHaveBeenCalledTimes(1); + expect(mockRun).toHaveBeenCalledWith([BIN_PATH, "mcp", "add", "clerk"]); + }); + }); + + describe("remove", () => { + test("reports removed:false without invoking the CLI when the entry is absent", async () => { + const client = makeClient(); + const result = await client.remove("clerk", "/ignored"); + expect(result).toEqual({ + client: "claude", + configPath: join(mockHome, ".fake", "config.json"), + removed: false, + }); + expect(mockRun).not.toHaveBeenCalled(); + }); + + test("runs the CLI remove when the entry is present", async () => { + await writeBaseConfig(); + const client = makeClient(); + const result = await client.remove("clerk", "/ignored"); + expect(result.removed).toBe(true); + expect(mockRun).toHaveBeenCalledWith([BIN_PATH, "mcp", "remove", "clerk"]); + }); + + test("rejects with mcp_client_cli_not_found when the binary is missing and the entry is present", async () => { + await writeBaseConfig(); + mockWhich.mockReturnValue(null); + const client = makeClient(); + await expect(client.remove("clerk", "/ignored")).rejects.toMatchObject({ + code: "mcp_client_cli_not_found", + }); + }); + + test("rejects with mcp_client_cli_failed when the CLI remove exits non-zero", async () => { + await writeBaseConfig(); + mockRun.mockImplementation(() => fail("cannot remove")); + const client = makeClient(); + await expect(client.remove("clerk", "/ignored")).rejects.toMatchObject({ + code: "mcp_client_cli_failed", + }); + }); + + test("attempts the CLI remove when the config is unreadable (presence unknown)", async () => { + const dir = join(mockHome, ".fake"); + await mkdir(dir, { recursive: true }); + await writeFile(join(dir, "config.json"), "{ not json"); + const client = makeClient(); + const result = await client.remove("clerk", "/ignored"); + expect(result.removed).toBe(true); + expect(mockRun).toHaveBeenCalledWith([BIN_PATH, "mcp", "remove", "clerk"]); + }); + + test("delegates entirely to the base file remove when the CLI has no remove command", async () => { + const path = await writeBaseConfig(); + const client = makeClient({ removeArgs: undefined }); + const result = await client.remove("clerk", "/ignored"); + expect(result.removed).toBe(true); + expect(mockRun).not.toHaveBeenCalled(); + const written = JSON.parse(await readFile(path, "utf8")) as { + mcpServers?: Record; + }; + expect(written.mcpServers?.clerk).toBeUndefined(); + }); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/clients/make-cli-client.ts b/packages/cli-core/src/commands/mcp/clients/make-cli-client.ts new file mode 100644 index 00000000..be6ffc86 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/make-cli-client.ts @@ -0,0 +1,142 @@ +/** + * Factory for clients whose own CLI performs the registration. + * + * Claude Code, Gemini, Codex, VS Code, OpenClaw, and Hermes ship a CLI that + * adds MCP servers to their config. Delegating the *write* to that CLI keeps each client the owner + * of its config format (and of concurrent-write safety); our file-backed base + * client stays in charge of *reads* — `configPath` and `list` (which `mcp list`, + * `doctor`, and the uninstall picker all use). + * + * Semantics: + * - `detect` = "is the client's binary on PATH" (`Bun.which`), not "does the + * config dir exist" — the picker only offers clients we can actually drive. + * - No fallback: a missing binary or failing CLI is that client's failure, + * surfaced with the CLI's own stderr. We never write these configs ourselves. + * - Install always converges: an existing entry is removed (via the client's + * own remove command, best-effort) before adding, so re-install works no + * matter how the CLI treats duplicate names. + */ + +import { CliError, ERROR_CODE, errorMessage } from "../../../lib/errors.ts"; +import { log } from "../../../lib/log.ts"; +import { findClientBinary, runClientCli } from "./cli-exec.ts"; +import { MCP_DOCS_URL } from "./types.ts"; +import type { McpClient, McpServerEntry, RemoveResult, UpsertResult } from "./types.ts"; + +interface CliClientSpec { + /** File-backed client used for `configPath`/`list` (and `remove` when the CLI has no remove command). */ + base: McpClient; + /** Binary name resolved on PATH (`claude`, `gemini`, `codex`, `code`, `openclaw`, `hermes`). */ + binary: string; + /** Appended to the not-found error: how to get the binary onto PATH. */ + installHint: string; + /** CLI argv (after the binary) that registers the entry. */ + addArgs: (name: string) => string[]; + /** CLI argv (after the binary) that removes the entry. Omit when the CLI can only add (VS Code). */ + removeArgs?: (name: string) => string[]; + /** + * Text piped to the add command's stdin (e.g. `"y\n"` when add ends in a + * confirm prompt, as Hermes' does). Default: stdin closed immediately. + */ + addStdin?: string; + /** + * Re-read the config after add and fail if the entry didn't land. For CLIs + * whose add can exit 0 without saving (Hermes cancels its prompt on + * unexpected input — with exit 0), where the exit code alone can't be + * trusted. Skipped when the config is unreadable (the CLI keeps final say). + */ + verifyAdd?: boolean; +} + +export function makeCliClient(spec: CliClientSpec): McpClient { + const { base, binary } = spec; + + // No display-name prefix in thrown messages: settleClients prefixes the + // client name when warning, so embedding it here would print it twice. + function requireBinary(): string { + const bin = findClientBinary(binary); + if (bin) return bin; + throw new CliError( + `\`${binary}\` CLI not found on PATH — registration is delegated to it. ${spec.installHint}`, + { code: ERROR_CODE.MCP_CLIENT_CLI_NOT_FOUND, docsUrl: MCP_DOCS_URL }, + ); + } + + /** + * Presence via our own read-only parse of the client's config — reads stay + * ours; only writes are delegated. "unknown" = config unreadable, in which + * case the CLI (which owns the format) gets the final say. + */ + async function presenceOf(name: string, cwd: string): Promise<"present" | "absent" | "unknown"> { + try { + const entries = await base.list(cwd); + return entries.some((entry) => entry.name === name) ? "present" : "absent"; + } catch { + return "unknown"; + } + } + + async function runOrThrow( + argv: [string, ...string[]], + action: string, + stdin?: string, + ): Promise { + const result = + stdin === undefined ? await runClientCli(argv) : await runClientCli(argv, { stdin }); + if (result.exitCode === 0) return; + const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`; + throw new CliError(`failed to ${action} — ${detail}`, { + code: ERROR_CODE.MCP_CLIENT_CLI_FAILED, + docsUrl: MCP_DOCS_URL, + }); + } + + return { + id: base.id, + displayName: base.displayName, + scope: base.scope, + activation: base.activation, + configPath: (cwd) => base.configPath(cwd), + detect: () => Promise.resolve(findClientBinary(binary) !== null), + list: (cwd) => base.list(cwd), + + async upsert(entry: McpServerEntry, cwd: string): Promise { + const bin = requireBinary(); + const presence = await presenceOf(entry.name, cwd); + if (presence !== "absent" && spec.removeArgs) { + // Best-effort pre-clean: duplicate-name behavior varies per CLI, so a + // failed remove (e.g. "no such server") just means add decides. A + // non-zero exit is already debug-logged by runClientCli; only a + // timeout rejects, which we log here before discarding. + await runClientCli([bin, ...spec.removeArgs(entry.name)]).catch((error: unknown) => { + log.debug(`mcp: ${base.id} pre-clean remove failed — ${errorMessage(error)}`); + }); + } else if (presence === "present") { + await base.remove(entry.name, cwd); + } + await runOrThrow( + [bin, ...spec.addArgs(entry.name)], + "register the MCP server", + spec.addStdin, + ); + if (spec.verifyAdd && (await presenceOf(entry.name, cwd)) === "absent") { + throw new CliError( + `the \`${binary}\` CLI reported success but did not save the entry — it may have prompted for input it didn't get. Register manually instead.`, + { code: ERROR_CODE.MCP_CLIENT_CLI_FAILED, docsUrl: MCP_DOCS_URL }, + ); + } + return { client: base.id, configPath: base.configPath(cwd), status: "installed" }; + }, + + async remove(name: string, cwd: string): Promise { + if (!spec.removeArgs) return base.remove(name, cwd); + const configPath = base.configPath(cwd); + if ((await presenceOf(name, cwd)) === "absent") { + return { client: base.id, configPath, removed: false }; + } + const bin = requireBinary(); + await runOrThrow([bin, ...spec.removeArgs(name)], "remove the MCP entry"); + return { client: base.id, configPath, removed: true }; + }, + }; +} 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 new file mode 100644 index 00000000..507d7960 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/make-client.test.ts @@ -0,0 +1,322 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises"; +import * as realOs from "node:os"; +import { join } from "node:path"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; + +// cursorClient writes under home now; redirect homedir to the cwd tmpdir so the +// `join(cwd, ".cursor", ...)` reads below stay isolated. Mock before importing +// the client so paths.ts binds the redirected homedir. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + +const { cursorClient } = await import("./cursor.ts"); +const { makeJsonClient, makeReadOnlyJsonClient } = await import("./make-client.ts"); + +useCaptureLog(); + +// 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"] }; + +// 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; + + beforeEach(async () => { + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-cursor-")); + mockHome = cwd; + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + async function read(): Promise<{ + otherTopLevel?: string; + mcpServers?: Record; + }> { + const text = await readFile(join(cwd, ".cursor", "mcp.json"), "utf8"); + return JSON.parse(text); + } + + describe("upsert", () => { + test("creates the config file when it does not exist", async () => { + const result = await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd); + expect(result.status).toBe("installed"); + const written = await read(); + expect(written.mcpServers?.clerk).toEqual(CURRENT_SHAPE); + }); + + 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: CLERK_URL }, cwd); + const written = await read(); + expect(written.otherTopLevel).toBe("keep"); + expect(written.mcpServers?.other).toEqual({ url: "http://x" }); + expect(written.mcpServers?.clerk).toEqual(CURRENT_SHAPE); + }); + + test("re-installing is idempotent and still reports installed", async () => { + await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd); + const result = await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd); + expect(result.status).toBe("installed"); + const written = await read(); + expect(written.mcpServers?.clerk).toEqual(CURRENT_SHAPE); + }); + + test("upgrades a legacy same-URL entry (bare { url }) to the bridge shape", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + join(cwd, ".cursor", "mcp.json"), + JSON.stringify({ mcpServers: { clerk: { url: CLERK_URL } } }), + ); + const result = await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd); + expect(result.status).toBe("installed"); + const written = await read(); + expect(written.mcpServers?.clerk).toEqual(CURRENT_SHAPE); + }); + + test("overwrites an entry pointing at a foreign server (install always converges)", 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); + expect(result.status).toBe("installed"); + const written = await read(); + 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: CLERK_URL }, cwd)).rejects.toThrow( + /not a JSON object/, + ); + }); + + test("writes an entry whose name shadows an inherited Object property", async () => { + // `toString` lives on Object.prototype; the write must land as an own + // property rather than being confused by the inherited function. + const result = await cursorClient.upsert({ name: "toString", url: CLERK_URL }, cwd); + expect(result.status).toBe("installed"); + const written = await read(); + expect(written.mcpServers?.["toString"]).toEqual(CURRENT_SHAPE); + }); + }); + + describe("remove", () => { + test("removes a present entry", async () => { + await cursorClient.upsert({ name: "clerk", url: CLERK_URL }, cwd); + 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: CLERK_URL }, + "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("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([]); + }); + + 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"); + await expect(cursorClient.list(cwd)).rejects.toMatchObject({ + code: "mcp_client_config_invalid", + }); + }); + + 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"] }), + ); + await expect(cursorClient.list(cwd)).rejects.toMatchObject({ + code: "mcp_client_config_invalid", + }); + }); + }); +}); + +describe("makeReadOnlyJsonClient (CLI-delegated bases)", () => { + // The invariant behind CLI delegation: configs owned by a client's CLI are + // never written by us. A write reaching a read-only base is a wiring bug. + const readOnly = makeReadOnlyJsonClient({ + id: "claude", + displayName: "ReadOnly", + scope: "user", + activation: "n/a", + topKey: "mcpServers", + encode: () => CURRENT_SHAPE, + extractUrl: () => CLERK_URL, + configPath: (cwd) => join(cwd, "readonly.json"), + }); + + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-readonly-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + test("reads entries but refuses upsert and remove", async () => { + await writeFile( + join(cwd, "readonly.json"), + JSON.stringify({ mcpServers: { clerk: CURRENT_SHAPE } }), + ); + expect((await readOnly.list(cwd)).map((e) => e.name)).toEqual(["clerk"]); + await expect(readOnly.upsert({ name: "clerk", url: CLERK_URL }, cwd)).rejects.toThrow( + /delegated/, + ); + await expect(readOnly.remove("clerk", cwd)).rejects.toThrow(/delegated/); + }); +}); + +describe("make-client nested topKey (OpenClaw-style mcp.servers)", () => { + // OpenClaw nests its server map two levels deep (`mcp.servers.`); the + // factory takes the key path as an array and must preserve sibling keys at + // every level and prune only empty objects it owns on remove. + const nested = makeJsonClient({ + id: "openclaw", + displayName: "Nested", + scope: "user", + activation: "n/a", + topKey: ["mcp", "servers"], + encode: () => CURRENT_SHAPE, + extractUrl: (d) => + typeof d === "object" && d !== null && "command" in d ? CLERK_URL : undefined, + configPath: (cwd) => join(cwd, "openclaw.json"), + }); + + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-nested-")); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + async function read(): Promise> { + return JSON.parse(await readFile(join(cwd, "openclaw.json"), "utf8")); + } + + test("upsert writes the entry under the nested path", async () => { + const result = await nested.upsert({ name: "clerk", url: CLERK_URL }, cwd); + expect(result.status).toBe("installed"); + expect(await read()).toEqual({ mcp: { servers: { clerk: CURRENT_SHAPE } } }); + }); + + test("upsert preserves sibling keys at both levels", async () => { + await writeFile( + join(cwd, "openclaw.json"), + JSON.stringify({ + agents: { keep: true }, + mcp: { timeout: 5, servers: { other: { command: "x", args: [] } } }, + }), + ); + await nested.upsert({ name: "clerk", url: CLERK_URL }, cwd); + expect(await read()).toEqual({ + agents: { keep: true }, + mcp: { + timeout: 5, + servers: { other: { command: "x", args: [] }, clerk: CURRENT_SHAPE }, + }, + }); + }); + + test("remove prunes empty maps up the path but keeps non-empty ancestors", async () => { + await writeFile( + join(cwd, "openclaw.json"), + JSON.stringify({ mcp: { timeout: 5, servers: { clerk: CURRENT_SHAPE } } }), + ); + const result = await nested.remove("clerk", cwd); + expect(result.removed).toBe(true); + // `servers` became empty and is dropped; `mcp` still holds `timeout`. + expect(await read()).toEqual({ mcp: { timeout: 5 } }); + }); + + test("remove drops the whole chain when nothing else remains", async () => { + await nested.upsert({ name: "clerk", url: CLERK_URL }, cwd); + await nested.remove("clerk", cwd); + expect(await read()).toEqual({}); + }); + + test("list resolves entries under the nested path", async () => { + await nested.upsert({ name: "clerk", url: CLERK_URL }, cwd); + const entries = await nested.list(cwd); + expect(entries).toEqual([ + expect.objectContaining({ client: "openclaw", name: "clerk", url: CLERK_URL }), + ]); + }); + + test("rejects when an intermediate key is not an object", async () => { + await writeFile(join(cwd, "openclaw.json"), JSON.stringify({ mcp: "nope" })); + await expect(nested.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 new file mode 100644 index 00000000..945ba41a --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/make-client.ts @@ -0,0 +1,223 @@ +/** + * Factory for file-backed MCP clients. + * + * Every supported client shares the same upsert/remove/list shape — a config + * file with a top-level map whose keys are server names and whose values are + * per-client server descriptors. The differences are the serialization format + * (JSON for most clients, TOML for Codex, YAML for Hermes), 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 { log } from "../../../lib/log.ts"; +import { isRecord } from "../../../lib/objects.ts"; +import { + getServerMap, + readJsonConfig, + refuseConfigWrite, + writeJsonConfig, + type ConfigRecord, +} from "./json-config.ts"; +import { readTomlConfig, writeTomlConfig } from "./toml-config.ts"; +import { readYamlConfig, writeYamlConfig } from "./yaml-config.ts"; +import type { + ClientId, + ListEntry, + McpClient, + McpServerEntry, + RemoveResult, + Scope, + UpsertResult, +} from "./types.ts"; + +export function hasStringProp( + value: unknown, + key: K, +): value is Record { + return ( + typeof value === "object" && + value !== null && + Object.prototype.hasOwnProperty.call(value, key) && + typeof (value as Record)[key] === "string" + ); +} + +interface FileClientSpec { + id: ClientId; + displayName: string; + scope: Scope; + activation: string; + /** Key (or non-empty key path, for clients that nest their server map) under which entries live. */ + topKey: string | readonly [string, ...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; + /** + * Omit for bases wrapped by `makeCliClient`, which replaces detection with + * its binary-on-PATH check — a config-dir probe would never run. A bare file + * client without `detect` is never offered by the picker. + */ + 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: ConfigRecord) => Promise; +} + +const JSON_CODEC: ConfigCodec = { read: readJsonConfig, write: writeJsonConfig }; +const READONLY_JSON_CODEC: ConfigCodec = { read: readJsonConfig, write: refuseConfigWrite }; +const TOML_CODEC: ConfigCodec = { read: readTomlConfig, write: writeTomlConfig }; +const YAML_CODEC: ConfigCodec = { read: readYamlConfig, write: writeYamlConfig }; + +/** + * Rebuild `config` with the server map replaced at `path`, preserving sibling + * keys at every level. An empty map is pruned, along with any ancestor object + * the pruning left empty — so removing the last entry never strands + * `{ "mcp": { "servers": {} } }` husks. + */ +function withServerMap( + config: ConfigRecord, + path: readonly [string, ...string[]], + servers: Record, +): ConfigRecord { + const [head, ...rest] = path; + let value: Record; + if (rest.length === 0) { + value = servers; + } else { + const child = config[head]; + // `rest` is non-empty here (length checked above); TS can't carry + // tuple-ness through a rest spread, so re-assert what the guard proved. + value = withServerMap(isRecord(child) ? child : {}, rest as [string, ...string[]], servers); + } + if (Object.keys(value).length === 0) { + const { [head]: _dropped, ...remaining } = config; + return remaining; + } + return { ...config, [head]: value }; +} + +/** Single source of truth for "is this host under clerk.com". */ +export function isClerkHost(hostname: string): boolean { + return hostname === "mcp.clerk.com" || hostname.endsWith(".clerk.com"); +} + +function isClerkUrl(url: string): boolean { + try { + return isClerkHost(new URL(url).hostname); + } catch { + return false; + } +} + +function makeFileClient(spec: FileClientSpec, codec: ConfigCodec): McpClient { + const topKeyPath: readonly [string, ...string[]] = + typeof spec.topKey === "string" ? [spec.topKey] : spec.topKey; + + /** Walk the key path, validating each level is an object (or absent → `{}`). */ + function serversIn(config: ConfigRecord, configPath: string): Record { + let node: Record = config; + for (const key of topKeyPath) node = getServerMap(node, key, configPath); + return node; + } + + return { + id: spec.id, + displayName: spec.displayName, + scope: spec.scope, + activation: spec.activation, + configPath: spec.configPath, + detect: spec.detect ?? (() => Promise.resolve(false)), + + async upsert(entry: McpServerEntry, cwd: string): Promise { + const configPath = spec.configPath(cwd); + const config = await codec.read(configPath); + const servers = serversIn(config, configPath); + + // Install always converges: whatever descriptor sits under this name + // (a legacy shape, a stale URL, a user's own entry) is overwritten with + // the current bridge shape — the same semantics the CLI-backed clients + // get from their remove-then-add. + const next = withServerMap(config, topKeyPath, { + ...servers, + [entry.name]: spec.encode(entry.url), + }); + await codec.write(configPath, next); + log.debug(`mcp: ${spec.id} installed "${entry.name}"`); + return { client: spec.id, configPath, status: "installed" }; + }, + + async remove(name: string, cwd: string): Promise { + const configPath = spec.configPath(cwd); + const config = await codec.read(configPath); + const servers = serversIn(config, configPath); + if (!Object.prototype.hasOwnProperty.call(servers, name)) { + return { client: spec.id, configPath, removed: false }; + } + const { [name]: _removed, ...rest } = servers; + // An emptied map is pruned (no `{ "mcpServers": {} }` husk) — nested + // ancestors emptied by the pruning go with it. + const next = withServerMap(config, topKeyPath, rest); + await codec.write(configPath, next); + log.debug(`mcp: ${spec.id} removed "${name}"`); + return { client: spec.id, configPath, removed: true }; + }, + + async list(cwd: string): Promise { + const configPath = spec.configPath(cwd); + // A half-written or structurally-invalid config 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 = serversIn(config, configPath); + const entries: ListEntry[] = []; + for (const [name, descriptor] of Object.entries(servers)) { + const url = spec.extractUrl(descriptor); + if (!url) continue; + if (name === "clerk" || isClerkUrl(url)) { + entries.push({ client: spec.id, configPath, name, url }); + } + } + return entries; + }, + }; +} + +/** + * A client whose JSON config we both read and write (Cursor, Windsurf, Warp, + * opencode — no usable registration CLI — plus VS Code, whose add-only CLI + * leaves removal to a file edit). + */ +export function makeJsonClient(spec: FileClientSpec): McpClient { + return makeFileClient(spec, JSON_CODEC); +} + +/** + * A client whose JSON config is read-only to us (Claude Code, Gemini, + * OpenClaw): its own CLI performs every mutation, so this base only powers + * `list`/`doctor`/presence reads — a write reaching it throws. + */ +export function makeReadOnlyJsonClient(spec: FileClientSpec): McpClient { + return makeFileClient(spec, READONLY_JSON_CODEC); +} + +/** A client whose config is a TOML file (Codex). */ +export function makeTomlClient(spec: FileClientSpec): McpClient { + return makeFileClient(spec, TOML_CODEC); +} + +/** + * A client whose config is a YAML file (Hermes). Read-only: writes throw, so + * only use as the base of a CLI-delegated client whose CLI owns add *and* + * remove. + */ +export function makeYamlClient(spec: FileClientSpec): McpClient { + return makeFileClient(spec, YAML_CODEC); +} diff --git a/packages/cli-core/src/commands/mcp/clients/openclaw.ts b/packages/cli-core/src/commands/mcp/clients/openclaw.ts new file mode 100644 index 00000000..848f7213 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/openclaw.ts @@ -0,0 +1,42 @@ +/** + * Registration is delegated to OpenClaw's own CLI: + * `openclaw mcp add --command clerk --arg mcp --arg run --no-probe`. + * `--no-probe` skips OpenClaw's default test-connect on add — the hosted + * server requires OAuth, so probing would fail an otherwise valid + * registration. Removal via `openclaw mcp unset ` (errors on a missing + * name, but the factory's presence check skips the CLI in that case). The + * file-backed base reads `~/.openclaw/openclaw.json` — server map nested at + * `mcp.servers.` — for `list`/`doctor`. + */ + +import { clerkRunArgs, clerkRunDescriptor, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { makeCliClient } from "./make-cli-client.ts"; +import { makeReadOnlyJsonClient } from "./make-client.ts"; +import { userPath } from "./paths.ts"; + +const openclawFileClient = makeReadOnlyJsonClient({ + id: "openclaw", + displayName: "OpenClaw", + scope: "user", + activation: "Restart OpenClaw (`clerk` must be on your PATH).", + topKey: ["mcp", "servers"], + encode: clerkRunDescriptor, + extractUrl: withLegacyUrl, + configPath: () => userPath(".openclaw", "openclaw.json"), +}); + +export const openclawClient = makeCliClient({ + base: openclawFileClient, + binary: "openclaw", + installHint: "Install OpenClaw: https://openclaw.ai", + addArgs: (name) => [ + "mcp", + "add", + name, + "--command", + RUN_COMMAND, + ...clerkRunArgs().flatMap((arg) => ["--arg", arg]), + "--no-probe", + ], + removeArgs: (name) => ["mcp", "unset", name], +}); diff --git a/packages/cli-core/src/commands/mcp/clients/opencode.ts b/packages/cli-core/src/commands/mcp/clients/opencode.ts new file mode 100644 index 00000000..9950582f --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/opencode.ts @@ -0,0 +1,47 @@ +/** + * Writes opencode's user-global `opencode.json` directly (XDG config dir on + * every platform). opencode ships an `mcp add` command, but it is an + * interactive wizard with no flag-driven path for stdio servers — under our + * closed-stdin guarantee it would EOF-error — and there is no removal command + * at all, so both mutations use the documented manual path: the config file. + * + * opencode's dialect differs from the common `{ command, args }` shape: + * entries live under top-level `mcp`, and a stdio server is + * `{ type: "local", command: ["clerk", "mcp", "run"] }` (single argv array). + */ + +import { isRecord } from "../../../lib/objects.ts"; +import { getMcpUrl } from "../../../lib/environment.ts"; +import { clerkRunArgs, RUN_COMMAND } from "./clerk-run.ts"; +import { makeJsonClient } from "./make-client.ts"; +import { pathExists, xdgConfigPath } from "./paths.ts"; + +function extractOpencodeUrl(descriptor: unknown): string | undefined { + if (!isRecord(descriptor)) return undefined; + const { command, url } = descriptor as { command?: unknown; url?: unknown }; + // Remote entries (`{ type: "remote", url }`) carry their URL directly. + if (typeof url === "string") return url; + // Our local bridge entry: the URL is resolved at runtime, so report the + // currently-resolved target (same as the other clients' bridge entries). + if ( + Array.isArray(command) && + command[0] === RUN_COMMAND && + command[1] === "mcp" && + command[2] === "run" + ) { + return getMcpUrl(); + } + return undefined; +} + +export const opencodeClient = makeJsonClient({ + id: "opencode", + displayName: "opencode", + scope: "user", + activation: "Restart opencode (`clerk` must be on your PATH).", + topKey: "mcp", + encode: () => ({ type: "local", command: [RUN_COMMAND, ...clerkRunArgs()] }), + extractUrl: extractOpencodeUrl, + configPath: () => xdgConfigPath("opencode", "opencode.json"), + detect: () => pathExists(xdgConfigPath("opencode")), +}); 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..cc3b440a --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/paths.ts @@ -0,0 +1,80 @@ +/** + * Cross-platform path + filesystem helpers for MCP client integrations. + * + * Most clients root their config at `~/./` regardless of platform, so a + * single homedir join is enough. VS Code is the exception — its user-level + * config lives under the OS-specific app-support dir (see `vscodeUserDir`). + */ + +import { existsSync } from "node:fs"; +import { stat } from "node:fs/promises"; +import { homedir, platform } from "node:os"; +import { join } from "node:path"; + +export function userPath(...segments: string[]): string { + return join(homedir(), ...segments); +} + +/** + * 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. + */ +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")]; + case "darwin": + return [join(home, "Library", "Application Support", "Code", "User")]; + default: + 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]!; +} + +/** + * XDG-style user config root: `$XDG_CONFIG_HOME` (or `~/.config`) on + * macOS/Linux, `%APPDATA%` on Windows. opencode follows this convention on + * every platform (no Application Support dir on macOS). + */ +export function xdgConfigPath(...segments: string[]): string { + if (platform() === "win32") { + const appData = process.env.APPDATA?.trim(); + return join(appData || join(homedir(), "AppData", "Roaming"), ...segments); + } + const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim(); + return join(xdgConfigHome || join(homedir(), ".config"), ...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..d3e95088 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/registry.ts @@ -0,0 +1,45 @@ +/** + * Registry of supported MCP clients. Order is the display order in the + * human-mode multiselect picker. + */ + +import { claudeClient } from "./claude.ts"; +import { codexClient } from "./codex.ts"; +import { cursorClient } from "./cursor.ts"; +import { geminiClient } from "./gemini.ts"; +import { hermesClient } from "./hermes.ts"; +import { openclawClient } from "./openclaw.ts"; +import { opencodeClient } from "./opencode.ts"; +import type { ClientId, McpClient } from "./types.ts"; +import { vscodeClient } from "./vscode.ts"; +import { warpClient } from "./warp.ts"; +import { windsurfClient } from "./windsurf.ts"; + +export const CLIENTS: readonly McpClient[] = [ + claudeClient, + cursorClient, + vscodeClient, + windsurfClient, + geminiClient, + codexClient, + opencodeClient, + openclawClient, + warpClient, + hermesClient, +]; + +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..04c41d31 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/toml-config.ts @@ -0,0 +1,40 @@ +/** + * Read-only TOML config access for MCP clients whose config is TOML (Codex). + * + * Codex stores its MCP servers in `~/.codex/config.toml` under the + * `[mcp_servers.]` table — same logical shape as the JSON clients, just + * a different on-disk format. Reads power `list`/`doctor` and the + * CLI-delegation presence checks. Writes are refused by design: Codex's own + * CLI handles both add and remove, so no code path needs a TOML write — and + * re-serializing would destroy the comments and formatting in a user's + * hand-maintained `config.toml`. + */ + +import { isRecord } from "../../../lib/objects.ts"; +import { CliError, ERROR_CODE, errorMessage } from "../../../lib/errors.ts"; +import { readConfigText, refuseConfigWrite, type ConfigRecord } from "./json-config.ts"; + +export async function readTomlConfig(path: string): Promise { + const text = await readConfigText(path); + if (text === undefined || text.trim().length === 0) return {}; + try { + const parsed: unknown = Bun.TOML.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 (!isRecord(parsed)) { + throw new CliError(`Config at ${path} is not a TOML table`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } + return parsed as ConfigRecord; + } 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: ConfigRecord): Promise { + return refuseConfigWrite(path); +} 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..670c0943 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/types.ts @@ -0,0 +1,81 @@ +/** + * Shared types for MCP client integrations. + * + * Each supported MCP client (Claude Code, Cursor, GitHub Copilot, Windsurf, + * Gemini, Codex, opencode, OpenClaw, Warp, Hermes) exposes an + * {@link McpClient} that knows how to read, upsert, and remove the `clerk` + * server entry in its own config file format. + */ + +export type ClientId = + | "claude" + | "cursor" + | "vscode" + | "windsurf" + | "gemini" + | "codex" + | "opencode" + | "openclaw" + | "warp" + | "hermes"; + +/** + * Per-client manual setup instructions for the Clerk MCP server. Attached as + * `docsUrl` to MCP errors so a user whose client we can't drive (missing CLI, + * failing CLI, nothing detected) always has the manual path. + */ +export const MCP_DOCS_URL = "https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server"; + +/** All supported clients register at user (global) scope today. */ +export type Scope = "user"; + +export interface McpServerEntry { + /** Entry name (key under `mcpServers` / `servers`). Default: `clerk`. */ + name: string; + /** Remote MCP endpoint. */ + url: string; +} + +/** + * Install always converges to the desired entry (overwriting whatever was + * there), so success has a single state. Failures reject and are settled + * per-client by the callers. + */ +export type UpsertResult = { client: ClientId; configPath: string; status: "installed" }; + +export interface RemoveResult { + client: ClientId; + configPath: string; + removed: boolean; +} + +export interface ListEntry { + client: ClientId; + configPath: string; + name: string; + url: string; +} + +export interface McpClient { + id: ClientId; + displayName: string; + scope: Scope; + /** + * What the user must do *after* the config is written for this client to + * connect — typically reload the editor, and sign in if the server requires + * it. Writing the file is not enough on its own, so `install` surfaces this + * as a next step. + */ + activation: string; + configPath(cwd: string): string; + /** + * Is this client usable on this machine? File-backed clients check for their + * well-known config dir; CLI-backed clients check for their binary on PATH + * (a config dir without the CLI is not installable by us). + */ + detect(cwd: string): Promise; + upsert(entry: McpServerEntry, cwd: string): Promise; + remove(name: string, cwd: string): Promise; + /** List `clerk`-flavored entries currently registered (those pointing at clerk.com URLs or named explicitly). */ + list(cwd: string): Promise; +} diff --git a/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts new file mode 100644 index 00000000..0faed4d6 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/user-scope.test.ts @@ -0,0 +1,372 @@ +import type { findClientBinary, runClientCli } from "./cli-exec.ts"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import * as realOs from "node:os"; +import { useCaptureLog } from "../../../test/lib/stubs.ts"; + +// Gemini and Windsurf read/write under the user's home (`~/.gemini`, +// `~/.codeium`), so their logic can't be exercised through a cwd tmpdir like +// project-scope paths. Bun's os.homedir() ignores $HOME, so redirect it via the +// module mock instead — registered at file top, before the clients are imported. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); +afterAll(() => mock.restore()); + +const mockIsAgent = mock(); +mock.module("../../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +// CLI-backed clients (claude, gemini, codex, vscode, openclaw, hermes) delegate writes to their +// own CLI; stub the subprocess layer so no real binaries are required. +const mockRun = mock(); +const mockWhich = mock(); +mock.module("./cli-exec.ts", () => ({ + findClientBinary: mockWhich, + runClientCli: mockRun, +})); + +const { geminiClient } = await import("./gemini.ts"); +const { windsurfClient } = await import("./windsurf.ts"); +const { codexClient } = await import("./codex.ts"); +const { mcpInstall } = await import("../install.ts"); +const { mcpUninstall } = await import("../uninstall.ts"); +const { checkMcp } = await import("../../doctor/check-mcp.ts"); + +const captured = useCaptureLog(); + +// 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", + "opencode", + "openclaw", + "warp", + "hermes", +]; + +// The entry shape the bridge registers — no URL in args. +const CURRENT_SHAPE = { command: "clerk", args: ["mcp", "run"] }; + +describe("user-scope MCP clients (homedir redirected)", () => { + beforeEach(async () => { + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-home-")); + mockWhich.mockImplementation((binary: string) => `/fake/bin/${binary}`); + mockRun.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + }); + + afterEach(async () => { + await rm(mockHome, { recursive: true, force: true }); + mockWhich.mockReset(); + mockRun.mockReset(); + }); + + describe("gemini (reads configs written by the gemini CLI)", () => { + test("round-trips a current-shape entry on list, resolving URL from getMcpUrl()", async () => { + const dir = join(mockHome, ".gemini"); + await mkdir(dir, { recursive: true }); + await Bun.write( + join(dir, "settings.json"), + JSON.stringify({ mcpServers: { clerk: CURRENT_SHAPE } }), + ); + const entries = await geminiClient.list("/ignored"); + expect(entries).toEqual([ + expect.objectContaining({ client: "gemini", name: "clerk", url: DEFAULT_URL }), + ]); + }); + + 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", 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"] }, + }, + }), + ); + const entries = await geminiClient.list("/ignored"); + expect(entries.map((e) => e.name)).toEqual(["clerk"]); + }); + }); + + describe("windsurf (file-backed — we write the config ourselves)", () => { + test("encodes the clerk-run shape and round-trips it on list", async () => { + await windsurfClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored"); + const parsed = (await Bun.file(windsurfClient.configPath("/ignored")).json()) as { + mcpServers: { clerk: { command: string; args: string[] } }; + }; + expect(parsed.mcpServers.clerk).toEqual(CURRENT_SHAPE); + + const entries = await windsurfClient.list("/ignored"); + expect(entries).toEqual([ + expect.objectContaining({ client: "windsurf", name: "clerk", url: DEFAULT_URL }), + ]); + }); + }); + + describe("codex (reads the TOML config written by the codex CLI)", () => { + test("round-trips a [mcp_servers.] entry on list, resolving URL from getMcpUrl()", async () => { + const dir = join(mockHome, ".codex"); + await mkdir(dir, { recursive: true }); + await Bun.write( + join(dir, "config.toml"), + '[mcp_servers.clerk]\ncommand = "clerk"\nargs = ["mcp", "run"]\n', + ); + const entries = await codexClient.list("/ignored"); + expect(entries).toEqual([ + expect.objectContaining({ client: "codex", name: "clerk", url: DEFAULT_URL }), + ]); + }); + + test("round-trips a legacy bare-url entry on list", 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', + ); + const entries = await codexClient.list("/ignored"); + expect(entries.map((e) => e.name)).toEqual(["clerk"]); + expect(entries[0]!.url).toBe(DEFAULT_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; + 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); + mockWhich.mockImplementation((binary: string) => `/fake/bin/${binary}`); + mockRun.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + }); + + 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(); + mockWhich.mockReset(); + mockRun.mockReset(); + }); + + test("install --all targets every detected client", async () => { + // File-backed clients (cursor, windsurf, opencode, warp) are detected via + // their marker directory; CLI-backed clients via their binary on PATH + // (mocked found). + await Promise.all([ + mkdir(join(mockHome, ".cursor"), { recursive: true }), + mkdir(join(mockHome, ".codeium", "windsurf"), { recursive: true }), + mkdir(join(mockHome, ".config", "opencode"), { recursive: true }), + mkdir(join(mockHome, ".warp"), { recursive: true }), + mkdir(join(mockHome, ".hermes"), { recursive: true }), + ]); + // Hermes verifies its entry landed after add (its CLI can exit 0 without + // saving); the mocked CLI writes nothing, so seed the config it would have + // written. + await Bun.write( + join(mockHome, ".hermes", "config.yaml"), + "mcp_servers:\n clerk:\n command: clerk\n args: [mcp, run]\n", + ); + + 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); + expect(payload.results.every((r) => r.status === "installed")).toBe(true); + }); + + test("--all with no detected client throws mcp_no_client_detected linking the setup docs", async () => { + mockWhich.mockReturnValue(null); // no client CLI on PATH, no config dirs + await expect(mcpInstall({ all: true })).rejects.toMatchObject({ + code: "mcp_no_client_detected", + docsUrl: expect.stringContaining("https://clerk.com/docs/guides/ai/mcp/clerk-mcp-server"), + }); + }); + + test("auto-selects the sole detected client without prompting", async () => { + await mkdir(join(mockHome, ".cursor"), { recursive: true }); + mockWhich.mockReturnValue(null); // no CLI-backed client available + mockIsAgent.mockReturnValue(false); + + await mcpInstall({}); + + const parsed = JSON.parse(await Bun.file(join(mockHome, ".cursor", "mcp.json")).text()) as { + mcpServers: { clerk: unknown }; + }; + expect(parsed.mcpServers.clerk).toEqual(CURRENT_SHAPE); + }); + + test("uninstall with no --client removes from every client", async () => { + await mcpInstall({ client: ["cursor"] }); + // Gemini's entry exists as its own CLI would have registered it. + await mkdir(join(mockHome, ".gemini"), { recursive: true }); + await Bun.write( + join(mockHome, ".gemini", "settings.json"), + JSON.stringify({ mcpServers: { clerk: CURRENT_SHAPE } }), + ); + 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); + // Gemini's removal went through its CLI, not a file edit of ours. + expect(mockRun).toHaveBeenCalledWith([ + "/fake/bin/gemini", + "mcp", + "remove", + "--scope", + "user", + "clerk", + ]); + }); +}); + +// `clerk doctor`'s MCP check (folded in from the former `clerk mcp doctor`). +// Lives here because it scans the user-scoped clients, needing the homedir +// redirect; a second os-mocking file would collide in the single-process runner. +describe("clerk doctor — checkMcp (homedir + cwd redirected)", () => { + let cwd: string; + let originalCwd: string; + let origXdgConfigHome: string | undefined; + let origAppData: string | undefined; + const originalFetch = globalThis.fetch; + + // Assign globalThis.fetch directly (cast to its own type) rather than via the + // typed stubFetch helper: this file imports node:* which pulls in undici's + // `Response` type, and stubFetch expects Bun's — the two aren't assignable. + function stubFetchWith(body: string, init: ResponseInit): void { + globalThis.fetch = (async () => new Response(body, init)) as unknown as typeof globalThis.fetch; + } + + const HANDSHAKE_BODY = `event: message\ndata: {"result":{"serverInfo":{"name":"Clerk MCP Server"}},"jsonrpc":"2.0","id":1}\n\n`; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-check-cwd-")); + mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-check-home-")); + origXdgConfigHome = process.env.XDG_CONFIG_HOME; + origAppData = process.env.APPDATA; + process.env.XDG_CONFIG_HOME = ""; + process.env.APPDATA = ""; + process.chdir(cwd); + mockIsAgent.mockReturnValue(true); + mockWhich.mockImplementation((binary: string) => `/fake/bin/${binary}`); + mockRun.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + }); + + afterEach(async () => { + process.chdir(originalCwd); + if (origXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = origXdgConfigHome; + } + if (origAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = origAppData; + } + await rm(cwd, { recursive: true, force: true }); + await rm(mockHome, { recursive: true, force: true }); + globalThis.fetch = originalFetch; + mockIsAgent.mockReset(); + mockWhich.mockReset(); + mockRun.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"] }); + 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"] }); + captured.clear(); + stubFetchWith("nope", { status: 503 }); + + const result = await checkMcp(); + expect(result.status).toBe("warn"); + }); + + 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(DEFAULT_URL); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/clients/vscode.ts b/packages/cli-core/src/commands/mcp/clients/vscode.ts new file mode 100644 index 00000000..485f6598 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/vscode.ts @@ -0,0 +1,39 @@ +/** + * Registration is delegated to VS Code's own CLI: `code --add-mcp ''`, + * which writes the user-profile `mcp.json`. VS Code has an add command but no + * removal counterpart, so `remove` (and the pre-clean before re-install) falls + * back to the file-backed base editing the user-global `mcp.json` under VS + * Code's per-OS user config dir (the file behind `MCP: Open User + * Configuration`). VS Code uses the top-level key `servers` (not `mcpServers`) + * and tags stdio servers with `type: "stdio"`; legacy `{ type: "http", url }` + * entries still round-trip on list/uninstall. + */ + +import { join } from "node:path"; +import { clerkRunArgs, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { makeCliClient } from "./make-cli-client.ts"; +import { makeJsonClient } from "./make-client.ts"; +import { vscodeUserDir } from "./paths.ts"; + +const vscodeFileClient = makeJsonClient({ + id: "vscode", + displayName: "GitHub Copilot", + scope: "user", + activation: + "Reload the VS Code window, then start the server from `MCP: List Servers` (`clerk` must be on your PATH).", + topKey: "servers", + encode: () => ({ type: "stdio", command: RUN_COMMAND, args: clerkRunArgs() }), + extractUrl: withLegacyUrl, + configPath: () => join(vscodeUserDir(), "mcp.json"), +}); + +export const vscodeClient = makeCliClient({ + base: vscodeFileClient, + binary: "code", + installHint: + "Install the `code` shell command from VS Code (\"Shell Command: Install 'code' command in PATH\").", + addArgs: (name) => [ + "--add-mcp", + JSON.stringify({ name, type: "stdio", command: RUN_COMMAND, args: clerkRunArgs() }), + ], +}); diff --git a/packages/cli-core/src/commands/mcp/clients/warp.ts b/packages/cli-core/src/commands/mcp/clients/warp.ts new file mode 100644 index 00000000..11a77354 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/warp.ts @@ -0,0 +1,24 @@ +/** + * Writes Warp's user-global `~/.warp/.mcp.json` directly — the documented + * file surface behind `Settings → Agents → MCP servers`. Warp ships no + * registration CLI (its `oz` CLI only attaches servers to cloud-agent runs), + * so the file write is the only non-interactive path. Standard + * `mcpServers` + `{ command, args }` dialect. + */ + +import { clerkRunDescriptor, withLegacyUrl } from "./clerk-run.ts"; +import { makeJsonClient } from "./make-client.ts"; +import { pathExists, userPath } from "./paths.ts"; + +export const warpClient = makeJsonClient({ + id: "warp", + displayName: "Warp", + scope: "user", + activation: + "Restart Warp, then enable the server under `Settings → Agents → MCP servers` (`clerk` must be on your PATH).", + topKey: "mcpServers", + encode: clerkRunDescriptor, + extractUrl: withLegacyUrl, + configPath: () => userPath(".warp", ".mcp.json"), + detect: () => pathExists(userPath(".warp")), +}); 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..d7e4fa36 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/windsurf.ts @@ -0,0 +1,22 @@ +/** + * 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 { clerkRunDescriptor, 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` (`clerk` must be on your PATH).", + topKey: "mcpServers", + encode: clerkRunDescriptor, + 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/clients/yaml-config.test.ts b/packages/cli-core/src/commands/mcp/clients/yaml-config.test.ts new file mode 100644 index 00000000..89f18907 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/yaml-config.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readYamlConfig, writeYamlConfig } from "./yaml-config.ts"; + +describe("yaml-config", () => { + let dir: string; + + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), "clerk-mcp-yaml-")); + }); + + afterEach(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + test("returns an empty record when the file is absent", async () => { + expect(await readYamlConfig(join(dir, "missing.yaml"))).toEqual({}); + }); + + test("parses a Hermes-style mcp_servers map", async () => { + const path = join(dir, "config.yaml"); + await writeFile( + path, + "mcp_servers:\n clerk:\n command: clerk\n args: [mcp, run]\n enabled: true\n", + ); + expect(await readYamlConfig(path)).toEqual({ + mcp_servers: { clerk: { command: "clerk", args: ["mcp", "run"], enabled: true } }, + }); + }); + + test("rejects malformed YAML with MCP_CLIENT_CONFIG_INVALID", async () => { + const path = join(dir, "config.yaml"); + await writeFile(path, "mcp_servers:\n clerk: [unclosed\n"); + await expect(readYamlConfig(path)).rejects.toMatchObject({ + code: "mcp_client_config_invalid", + }); + }); + + test("rejects a non-object top level with MCP_CLIENT_CONFIG_INVALID", async () => { + const path = join(dir, "config.yaml"); + await writeFile(path, "- just\n- a\n- list\n"); + await expect(readYamlConfig(path)).rejects.toMatchObject({ + code: "mcp_client_config_invalid", + }); + }); + + test("writes are refused — Hermes config edits are delegated to its CLI", async () => { + // Reads are ours (list/doctor); rewriting the user's YAML would destroy + // comments and formatting, and no code path needs it (the Hermes CLI owns + // both add and remove). Unreachable in practice; explicit if ever reached. + await expect(writeYamlConfig(join(dir, "config.yaml"), {})).rejects.toThrow(/delegated/); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/clients/yaml-config.ts b/packages/cli-core/src/commands/mcp/clients/yaml-config.ts new file mode 100644 index 00000000..e0a0a027 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/clients/yaml-config.ts @@ -0,0 +1,35 @@ +/** + * Read-only YAML config access for MCP clients whose config is YAML (Hermes). + * + * Reads power `list`/`doctor` and the CLI-delegation presence checks. Writes + * are refused by design: Hermes' own CLI handles both add and remove, so no + * code path needs a YAML write — and `Bun.YAML.stringify` would destroy the + * comments and formatting in a user's hand-maintained `config.yaml`. + */ + +import { isRecord } from "../../../lib/objects.ts"; +import { CliError, ERROR_CODE, errorMessage } from "../../../lib/errors.ts"; +import { readConfigText, refuseConfigWrite, type ConfigRecord } from "./json-config.ts"; + +export async function readYamlConfig(path: string): Promise { + const text = await readConfigText(path); + if (text === undefined || text.trim().length === 0) return {}; + let parsed: unknown; + try { + parsed = Bun.YAML.parse(text); + } catch (error) { + throw new CliError(`Could not parse ${path} as YAML: ${errorMessage(error)}`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } + if (!isRecord(parsed)) { + throw new CliError(`Config at ${path} is not a YAML mapping`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } + return parsed as ConfigRecord; +} + +export async function writeYamlConfig(path: string, _config: ConfigRecord): Promise { + return refuseConfigWrite(path); +} 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..bbb7a842 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/collect.ts @@ -0,0 +1,44 @@ +/** + * 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. + * 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"; +import { log } from "../../lib/log.ts"; +import { CLIENTS } from "./clients/registry.ts"; +import type { ClientId, ListEntry } from "./clients/types.ts"; + +/** A client whose config exists but couldn't be read or parsed. */ +export interface CollectFailure { + client: ClientId; + displayName: 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))); + 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]!; + const message = errorMessage(outcome.reason); + failures.push({ client: client.id, displayName: client.displayName, message }); + log.warn(`${client.displayName}: could not read MCP config — ${message}`); + } + return { entries, failures }; +} 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..78e3b9b8 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/index.ts @@ -0,0 +1,108 @@ +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"; +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"; + +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 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" }, + { + command: "clerk mcp run", + description: "stdio bridge an editor launches (not run by hand)", + }, + ]); + + 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_ID_CHOICES]) + .argParser(collectOptionValues) + .default([] as string[]), + ) + .option("--name ", 'Entry name in the client config (default: "clerk")') + .option("--all", "Install into every detected client without prompting") + .option("--json", "Output as JSON") + .setExamples([ + { + command: "clerk mcp install", + description: "Pick clients interactively (or all in agent mode)", + }, + { command: "clerk mcp install --all", description: "Install into every detected client" }, + { + command: "clerk mcp install --client claude --client vscode", + description: "Install into specific clients", + }, + ]) + .action((options) => 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("run") + .description( + "stdio bridge to the remote MCP server (clients spawn this; not meant to be run by hand)", + ) + .setExamples([ + { + command: "clerk mcp run", + 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") + .addOption( + createOption( + "--client ", + "MCP client to target (repeatable). Default in human mode: pick from installed; in agent mode: all clients.", + ) + .choices([...CLIENT_ID_CHOICES]) + .argParser(collectOptionValues) + .default([] as string[]), + ) + .option("--all", "Remove from every client without prompting") + .option("--name ", 'Entry name to remove (default: "clerk")') + .option("--json", "Output as JSON") + .setExamples([ + { + command: "clerk mcp uninstall", + description: "Pick which installed clients to remove from", + }, + { command: "clerk mcp uninstall --all", description: "Remove from every client" }, + { + command: "clerk mcp uninstall --client claude", + description: "Remove from Claude Code only", + }, + ]) + .action((options) => 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..e19a14a2 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -0,0 +1,313 @@ +import type { findClientBinary, runClientCli } from "./clients/cli-exec.ts"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import * as realOs from "node:os"; +import { join } from "node:path"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +// All clients write under the user's home now. Redirect homedir to the same +// tmpdir we use as cwd (Bun's os.homedir() ignores $HOME) so writes stay +// isolated and the `join(cwd, ...)` assertions below still resolve. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + +// CLI-backed clients (claude, gemini, codex, vscode, openclaw, hermes) delegate registration to +// their own CLI — stub the subprocess layer so no real binaries are needed. +const mockRun = mock(); +const mockWhich = mock(); +mock.module("./clients/cli-exec.ts", () => ({ + findClientBinary: mockWhich, + runClientCli: mockRun, +})); +afterAll(() => mock.restore()); + +const { mcpInstall } = await import("./install.ts"); + +// 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 pre-existing foreign entry. +const FOREIGN_URL = "http://localhost:8787/mcp"; + +// 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(); + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-install-")); + mockHome = cwd; + process.chdir(cwd); + mockIsAgent.mockReturnValue(false); + mockWhich.mockImplementation((binary: string) => `/fake/bin/${binary}`); + mockRun.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + mockIsAgent.mockReset(); + mockWhich.mockReset(); + mockRun.mockReset(); + }); + + test.each([ + // The name is spliced into client CLI argv (and through cmd.exe /c on + // Windows, where `&` is a live operator) and used as a config key. + ["shell metacharacters", "clerk & calc.exe"], + ["a flag lookalike", "--scope"], + ["a leading dash", "-x"], + ["an empty string", ""], + ["an overlong name", "a".repeat(65)], + ])("rejects a --name with %s as a usage error", async (_label, name) => { + await expect(mcpInstall({ client: ["cursor"], name })).rejects.toMatchObject({ + code: "usage_error", + }); + expect(mockRun).not.toHaveBeenCalled(); + }); + + test("accepts a --name with dashes and underscores", async () => { + await mcpInstall({ client: ["cursor"], name: "clerk_dev-2" }); + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(parsed.mcpServers["clerk_dev-2"]).toEqual(CURRENT_SHAPE); + }); + + test("writes the Cursor config when --client cursor is passed", async () => { + 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(CURRENT_SHAPE); + }); + + test("delegates Claude Code registration to `claude mcp add`", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["claude"] }); + + const argv = mockRun.mock.calls.at(-1)?.[0] as string[]; + expect(argv[0]).toBe("/fake/bin/claude"); + expect(argv).toContain("add"); + // No file written by us — the CLI owns the config. + await expect(readFile(join(cwd, ".claude.json"), "utf8")).rejects.toThrow(); + }); + + test("fails the client when its CLI binary is not on PATH (no file fallback)", async () => { + mockWhich.mockReturnValue(null); + await expect(mcpInstall({ client: ["claude"] })).rejects.toMatchObject({ + code: "mcp_client_cli_not_found", + }); + expect(mockRun).not.toHaveBeenCalled(); + // settleClients prefixes the display name on warn lines; the error message + // itself must not repeat it ("Claude Code: Claude Code: …"). + expect(captured.err).toContain("Claude Code:"); + expect(captured.err).not.toContain("Claude Code: Claude Code:"); + }); + + test("emits JSON to stdout in agent mode and skips intro/outro", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"] }); + + const payload = JSON.parse(captured.out) as { + url: string; + name: string; + results: { client: string; status: string }[]; + }; + expect(payload.url).toBe(DEFAULT_URL); + expect(payload.name).toBe("clerk"); + expect(payload.results).toEqual([ + expect.objectContaining({ client: "cursor", status: "installed" }), + ]); + expect(captured.err).not.toContain("┌"); // intro suppressed in agent mode + }); + + test("--json forces JSON output even in human mode", async () => { + await mcpInstall({ client: ["cursor"], json: true }); + expect(() => JSON.parse(captured.out)).not.toThrow(); + }); + + test("re-install is idempotent and reports installed again", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"] }); + captured.clear(); + await mcpInstall({ client: ["cursor"] }); + + const payload = JSON.parse(captured.out) as { + results: { status: string }[]; + }; + expect(payload.results[0]?.status).toBe("installed"); + }); + + test("overwrites an existing entry pointing at a foreign server (always converges)", 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"] }); + + const payload = JSON.parse(captured.out) as { + results: { status: string }[]; + }; + expect(payload.results[0]?.status).toBe("installed"); + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: { clerk: unknown }; + }; + expect(parsed.mcpServers.clerk).toEqual(CURRENT_SHAPE); + }); + + test("uses --name to customize the entry key", async () => { + 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(CURRENT_SHAPE); + expect(parsed.mcpServers.clerk).toBeUndefined(); + }); + + test("passes --name through to the client CLI argv", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["claude"], name: "clerk-staging" }); + const argv = mockRun.mock.calls.at(-1)?.[0] as string[]; + expect(argv).toContain("clerk-staging"); + }); + + 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(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 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(DEFAULT_URL); + }); + + test.each([ + ["file:///etc/passwd"], + ["data:text/plain,clerk"], + ["javascript:alert(1)"], + ["ftp://example.com/mcp"], + ])("rejects non-http(s) CLERK_MCP_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("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 after a human-mode install", async () => { + await mcpInstall({ client: ["cursor"] }); + expect(captured.err).toContain("Next steps"); + expect(captured.err).toContain("Reload Cursor"); + }); + + test("omits next steps from JSON output", async () => { + await mcpInstall({ client: ["cursor"], json: true }); + expect(captured.err).not.toContain("Next steps"); + }); + + test("rejects an unknown --client id", async () => { + await expect(mcpInstall({ client: ["bogus"] })).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; Windsurf's is absent (clean). + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "{ not json"); + + await mcpInstall({ client: ["cursor", "windsurf"] }); + + const payload = JSON.parse(captured.out) as { + results: { client: string; status: string }[]; + failures: { client: string; error: string }[]; + }; + expect(payload.results).toEqual([ + expect.objectContaining({ client: "windsurf", status: "installed" }), + ]); + // The failed client is structurally visible to JSON/agent consumers, not + // only as a free-text stderr warning. + expect(payload.failures).toEqual([expect.objectContaining({ client: "cursor" })]); + expect(captured.err).toContain("Cursor"); // per-client warning for the failure + }); + + test("reports a client CLI failure in the JSON failures array", async () => { + mockIsAgent.mockReturnValue(true); + mockRun.mockResolvedValue({ exitCode: 1, stdout: "", stderr: "flag not recognized" }); + + await mcpInstall({ client: ["claude", "cursor"] }); + + const payload = JSON.parse(captured.out) as { + results: { client: string }[]; + failures: { client: string; error: string }[]; + }; + expect(payload.results).toEqual([expect.objectContaining({ client: "cursor" })]); + expect(payload.failures).toEqual([expect.objectContaining({ client: "claude" })]); + expect(payload.failures[0]?.error).toContain("flag not recognized"); + }); + + 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"] })).rejects.toMatchObject({ + code: "mcp_client_config_invalid", + }); + expect(captured.err).toContain("Cursor"); + }); + + test("surfaces the client CLI's stderr when the add command fails", async () => { + mockRun.mockResolvedValue({ exitCode: 1, stdout: "", stderr: "unexpected flag" }); + await expect(mcpInstall({ client: ["claude"] })).rejects.toMatchObject({ + code: "mcp_client_cli_failed", + }); + expect(captured.err).toContain("unexpected flag"); + }); +}); 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..9684418c --- /dev/null +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -0,0 +1,96 @@ +/** + * `clerk mcp install` — register the Clerk remote MCP server in supported clients. + * + * 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). + * Install always converges: whatever entry exists under the name is replaced. + * Clients with their own CLI (claude, gemini, codex, vscode, openclaw, hermes) + * are registered by shelling out to it; the rest get their config file written + * directly. + */ + +import { log } from "../../lib/log.ts"; +import { cyan, dim, green } from "../../lib/color.ts"; +import { withGutter } from "../../lib/spinner.ts"; +import { + pickClients, + resolveName, + resolveUrl, + settleClients, + targetClients, + wantsJson, + type McpOptions, +} from "./shared.ts"; +import { detectInstalledClients } from "./clients/registry.ts"; +import type { McpClient, UpsertResult } from "./clients/types.ts"; + +async function chooseClients(options: McpOptions, cwd: string): Promise { + // `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); + } + const detected = await detectInstalledClients(cwd); + // No clients on the system isn't a pickable state — defer to `targetClients`, + // which throws `MCP_NO_CLIENT_DETECTED` with the supported list and the + // `--client` escape hatch, instead of the picker's empty-selection message. + if (detected.length === 0) return targetClients(options, cwd); + return pickClients(detected, "Select MCP clients to install into:", { + autoSelectSingle: true, + }); +} + +function printResult(client: McpClient, result: UpsertResult): void { + log.info(`${client.displayName} → ${dim(result.configPath)}: ${green(result.status)}`); +} + +type ClientUpsert = { client: McpClient; result: UpsertResult }; + +// Registering the entry 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 installed into, so "installed" doesn't read as "done and +// working". +function installNextSteps(settled: ClientUpsert[]): string[] { + return settled.map(({ client }) => `${client.displayName}: ${client.activation}`); +} + +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 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; + } + + await withGutter( + `Installing Clerk MCP (${cyan(url)})`, + async ({ setNextSteps }) => { + const { succeeded, failed } = await settleClients(clients, (c) => + c.upsert({ name, url }, cwd), + ); + if (json) { + log.data( + JSON.stringify( + { url, name, results: succeeded.map((s) => s.result), failures: failed }, + null, + 2, + ), + ); + return; + } + succeeded.forEach(({ client, result }) => printResult(client, result)); + const steps = installNextSteps(succeeded); + if (steps.length > 0) setNextSteps(steps); + }, + { skip: json }, + ); +} diff --git a/packages/cli-core/src/commands/mcp/list.test.ts b/packages/cli-core/src/commands/mcp/list.test.ts new file mode 100644 index 00000000..fb008f8c --- /dev/null +++ b/packages/cli-core/src/commands/mcp/list.test.ts @@ -0,0 +1,118 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import * as realOs from "node:os"; +import { join } from "node:path"; +import { captureUi, useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +// User-scoped clients write under home; redirect homedir to the cwd tmpdir so +// install writes (read back by list) stay isolated to the test. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + +const { mcpInstall } = await import("./install.ts"); +const { mcpList } = await import("./list.ts"); + +const URL = "https://mcp.clerk.com/mcp"; + +describe("mcp list", () => { + const captured = useCaptureLog(); + let uiCapture: ReturnType; + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-list-")); + mockHome = cwd; + process.chdir(cwd); + uiCapture = captureUi(); + uiCapture.install(); + mockIsAgent.mockReturnValue(true); + }); + + afterEach(async () => { + uiCapture.teardown(); + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + mockIsAgent.mockReset(); + }); + + test("returns empty entries and failures when no clients have entries", async () => { + await mcpList({}); + expect(JSON.parse(captured.out)).toEqual({ entries: [], failures: [] }); + }); + + 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 { + entries: { client: string; name: string; url: string }[]; + failures: unknown[]; + }; + expect(payload.entries).toEqual([ + expect.objectContaining({ client: "cursor", name: "clerk", url: URL }), + ]); + expect(payload.failures).toEqual([]); + }); + + test("reports an unreadable config as a structural failure, not an empty list", async () => { + // A corrupt config is not "nothing installed" — a registered entry may be + // hiding inside it. JSON/agent consumers must see the failure structurally, + // matching the `failures` array install/uninstall already emit. + const cursorDir = join(cwd, ".cursor"); + await mkdir(cursorDir, { recursive: true }); + await writeFile(join(cursorDir, "mcp.json"), "{ not json"); + await mcpList({}); + const payload = JSON.parse(captured.out) as { + entries: unknown[]; + failures: { client: string; error: string }[]; + }; + expect(payload.entries).toEqual([]); + expect(payload.failures).toEqual([ + expect.objectContaining({ client: "cursor", error: expect.stringContaining("JSON") }), + ]); + }); + + test("human-mode unreadable config warns instead of claiming nothing is installed", async () => { + mockIsAgent.mockReturnValue(false); + const cursorDir = join(cwd, ".cursor"); + await mkdir(cursorDir, { recursive: true }); + await writeFile(join(cursorDir, "mcp.json"), "{ not json"); + await mcpList({}); + expect(uiCapture.out).not.toContain("No Clerk MCP entries"); + expect(uiCapture.out).toContain("could not be read"); + }); + + test("human-mode empty state shows the hint on the prompt rail, nothing to stdout", async () => { + mockIsAgent.mockReturnValue(false); + await mcpList({}); + expect(captured.out).toBe(""); + expect(uiCapture.out).toContain("No Clerk MCP entries"); + }); + + test("human-mode renders the table and next steps after an install", async () => { + mockIsAgent.mockReturnValue(true); + await mcpInstall({ client: ["cursor"], url: URL }); + mockIsAgent.mockReturnValue(false); + captured.clear(); + await mcpList({}); + + // Table, count, and the outro next-steps all render on the clack prompt + // rail; with captureUi installed, intro/outro write to that stream too. + expect(uiCapture.out).toContain("cursor"); + expect(uiCapture.out).toContain("clerk"); + expect(uiCapture.out).toContain(URL); + expect(uiCapture.out).toContain("1 entry"); + expect(uiCapture.out).toContain("Next steps"); + expect(uiCapture.out).toContain("clerk doctor"); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/list.ts b/packages/cli-core/src/commands/mcp/list.ts new file mode 100644 index 00000000..22ff13da --- /dev/null +++ b/packages/cli-core/src/commands/mcp/list.ts @@ -0,0 +1,84 @@ +/** + * `clerk mcp list` — show Clerk-named entries across detected MCP clients. + * + * Walks the registry, reads each client's config (if present), and reports + * any entry whose name is `clerk` or whose URL hostname is under `clerk.com`. + */ + +import { cyan, dim } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import { withGutter } from "../../lib/spinner.ts"; +import { ui } from "../../lib/ui.ts"; +import type { ListEntry } from "./clients/types.ts"; +import { collectEntries } from "./collect.ts"; +import { wantsJson, type McpOptions } from "./shared.ts"; + +const COLUMN_PADDING = 2; + +function columnWidth(header: string, values: string[]): number { + return Math.max(header.length, ...values.map((v) => v.length)) + COLUMN_PADDING; +} + +function formatTable(entries: ListEntry[]): void { + const clientWidth = columnWidth( + "CLIENT", + entries.map((e) => e.client), + ); + const nameWidth = columnWidth( + "NAME", + entries.map((e) => e.name), + ); + const urlWidth = columnWidth( + "URL", + entries.map((e) => e.url), + ); + + const header = `${"CLIENT".padEnd(clientWidth)}${"NAME".padEnd(nameWidth)}${"URL".padEnd(urlWidth)}PATH`; + const rows = entries.map((e) => { + const client = cyan(e.client.padEnd(clientWidth)); + const name = e.name.padEnd(nameWidth); + const url = e.url.padEnd(urlWidth); + return `${client}${name}${url}${dim(e.configPath)}`; + }); + + ui.message([dim(header), ...rows]); +} + +export async function mcpList(options: McpOptions = {}): Promise { + // Unreadable configs are warned per client by collectEntries (stderr) and + // carried in `failures` so JSON/agent consumers see them structurally too. + const { entries: all, failures } = await collectEntries(process.cwd()); + + if (wantsJson(options)) { + log.data( + JSON.stringify( + { entries: all, failures: failures.map((f) => ({ client: f.client, error: f.message })) }, + null, + 2, + ), + ); + return; + } + + await withGutter("Listing Clerk MCP entries", async ({ setNextSteps }) => { + if (all.length === 0) { + // An unreadable config is not "nothing installed" — an entry may be + // hiding inside it. Don't claim a clean slate we couldn't verify. + if (failures.length > 0) { + const clients = failures.map((f) => f.displayName).join(", "); + ui.warn( + `Some MCP configs could not be read (${clients}) — fix or remove them, then re-run \`clerk mcp list\`.`, + ); + return; + } + ui.warn("No Clerk MCP entries found. Run `clerk mcp install` to register one."); + return; + } + formatTable(all); + ui.message(`${all.length} entr${all.length === 1 ? "y" : "ies"}`); + setNextSteps([ + "Verify a server is reachable with `clerk doctor`.", + "Remove an entry with `clerk mcp uninstall`.", + ]); + }); +} diff --git a/packages/cli-core/src/commands/mcp/probe.test.ts b/packages/cli-core/src/commands/mcp/probe.test.ts new file mode 100644 index 00000000..5f631ae9 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/probe.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import { probeMcp } from "./probe.ts"; + +const URL = "https://mcp.clerk.com/mcp"; + +const INITIALIZE_RESULT = { + result: { serverInfo: { name: "Clerk MCP Server", version: "0.0.0" } }, + jsonrpc: "2.0", + id: 1, +}; + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function sseResponse(payload: unknown): Response { + return new Response(`event: message\ndata: ${JSON.stringify(payload)}\n\n`, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function sse(body: string): Response { + return new Response(body, { status: 200, headers: { "content-type": "text/event-stream" } }); +} + +describe("probeMcp", () => { + useCaptureLog(); + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + test.each([ + ["text/event-stream", sseResponse(INITIALIZE_RESULT)], + ["application/json", jsonResponse(INITIALIZE_RESULT)], + ])("returns ok with the server name via %s", async (_label, response) => { + stubFetch(async () => response); + expect(await probeMcp(URL)).toEqual({ ok: true, status: 200, serverName: "Clerk MCP Server" }); + }); + + test("POSTs an initialize handshake", async () => { + let method = ""; + let body = ""; + stubFetch(async (_input, init) => { + method = init?.method ?? ""; + body = typeof init?.body === "string" ? init.body : ""; + return sseResponse(INITIALIZE_RESULT); + }); + await probeMcp(URL); + expect(method).toBe("POST"); + expect(body).toContain('"method":"initialize"'); + }); + + test("parses an SSE frame with CRLF line endings", async () => { + stubFetch(async () => + sse(`event: message\r\ndata: ${JSON.stringify(INITIALIZE_RESULT)}\r\n\r\n`), + ); + expect((await probeMcp(URL)).ok).toBe(true); + }); + + test("reassembles an SSE payload split across multiple data lines", async () => { + stubFetch(async () => + sse( + `event: message\n` + + `data: {"result":{"serverInfo":{"name":"Clerk MCP Server"}},\n` + + `data: "jsonrpc":"2.0","id":1}\n\n`, + ), + ); + expect(await probeMcp(URL)).toMatchObject({ ok: true, serverName: "Clerk MCP Server" }); + }); + + test("fails when the SSE frame has no data line", async () => { + stubFetch(async () => sse("event: message\n\n")); + expect(await probeMcp(URL)).toMatchObject({ ok: false }); + }); + + test("fails when the SSE data line is malformed JSON", async () => { + stubFetch(async () => sse("event: message\ndata: {broken\n\n")); + expect(await probeMcp(URL)).toMatchObject({ ok: false }); + }); + + test("fails when 200 but not an MCP initialize result", async () => { + stubFetch(async () => jsonResponse({ hello: "world" })); + expect(await probeMcp(URL)).toMatchObject({ ok: false, status: 200 }); + }); + + test("fails on non-2xx, carrying the status", async () => { + stubFetch(async () => new Response("nope", { status: 404 })); + expect(await probeMcp(URL)).toEqual({ ok: false, status: 404 }); + }); + + test("fails on a network error, carrying the message", async () => { + stubFetch(async () => { + throw new Error("ECONNREFUSED"); + }); + expect(await probeMcp(URL)).toMatchObject({ + ok: false, + error: expect.stringContaining("ECONNREFUSED"), + }); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/probe.ts b/packages/cli-core/src/commands/mcp/probe.ts new file mode 100644 index 00000000..a40968de --- /dev/null +++ b/packages/cli-core/src/commands/mcp/probe.ts @@ -0,0 +1,107 @@ +/** + * 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 { isRecord } from "../../lib/objects.ts"; +import { errorMessage } from "../../lib/errors.ts"; +import { loggedFetch } from "../../lib/fetch.ts"; +import { DEV_CLI_VERSION, resolveCliVersion } from "../../lib/version.ts"; + +// Discriminated on `ok`: a healthy probe always carries a server name; a failed +// one never does. "ok but no serverName" is unrepresentable. +export type McpProbeResult = + | { ok: true; status: number; serverName: string } + | { ok: false; status?: number; error?: string }; + +// A hostile or wrong URL shouldn't hang the CLI: cap the probe so a slow or +// never-ending response surfaces as a failure instead of blocking forever. +// 5s covers a cold-start server while keeping `clerk doctor` snappy on a dead one. +const PROBE_TIMEOUT_MS = 5_000; + +const INITIALIZE_REQUEST = { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2024-11-05", + capabilities: {}, + clientInfo: { name: "clerk-cli", version: resolveCliVersion() ?? DEV_CLI_VERSION }, + }, +}; + +function safeJsonParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +// The streamable-HTTP transport answers `initialize` as either application/json +// or a text/event-stream frame (`event: message\ndata: {…}`). Pull the JSON-RPC +// payload out of whichever the server returned. For SSE, reassemble the first +// event's `data:` lines (the spec allows a payload to span several). +function parseHandshake(contentType: string, body: string): unknown { + if (!contentType.includes("text/event-stream")) return safeJsonParse(body); + const firstEvent = body.split(/\r?\n\r?\n/)[0] ?? ""; + const data = firstEvent + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .join("\n"); + return data === "" ? undefined : safeJsonParse(data); +} + +// 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 { + if (!isRecord(payload)) return undefined; + const result = (payload as { result?: unknown }).result; + if (!isRecord(result)) return undefined; + const serverInfo = (result as { serverInfo?: unknown }).serverInfo; + if (!isRecord(serverInfo)) return undefined; + const name = (serverInfo as { name?: unknown }).name; + return typeof name === "string" ? stripControl(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/run.test.ts b/packages/cli-core/src/commands/mcp/run.test.ts new file mode 100644 index 00000000..12de3750 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/run.test.ts @@ -0,0 +1,468 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; +import { mcpRun, pipeEventStream, readTextCapped } 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("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 discards an oversized buffer instead of growing unbounded", async () => { + // The server->client SSE stream lives as long as the bridge process; a + // stream that never emits a `\n\n` boundary must not accumulate forever. + // Events that start after the discard still parse. + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode("x".repeat(100))); + controller.enqueue(encoder.encode('data: {"ok":true}\n\n')); + controller.close(); + }, + }); + const response = new Response(body, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + const emitted: unknown[] = []; + + await pipeEventStream(response, async (p) => void emitted.push(p), { maxBufferBytes: 64 }); + + expect(emitted).toEqual([{ ok: true }]); + }); + + test("ignores non-object JSON frames on stdin without dispatching them", async () => { + // `5` and `[1,2]` are valid JSON but not JSON-RPC messages; forwarding + // them upstream as no-op "notifications" would be silent garbage-in. + stub((req) => noServerStream(req) ?? json(INIT_RESULT)); + const out: string[] = []; + + await mcpRun( + { url: URL }, + { + input: (async function* () { + yield "5\n"; + yield "[1,2]\n"; + })(), + write: (c) => out.push(c), + }, + ); + + expect(requests.filter((r) => r.method === "POST")).toHaveLength(0); + expect(out).toEqual([]); + }); + + test("readTextCapped returns the body under the cap and undefined over it", async () => { + expect(await readTextCapped(new Response("hello"), 64)).toBe("hello"); + expect(await readTextCapped(new Response("x".repeat(100)), 64)).toBeUndefined(); + }); + + 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)); + + 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..53a0ed81 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/run.ts @@ -0,0 +1,454 @@ +/** + * `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 { isRecord } from "../../lib/objects.ts"; +import { resolveUrl, type McpOptions } from "./shared.ts"; + +/** Injectable streams so the bridge can be driven in-process by tests. */ +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"; + // 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; + }; + + // 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 (isRecord(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)); + }; + const resetServerStream = (): void => { + serverStream = undefined; + }; + + try { + for await (const message of readJsonRpcLines(input, MAX_LINE_BYTES)) { + await dispatch(message, { + url, + session, + emit, + emitPayload, + track, + ensureServerStream, + resetServerStream, + 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; + resetServerStream: () => void; + signal: AbortSignal; +} + +async function dispatch(message: JsonRpcMessage, ctx: DispatchCtx): Promise { + const { url, session, emit, emitPayload, track, ensureServerStream, resetServerStream, 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(); + } + + // 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. + const needsAuth = response.status === 401 || response.status === 403; + if (needsAuth && session.id === undefined) { + throw new CliError( + `The MCP server at ${url} requires authentication, which \`clerk mcp run\` does not yet provide. ` + + "Set CLERK_MCP_URL to a server that doesn't require auth, or wait for a release with built-in sign-in.", + { code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID }, + ); + } + if (needsAuth) { + 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). + // Also drop the server→client stream tied to the old session — otherwise + // `ensureServerStream()` short-circuits on the next `initialize` and the new + // session never reopens its GET/SSE channel (the `suppress(...)` wrapper + // already makes a second GET safe). + if (response.status === 404 && session.id) { + session.id = undefined; + session.protocolVersion = undefined; + resetServerStream(); + 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")) { + // If the stream dies before this request's response frame arrives, answer + // with a JSON-RPC error so the client doesn't hang on the request forever. + // `replied` guards the case where the response was already delivered and a + // later event errors — a second reply for the same id would be a protocol + // violation. Notifications (no id) never need a reply. + let replied = message.id === undefined; + const markReplied: Emit = async (parsed) => { + if (!replied && hasReplyFor(parsed, message.id)) replied = true; + await emitPayload(parsed); + }; + track( + pipeEventStream(response, markReplied, { + signal, + onStreamError: 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 + // protocol version captured inside emitPayload→emit is set before the next + // request fires because the MCP client awaits the initialize reply. + track(forwardJsonBody(response, message, emit, emitPayload)); +} + +/** True when any frame in the payload answers the request with this id. */ +function hasReplyFor(parsed: unknown, id: JsonRpcMessage["id"]): boolean { + return (Array.isArray(parsed) ? parsed : [parsed]).some( + (item) => isRecord(item) && (item as JsonRpcMessage).id === id, + ); +} + +/** + * Read a response body as text, or `undefined` once it exceeds `maxBytes` — + * the JSON-body counterpart of the SSE buffer cap: the upstream server is a + * trust boundary and must not be able to grow the bridge's memory unbounded. + */ +export async function readTextCapped( + response: Response, + maxBytes: number, +): Promise { + const reader = response.body?.getReader(); + if (!reader) return ""; + const decoder = new TextDecoder(); + let text = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + text += decoder.decode(value, { stream: true }); + if (text.length > maxBytes) { + await reader.cancel().catch(() => {}); + return undefined; + } + } + return text + decoder.decode(); +} + +async function forwardJsonBody( + response: Response, + message: JsonRpcMessage, + emit: Emit, + emitPayload: Emit, +): Promise { + const text = await readTextCapped(response, MAX_LINE_BYTES); + if (text === undefined) { + await emitError(message, emit, -32000, "Upstream response too large."); + return; + } + 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 (!isRecord(message)) return; + const result = (message as { result?: unknown }).result; + if (!isRecord(result)) 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. */ +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 = ""; + } + } + // 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; +} + +function parseLine(line: string): JsonRpcMessage | undefined { + if (line.length === 0) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + log.warn(`mcp run: ignoring non-JSON line on stdin`); + return undefined; + } + // Mirrors the outgoing-frame guard in emitPayload: a bare scalar or array is + // valid JSON but not a JSON-RPC message, and must not be forwarded upstream. + if (!isRecord(parsed)) { + log.warn(`mcp run: ignoring non-object frame on stdin`); + return undefined; + } + return parsed as JsonRpcMessage; +} + +interface PipeEventStreamOptions { + signal?: AbortSignal; + onStreamError?: (error: unknown) => Promise; + maxBufferBytes?: number; +} + +/** Parse an SSE stream, emitting each event's JSON `data:` payload. */ +export async function pipeEventStream( + response: Response, + emitPayload: Emit, + options: PipeEventStreamOptions = {}, +): Promise { + const { signal, onStreamError, maxBufferBytes = MAX_LINE_BYTES } = options; + 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); + } + // This stream can be process-lifetime (the server→client GET stream), so + // an upstream that never sends an event boundary must not accumulate + // forever — mirror the stdin path's oversized-line discard. + if (buffer.length > maxBufferBytes) { + log.warn("mcp run: discarding oversized SSE event buffer"); + buffer = ""; + } + } + // 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 (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); + } +} + +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 new file mode 100644 index 00000000..f75dde2f --- /dev/null +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -0,0 +1,188 @@ +/** + * Shared options and helpers for `clerk mcp` subcommands. + */ + +import { getMcpUrl } from "../../lib/environment.ts"; +import { CliError, ERROR_CODE, errorMessage, throwUsageError } from "../../lib/errors.ts"; +import { log } from "../../lib/log.ts"; +import { isAgent } from "../../mode.ts"; +import { CLIENT_ALIASES, CLIENT_IDS, CLIENTS, detectInstalledClients } from "./clients/registry.ts"; +import { MCP_DOCS_URL } from "./clients/types.ts"; +import type { ClientId, McpClient } from "./clients/types.ts"; + +/** Options shared across `clerk mcp` subcommands. */ +export type McpOptions = { + json?: boolean; + url?: string; + name?: string; + /** Raw client IDs from the CLI. Validated through {@link resolveClients}. */ + client?: string[]; + all?: 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 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. + 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, + ); + } + // 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; +} + +// First char alphanumeric/underscore so a name can never parse as a flag. +const ENTRY_NAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_-]{0,63}$/; + +/** + * Resolve the MCP entry name from options, defaulting to + * {@link DEFAULT_ENTRY_NAME}. Allowlisted rather than escaped: the name is + * spliced into client CLI argv (on Windows through a `cmd.exe /c` shim where + * `&`/`|`/`^` are live operators) and used as a config key, and every consumer + * would need different escaping. + */ +export function resolveName(options: McpOptions): string { + const name = options.name ?? DEFAULT_ENTRY_NAME; + if (!ENTRY_NAME_PATTERN.test(name)) { + throwUsageError( + `Invalid MCP entry name "${name}". Use letters, digits, dashes, and underscores (must not start with a dash), max 64 characters.`, + ); + } + return 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(); + // Dedupe by canonical id so `--client copilot --client vscode` (aliases of the + // same client) or a repeated flag operates on each client once. + return ids.flatMap((id) => { + const client = byId.get(CLIENT_ALIASES[id] ?? id); + if (!client) { + throwUsageError( + `Unknown MCP client "${id}". Supported: ${CLIENT_IDS.join(", ")}.`, + undefined, + ERROR_CODE.MCP_CLIENT_NOT_SUPPORTED, + ); + } + if (seen.has(client.id)) return []; + seen.add(client.id); + return [client]; + }); +} + +/** + * 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, + options: { autoSelectSingle?: boolean; required?: boolean; preselect?: boolean } = {}, +): Promise { + if (detected.length === 0) return []; + if (detected.length === 1 && options.autoSelectSingle) return detected; + // Imported lazily (like `doctor` does) so the prompt layer stays off the + // module-load path for non-interactive callers and tests. + const { multiselect } = await import("../../lib/prompts.ts"); + const preselect = options.preselect ?? true; + const selected = await multiselect({ + message, + options: detected.map((c) => ({ value: c.id, label: `${c.displayName} (${c.scope})` })), + initialValues: preselect ? detected.map((c) => c.id) : [], + required: options.required ?? true, + }); + return resolveClients(selected); +} + +/** + * 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); + } + 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, docsUrl: MCP_DOCS_URL }, + ); + } + return detected; +} + +/** True when output should be machine-readable JSON (explicit flag or agent mode). */ +export function wantsJson(options: McpOptions): boolean { + return Boolean(options.json) || isAgent(); +} + +/** A per-client failure, structurally reportable in `--json` output. */ +type ClientFailure = { client: ClientId; error: string }; + +/** + * 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 and + * returned (so JSON consumers see which client failed, not just stderr text). + * 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<{ succeeded: { client: McpClient; result: T }[]; failed: ClientFailure[] }> { + const settled = await Promise.allSettled(clients.map(op)); + const succeeded: { client: McpClient; result: T }[] = []; + const failed: ClientFailure[] = []; + const errors: unknown[] = []; + for (const [i, outcome] of settled.entries()) { + const client = clients[i]!; + if (outcome.status === "fulfilled") { + succeeded.push({ client, result: outcome.value }); + continue; + } + errors.push(outcome.reason); + failed.push({ client: client.id, error: errorMessage(outcome.reason) }); + log.warn(`${client.displayName}: ${errorMessage(outcome.reason)}`); + } + if (succeeded.length === 0 && errors.length > 0) throw errors[0]; + return { succeeded, failed }; +} 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..88e63c28 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -0,0 +1,231 @@ +import type { findClientBinary, runClientCli } from "./clients/cli-exec.ts"; +import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import * as realOs from "node:os"; +import { join } from "node:path"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => (mockIsAgent() ? "agent" : "human"), +})); + +// User-scoped clients write under home; redirect homedir to the cwd tmpdir so +// writes stay isolated and `join(cwd, ...)` reads still resolve. +let mockHome = realOs.tmpdir(); +mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); + +const mockMultiselect = mock(); +mock.module("../../lib/prompts.ts", () => ({ + multiselect: (...args: unknown[]) => mockMultiselect(...args), +})); + +// CLI-backed clients (claude, gemini, codex, openclaw, hermes) delegate removal to their own CLI. +const mockRun = mock(); +const mockWhich = mock(); +mock.module("./clients/cli-exec.ts", () => ({ + findClientBinary: mockWhich, + runClientCli: mockRun, +})); +afterAll(() => mock.restore()); + +const { mcpInstall } = await import("./install.ts"); +const { mcpUninstall } = await import("./uninstall.ts"); + +const URL = "https://mcp.clerk.com/mcp"; +const RUN_SHAPE = { command: "clerk", args: ["mcp", "run"] }; +const WINDSURF_CONFIG = [".codeium", "windsurf", "mcp_config.json"]; + +describe("mcp uninstall", () => { + const captured = useCaptureLog(); + let cwd: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-uninstall-")); + mockHome = cwd; + process.chdir(cwd); + mockIsAgent.mockReturnValue(true); + mockWhich.mockImplementation((binary: string) => `/fake/bin/${binary}`); + mockRun.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(cwd, { recursive: true, force: true }); + mockIsAgent.mockReset(); + mockMultiselect.mockReset(); + mockWhich.mockReset(); + mockRun.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; + }; + // Removing the only entry drops the now-empty `mcpServers` key entirely + // rather than leaving `{ "mcpServers": {} }` behind. + expect(parsed.mcpServers).toBeUndefined(); + }); + + test("delegates Claude Code removal to `claude mcp remove`", async () => { + // Pre-write the entry as Claude Code's own CLI would have registered it. + await writeFile( + join(cwd, ".claude.json"), + JSON.stringify({ mcpServers: { clerk: RUN_SHAPE } }), + ); + await mcpUninstall({ client: ["claude"] }); + + const payload = JSON.parse(captured.out) as { results: { client: string; removed: boolean }[] }; + expect(payload.results).toEqual([expect.objectContaining({ client: "claude", removed: true })]); + expect(mockRun).toHaveBeenCalledWith([ + "/fake/bin/claude", + "mcp", + "remove", + "--scope", + "user", + "clerk", + ]); + }); + + test("removes the healthy client and warns when another's config is corrupt", async () => { + await mcpInstall({ client: ["windsurf"], url: URL }); + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "{ not json"); + captured.clear(); + + await mcpUninstall({ client: ["cursor", "windsurf"] }); + + const payload = JSON.parse(captured.out) as { + results: { client: string; removed: boolean }[]; + failures: { client: string; error: string }[]; + }; + expect(payload.results).toEqual([ + expect.objectContaining({ client: "windsurf", removed: true }), + ]); + expect(payload.failures).toEqual([expect.objectContaining({ client: "cursor" })]); + expect(captured.err).toContain("Cursor"); + }); + + test("emits JSON results on stdout in agent mode", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + captured.clear(); + await mcpUninstall({ client: ["cursor"] }); + + const payload = JSON.parse(captured.out) as { + name: string; + results: { client: string; removed: boolean }[]; + }; + expect(payload.name).toBe("clerk"); + expect(payload.results).toEqual([expect.objectContaining({ client: "cursor", removed: true })]); + }); + + test("agent mode: reports removed:false when nothing is registered (no error)", async () => { + await mcpUninstall({ client: ["cursor"] }); + const payload = JSON.parse(captured.out) as { results: { client: string; removed: boolean }[] }; + expect(payload.results).toEqual([ + expect.objectContaining({ client: "cursor", removed: false }), + ]); + }); + + test("respects --name", async () => { + await mcpInstall({ client: ["cursor"], url: URL, name: "clerk-staging" }); + captured.clear(); + await mcpUninstall({ client: ["cursor"], name: "clerk-staging" }); + + const parsed = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers?: Record; + }; + expect(parsed.mcpServers?.["clerk-staging"]).toBeUndefined(); + }); + + test("rejects an unknown --client id", async () => { + await expect(mcpUninstall({ client: ["bogus"] })).rejects.toMatchObject({ + code: "mcp_client_not_supported", + }); + }); + + test("human mode: warns how to install when nothing is registered (no error)", async () => { + mockIsAgent.mockReturnValue(false); + await mcpUninstall({ client: ["cursor"] }); + expect(captured.err).toContain("clerk mcp install"); + expect(captured.err).not.toContain("Removing MCP entry"); // no success gutter for a no-op + }); + + test("human mode: prints reload next steps after a successful removal", async () => { + await mcpInstall({ client: ["cursor"], url: URL }); + mockIsAgent.mockReturnValue(false); + captured.clear(); + await mcpUninstall({ client: ["cursor"] }); + expect(captured.err).toContain("Next steps"); + expect(captured.err).toContain("Reload Cursor"); + }); + + test("human mode: removes the selected clients and leaves the rest", async () => { + await mcpInstall({ client: ["cursor", "windsurf"], url: URL }); + mockIsAgent.mockReturnValue(false); + mockMultiselect.mockResolvedValueOnce(["cursor"]); // select Cursor → remove Cursor + captured.clear(); + + await mcpUninstall({}); + + expect(mockMultiselect).toHaveBeenCalledTimes(1); + const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers?: Record; + }; + expect(cursorCfg.mcpServers?.clerk).toBeUndefined(); + const windsurfCfg = JSON.parse(await readFile(join(cwd, ...WINDSURF_CONFIG), "utf8")) as { + mcpServers: Record; + }; + expect(windsurfCfg.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", "windsurf"], url: URL }); + mockIsAgent.mockReturnValue(false); + mockMultiselect.mockResolvedValueOnce([]); + captured.clear(); + + await mcpUninstall({}); + + expect(captured.err).toContain("Nothing removed"); + const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers: Record; + }; + expect(cursorCfg.mcpServers.clerk).toBeDefined(); + }); + + test("human mode: --all removes from every client without prompting", async () => { + await mcpInstall({ client: ["cursor", "windsurf"], url: URL }); + mockIsAgent.mockReturnValue(false); + captured.clear(); + + await mcpUninstall({ all: true }); + + expect(mockMultiselect).not.toHaveBeenCalled(); + const cursorCfg = JSON.parse(await readFile(join(cwd, ".cursor", "mcp.json"), "utf8")) as { + mcpServers?: Record; + }; + expect(cursorCfg.mcpServers?.clerk).toBeUndefined(); + }); +}); diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts new file mode 100644 index 00000000..c1c0a843 --- /dev/null +++ b/packages/cli-core/src/commands/mcp/uninstall.ts @@ -0,0 +1,109 @@ +/** + * `clerk mcp uninstall` — remove the `clerk` MCP entry from supported clients. + */ + +import { cyan, dim, green, yellow } from "../../lib/color.ts"; +import { log } from "../../lib/log.ts"; +import { withGutter } from "../../lib/spinner.ts"; +import { CLIENTS } from "./clients/registry.ts"; +import { collectEntries } from "./collect.ts"; +import type { McpClient, RemoveResult } from "./clients/types.ts"; +import { + pickClients, + resolveClients, + resolveName, + settleClients, + wantsJson, + type McpOptions, +} from "./shared.ts"; + +function printResult(client: McpClient, result: RemoveResult): void { + const label = `${client.displayName} → ${dim(result.configPath)}`; + log.info(`${label}: ${result.removed ? green("removed") : yellow("not present")}`); +} + +function warnNotInstalled(name: string): void { + log.warn(`No \`${name}\` MCP entry is installed. Run \`clerk mcp install\` to add it.`); +} + +async function installedClients(name: string, cwd: string): Promise { + // collectEntries settles per client (an unreadable config warns instead of + // aborting detection); a failed client is not offered in the picker. + const { entries, failures } = await collectEntries(cwd); + const failed = new Set(failures.map((f) => f.client)); + return CLIENTS.filter( + (client) => + !failed.has(client.id) && entries.some((e) => e.client === client.id && e.name === name), + ); +} + +// The checked clients are removed; nothing is pre-checked, so the safe default +// (submitting with no selection) removes nothing. +async function pickClientsToRemove(installed: McpClient[], name: string): Promise { + return pickClients(installed, `Select the clients to remove \`${name}\` from:`, { + required: false, + preselect: false, + }); +} + +async function removeFrom( + clients: McpClient[], + name: string, + cwd: string, + json: boolean, +): Promise { + const { succeeded, failed } = await settleClients(clients, (c) => c.remove(name, cwd)); + const results = succeeded.map((s) => s.result); + const removedCount = results.filter((r) => r.removed).length; + + if (json) { + log.data(JSON.stringify({ name, results, failures: failed }, null, 2)); + return; + } + + if (removedCount === 0) { + warnNotInstalled(name); + return; + } + + // Removing the config entry doesn't drop a live editor session — it lingers + // until the editor reloads. Surface that as a next step per removed client. + await withGutter(`Removing MCP entry ${cyan(name)}`, async ({ setNextSteps }) => { + succeeded.forEach(({ client, result }) => printResult(client, result)); + setNextSteps( + succeeded + .filter(({ result }) => result.removed) + .map(({ client }) => `Reload ${client.displayName} to drop the active connection.`), + ); + }); +} + +export async function mcpUninstall(options: McpOptions = {}): Promise { + const name = resolveName(options); + const cwd = process.cwd(); + const json = wantsJson(options); + const explicit = + options.client && options.client.length > 0 ? resolveClients(options.client) : undefined; + + // Non-interactive: explicit `--client`, `--all`, agent mode, or `--json` + // operate directly on the targeted clients. + if (json || explicit || options.all) { + await removeFrom(explicit ?? Array.from(CLIENTS), name, cwd, json); + return; + } + + // Human, interactive: pick which installed clients to keep; remove the rest. + const installed = await installedClients(name, cwd); + if (installed.length === 0) { + warnNotInstalled(name); + return; + } + + const toRemove = await pickClientsToRemove(installed, name); + if (toRemove.length === 0) { + log.info("No clients selected. Nothing removed."); + return; + } + + await removeFrom(toRemove, name, cwd, json); +} diff --git a/packages/cli-core/src/lib/environment.ts b/packages/cli-core/src/lib/environment.ts index 91643a28..a695badd 100644 --- a/packages/cli-core/src/lib/environment.ts +++ b/packages/cli-core/src/lib/environment.ts @@ -19,8 +19,12 @@ export interface EnvProfileConfig { platformApiUrl: string; backendApiUrl: string; dashboardUrl?: string; + mcpUrl?: string; } +/** Clerk's hosted remote MCP server — the default for every environment. */ +const DEFAULT_MCP_URL = "https://mcp.clerk.com/mcp"; + const DEFAULT_PROFILES: Record = { production: { oauthClientId: "ins_1lyWDZiobr600AKUeQDoSlrEmoM", @@ -28,6 +32,7 @@ const DEFAULT_PROFILES: Record = { platformApiUrl: "https://api.clerk.com", backendApiUrl: "https://api.clerk.dev", dashboardUrl: "https://dashboard.clerk.com", + mcpUrl: DEFAULT_MCP_URL, }, }; @@ -145,3 +150,18 @@ export function getDashboardUrl(): string { process.env.CLERK_DASHBOARD_URL ?? getCurrentEnv().dashboardUrl ?? "https://dashboard.clerk.com" ); } + +/** + * Remote MCP server URL for the active environment. + * + * Resolution: `CLERK_MCP_URL` (local worker dev, e.g. + * `http://localhost:8787/mcp`) > the active env profile's `mcpUrl` + * (carried automatically by `switch-env`) > Clerk's hosted server. Always + * returns a usable URL — the hosted default applies even when a build-time + * profile omits `mcpUrl`, mirroring `getDashboardUrl`. + */ +export function getMcpUrl(): string { + const envUrl = process.env.CLERK_MCP_URL?.trim(); + const profileUrl = getCurrentEnv().mcpUrl?.trim(); + return envUrl || profileUrl || DEFAULT_MCP_URL; +} diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index 0d3fdc85..92546439 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -66,6 +66,18 @@ 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", + /** 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", + /** The target client's own CLI (e.g. `claude`, `code`) is not on PATH, so registration can't be delegated to it. */ + MCP_CLIENT_CLI_NOT_FOUND: "mcp_client_cli_not_found", + /** The target client's own CLI exited non-zero or timed out while registering/removing the entry. */ + MCP_CLIENT_CLI_FAILED: "mcp_client_cli_failed", } as const; export type ErrorCode = (typeof ERROR_CODE)[keyof typeof ERROR_CODE]; diff --git a/packages/cli-core/src/lib/input-json.test.ts b/packages/cli-core/src/lib/input-json.test.ts index 6d3a2d2f..40d57c4e 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"]; + 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"]; + 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. */ diff --git a/packages/cli-core/src/lib/objects.ts b/packages/cli-core/src/lib/objects.ts new file mode 100644 index 00000000..79df3a86 --- /dev/null +++ b/packages/cli-core/src/lib/objects.ts @@ -0,0 +1,8 @@ +/** + * Narrow an unknown value to a plain record. The everywhere-guard for parsed + * JSON/YAML/TOML shapes: arrays and null are valid JSON but never a valid + * config table or JSON-RPC frame in this codebase. + */ +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} 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 c7b1cc050a020d1b4be75c910eba0e41e9c21f59 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Tue, 21 Jul 2026 20:21:43 -0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(mcp):=20address=20review=20feedback=20?= =?UTF-8?q?=E2=80=94=20agent-only=20all-client=20targeting,=20verified=20r?= =?UTF-8?q?emoves,=20SDK-typed=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bun.lock | 175 ++++++++++++++++++ packages/cli-core/package.json | 1 + .../src/commands/doctor/check-mcp.test.ts | 13 ++ .../cli-core/src/commands/doctor/check-mcp.ts | 21 ++- packages/cli-core/src/commands/mcp/README.md | 51 +++-- .../commands/mcp/clients/clerk-run.test.ts | 47 +---- .../src/commands/mcp/clients/clerk-run.ts | 35 +--- .../src/commands/mcp/clients/clients.test.ts | 9 +- .../src/commands/mcp/clients/json-config.ts | 44 ++++- .../mcp/clients/make-cli-client.test.ts | 17 +- .../commands/mcp/clients/make-cli-client.ts | 100 +++++----- .../commands/mcp/clients/make-client.test.ts | 23 +++ .../src/commands/mcp/clients/make-client.ts | 32 ++-- .../src/commands/mcp/clients/opencode.ts | 25 ++- .../src/commands/mcp/clients/toml-config.ts | 29 +-- .../commands/mcp/clients/user-scope.test.ts | 8 + .../src/commands/mcp/clients/yaml-config.ts | 25 +-- .../cli-core/src/commands/mcp/install.test.ts | 41 ++++ packages/cli-core/src/commands/mcp/install.ts | 16 +- .../cli-core/src/commands/mcp/probe.test.ts | 10 + packages/cli-core/src/commands/mcp/probe.ts | 26 ++- .../cli-core/src/commands/mcp/run.test.ts | 123 +++++++++++- packages/cli-core/src/commands/mcp/run.ts | 7 +- packages/cli-core/src/commands/mcp/shared.ts | 35 +++- packages/cli-core/src/commands/mcp/sse.ts | 15 ++ .../src/commands/mcp/uninstall.test.ts | 27 +++ .../cli-core/src/commands/mcp/uninstall.ts | 15 +- packages/cli-core/src/lib/objects.ts | 13 ++ 28 files changed, 737 insertions(+), 246 deletions(-) create mode 100644 packages/cli-core/src/commands/mcp/sse.ts diff --git a/bun.lock b/bun.lock index d3cc3cf5..71be0735 100644 --- a/bun.lock +++ b/bun.lock @@ -44,6 +44,7 @@ }, "devDependencies": { "@clerk/shared": "^4.13.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@types/semver": "^7.7.1", }, }, @@ -120,12 +121,16 @@ "@commander-js/extra-typings": ["@commander-js/extra-typings@15.0.0", "", { "peerDependencies": { "commander": "~15.0.0" } }, "sha512-yeJlba62xqmkgELUsn7356MEnzLLu/fw2x4lofFqGnXh6YysRdEs2BaLeLtg1+KU0AXvMeqQvTTp+3hBEBK+EA=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@inquirer/external-editor": ["@inquirer/external-editor@1.0.3", "", { "dependencies": { "chardet": "^2.1.1", "iconv-lite": "^0.7.0" }, "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA=="], "@manypkg/find-root": ["@manypkg/find-root@1.1.0", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@types/node": "^12.7.1", "find-up": "^4.1.0", "fs-extra": "^8.1.0" } }, "sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA=="], "@manypkg/get-packages": ["@manypkg/get-packages@1.1.3", "", { "dependencies": { "@babel/runtime": "^7.5.5", "@changesets/types": "^4.0.1", "@manypkg/find-root": "^1.1.0", "fs-extra": "^8.1.0", "globby": "^11.0.0", "read-yaml-file": "^1.1.0" } }, "sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ=="], @@ -244,6 +249,12 @@ "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -254,18 +265,40 @@ "better-path-resolve": ["better-path-resolve@1.0.0", "", { "dependencies": { "is-windows": "^1.0.0" } }, "sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g=="], + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], "clerk": ["clerk@workspace:packages/cli"], "commander": ["commander@15.0.0", "", {}, "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "detect-indent": ["detect-indent@6.1.0", "", {}, "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA=="], @@ -274,16 +307,42 @@ "dotenv": ["dotenv@17.2.2", "", {}, "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "enquirer": ["enquirer@2.4.1", "", { "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" } }, "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ=="], "env-paths": ["env-paths@4.0.0", "", { "dependencies": { "is-safe-filename": "^0.1.0" } }, "sha512-pxP8eL2SwwaTRi/KHYwLYXinDs7gL3jxFcBYmEdYfZmZXbaVDvdppd0XBU8qVz03rDfKZMXg1omHCbsJjZrMsw=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.6.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA=="], + "extendable-error": ["extendable-error@0.1.7", "", {}, "sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg=="], "external-editor": ["external-editor@3.1.0", "", { "dependencies": { "chardet": "^0.7.0", "iconv-lite": "^0.4.24", "tmp": "^0.0.33" } }, "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], @@ -292,38 +351,70 @@ "fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="], + "fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="], + "fast-wrap-ansi": ["fast-wrap-ansi@0.2.0", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-rLV8JHxTyhVmFYhBJuMujcrHqOT2cnO5Zxj37qROj23CP39GXubJRBUFF0z8KFK77Uc0SukZUf7JZhsVEQ6n8w=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], "globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "human-id": ["human-id@4.1.3", "", { "bin": { "human-id": "dist/cli.js" } }, "sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q=="], "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-safe-filename": ["is-safe-filename@0.1.1", "", {}, "sha512-4SrR7AdnY11LHfDKTZY1u6Ga3RuxZdl3YKWWShO5iyuG5h8QS4GD2tOb04peBJ5I7pXbR+CGBNEhTcwK+FzN3g=="], "is-subdir": ["is-subdir@1.2.0", "", { "dependencies": { "better-path-resolve": "1.0.0" } }, "sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw=="], @@ -332,10 +423,16 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "js-cookie": ["js-cookie@3.0.7", "", {}, "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], @@ -344,14 +441,36 @@ "magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "nano-staged": ["nano-staged@1.0.2", "", { "bin": { "nano-staged": "lib/bin.js" } }, "sha512-Fytar3zHLY99nlMfqPPbraxZodqQAHPpdPRyYaplL+lB9DCR6pUrafxbG+Btz4+7fO5Rm/+DO4ZeDO/nLSUMhw=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], "outdent": ["outdent@0.5.0", "", {}, "sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q=="], @@ -372,10 +491,14 @@ "package-manager-detector": ["package-manager-detector@0.2.11", "", { "dependencies": { "quansync": "^0.2.7" } }, "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -384,32 +507,60 @@ "pify": ["pify@4.0.1", "", {}, "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="], "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="], "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], + "quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "read-yaml-file": ["read-yaml-file@1.1.0", "", { "dependencies": { "graceful-fs": "^4.1.5", "js-yaml": "^3.6.1", "pify": "^4.0.1", "strip-bom": "^3.0.0" } }, "sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], @@ -424,6 +575,8 @@ "standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], @@ -438,18 +591,32 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + "@changesets/apply-release-plan/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@changesets/assemble-release-plan/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], @@ -470,8 +637,16 @@ "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], + "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + + "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "read-yaml-file/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "read-yaml-file/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="], } } diff --git a/packages/cli-core/package.json b/packages/cli-core/package.json index 408dc45e..f6be6ee9 100644 --- a/packages/cli-core/package.json +++ b/packages/cli-core/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@clerk/shared": "^4.13.1", + "@modelcontextprotocol/sdk": "^1.29.0", "@types/semver": "^7.7.1" } } diff --git a/packages/cli-core/src/commands/doctor/check-mcp.test.ts b/packages/cli-core/src/commands/doctor/check-mcp.test.ts index b9f3fb97..f1dc8319 100644 --- a/packages/cli-core/src/commands/doctor/check-mcp.test.ts +++ b/packages/cli-core/src/commands/doctor/check-mcp.test.ts @@ -93,6 +93,19 @@ describe("checkMcp", () => { expect(probedUrls).toEqual([HOSTED]); }); + test("passes as reachable (authentication required) when the probe answers 401", async () => { + // The hosted server currently accepts an unauthenticated initialize, but a + // server-side tightening to 401 must not flag a just-installed entry. + collected = { entries: [entry("claude", HOSTED)], failures: [] }; + probes = { [HOSTED]: { ok: false, status: 401, authRequired: true } }; + + const result = await checkMcp(); + + expect(result.status).toBe("pass"); + expect(result.message).toContain("authentication required"); + expect(result.message).toContain(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" } }; diff --git a/packages/cli-core/src/commands/doctor/check-mcp.ts b/packages/cli-core/src/commands/doctor/check-mcp.ts index b6aad7ea..a52f8e34 100644 --- a/packages/cli-core/src/commands/doctor/check-mcp.ts +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -14,11 +14,19 @@ import type { CheckResult } from "./types.ts"; const NAME = "MCP server"; type UrlProbe = { url: string; result: McpProbeResult }; -type ReachableProbe = { url: string; result: Extract }; -// 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(", "); +// A 401/403 answer proves the server is there — it gates the handshake behind +// auth the editor performs itself — so it reads as reachable, not broken. +function isReachable(result: McpProbeResult): boolean { + return result.ok || result.authRequired === true; +} + +function describeReachable(probes: UrlProbe[]): string { + return probes + .map(({ url, result }) => + result.ok ? `${result.serverName} (${url})` : `authentication required (${url})`, + ) + .join(", "); } function describeFailure(result: McpProbeResult): string { @@ -46,7 +54,7 @@ export async function checkMcp(): Promise { // otherwise skip silently rather than probing a server they don't use. const { entries, failures } = await collectEntries(process.cwd()); const probes = await probeEntries(entries); - const unreachable = probes.filter((p): p is UrlProbe => !p.result.ok); + const unreachable = probes.filter((p) => !isReachable(p.result)); // An unreadable client config is not "nothing installed" — a previously // registered entry may be hiding inside it. The stderr warning alone gets @@ -70,8 +78,7 @@ export async function checkMcp(): Promise { } if (unreachable.length === 0) { - const reachable = probes.filter((p): p is ReachableProbe => p.result.ok); - return { name: NAME, status: "pass", message: `Reachable — ${describeReachable(reachable)}` }; + return { name: NAME, status: "pass", message: `Reachable — ${describeReachable(probes)}` }; } return { diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index 4814cf65..5ec6af7c 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -2,23 +2,16 @@ 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, and remove the Clerk entry per client, and probe the server via `clerk doctor`. Clients that ship a **non-interactive** MCP registration CLI (Claude Code, Gemini, Codex, VS Code, OpenClaw, Hermes) are registered by shelling out to it — the client owns its config format and write safety; for the rest (Cursor, Windsurf, Warp, opencode) we write the config -file directly. opencode does ship an `mcp add` command, but it is an -interactive wizard with no flag-driven stdio path (and no remove command), so -it counts as file-backed. Reads (`list`, `doctor`, the uninstall picker) -always parse the config files directly. 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, -`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`). +file directly. Reads (`list`, `doctor`, the uninstall picker) always parse the +config files directly. The server URL defaults to Clerk's hosted server +(`https://mcp.clerk.com/mcp`), so `clerk mcp install` works out of the box with +no flags or profile setup (see [Development](#development) for the override +order). No Clerk API endpoints are called. To verify the server is reachable, run `clerk doctor` — its MCP check performs the `initialize` handshake against each @@ -72,7 +65,9 @@ is passed last to its CLI because it swallows the rest of the argv). Per-client dialect notes: -- **opencode** nests entries under top-level `mcp` and uses a single argv +- **opencode** does ship an `mcp add` command, but it is an interactive wizard + with no flag-driven stdio path (and no remove command), so it counts as + file-backed. It nests entries under top-level `mcp` and uses a single argv array: `{ "type": "local", "command": ["clerk", "mcp", "run"] }`. Its config root follows XDG on every platform: `$XDG_CONFIG_HOME/opencode/opencode.json` (default `~/.config/opencode/opencode.json`) on macOS/Linux, @@ -141,7 +136,10 @@ file-backed clients the entry is overwritten in place. Success reports `status: installed` per client. Failures are warned per client on stderr and listed in the `--json` output's `failures` array (`{ client, error }`); `uninstall --json` reports the same shape. The command exits non-zero only -when every targeted client fails. +when every targeted client fails — and in `--json` mode the +`{ results, failures }` envelope is still emitted on stdout in that case (the +exit code carries the failure), so machine consumers always get the structured +output. **After install:** registering the entry does not connect the server on its own. In human mode, `install` prints per-client next steps — the server only @@ -157,8 +155,9 @@ goes live once you **reload the editor**, which then spawns `clerk mcp run` ### `clerk mcp list` -Print every Clerk-flavored MCP entry across all supported clients (entries -named `clerk` or pointing at any `*.clerk.com` host). The `--json` (and +Print every Clerk-flavored MCP entry across all supported clients: any +`clerk mcp run` bridge entry (regardless of its name or currently-resolved +URL), plus entries named `clerk` or pointing at any `*.clerk.com` host. The `--json` (and agent-mode) output is `{ entries, failures }`: a client whose config exists but can't be read or parsed appears in `failures` (`{ client, error }`) rather than being silently folded into "no entries" — the same structural-failure contract @@ -187,7 +186,10 @@ Remove the entry. For CLI-registered clients (claude, gemini, codex, openclaw, hermes), removal runs the client's own remove command; when our read of the config shows no entry, `removed: false` is reported without invoking any CLI, and when the entry is present but the client's binary is missing, that client -fails with `mcp_client_cli_not_found`. Cursor, Windsurf, Warp, opencode, and +fails with `mcp_client_cli_not_found`. After the remove command reports +success, the config is re-read — if the entry is somehow still present, the +client fails with `mcp_client_cli_failed` rather than reporting a removal that +didn't happen (the mirror of the add-side `verifyAdd` check). Cursor, Windsurf, Warp, opencode, and VS Code (add-only CLI) are removed by editing the config file directly. In human mode with no `--client`/`--all`, it prompts with a @@ -203,7 +205,20 @@ editor. > **Reachability:** there is no `mcp doctor` subcommand. Server health is part > of `clerk doctor`, which probes each distinct configured MCP URL via the > `initialize` handshake when an entry is installed (warns, does not fail, when -> any is unreachable). +> any is unreachable). A `401`/`403` answer counts as reachable — the server is +> there, it just gates the handshake behind the OAuth flow the editor runs +> itself — and is reported as "authentication required". + +## Development + +The hosted server's source lives at +[clerk/cloudflare-workers/workers/remote-mcp-server](https://github.com/clerk/cloudflare-workers/tree/main/workers/remote-mcp-server). +The URL every subcommand (and the bridge at spawn time) targets 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`). +`CLERK_MCP_URL` is the convenient override when developing the worker locally +(e.g. `http://localhost:8787/mcp`). ## Error codes 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 6c9f3521..964e2baa 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,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { clerkRunArgs, extractClerkRunUrl, isClerkRunEntry, RUN_COMMAND } from "./clerk-run.ts"; +import { clerkRunArgs, isClerkRunEntry, RUN_COMMAND } from "./clerk-run.ts"; describe("clerk-run descriptor", () => { test("clerkRunArgs builds the run invocation without a URL", () => { @@ -10,7 +10,7 @@ describe("clerk-run descriptor", () => { expect(isClerkRunEntry({ command: RUN_COMMAND, args: clerkRunArgs() })).toBe(true); }); - test("isClerkRunEntry rejects a legacy --url entry", () => { + test("isClerkRunEntry rejects an entry with a --url arg (never a shape this CLI wrote)", () => { expect( isClerkRunEntry({ command: "clerk", @@ -25,41 +25,12 @@ describe("clerk-run descriptor", () => { expect(isClerkRunEntry({ type: "stdio", command: "clerk", args: clerkRunArgs() })).toBe(true); }); - 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(); - }); + test.each([ + ["a different command", { command: "npx", args: ["-y", "mcp-remote", "x"] }], + ["non-string args", { command: "clerk", args: [1, 2, 3] }], + ["not an object", "clerk mcp run"], + ["null", null], + ])("isClerkRunEntry rejects %s", (_label, descriptor) => { + expect(isClerkRunEntry(descriptor)).toBe(false); }); }); 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 b8061bcd..e5448876 100644 --- a/packages/cli-core/src/commands/mcp/clients/clerk-run.ts +++ b/packages/cli-core/src/commands/mcp/clients/clerk-run.ts @@ -9,9 +9,8 @@ * they should change it. */ -import { isRecord } from "../../../lib/objects.ts"; +import { hasStringProp, isRecord } from "../../../lib/objects.ts"; 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"; @@ -30,22 +29,6 @@ export function clerkRunDescriptor(): Record { return { command: RUN_COMMAND, args: clerkRunArgs() }; } -/** - * 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 (!isRecord(descriptor)) 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; -} - /** * True when the descriptor matches the current `clerk mcp run` shape (no URL * in args). Used to detect already-current entries during upsert. @@ -59,24 +42,20 @@ export function isClerkRunEntry(descriptor: unknown): boolean { } /** - * `extractUrl` for the clients that share the new bridge shape and fall back to - * legacy descriptor shapes so existing installs still round-trip on - * list/uninstall. + * `extractUrl` for the clients that share the standard bridge shape. * * 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. + * 2. Direct-URL format: `{ url }` or `{ serverUrl }` — plain key lookup. Not a + * shape this CLI ever wrote: it round-trips entries users added by hand + * following the Clerk MCP docs (e.g. `{ type: "http", url }`), so those + * still show up in list/doctor and can be uninstalled. */ 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) - ); + return 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 b2640bf6..c58e817f 100644 --- a/packages/cli-core/src/commands/mcp/clients/clients.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/clients.test.ts @@ -267,7 +267,14 @@ describe("client contracts (homedir redirected)", () => { "$name removes through its own CLI", async ({ client, binary, removeArgv }) => { // Pre-write the entry (as the client's CLI would have) so presence checks pass. - await writeClientEntry(client.configPath("/ignored")); + const configPath = client.configPath("/ignored"); + await writeClientEntry(configPath); + // Simulate the CLI mutating its own config — the factory re-reads it + // after a successful remove and refuses to report a phantom removal. + mockRun.mockImplementation(async () => { + await rm(configPath, { force: true }); + return { exitCode: 0, stdout: "", stderr: "" }; + }); const result = await client.remove("clerk", "/ignored"); expect(result.removed).toBe(true); expect(mockRun).toHaveBeenCalledWith([`/fake/bin/${binary}`, ...removeArgv]); 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 452598f9..af86f98b 100644 --- a/packages/cli-core/src/commands/mcp/clients/json-config.ts +++ b/packages/cli-core/src/commands/mcp/clients/json-config.ts @@ -8,6 +8,13 @@ * (`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. + * + * Known limitation: reads use strict `JSON.parse`, so a hand-edited config + * with comments (JSONC) fails to read, and writes re-serialize the whole + * document, so any custom formatting is normalized away. Accepted because the + * client owns its file: the clients we write for document plain JSON, and the + * JSONC-tolerant ones (VS Code) delegate writes to their own CLI wherever + * possible. */ import { isRecord } from "../../../lib/objects.ts"; @@ -34,23 +41,40 @@ export async function readConfigText(path: string): Promise } } -export async function readJsonConfig(path: string): Promise { +/** + * The read half shared by every codec: read the file, parse it with the + * format's parser, and guard that the top level is a map. An absent or empty + * file reads as `{}`; parse and shape failures surface as + * MCP_CLIENT_CONFIG_INVALID. + */ +export async function readParsedConfig( + path: string, + format: { name: string; shape: string; parse: (text: string) => unknown }, +): Promise { const text = await readConfigText(path); if (text === undefined || text.trim().length === 0) return {}; + let parsed: unknown; try { - const parsed: unknown = JSON.parse(text); - if (!isRecord(parsed)) { - throw new CliError(`Config at ${path} is not a JSON object`, { - code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, - }); - } - return parsed as ConfigRecord; + parsed = format.parse(text); } catch (error) { - if (error instanceof CliError) throw error; - throw new CliError(`Could not parse ${path} as JSON: ${errorMessage(error)}`, { + throw new CliError(`Could not parse ${path} as ${format.name}: ${errorMessage(error)}`, { + code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, + }); + } + if (!isRecord(parsed)) { + throw new CliError(`Config at ${path} is not a ${format.shape}`, { code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, }); } + return parsed as ConfigRecord; +} + +export async function readJsonConfig(path: string): Promise { + return readParsedConfig(path, { + name: "JSON", + shape: "JSON object", + parse: (text) => JSON.parse(text) as unknown, + }); } export async function writeJsonConfig(path: string, config: ConfigRecord): Promise { diff --git a/packages/cli-core/src/commands/mcp/clients/make-cli-client.test.ts b/packages/cli-core/src/commands/mcp/clients/make-cli-client.test.ts index 394da86d..c476e781 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-cli-client.test.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-cli-client.test.ts @@ -274,13 +274,28 @@ describe("makeCliClient", () => { }); test("runs the CLI remove when the entry is present", async () => { - await writeBaseConfig(); + const path = await writeBaseConfig(); + mockRun.mockImplementation(async (argv: string[]) => { + // Simulate the client CLI dropping the entry from its own config. + if (argv.includes("remove")) await writeFile(path, JSON.stringify({ mcpServers: {} })); + return ok(); + }); const client = makeClient(); const result = await client.remove("clerk", "/ignored"); expect(result.removed).toBe(true); expect(mockRun).toHaveBeenCalledWith([BIN_PATH, "mcp", "remove", "clerk"]); }); + test("rejects with mcp_client_cli_failed when the CLI exits 0 but the entry is still present", async () => { + // The remove-side mirror of verifyAdd: a CLI that no-ops with exit 0 + // must not be reported as a removal that happened. + await writeBaseConfig(); + const client = makeClient(); + await expect(client.remove("clerk", "/ignored")).rejects.toMatchObject({ + code: "mcp_client_cli_failed", + }); + }); + test("rejects with mcp_client_cli_not_found when the binary is missing and the entry is present", async () => { await writeBaseConfig(); mockWhich.mockReturnValue(null); diff --git a/packages/cli-core/src/commands/mcp/clients/make-cli-client.ts b/packages/cli-core/src/commands/mcp/clients/make-cli-client.ts index be6ffc86..bb86a12e 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-cli-client.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-cli-client.ts @@ -48,48 +48,52 @@ interface CliClientSpec { verifyAdd?: boolean; } -export function makeCliClient(spec: CliClientSpec): McpClient { - const { base, binary } = spec; +// No display-name prefix in thrown messages: settleClients prefixes the +// client name when warning, so embedding it here would print it twice. +function requireBinary(binary: string, installHint: string): string { + const bin = findClientBinary(binary); + if (bin) return bin; + throw new CliError( + `\`${binary}\` CLI not found on PATH — registration is delegated to it. ${installHint}`, + { code: ERROR_CODE.MCP_CLIENT_CLI_NOT_FOUND, docsUrl: MCP_DOCS_URL }, + ); +} - // No display-name prefix in thrown messages: settleClients prefixes the - // client name when warning, so embedding it here would print it twice. - function requireBinary(): string { - const bin = findClientBinary(binary); - if (bin) return bin; - throw new CliError( - `\`${binary}\` CLI not found on PATH — registration is delegated to it. ${spec.installHint}`, - { code: ERROR_CODE.MCP_CLIENT_CLI_NOT_FOUND, docsUrl: MCP_DOCS_URL }, - ); +/** + * Presence via our own read-only parse of the client's config — reads stay + * ours; only writes are delegated. "unknown" = config unreadable, in which + * case the CLI (which owns the format) gets the final say. + */ +async function presenceOf( + base: McpClient, + name: string, + cwd: string, +): Promise<"present" | "absent" | "unknown"> { + try { + const entries = await base.list(cwd); + return entries.some((entry) => entry.name === name) ? "present" : "absent"; + } catch { + return "unknown"; } +} - /** - * Presence via our own read-only parse of the client's config — reads stay - * ours; only writes are delegated. "unknown" = config unreadable, in which - * case the CLI (which owns the format) gets the final say. - */ - async function presenceOf(name: string, cwd: string): Promise<"present" | "absent" | "unknown"> { - try { - const entries = await base.list(cwd); - return entries.some((entry) => entry.name === name) ? "present" : "absent"; - } catch { - return "unknown"; - } - } +async function runOrThrow( + argv: [string, ...string[]], + action: string, + stdin?: string, +): Promise { + const result = + stdin === undefined ? await runClientCli(argv) : await runClientCli(argv, { stdin }); + if (result.exitCode === 0) return; + const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`; + throw new CliError(`failed to ${action} — ${detail}`, { + code: ERROR_CODE.MCP_CLIENT_CLI_FAILED, + docsUrl: MCP_DOCS_URL, + }); +} - async function runOrThrow( - argv: [string, ...string[]], - action: string, - stdin?: string, - ): Promise { - const result = - stdin === undefined ? await runClientCli(argv) : await runClientCli(argv, { stdin }); - if (result.exitCode === 0) return; - const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`; - throw new CliError(`failed to ${action} — ${detail}`, { - code: ERROR_CODE.MCP_CLIENT_CLI_FAILED, - docsUrl: MCP_DOCS_URL, - }); - } +export function makeCliClient(spec: CliClientSpec): McpClient { + const { base, binary } = spec; return { id: base.id, @@ -101,8 +105,8 @@ export function makeCliClient(spec: CliClientSpec): McpClient { list: (cwd) => base.list(cwd), async upsert(entry: McpServerEntry, cwd: string): Promise { - const bin = requireBinary(); - const presence = await presenceOf(entry.name, cwd); + const bin = requireBinary(binary, spec.installHint); + const presence = await presenceOf(base, entry.name, cwd); if (presence !== "absent" && spec.removeArgs) { // Best-effort pre-clean: duplicate-name behavior varies per CLI, so a // failed remove (e.g. "no such server") just means add decides. A @@ -119,7 +123,7 @@ export function makeCliClient(spec: CliClientSpec): McpClient { "register the MCP server", spec.addStdin, ); - if (spec.verifyAdd && (await presenceOf(entry.name, cwd)) === "absent") { + if (spec.verifyAdd && (await presenceOf(base, entry.name, cwd)) === "absent") { throw new CliError( `the \`${binary}\` CLI reported success but did not save the entry — it may have prompted for input it didn't get. Register manually instead.`, { code: ERROR_CODE.MCP_CLIENT_CLI_FAILED, docsUrl: MCP_DOCS_URL }, @@ -131,11 +135,21 @@ export function makeCliClient(spec: CliClientSpec): McpClient { async remove(name: string, cwd: string): Promise { if (!spec.removeArgs) return base.remove(name, cwd); const configPath = base.configPath(cwd); - if ((await presenceOf(name, cwd)) === "absent") { + if ((await presenceOf(base, name, cwd)) === "absent") { return { client: base.id, configPath, removed: false }; } - const bin = requireBinary(); + const bin = requireBinary(binary, spec.installHint); await runOrThrow([bin, ...spec.removeArgs(name)], "remove the MCP entry"); + // Mirror `verifyAdd`: an exit code 0 alone doesn't prove the entry is + // gone (a CLI can no-op successfully). Re-read the config and refuse to + // report a removal that didn't happen; "unknown" (unreadable config) + // keeps the CLI's final say, same as the add side. + if ((await presenceOf(base, name, cwd)) === "present") { + throw new CliError( + `the \`${binary}\` CLI reported success but the entry is still present. Remove it manually instead.`, + { code: ERROR_CODE.MCP_CLIENT_CLI_FAILED, docsUrl: MCP_DOCS_URL }, + ); + } return { client: base.id, configPath, removed: true }; }, }; 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 507d7960..23bd3f3d 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 @@ -169,6 +169,29 @@ describe("make-client (via cursor)", () => { expect(entries[0]!.url).toBe(CLERK_URL); }); + test("lists a custom-named bridge entry even when the resolved URL is not a clerk.com host", async () => { + // `--name foo` with `CLERK_MCP_URL` pointing at a local worker: the + // descriptor shape identifies the entry as ours, so it must not fall out + // of list/doctor (or become unremovable) just because both the name and + // the resolved URL miss the clerk heuristics. + const originalMcpUrl = process.env.CLERK_MCP_URL; + process.env.CLERK_MCP_URL = "http://localhost:8787/mcp"; + try { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile( + join(cwd, ".cursor", "mcp.json"), + JSON.stringify({ mcpServers: { foo: CURRENT_SHAPE } }), + ); + const entries = await cursorClient.list(cwd); + expect(entries).toHaveLength(1); + expect(entries[0]!.name).toBe("foo"); + expect(entries[0]!.url).toBe("http://localhost:8787/mcp"); + } finally { + if (originalMcpUrl === undefined) delete process.env.CLERK_MCP_URL; + else process.env.CLERK_MCP_URL = originalMcpUrl; + } + }); + 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/make-client.ts b/packages/cli-core/src/commands/mcp/clients/make-client.ts index 945ba41a..a5855c5c 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-client.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-client.ts @@ -12,6 +12,7 @@ import { log } from "../../../lib/log.ts"; import { isRecord } from "../../../lib/objects.ts"; +import { isClerkRunEntry } from "./clerk-run.ts"; import { getServerMap, readJsonConfig, @@ -31,18 +32,6 @@ import type { UpsertResult, } from "./types.ts"; -export function hasStringProp( - value: unknown, - key: K, -): value is Record { - return ( - typeof value === "object" && - value !== null && - Object.prototype.hasOwnProperty.call(value, key) && - typeof (value as Record)[key] === "string" - ); -} - interface FileClientSpec { id: ClientId; displayName: string; @@ -54,6 +43,13 @@ interface FileClientSpec { 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; + /** + * Recognize a `clerk mcp run` bridge descriptor in this client's dialect. + * Only needed by clients whose encoding diverges from the standard + * `{ command, args }` shape (opencode's single argv array); the default is + * {@link isClerkRunEntry}. + */ + isOurs?: (descriptor: unknown) => boolean; configPath: (cwd: string) => string; /** * Omit for bases wrapped by `makeCliClient`, which replaces detection with @@ -119,10 +115,14 @@ function makeFileClient(spec: FileClientSpec, codec: ConfigCodec): McpClient { const topKeyPath: readonly [string, ...string[]] = typeof spec.topKey === "string" ? [spec.topKey] : spec.topKey; + const isOurs = spec.isOurs ?? isClerkRunEntry; + /** Walk the key path, validating each level is an object (or absent → `{}`). */ function serversIn(config: ConfigRecord, configPath: string): Record { let node: Record = config; - for (const key of topKeyPath) node = getServerMap(node, key, configPath); + for (const key of topKeyPath) { + node = getServerMap(node, key, configPath); + } return node; } @@ -181,7 +181,11 @@ function makeFileClient(spec: FileClientSpec, codec: ConfigCodec): McpClient { for (const [name, descriptor] of Object.entries(servers)) { const url = spec.extractUrl(descriptor); if (!url) continue; - if (name === "clerk" || isClerkUrl(url)) { + // Descriptor shape first: a `clerk mcp run` bridge is ours no matter + // what the entry is named or what URL it currently resolves to (e.g. + // `--name foo` with `CLERK_MCP_URL` pointing at localhost) — otherwise + // such an entry would fall out of list/doctor and couldn't be removed. + if (isOurs(descriptor) || name === "clerk" || isClerkUrl(url)) { entries.push({ client: spec.id, configPath, name, url }); } } diff --git a/packages/cli-core/src/commands/mcp/clients/opencode.ts b/packages/cli-core/src/commands/mcp/clients/opencode.ts index 9950582f..10bf3b6a 100644 --- a/packages/cli-core/src/commands/mcp/clients/opencode.ts +++ b/packages/cli-core/src/commands/mcp/clients/opencode.ts @@ -16,22 +16,26 @@ import { clerkRunArgs, RUN_COMMAND } from "./clerk-run.ts"; import { makeJsonClient } from "./make-client.ts"; import { pathExists, xdgConfigPath } from "./paths.ts"; +// opencode's bridge dialect: a single argv array instead of `{ command, args }`. +function isOpencodeBridge(descriptor: unknown): boolean { + if (!isRecord(descriptor)) return false; + const command = (descriptor as { command?: unknown }).command; + return ( + Array.isArray(command) && + command[0] === RUN_COMMAND && + command[1] === "mcp" && + command[2] === "run" + ); +} + function extractOpencodeUrl(descriptor: unknown): string | undefined { if (!isRecord(descriptor)) return undefined; - const { command, url } = descriptor as { command?: unknown; url?: unknown }; + const url = (descriptor as { url?: unknown }).url; // Remote entries (`{ type: "remote", url }`) carry their URL directly. if (typeof url === "string") return url; // Our local bridge entry: the URL is resolved at runtime, so report the // currently-resolved target (same as the other clients' bridge entries). - if ( - Array.isArray(command) && - command[0] === RUN_COMMAND && - command[1] === "mcp" && - command[2] === "run" - ) { - return getMcpUrl(); - } - return undefined; + return isOpencodeBridge(descriptor) ? getMcpUrl() : undefined; } export const opencodeClient = makeJsonClient({ @@ -42,6 +46,7 @@ export const opencodeClient = makeJsonClient({ topKey: "mcp", encode: () => ({ type: "local", command: [RUN_COMMAND, ...clerkRunArgs()] }), extractUrl: extractOpencodeUrl, + isOurs: isOpencodeBridge, configPath: () => xdgConfigPath("opencode", "opencode.json"), detect: () => pathExists(xdgConfigPath("opencode")), }); 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 04c41d31..9377c5a5 100644 --- a/packages/cli-core/src/commands/mcp/clients/toml-config.ts +++ b/packages/cli-core/src/commands/mcp/clients/toml-config.ts @@ -10,29 +10,16 @@ * hand-maintained `config.toml`. */ -import { isRecord } from "../../../lib/objects.ts"; -import { CliError, ERROR_CODE, errorMessage } from "../../../lib/errors.ts"; -import { readConfigText, refuseConfigWrite, type ConfigRecord } from "./json-config.ts"; +import { readParsedConfig, refuseConfigWrite, type ConfigRecord } from "./json-config.ts"; export async function readTomlConfig(path: string): Promise { - const text = await readConfigText(path); - if (text === undefined || text.trim().length === 0) return {}; - try { - const parsed: unknown = Bun.TOML.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 (!isRecord(parsed)) { - throw new CliError(`Config at ${path} is not a TOML table`, { - code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, - }); - } - return parsed as ConfigRecord; - } 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, - }); - } + // A valid TOML document is always a table, so the shape guard can only fire + // on a future parser swap — kept anyway for the shared contract. + return readParsedConfig(path, { + name: "TOML", + shape: "TOML table", + parse: (text) => Bun.TOML.parse(text), + }); } export async function writeTomlConfig(path: string, _config: ConfigRecord): 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 0faed4d6..e290fd66 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 @@ -259,6 +259,14 @@ describe("install/uninstall across all clients (homedir + cwd redirected)", () = join(mockHome, ".gemini", "settings.json"), JSON.stringify({ mcpServers: { clerk: CURRENT_SHAPE } }), ); + // Simulate the gemini CLI mutating its own config — the factory re-reads + // it after a successful remove and refuses to report a phantom removal. + mockRun.mockImplementation(async (argv: string[]) => { + if (argv.includes("remove")) { + await rm(join(mockHome, ".gemini", "settings.json"), { force: true }); + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); captured.clear(); await mcpUninstall({}); diff --git a/packages/cli-core/src/commands/mcp/clients/yaml-config.ts b/packages/cli-core/src/commands/mcp/clients/yaml-config.ts index e0a0a027..1af92694 100644 --- a/packages/cli-core/src/commands/mcp/clients/yaml-config.ts +++ b/packages/cli-core/src/commands/mcp/clients/yaml-config.ts @@ -7,27 +7,14 @@ * comments and formatting in a user's hand-maintained `config.yaml`. */ -import { isRecord } from "../../../lib/objects.ts"; -import { CliError, ERROR_CODE, errorMessage } from "../../../lib/errors.ts"; -import { readConfigText, refuseConfigWrite, type ConfigRecord } from "./json-config.ts"; +import { readParsedConfig, refuseConfigWrite, type ConfigRecord } from "./json-config.ts"; export async function readYamlConfig(path: string): Promise { - const text = await readConfigText(path); - if (text === undefined || text.trim().length === 0) return {}; - let parsed: unknown; - try { - parsed = Bun.YAML.parse(text); - } catch (error) { - throw new CliError(`Could not parse ${path} as YAML: ${errorMessage(error)}`, { - code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, - }); - } - if (!isRecord(parsed)) { - throw new CliError(`Config at ${path} is not a YAML mapping`, { - code: ERROR_CODE.MCP_CLIENT_CONFIG_INVALID, - }); - } - return parsed as ConfigRecord; + return readParsedConfig(path, { + name: "YAML", + shape: "YAML mapping", + parse: (text) => Bun.YAML.parse(text), + }); } export async function writeYamlConfig(path: string, _config: ConfigRecord): Promise { diff --git a/packages/cli-core/src/commands/mcp/install.test.ts b/packages/cli-core/src/commands/mcp/install.test.ts index e19a14a2..2a7f2b4d 100644 --- a/packages/cli-core/src/commands/mcp/install.test.ts +++ b/packages/cli-core/src/commands/mcp/install.test.ts @@ -19,6 +19,11 @@ mock.module("../../mode.ts", () => ({ let mockHome = realOs.tmpdir(); mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome })); +const mockMultiselect = mock(); +mock.module("../../lib/prompts.ts", () => ({ + multiselect: (...args: unknown[]) => mockMultiselect(...args), +})); + // CLI-backed clients (claude, gemini, codex, vscode, openclaw, hermes) delegate registration to // their own CLI — stub the subprocess layer so no real binaries are needed. const mockRun = mock(); @@ -58,6 +63,7 @@ describe("mcp install", () => { process.chdir(originalCwd); await rm(cwd, { recursive: true, force: true }); mockIsAgent.mockReset(); + mockMultiselect.mockReset(); mockWhich.mockReset(); mockRun.mockReset(); }); @@ -139,6 +145,41 @@ describe("mcp install", () => { expect(() => JSON.parse(captured.out)).not.toThrow(); }); + test("human mode: --json alone still prompts the picker instead of installing everywhere", async () => { + // `--json` is an output format, not a targeting choice — only agent mode + // (or --client/--all) skips the picker. + mockMultiselect.mockResolvedValueOnce(["cursor"]); + + await mcpInstall({ json: true }); + + expect(mockMultiselect).toHaveBeenCalledTimes(1); + const payload = JSON.parse(captured.out) as { results: { client: string }[] }; + expect(payload.results).toEqual([ + expect.objectContaining({ client: "cursor", status: "installed" }), + ]); + }); + + test("json mode: emits the failure envelope and sets a non-zero exit code when every client fails", async () => { + await mkdir(join(cwd, ".cursor"), { recursive: true }); + await writeFile(join(cwd, ".cursor", "mcp.json"), "{ not json"); + const originalExitCode = process.exitCode; + try { + await mcpInstall({ client: ["cursor"], json: true }); + + // The envelope (with `failures`) still lands on stdout — exactly the + // case a machine consumer needs it — and the exit code carries the failure. + const payload = JSON.parse(captured.out) as { + results: unknown[]; + failures: { client: string }[]; + }; + expect(payload.results).toEqual([]); + expect(payload.failures).toEqual([expect.objectContaining({ client: "cursor" })]); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = originalExitCode; + } + }); + test("re-install is idempotent and reports installed again", async () => { mockIsAgent.mockReturnValue(true); await mcpInstall({ client: ["cursor"] }); diff --git a/packages/cli-core/src/commands/mcp/install.ts b/packages/cli-core/src/commands/mcp/install.ts index 9684418c..6963d940 100644 --- a/packages/cli-core/src/commands/mcp/install.ts +++ b/packages/cli-core/src/commands/mcp/install.ts @@ -13,7 +13,9 @@ import { log } from "../../lib/log.ts"; import { cyan, dim, green } from "../../lib/color.ts"; import { withGutter } from "../../lib/spinner.ts"; +import { isAgent } from "../../mode.ts"; import { + failWhenAllFailed, pickClients, resolveName, resolveUrl, @@ -26,9 +28,10 @@ import { detectInstalledClients } from "./clients/registry.ts"; import type { McpClient, UpsertResult } from "./clients/types.ts"; async function chooseClients(options: McpOptions, cwd: string): Promise { - // `wantsJson` covers `--json` and agent mode, so non-interactive callers never - // block on the picker. - if (options.client?.length || options.all || wantsJson(options)) { + // Only agent mode implies "no picker" — `--json` is an output format, not a + // targeting choice, so a human passing it still gets the interactive picker + // rather than a surprise install into every detected client. + if (options.client?.length || options.all || isAgent()) { return targetClients(options, cwd); } const detected = await detectInstalledClients(cwd); @@ -74,9 +77,8 @@ export async function mcpInstall(options: McpOptions = {}): Promise { await withGutter( `Installing Clerk MCP (${cyan(url)})`, async ({ setNextSteps }) => { - const { succeeded, failed } = await settleClients(clients, (c) => - c.upsert({ name, url }, cwd), - ); + const outcome = await settleClients(clients, (c) => c.upsert({ name, url }, cwd)); + const { succeeded, failed } = outcome; if (json) { log.data( JSON.stringify( @@ -85,8 +87,10 @@ export async function mcpInstall(options: McpOptions = {}): Promise { 2, ), ); + failWhenAllFailed(outcome, json); return; } + failWhenAllFailed(outcome, json); succeeded.forEach(({ client, result }) => printResult(client, result)); const steps = installNextSteps(succeeded); if (steps.length > 0) setNextSteps(steps); diff --git a/packages/cli-core/src/commands/mcp/probe.test.ts b/packages/cli-core/src/commands/mcp/probe.test.ts index 5f631ae9..d09c7fe2 100644 --- a/packages/cli-core/src/commands/mcp/probe.test.ts +++ b/packages/cli-core/src/commands/mcp/probe.test.ts @@ -95,6 +95,16 @@ describe("probeMcp", () => { expect(await probeMcp(URL)).toEqual({ ok: false, status: 404 }); }); + test.each([[401], [403]])( + "marks a %i answer as auth-required, not unreachable", + async (status) => { + // An auth-gated server answered — it's demonstrably there. The editor runs + // its own OAuth flow, so doctor must not flag the entry as unreachable. + stubFetch(async () => new Response("unauthorized", { status })); + expect(await probeMcp(URL)).toEqual({ ok: false, status, authRequired: true }); + }, + ); + test("fails on a network error, carrying the message", async () => { stubFetch(async () => { throw new Error("ECONNREFUSED"); diff --git a/packages/cli-core/src/commands/mcp/probe.ts b/packages/cli-core/src/commands/mcp/probe.ts index a40968de..664c1f70 100644 --- a/packages/cli-core/src/commands/mcp/probe.ts +++ b/packages/cli-core/src/commands/mcp/probe.ts @@ -12,12 +12,19 @@ import { isRecord } from "../../lib/objects.ts"; import { errorMessage } from "../../lib/errors.ts"; import { loggedFetch } from "../../lib/fetch.ts"; import { DEV_CLI_VERSION, resolveCliVersion } from "../../lib/version.ts"; +import { sseEventData } from "./sse.ts"; +// Type-only: erased at compile, so the SDK stays a devDependency and is never +// bundled — it exists purely as a TS gate keeping this request spec-valid. +import type { InitializeRequest, JSONRPCRequest } from "@modelcontextprotocol/sdk/types.js"; // Discriminated on `ok`: a healthy probe always carries a server name; a failed -// one never does. "ok but no serverName" is unrepresentable. +// one never does. "ok but no serverName" is unrepresentable. `authRequired` +// marks a 401/403 answer: the server is demonstrably there, it just gates the +// handshake behind auth we don't send — callers should report "reachable, +// requires authentication" rather than "not reachable". export type McpProbeResult = | { ok: true; status: number; serverName: string } - | { ok: false; status?: number; error?: string }; + | { ok: false; status?: number; error?: string; authRequired?: boolean }; // 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. @@ -33,7 +40,7 @@ const INITIALIZE_REQUEST = { capabilities: {}, clientInfo: { name: "clerk-cli", version: resolveCliVersion() ?? DEV_CLI_VERSION }, }, -}; +} satisfies JSONRPCRequest & InitializeRequest; function safeJsonParse(text: string): unknown { try { @@ -50,11 +57,7 @@ function safeJsonParse(text: string): unknown { 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"); + const data = sseEventData(firstEvent); return data === "" ? undefined : safeJsonParse(data); } @@ -93,6 +96,13 @@ export async function probeMcp(url: string): Promise { body: JSON.stringify(INITIALIZE_REQUEST), signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), }); + // The hosted server currently answers an unauthenticated `initialize` with + // 200 (verified live), but a server-side tightening to 401 must not make + // `doctor` flag a just-installed entry as unreachable — editors run their + // own OAuth flow that this probe deliberately doesn't perform. + if (response.status === 401 || response.status === 403) { + return { ok: false, status: response.status, authRequired: true }; + } if (!response.ok) return { ok: false, status: response.status }; const contentType = response.headers.get("content-type") ?? ""; diff --git a/packages/cli-core/src/commands/mcp/run.test.ts b/packages/cli-core/src/commands/mcp/run.test.ts index 12de3750..71c3c71c 100644 --- a/packages/cli-core/src/commands/mcp/run.test.ts +++ b/packages/cli-core/src/commands/mcp/run.test.ts @@ -1,4 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { JSONRPCMessageSchema, type JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js"; +import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; import { stubFetch, useCaptureLog } from "../../test/lib/stubs.ts"; import { mcpRun, pipeEventStream, readTextCapped } from "./run.ts"; @@ -56,12 +59,74 @@ async function* lines(...messages: unknown[]): AsyncGenerator { } function framesFrom(chunks: string[]): Array> { - return chunks - .join("") - .trim() - .split("\n") - .filter((line) => line.length > 0) - .map((line) => JSON.parse(line) as Record); + return ( + chunks + .join("") + .trim() + .split("\n") + .filter((line) => line.length > 0) + // `.parse` throws if the bridge wrote anything that isn't a spec-valid + // JSON-RPC frame, so every assertion below doubles as a conformance check — + // a frame missing `jsonrpc: "2.0"` fails here, not just in a real client. + .map((line) => JSONRPCMessageSchema.parse(JSON.parse(line)) as Record) + ); +} + +/** + * Binds a real MCP SDK `Client` to the real bridge in-process: the client's + * sends feed `mcpRun`'s injected `input` stream, and the bridge's stdout + * writes come back through `onmessage` — no child process, upstream stubbed. + */ +class BridgeTransport implements Transport { + onclose?: () => void; + onerror?: (error: Error) => void; + onmessage?: (message: JSONRPCMessage) => void; + /** The bridge's run promise, so tests can assert a clean shutdown. */ + done: Promise = Promise.resolve(); + + private queue: string[] = []; + private wake: (() => void) | undefined; + private closed = false; + + private async *input(): AsyncGenerator { + while (!this.closed || this.queue.length > 0) { + const next = this.queue.shift(); + if (next !== undefined) { + yield next; + continue; + } + if (this.closed) return; + await new Promise((resolve) => (this.wake = resolve)); + } + } + + async start(): Promise { + this.done = mcpRun( + { url: URL }, + { + input: this.input(), + write: (chunk) => { + for (const line of chunk.split("\n").filter((l) => l.length > 0)) { + this.onmessage?.(JSONRPCMessageSchema.parse(JSON.parse(line))); + } + }, + }, + ); + } + + async send(message: JSONRPCMessage): Promise { + this.queue.push(JSON.stringify(message) + "\n"); + this.wake?.(); + this.wake = undefined; + } + + async close(): Promise { + this.closed = true; + this.wake?.(); + this.wake = undefined; + await this.done; + this.onclose?.(); + } } describe("mcp run (stdio bridge)", () => { @@ -451,6 +516,52 @@ describe("mcp run (stdio bridge)", () => { expect(removed).toEqual(added); }); + test("a real MCP SDK client completes initialize and tools/list through the bridge", async () => { + // Conformance: if a genuine `Client` gets through `initialize` (with the + // SDK validating the result shape) and `tools/list`, then session-id + // threading and protocol-version echo work end to end — stronger than + // asserting on hand-rolled frames. + stub((req) => { + const blocked = noServerStream(req); + if (blocked) return blocked; + const msg = JSON.parse(req.body!) as { id?: number; method?: string }; + if (msg.method === "initialize") { + return json( + { + jsonrpc: "2.0", + id: msg.id, + result: { + protocolVersion: "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "Clerk MCP Server", version: "0.0.0" }, + }, + }, + { "mcp-session-id": "sess-1" }, + ); + } + if (msg.method === "tools/list") { + return json({ + jsonrpc: "2.0", + id: msg.id, + result: { tools: [{ name: "create_user", inputSchema: { type: "object" } }] }, + }); + } + // Notifications (e.g. notifications/initialized) are accepted bodyless. + return new Response(null, { status: 202 }); + }); + + const transport = new BridgeTransport(); + const client = new Client({ name: "conformance-test", version: "0.0.0" }); + await client.connect(transport); + const tools = await client.listTools(); + await client.close(); + await transport.done; + + expect(tools.tools.map((t) => t.name)).toEqual(["create_user"]); + const posts = requests.filter((r) => r.method === "POST"); + expect(posts.at(-1)?.headers.get("mcp-session-id")).toBe("sess-1"); + }); + 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 53a0ed81..b1d94672 100644 --- a/packages/cli-core/src/commands/mcp/run.ts +++ b/packages/cli-core/src/commands/mcp/run.ts @@ -20,6 +20,7 @@ import { loggedFetch } from "../../lib/fetch.ts"; import { log } from "../../lib/log.ts"; import { isRecord } from "../../lib/objects.ts"; import { resolveUrl, type McpOptions } from "./shared.ts"; +import { sseEventData } from "./sse.ts"; /** Injectable streams so the bridge can be driven in-process by tests. */ interface RunStreams { @@ -440,11 +441,7 @@ export async function pipeEventStream( } 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"); + const data = sseEventData(rawEvent); if (data.length === 0) return; try { await emitPayload(JSON.parse(data)); diff --git a/packages/cli-core/src/commands/mcp/shared.ts b/packages/cli-core/src/commands/mcp/shared.ts index f75dde2f..7fc9102c 100644 --- a/packages/cli-core/src/commands/mcp/shared.ts +++ b/packages/cli-core/src/commands/mcp/shared.ts @@ -157,32 +157,53 @@ export function wantsJson(options: McpOptions): boolean { /** A per-client failure, structurally reportable in `--json` output. */ type ClientFailure = { client: ClientId; error: string }; +/** The settled outcome of running an op against every targeted client. */ +export interface SettledClients { + succeeded: { client: McpClient; result: T }[]; + failed: ClientFailure[]; + /** The first client's original error, kept for {@link failWhenAllFailed}. */ + firstError?: unknown; +} + /** * 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 and * returned (so JSON consumers see which client failed, not just stderr text). - * If *every* client failed, the first error is rethrown so the command still - * exits non-zero with a real message. + * Never throws: callers emit their output first, then call + * {@link failWhenAllFailed} so the exit code still reflects a total failure. */ export async function settleClients( clients: readonly McpClient[], op: (client: McpClient) => Promise, -): Promise<{ succeeded: { client: McpClient; result: T }[]; failed: ClientFailure[] }> { +): Promise> { const settled = await Promise.allSettled(clients.map(op)); const succeeded: { client: McpClient; result: T }[] = []; const failed: ClientFailure[] = []; - const errors: unknown[] = []; + let firstError: unknown; for (const [i, outcome] of settled.entries()) { const client = clients[i]!; if (outcome.status === "fulfilled") { succeeded.push({ client, result: outcome.value }); continue; } - errors.push(outcome.reason); + firstError ??= outcome.reason; failed.push({ client: client.id, error: errorMessage(outcome.reason) }); log.warn(`${client.displayName}: ${errorMessage(outcome.reason)}`); } - if (succeeded.length === 0 && errors.length > 0) throw errors[0]; - return { succeeded, failed }; + return { succeeded, failed, firstError }; +} + +/** + * Exit non-zero when every targeted client failed. In `--json` mode the + * `{ results, failures }` envelope is already on stdout — exactly the case + * where `failures` is most useful — so rethrowing would append a second + * (error) document and corrupt the stream; set the exit code instead. Human + * mode rethrows the first client's original error so the global handler + * formats it with its code and docs URL. + */ +export function failWhenAllFailed(outcome: SettledClients, json: boolean): void { + if (outcome.succeeded.length > 0 || outcome.firstError === undefined) return; + if (!json) throw outcome.firstError; + process.exitCode = 1; } diff --git a/packages/cli-core/src/commands/mcp/sse.ts b/packages/cli-core/src/commands/mcp/sse.ts new file mode 100644 index 00000000..9991ebdf --- /dev/null +++ b/packages/cli-core/src/commands/mcp/sse.ts @@ -0,0 +1,15 @@ +/** + * Reassemble the `data:` payload of a single SSE event block. The SSE spec + * allows a payload to span several `data:` lines; they join back with + * newlines. Returns "" when the block carries no data lines. + * + * Kept dependency-free (used by both the `clerk mcp run` bridge and the + * `doctor` probe) so neither drags in the other's module graph. + */ +export function sseEventData(rawEvent: string): string { + return rawEvent + .split(/\r?\n/) + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice("data:".length).trim()) + .join("\n"); +} diff --git a/packages/cli-core/src/commands/mcp/uninstall.test.ts b/packages/cli-core/src/commands/mcp/uninstall.test.ts index 88e63c28..f6dd0228 100644 --- a/packages/cli-core/src/commands/mcp/uninstall.test.ts +++ b/packages/cli-core/src/commands/mcp/uninstall.test.ts @@ -82,6 +82,14 @@ describe("mcp uninstall", () => { join(cwd, ".claude.json"), JSON.stringify({ mcpServers: { clerk: RUN_SHAPE } }), ); + // Simulate the CLI mutating its own config — the factory re-reads it after + // a successful remove and refuses to report a removal that didn't happen. + mockRun.mockImplementation(async (argv: string[]) => { + if (argv.includes("remove")) { + await writeFile(join(cwd, ".claude.json"), JSON.stringify({ mcpServers: {} })); + } + return { exitCode: 0, stdout: "", stderr: "" }; + }); await mcpUninstall({ client: ["claude"] }); const payload = JSON.parse(captured.out) as { results: { client: string; removed: boolean }[] }; @@ -215,6 +223,25 @@ describe("mcp uninstall", () => { expect(cursorCfg.mcpServers.clerk).toBeDefined(); }); + test("human mode: --json alone still prompts the picker instead of removing everywhere", async () => { + // `--json` only changes the output format; treating it as targeting would + // let a human inspecting machine output wipe every client unprompted. + await mcpInstall({ client: ["cursor", "windsurf"], url: URL }); + mockIsAgent.mockReturnValue(false); + mockMultiselect.mockResolvedValueOnce(["cursor"]); + captured.clear(); + + await mcpUninstall({ json: true }); + + expect(mockMultiselect).toHaveBeenCalledTimes(1); + const payload = JSON.parse(captured.out) as { results: { client: string }[] }; + expect(payload.results).toEqual([expect.objectContaining({ client: "cursor", removed: true })]); + const windsurfCfg = JSON.parse(await readFile(join(cwd, ...WINDSURF_CONFIG), "utf8")) as { + mcpServers: Record; + }; + expect(windsurfCfg.mcpServers.clerk).toBeDefined(); + }); + test("human mode: --all removes from every client without prompting", async () => { await mcpInstall({ client: ["cursor", "windsurf"], url: URL }); mockIsAgent.mockReturnValue(false); diff --git a/packages/cli-core/src/commands/mcp/uninstall.ts b/packages/cli-core/src/commands/mcp/uninstall.ts index c1c0a843..612a905d 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 { log } from "../../lib/log.ts"; import { withGutter } from "../../lib/spinner.ts"; +import { isAgent } from "../../mode.ts"; import { CLIENTS } from "./clients/registry.ts"; import { collectEntries } from "./collect.ts"; import type { McpClient, RemoveResult } from "./clients/types.ts"; import { + failWhenAllFailed, pickClients, resolveClients, resolveName, @@ -52,14 +54,17 @@ async function removeFrom( cwd: string, json: boolean, ): Promise { - const { succeeded, failed } = await settleClients(clients, (c) => c.remove(name, cwd)); + const outcome = await settleClients(clients, (c) => c.remove(name, cwd)); + const { succeeded, failed } = outcome; const results = succeeded.map((s) => s.result); const removedCount = results.filter((r) => r.removed).length; if (json) { log.data(JSON.stringify({ name, results, failures: failed }, null, 2)); + failWhenAllFailed(outcome, json); return; } + failWhenAllFailed(outcome, json); if (removedCount === 0) { warnNotInstalled(name); @@ -85,9 +90,11 @@ export async function mcpUninstall(options: McpOptions = {}): Promise { 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) { + // Non-interactive: explicit `--client`, `--all`, or agent mode operate + // directly on the targeted clients. `--json` alone does NOT skip the picker — + // it's an output format, and treating it as targeting would let a human + // inspecting machine output wipe the entry from every client unprompted. + if (explicit || options.all || isAgent()) { await removeFrom(explicit ?? Array.from(CLIENTS), name, cwd, json); return; } diff --git a/packages/cli-core/src/lib/objects.ts b/packages/cli-core/src/lib/objects.ts index 79df3a86..3980456a 100644 --- a/packages/cli-core/src/lib/objects.ts +++ b/packages/cli-core/src/lib/objects.ts @@ -6,3 +6,16 @@ export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + +/** Narrow an unknown value to an object carrying a string-valued `key`. */ +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" + ); +} From 7a043c3936e78a247eb8510508ddb9a89de60d2e Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Tue, 21 Jul 2026 21:00:23 -0300 Subject: [PATCH 3/4] refactor(mcp): drop legacy direct-url round-trip and inline the doctor check name --- .../cli-core/src/commands/doctor/check-mcp.ts | 18 ++++++--- packages/cli-core/src/commands/mcp/README.md | 8 ++-- .../src/commands/mcp/clients/claude.ts | 7 ++-- .../src/commands/mcp/clients/clerk-run.ts | 23 ++++------- .../src/commands/mcp/clients/codex.ts | 7 ++-- .../src/commands/mcp/clients/cursor.ts | 7 ++-- .../src/commands/mcp/clients/gemini.ts | 39 ++----------------- .../src/commands/mcp/clients/hermes.ts | 4 +- .../commands/mcp/clients/make-client.test.ts | 10 +++-- .../src/commands/mcp/clients/make-client.ts | 7 ++-- .../src/commands/mcp/clients/openclaw.ts | 4 +- .../commands/mcp/clients/user-scope.test.ts | 27 ++----------- .../src/commands/mcp/clients/vscode.ts | 7 ++-- .../cli-core/src/commands/mcp/clients/warp.ts | 4 +- .../src/commands/mcp/clients/windsurf.ts | 7 ++-- packages/cli-core/src/lib/objects.ts | 13 ------- 16 files changed, 63 insertions(+), 129 deletions(-) diff --git a/packages/cli-core/src/commands/doctor/check-mcp.ts b/packages/cli-core/src/commands/doctor/check-mcp.ts index a52f8e34..9f85f97c 100644 --- a/packages/cli-core/src/commands/doctor/check-mcp.ts +++ b/packages/cli-core/src/commands/doctor/check-mcp.ts @@ -11,8 +11,6 @@ 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"; - type UrlProbe = { url: string; result: McpProbeResult }; // A 401/403 answer proves the server is there — it gates the handshake behind @@ -62,7 +60,7 @@ export async function checkMcp(): Promise { if (failures.length > 0) { const clients = failures.map((f) => f.displayName).join(", "); return { - name: NAME, + name: "MCP server", status: "warn", message: `Could not read the MCP config for ${clients}`, detail: [ @@ -74,15 +72,23 @@ export async function checkMcp(): Promise { } if (entries.length === 0) { - return { name: NAME, status: "pass", message: "Skipped (no Clerk MCP entry installed)" }; + return { + name: "MCP server", + status: "pass", + message: "Skipped (no Clerk MCP entry installed)", + }; } if (unreachable.length === 0) { - return { name: NAME, status: "pass", message: `Reachable — ${describeReachable(probes)}` }; + return { + name: "MCP server", + status: "pass", + message: `Reachable — ${describeReachable(probes)}`, + }; } return { - name: NAME, + name: "MCP server", status: "warn", message: describeUnreachable(unreachable, probes.length), detail: unreachable.map((p) => `${p.url}: ${describeFailure(p.result)}`).join("; "), diff --git a/packages/cli-core/src/commands/mcp/README.md b/packages/cli-core/src/commands/mcp/README.md index 5ec6af7c..cc79872b 100644 --- a/packages/cli-core/src/commands/mcp/README.md +++ b/packages/cli-core/src/commands/mcp/README.md @@ -155,9 +155,11 @@ goes live once you **reload the editor**, which then spawns `clerk mcp run` ### `clerk mcp list` -Print every Clerk-flavored MCP entry across all supported clients: any -`clerk mcp run` bridge entry (regardless of its name or currently-resolved -URL), plus entries named `clerk` or pointing at any `*.clerk.com` host. The `--json` (and +Print every Clerk MCP entry across all supported clients: any `clerk mcp run` +bridge entry, matched by its descriptor shape regardless of its name or +currently-resolved URL (plus, for opencode's remote dialect, entries named +`clerk` or pointing at a `*.clerk.com` host). Entries this CLI never wrote — +e.g. a hand-added direct-URL entry — are left alone. The `--json` (and agent-mode) output is `{ entries, failures }`: a client whose config exists but can't be read or parsed appears in `failures` (`{ client, error }`) rather than being silently folded into "no entries" — the same structural-failure contract diff --git a/packages/cli-core/src/commands/mcp/clients/claude.ts b/packages/cli-core/src/commands/mcp/clients/claude.ts index 586c7d1a..63bd1fa5 100644 --- a/packages/cli-core/src/commands/mcp/clients/claude.ts +++ b/packages/cli-core/src/commands/mcp/clients/claude.ts @@ -3,11 +3,10 @@ * `claude mcp add --scope user … -- clerk mcp run`, so Claude Code owns its * config format and write safety. The file-backed base still reads the * user-global `~/.claude.json` (`mcpServers`) — the store `--scope user` - * writes to — for `list`/`doctor`; legacy `{ type: "http", url }` entries - * still round-trip there. + * writes to — for `list`/`doctor`. */ -import { clerkRunArgs, clerkRunDescriptor, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { clerkRunArgs, clerkRunDescriptor, clerkRunUrl, RUN_COMMAND } from "./clerk-run.ts"; import { makeCliClient } from "./make-cli-client.ts"; import { makeReadOnlyJsonClient } from "./make-client.ts"; import { userPath } from "./paths.ts"; @@ -19,7 +18,7 @@ const claudeFileClient = makeReadOnlyJsonClient({ activation: "Restart Claude Code, then run `/mcp` to connect (`clerk` must be on your PATH).", topKey: "mcpServers", encode: clerkRunDescriptor, - extractUrl: withLegacyUrl, + extractUrl: clerkRunUrl, configPath: () => userPath(".claude.json"), }); 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 e5448876..832b437a 100644 --- a/packages/cli-core/src/commands/mcp/clients/clerk-run.ts +++ b/packages/cli-core/src/commands/mcp/clients/clerk-run.ts @@ -9,7 +9,7 @@ * they should change it. */ -import { hasStringProp, isRecord } from "../../../lib/objects.ts"; +import { isRecord } from "../../../lib/objects.ts"; import { getMcpUrl } from "../../../lib/environment.ts"; /** The binary clients spawn. Must be on the user's PATH. */ @@ -42,20 +42,11 @@ export function isClerkRunEntry(descriptor: unknown): boolean { } /** - * `extractUrl` for the clients that share the standard bridge shape. - * - * Priority: - * 1. Current format: `{ command: "clerk", args: ["mcp", "run"] }` — no URL in - * args; resolves to `getMcpUrl()` so list/upsert see a comparable URL. - * 2. Direct-URL format: `{ url }` or `{ serverUrl }` — plain key lookup. Not a - * shape this CLI ever wrote: it round-trips entries users added by hand - * following the Clerk MCP docs (e.g. `{ type: "http", url }`), so those - * still show up in list/doctor and can be uninstalled. + * `extractUrl` for the clients that store the standard bridge shape: a + * `clerk mcp run` descriptor carries no URL in args, so it resolves to + * `getMcpUrl()` — list/upsert then see a comparable URL. Anything else + * (including hand-added direct-URL entries) is not ours and yields undefined. */ -export function withLegacyUrl( - descriptor: unknown, - legacyKey: "url" | "serverUrl" = "url", -): string | undefined { - if (isClerkRunEntry(descriptor)) return getMcpUrl(); - return hasStringProp(descriptor, legacyKey) ? descriptor[legacyKey] : undefined; +export function clerkRunUrl(descriptor: unknown): string | undefined { + return isClerkRunEntry(descriptor) ? getMcpUrl() : undefined; } diff --git a/packages/cli-core/src/commands/mcp/clients/codex.ts b/packages/cli-core/src/commands/mcp/clients/codex.ts index 5a00ee72..abc3bf5d 100644 --- a/packages/cli-core/src/commands/mcp/clients/codex.ts +++ b/packages/cli-core/src/commands/mcp/clients/codex.ts @@ -2,11 +2,10 @@ * Registration is delegated to Codex's own CLI: * `codex mcp add … -- clerk mcp run` (Codex's config is global, no scope * flag). The file-backed base still reads `~/.codex/config.toml` - * (`[mcp_servers.]`) for `list`/`doctor`; legacy bare `{ url }` entries - * still round-trip there. + * (`[mcp_servers.]`) for `list`/`doctor`. */ -import { clerkRunArgs, clerkRunDescriptor, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { clerkRunArgs, clerkRunDescriptor, clerkRunUrl, RUN_COMMAND } from "./clerk-run.ts"; import { makeCliClient } from "./make-cli-client.ts"; import { makeTomlClient } from "./make-client.ts"; import { userPath } from "./paths.ts"; @@ -18,7 +17,7 @@ const codexFileClient = makeTomlClient({ activation: "Restart Codex (`clerk` must be on your PATH).", topKey: "mcp_servers", encode: clerkRunDescriptor, - extractUrl: withLegacyUrl, + extractUrl: clerkRunUrl, configPath: () => userPath(".codex", "config.toml"), }); diff --git a/packages/cli-core/src/commands/mcp/clients/cursor.ts b/packages/cli-core/src/commands/mcp/clients/cursor.ts index 0e3dd40e..c312dc0b 100644 --- a/packages/cli-core/src/commands/mcp/clients/cursor.ts +++ b/packages/cli-core/src/commands/mcp/clients/cursor.ts @@ -1,11 +1,10 @@ /** * 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. Installs the - * `clerk mcp run` stdio bridge; legacy bare `{ url }` entries still round-trip - * on list/uninstall. + * `clerk mcp run` stdio bridge. */ -import { clerkRunDescriptor, withLegacyUrl } from "./clerk-run.ts"; +import { clerkRunDescriptor, clerkRunUrl } from "./clerk-run.ts"; import { makeJsonClient } from "./make-client.ts"; import { pathExists, userPath } from "./paths.ts"; @@ -17,7 +16,7 @@ export const cursorClient = makeJsonClient({ "Reload Cursor, then enable the server under `Settings → MCP` (`clerk` must be on your PATH).", topKey: "mcpServers", encode: clerkRunDescriptor, - extractUrl: withLegacyUrl, + extractUrl: clerkRunUrl, 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 d236b2e2..8835be36 100644 --- a/packages/cli-core/src/commands/mcp/clients/gemini.ts +++ b/packages/cli-core/src/commands/mcp/clients/gemini.ts @@ -2,45 +2,14 @@ * Registration is delegated to Gemini's own CLI: * `gemini mcp add --scope user --transport stdio … clerk mcp run`. The * file-backed base still reads `~/.gemini/settings.json` for `list`/`doctor`. - * Gemini has no native HTTP transport, so it always needs the stdio bridge — - * `clerk mcp run` today, previously `npx -y mcp-remote `; legacy - * `mcp-remote` entries still round-trip on list/uninstall. + * Gemini has no native HTTP transport, so it always needs the stdio bridge. */ -import { isRecord } from "../../../lib/objects.ts"; -import { clerkRunArgs, clerkRunDescriptor, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { clerkRunArgs, clerkRunDescriptor, clerkRunUrl, RUN_COMMAND } from "./clerk-run.ts"; import { makeCliClient } from "./make-cli-client.ts"; -import { isClerkHost, makeReadOnlyJsonClient } from "./make-client.ts"; +import { makeReadOnlyJsonClient } from "./make-client.ts"; import { userPath } from "./paths.ts"; -// 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 (!isRecord(value)) 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 ( - isClerkHost(parsed.hostname) && - (parsed.protocol === "https:" || parsed.protocol === "http:") - ) { - return parsed.href; - } - } catch { - // not a URL - } - } - return undefined; -} - const geminiFileClient = makeReadOnlyJsonClient({ id: "gemini", displayName: "Gemini Code Assist / CLI", @@ -48,7 +17,7 @@ const geminiFileClient = makeReadOnlyJsonClient({ activation: "Restart Gemini (`clerk` must be on your PATH).", topKey: "mcpServers", encode: clerkRunDescriptor, - extractUrl: (d) => withLegacyUrl(d) ?? extractLegacyBridgeUrl(d), + extractUrl: clerkRunUrl, configPath: () => userPath(".gemini", "settings.json"), }); diff --git a/packages/cli-core/src/commands/mcp/clients/hermes.ts b/packages/cli-core/src/commands/mcp/clients/hermes.ts index 6a523927..bdd8b3ad 100644 --- a/packages/cli-core/src/commands/mcp/clients/hermes.ts +++ b/packages/cli-core/src/commands/mcp/clients/hermes.ts @@ -7,7 +7,7 @@ * mutations, and rewriting the user's YAML would destroy comments). */ -import { clerkRunArgs, clerkRunDescriptor, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { clerkRunArgs, clerkRunDescriptor, clerkRunUrl, RUN_COMMAND } from "./clerk-run.ts"; import { makeCliClient } from "./make-cli-client.ts"; import { makeYamlClient } from "./make-client.ts"; import { userPath } from "./paths.ts"; @@ -19,7 +19,7 @@ const hermesFileClient = makeYamlClient({ activation: "Restart Hermes — or run `/reload-mcp` in a session (`clerk` must be on your PATH).", topKey: "mcp_servers", encode: clerkRunDescriptor, - extractUrl: withLegacyUrl, + extractUrl: clerkRunUrl, configPath: () => userPath(".hermes", "config.yaml"), }); 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 23bd3f3d..8bb6f2a7 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 @@ -141,7 +141,10 @@ describe("make-client (via cursor)", () => { }); describe("list", () => { - test("returns clerk-named and clerk-hosted entries, ignores others", async () => { + test("returns only bridge-shaped entries, ignoring direct-URL descriptors", async () => { + // Direct-URL entries (hand-added or another tool's) are not ours: the + // CLI never wrote that shape, so list/uninstall leave them alone — even + // under the `clerk` name. const configPath = join(cwd, ".cursor", "mcp.json"); await mkdir(join(cwd, ".cursor"), { recursive: true }); await writeFile( @@ -149,14 +152,13 @@ describe("make-client (via cursor)", () => { JSON.stringify({ mcpServers: { clerk: { url: CLERK_URL }, - "other-clerk": { url: "https://mcp.clerk.com/mcp" }, + bridge: CURRENT_SHAPE, 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"]); + expect(entries.map((e) => e.name)).toEqual(["bridge"]); }); test("lists a current-shape entry by name, resolving URL from getMcpUrl()", async () => { 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 a5855c5c..366a8bd0 100644 --- a/packages/cli-core/src/commands/mcp/clients/make-client.ts +++ b/packages/cli-core/src/commands/mcp/clients/make-client.ts @@ -5,8 +5,9 @@ * 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 most clients, TOML for Codex, YAML for Hermes), 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 + * vs `servers` vs `mcp_servers`) and the descriptor encoding (the standard + * `{ command, args }` vs VS Code's `type: "stdio"`-tagged variant vs + * opencode's single argv array). This factory captures those as a * codec + `topKey` + `encode` + `extractUrl` and reuses the rest. */ @@ -99,7 +100,7 @@ function withServerMap( } /** Single source of truth for "is this host under clerk.com". */ -export function isClerkHost(hostname: string): boolean { +function isClerkHost(hostname: string): boolean { return hostname === "mcp.clerk.com" || hostname.endsWith(".clerk.com"); } diff --git a/packages/cli-core/src/commands/mcp/clients/openclaw.ts b/packages/cli-core/src/commands/mcp/clients/openclaw.ts index 848f7213..1cb4499a 100644 --- a/packages/cli-core/src/commands/mcp/clients/openclaw.ts +++ b/packages/cli-core/src/commands/mcp/clients/openclaw.ts @@ -9,7 +9,7 @@ * `mcp.servers.` — for `list`/`doctor`. */ -import { clerkRunArgs, clerkRunDescriptor, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { clerkRunArgs, clerkRunDescriptor, clerkRunUrl, RUN_COMMAND } from "./clerk-run.ts"; import { makeCliClient } from "./make-cli-client.ts"; import { makeReadOnlyJsonClient } from "./make-client.ts"; import { userPath } from "./paths.ts"; @@ -21,7 +21,7 @@ const openclawFileClient = makeReadOnlyJsonClient({ activation: "Restart OpenClaw (`clerk` must be on your PATH).", topKey: ["mcp", "servers"], encode: clerkRunDescriptor, - extractUrl: withLegacyUrl, + extractUrl: clerkRunUrl, configPath: () => userPath(".openclaw", "openclaw.json"), }); 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 e290fd66..56a957b3 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 @@ -84,32 +84,14 @@ describe("user-scope MCP clients (homedir redirected)", () => { ]); }); - 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). + test("ignores foreign stdio entries that are not the clerk bridge", 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] }, - }, - }), - ); - 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] }, + clerk: CURRENT_SHAPE, "other-tool": { command: "npx", args: ["serve", "--port", "3000"] }, }, }), @@ -148,7 +130,7 @@ describe("user-scope MCP clients (homedir redirected)", () => { ]); }); - test("round-trips a legacy bare-url entry on list", async () => { + test("ignores a direct-URL entry the CLI never wrote, even under the clerk name", async () => { const dir = join(mockHome, ".codex"); await mkdir(dir, { recursive: true }); await Bun.write( @@ -156,8 +138,7 @@ describe("user-scope MCP clients (homedir redirected)", () => { 'model = "o3"\n\n[mcp_servers.clerk]\nurl = "https://mcp.clerk.com/mcp"\n', ); const entries = await codexClient.list("/ignored"); - expect(entries.map((e) => e.name)).toEqual(["clerk"]); - expect(entries[0]!.url).toBe(DEFAULT_URL); + expect(entries).toEqual([]); }); }); }); diff --git a/packages/cli-core/src/commands/mcp/clients/vscode.ts b/packages/cli-core/src/commands/mcp/clients/vscode.ts index 485f6598..a32297d2 100644 --- a/packages/cli-core/src/commands/mcp/clients/vscode.ts +++ b/packages/cli-core/src/commands/mcp/clients/vscode.ts @@ -5,12 +5,11 @@ * back to the file-backed base editing the user-global `mcp.json` under VS * Code's per-OS user config dir (the file behind `MCP: Open User * Configuration`). VS Code uses the top-level key `servers` (not `mcpServers`) - * and tags stdio servers with `type: "stdio"`; legacy `{ type: "http", url }` - * entries still round-trip on list/uninstall. + * and tags stdio servers with `type: "stdio"`. */ import { join } from "node:path"; -import { clerkRunArgs, RUN_COMMAND, withLegacyUrl } from "./clerk-run.ts"; +import { clerkRunArgs, clerkRunUrl, RUN_COMMAND } from "./clerk-run.ts"; import { makeCliClient } from "./make-cli-client.ts"; import { makeJsonClient } from "./make-client.ts"; import { vscodeUserDir } from "./paths.ts"; @@ -23,7 +22,7 @@ const vscodeFileClient = makeJsonClient({ "Reload the VS Code window, then start the server from `MCP: List Servers` (`clerk` must be on your PATH).", topKey: "servers", encode: () => ({ type: "stdio", command: RUN_COMMAND, args: clerkRunArgs() }), - extractUrl: withLegacyUrl, + extractUrl: clerkRunUrl, configPath: () => join(vscodeUserDir(), "mcp.json"), }); diff --git a/packages/cli-core/src/commands/mcp/clients/warp.ts b/packages/cli-core/src/commands/mcp/clients/warp.ts index 11a77354..a22d240b 100644 --- a/packages/cli-core/src/commands/mcp/clients/warp.ts +++ b/packages/cli-core/src/commands/mcp/clients/warp.ts @@ -6,7 +6,7 @@ * `mcpServers` + `{ command, args }` dialect. */ -import { clerkRunDescriptor, withLegacyUrl } from "./clerk-run.ts"; +import { clerkRunDescriptor, clerkRunUrl } from "./clerk-run.ts"; import { makeJsonClient } from "./make-client.ts"; import { pathExists, userPath } from "./paths.ts"; @@ -18,7 +18,7 @@ export const warpClient = makeJsonClient({ "Restart Warp, then enable the server under `Settings → Agents → MCP servers` (`clerk` must be on your PATH).", topKey: "mcpServers", encode: clerkRunDescriptor, - extractUrl: withLegacyUrl, + extractUrl: clerkRunUrl, configPath: () => userPath(".warp", ".mcp.json"), detect: () => pathExists(userPath(".warp")), }); diff --git a/packages/cli-core/src/commands/mcp/clients/windsurf.ts b/packages/cli-core/src/commands/mcp/clients/windsurf.ts index d7e4fa36..324bd552 100644 --- a/packages/cli-core/src/commands/mcp/clients/windsurf.ts +++ b/packages/cli-core/src/commands/mcp/clients/windsurf.ts @@ -1,10 +1,9 @@ /** * 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. + * `clerk mcp run` stdio bridge. */ -import { clerkRunDescriptor, withLegacyUrl } from "./clerk-run.ts"; +import { clerkRunDescriptor, clerkRunUrl } from "./clerk-run.ts"; import { makeJsonClient } from "./make-client.ts"; import { pathExists, userPath } from "./paths.ts"; @@ -16,7 +15,7 @@ export const windsurfClient = makeJsonClient({ "Reload Windsurf, then turn on the server in `Cascade → MCP` (`clerk` must be on your PATH).", topKey: "mcpServers", encode: clerkRunDescriptor, - extractUrl: (d) => withLegacyUrl(d, "serverUrl"), + extractUrl: clerkRunUrl, configPath: () => userPath(".codeium", "windsurf", "mcp_config.json"), detect: () => pathExists(userPath(".codeium", "windsurf")), }); diff --git a/packages/cli-core/src/lib/objects.ts b/packages/cli-core/src/lib/objects.ts index 3980456a..79df3a86 100644 --- a/packages/cli-core/src/lib/objects.ts +++ b/packages/cli-core/src/lib/objects.ts @@ -6,16 +6,3 @@ export function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } - -/** Narrow an unknown value to an object carrying a string-valued `key`. */ -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" - ); -} From e22734c2d7cf9b0b82035b6678266a7d663873bb Mon Sep 17 00:00:00 2001 From: Rafael Thayto Tani Date: Wed, 22 Jul 2026 11:26:09 -0300 Subject: [PATCH 4/4] refactor(mcp): type the run bridge with SDK JSONRPCMessage and RequestId --- packages/cli-core/src/commands/mcp/run.ts | 25 ++++++++++++----------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/cli-core/src/commands/mcp/run.ts b/packages/cli-core/src/commands/mcp/run.ts index b1d94672..3219b28f 100644 --- a/packages/cli-core/src/commands/mcp/run.ts +++ b/packages/cli-core/src/commands/mcp/run.ts @@ -21,6 +21,7 @@ import { log } from "../../lib/log.ts"; import { isRecord } from "../../lib/objects.ts"; import { resolveUrl, type McpOptions } from "./shared.ts"; import { sseEventData } from "./sse.ts"; +import type { JSONRPCMessage, RequestId } from "@modelcontextprotocol/sdk/types.js"; /** Injectable streams so the bridge can be driven in-process by tests. */ interface RunStreams { @@ -28,7 +29,6 @@ interface RunStreams { write?: (chunk: string) => void; } -type JsonRpcMessage = { id?: string | number; method?: string; result?: unknown }; type Session = { id?: string; protocolVersion?: string }; type Emit = (message: unknown) => Promise; @@ -127,7 +127,7 @@ interface DispatchCtx { signal: AbortSignal; } -async function dispatch(message: JsonRpcMessage, ctx: DispatchCtx): Promise { +async function dispatch(message: JSONRPCMessage, ctx: DispatchCtx): Promise { const { url, session, emit, emitPayload, track, ensureServerStream, resetServerStream, signal } = ctx; let response: Response; @@ -202,9 +202,10 @@ async function dispatch(message: JsonRpcMessage, ctx: DispatchCtx): Promise { - if (!replied && hasReplyFor(parsed, message.id)) replied = true; + if (!replied && requestId !== undefined && hasReplyFor(parsed, requestId)) replied = true; await emitPayload(parsed); }; track( @@ -225,9 +226,9 @@ async function dispatch(message: JsonRpcMessage, ctx: DispatchCtx): Promise isRecord(item) && (item as JsonRpcMessage).id === id, + (item) => isRecord(item) && "id" in item && item.id === id, ); } @@ -258,7 +259,7 @@ export async function readTextCapped( async function forwardJsonBody( response: Response, - message: JsonRpcMessage, + message: JSONRPCMessage, emit: Emit, emitPayload: Emit, ): Promise { @@ -279,13 +280,13 @@ async function forwardJsonBody( } async function emitError( - message: JsonRpcMessage, + 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; + if (!("id" in message)) return; await emit({ jsonrpc: "2.0", id: message.id, error: { code, message: text } }); } @@ -341,7 +342,7 @@ async function listenForServerMessages( async function* readJsonRpcLines( input: AsyncIterable, maxLineBytes = MAX_LINE_BYTES, -): AsyncGenerator { +): AsyncGenerator { const decoder = new TextDecoder(); let buffer = ""; for await (const chunk of input) { @@ -365,7 +366,7 @@ async function* readJsonRpcLines( if (parsed) yield parsed; } -function parseLine(line: string): JsonRpcMessage | undefined { +function parseLine(line: string): JSONRPCMessage | undefined { if (line.length === 0) return undefined; let parsed: unknown; try { @@ -380,7 +381,7 @@ function parseLine(line: string): JsonRpcMessage | undefined { log.warn(`mcp run: ignoring non-object frame on stdin`); return undefined; } - return parsed as JsonRpcMessage; + return parsed as JSONRPCMessage; } interface PipeEventStreamOptions {