Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
cb59ee3
feat(mcp): add clerk mcp install/list/uninstall commands
rafa-thayto May 25, 2026
c2ea58f
fix(mcp): import `checkbox` lazily in pickClients
rafa-thayto May 25, 2026
384cbfc
test(doctor): use restorable spyOn for config mock in context.test
rafa-thayto May 25, 2026
9f5b89c
test(mcp): address CodeRabbit review on user-scope.test.ts
rafa-thayto May 31, 2026
58b48af
fix(mcp): install to user-global config and default to Clerk's hosted…
rafa-thayto Jun 4, 2026
19ccbe0
fix: treat empty env vars as unset in vscodeUserDir/getMcpUrl + test …
rafa-thayto Jun 4, 2026
327b8c9
fix(mcp): use Clack multiselect for the client picker
rafa-thayto Jun 8, 2026
92ef3ff
refactor(mcp): adopt Clack gutter/rail conventions for install/list/u…
rafa-thayto Jun 8, 2026
d2de17c
feat(mcp): interactive uninstall picker + rename `claude-code` client…
rafa-thayto Jun 9, 2026
d32e442
refactor(mcp): drop explanatory comments added in the uninstall picker
rafa-thayto Jun 9, 2026
c72a8fb
feat(mcp): add Codex client, GitHub Copilot alias, refine uninstall/d…
rafa-thayto Jun 9, 2026
705e4a1
feat(mcp): add `clerk mcp run` stdio bridge; route all clients throug…
rafa-thayto Jun 17, 2026
af01f01
fix(mcp): address code review — atomic config write, resilient emit c…
rafa-thayto Jun 18, 2026
d88ba3f
fix(mcp): atomic write for Codex TOML config
rafa-thayto Jun 22, 2026
e24f8e1
docs(mcp): add JSDoc to shared.ts exports; drop misleading auth insta…
rafa-thayto Jun 22, 2026
d85a92d
fix(mcp): drop --url from public CLI; store URL-less bridge args; mat…
rafa-thayto Jun 22, 2026
29334b1
fix(mcp): address code review — stale --url docs, dropped serverStrea…
rafa-thayto Jul 1, 2026
d39ae0b
docs(mcp): document lost-update risk on client config writes
rafa-thayto Jul 1, 2026
b8a56a9
fix(mcp): plug abort-listener leak and surface SSE stream death in th…
rafa-thayto Jul 7, 2026
df5a347
fix(doctor): warn on unreadable MCP client configs instead of passing…
rafa-thayto Jul 7, 2026
89d5811
fix(mcp): drop inert run --name flag; reuse shared collect helper; sy…
rafa-thayto Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-install.md
Original file line number Diff line number Diff line change
@@ -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, and Codex. Each client is configured to launch `clerk mcp run` — a built-in stdio bridge that forwards the editor's stdio JSON-RPC to the remote server over HTTP (the job `npx mcp-remote` did, now with no npx dependency), so `clerk` must be on your PATH. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`, `~/.codex/config.toml`), available across every project regardless of where you run the CLI. `clerk doctor` gains an MCP reachability check that probes each configured server via the MCP `initialize` handshake when an entry is installed. The URL resolves in order: the `CLERK_MCP_URL` override (for local worker development) > the active env profile's `mcpUrl` field > the hosted server, so `clerk mcp install` works with no flags. The bridge is transport-only for now; against an auth-required server it surfaces a clear error rather than signing in.
35 changes: 35 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Clerk CLI — MCP Integration

The `clerk mcp` command suite connects AI tools to Clerk's remote MCP server. This context covers the domain language for registering, running, and diagnosing that connection.

## Language

**Client**:
An AI tool (Claude Code, Cursor, Codex, Gemini CLI, VS Code, Windsurf) whose config file can register MCP servers.
_Avoid_: editor, app, tool

**Entry**:
A named server record inside a Client's config file. The default entry name is `clerk`. One Client holds many Entries; `clerk mcp` only manages Clerk's.
_Avoid_: server (that's the remote end), registration, config

**Bridge**:
The `clerk mcp run` stdio process a Client spawns. It forwards newline-delimited JSON-RPC from the Client to the remote MCP server over Streamable HTTP.
_Avoid_: proxy, relay, server

**Probe**:
A one-shot `initialize` round-trip against an MCP URL to judge reachability. Used by `clerk doctor`; never opens a session for real work.
_Avoid_: ping, health check

**Scope**:
Where a Client's config lives — `user` (global, e.g. `~/.claude.json`) or `project` (checked into or beside the repo). Install targets user scope.

**Unreadable config**:
A Client config file that exists but cannot be read or parsed. Distinct from "no Entry installed" — doctor must warn about the former and may skip on the latter.
_Avoid_: missing config, empty config

## Example dialogue

> **Dev:** `clerk doctor` says "Skipped — no Clerk MCP entry installed", but I installed yesterday.
> **Expert:** Then an Entry probably exists but its Client's config is an Unreadable config — doctor warns naming the Client rather than passing. Which Client? If Claude Code, check `~/.claude.json` parses.
> **Dev:** So the Bridge is broken?
> **Expert:** No — the Bridge only runs when a Client spawns it. This is an install-side problem: the Entry can't be read, so nothing ever spawns the Bridge. Fix the config, re-run `clerk mcp install`, and let doctor Probe the URL again.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ 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
Expand Down
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/cli-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"external-editor": "^3.1.0",
"magicast": "^0.5.3",
"semver": "^7.8.5",
"smol-toml": "^1.6.1",
"yaml": "^2.9.0"
},
"devDependencies": {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -68,6 +69,7 @@ const registrants: CommandRegistrant[] = [
registerToggles,
registerApi,
registerDoctor,
registerMcp,
registerSwitchEnv,
registerCompletion,
registerUpdate,
Expand Down
21 changes: 11 additions & 10 deletions packages/cli-core/src/commands/doctor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
118 changes: 118 additions & 0 deletions packages/cli-core/src/commands/doctor/check-mcp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { beforeEach, describe, expect, mock, test } from "bun:test";
import { useCaptureLog } from "../../test/lib/stubs.ts";
import type { CollectResult } from "../mcp/collect.ts";
import type { McpProbeResult } from "../mcp/probe.ts";
import type { ListEntry } from "../mcp/clients/types.ts";

let collected: CollectResult;
let probes: Record<string, McpProbeResult>;
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<McpProbeResult> => {
probedUrls.push(url);
return probes[url] ?? { ok: false, error: "unstubbed url" };
},
}));

const { checkMcp } = await import("./check-mcp.ts");

const HOSTED = "https://mcp.clerk.com/mcp";
const LOCAL = "http://localhost:9000/mcp";

function entry(client: ListEntry["client"], url: string): ListEntry {
return { client, configPath: `/tmp/${client}.json`, name: "clerk", url };
}

describe("checkMcp", () => {
useCaptureLog();

beforeEach(() => {
collected = { entries: [], failures: [] };
probes = {};
probedUrls = [];
});

test("passes as skipped when nothing is installed", async () => {
const result = await checkMcp();

expect(result.status).toBe("pass");
expect(result.message).toContain("Skipped");
});

test("warns naming the client when a config is unreadable, instead of passing as skipped", async () => {
collected = {
entries: [],
failures: [{ client: "Claude Code", message: "Could not parse ~/.claude.json as JSON" }],
};

const result = await checkMcp();

expect(result.status).toBe("warn");
expect(result.message).toContain("Claude Code");
expect(result.detail).toContain("Could not parse");
expect(result.remedy).toContain("clerk mcp install");
});

test("still reports probe failures alongside an unreadable config", async () => {
collected = {
entries: [entry("cursor", LOCAL)],
failures: [{ client: "Claude Code", message: "unreadable" }],
};
probes = { [LOCAL]: { ok: false, status: 502 } };

const result = await checkMcp();

expect(result.status).toBe("warn");
expect(result.message).toContain("Claude Code");
expect(result.detail).toContain(`${LOCAL}: HTTP 502`);
});

test("passes with server names when every distinct URL is reachable, probing each once", async () => {
collected = {
entries: [entry("claude", HOSTED), entry("cursor", HOSTED)],
failures: [],
};
probes = { [HOSTED]: { ok: true, status: 200, serverName: "Clerk MCP Server" } };

const result = await checkMcp();

expect(result.status).toBe("pass");
expect(result.message).toBe(`Reachable — Clerk MCP Server (${HOSTED})`);
expect(probedUrls).toEqual([HOSTED]);
});

test("warns with singular wording when the only configured server is unreachable", async () => {
collected = { entries: [entry("claude", LOCAL)], failures: [] };
probes = { [LOCAL]: { ok: false, error: "fetch failed" } };

const result = await checkMcp();

expect(result.status).toBe("warn");
expect(result.message).toContain("Configured MCP server is not reachable");
expect(result.detail).toBe(`${LOCAL}: fetch failed`);
});

test("a healthy first URL does not mask a broken second", async () => {
collected = {
entries: [entry("claude", HOSTED), entry("cursor", LOCAL)],
failures: [],
};
probes = {
[HOSTED]: { ok: true, status: 200, serverName: "Clerk MCP Server" },
[LOCAL]: { ok: false, error: "fetch failed" },
};

const result = await checkMcp();

expect(result.status).toBe("warn");
expect(result.message).toContain("One or more configured MCP servers are not reachable");
expect(result.message).toContain(LOCAL);
expect(result.message).not.toContain(HOSTED);
});
});
84 changes: 84 additions & 0 deletions packages/cli-core/src/commands/doctor/check-mcp.ts
Original file line number Diff line number Diff line change
@@ -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<McpProbeResult, { ok: true }> };

// 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<UrlProbe[]> {
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<CheckResult> {
// 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.client).join(", ");
return {
name: NAME,
status: "warn",
message: `Could not read the MCP config for ${clients}`,
detail: [
...failures.map((f) => `${f.client}: ${f.message}`),
...unreachable.map((p) => `${p.url}: ${describeFailure(p.result)}`),
].join("; "),
remedy: "Fix or remove the unreadable config file, then re-run `clerk mcp install`.",
};
}

if (entries.length === 0) {
return { name: NAME, status: "pass", message: "Skipped (no Clerk MCP entry installed)" };
}

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.",
};
}
24 changes: 10 additions & 14 deletions packages/cli-core/src/commands/doctor/context.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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);

Expand Down Expand Up @@ -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);
});
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-core/src/commands/doctor/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -30,6 +31,7 @@ const BASE_CHECKS: CheckFn[] = [
checkEnvVars,
checkConfigFile,
checkShellCompletion,
checkMcp,
];

function getChecks(): CheckFn[] {
Expand Down
Loading