Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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, 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.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
177 changes: 176 additions & 1 deletion bun.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/cli-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
},
"devDependencies": {
"@clerk/shared": "^4.13.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@types/semver": "^7.7.1"
}
}
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
137 changes: 137 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,137 @@
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",
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("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" } };

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);
});
});
97 changes: 97 additions & 0 deletions packages/cli-core/src/commands/doctor/check-mcp.ts
Comment thread
rafa-thayto marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* `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";

type UrlProbe = { url: string; result: McpProbeResult };

// 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 {
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) => !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
// 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: "MCP server",
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: "MCP server",
status: "pass",
message: "Skipped (no Clerk MCP entry installed)",
};
}

if (unreachable.length === 0) {
return {
name: "MCP server",
status: "pass",
message: `Reachable — ${describeReachable(probes)}`,
};
}

return {
name: "MCP server",
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
Loading