Skip to content
Open
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`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, GitHub Copilot (VS Code; `--client vscode` or `--client copilot`), Windsurf, Gemini, and Codex. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`, `~/.codex/config.toml`), so the server is available across every project regardless of the directory you run the CLI from. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. By default the commands target Clerk's hosted server, so `clerk mcp install` works with no flags. The URL resolves in order: `--url` > the `CLERK_MCP_URL` override (for local worker development) > the active env profile's new `mcpUrl` field > the hosted server.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ Commands:
open Open Clerk resources in your browser
apps Manage your Clerk applications
users [options] Manage Clerk users
mcp Manage the Clerk remote MCP server connection for AI editors and CLIs
env Manage environment variables
config Manage instance configuration
enable Enable Clerk features on the linked instance
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.1",
"smol-toml": "^1.6.1",
"yaml": "^2.9.0"
},
"devDependencies": {
Expand Down
79 changes: 79 additions & 0 deletions packages/cli-core/src/cli-program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import { link } from "./commands/link/index.ts";
import { unlink } from "./commands/unlink/index.ts";
import { apps as appsHandlers } from "./commands/apps/index.ts";
import { users as usersHandlers } from "./commands/users/index.ts";
import {
mcp as mcpHandlers,
CLIENT_ID_CHOICES as MCP_CLIENT_CHOICES,
} from "./commands/mcp/index.ts";
import { doctor } from "./commands/doctor/index.ts";
import { switchEnv } from "./commands/switch-env/index.ts";
import { openDashboard } from "./commands/open/index.ts";
Expand Down Expand Up @@ -466,6 +470,81 @@ export function createProgram() {
}),
);

const mcp = program
.command("mcp")
.description("Manage the Clerk remote MCP server connection for AI editors and CLIs")
.setExamples([
{ command: "clerk mcp install", description: "Install into all detected MCP clients" },
{
command: "clerk mcp install --client claude",
description: "Install into Claude Code only",
},
{ command: "clerk mcp list", description: "Show registered Clerk entries" },
{ command: "clerk mcp uninstall", description: "Remove the Clerk entry from all clients" },
]);

mcp
.command("install")
.description("Register the Clerk remote MCP server in supported clients")
.addOption(
createOption("--client <id>", "MCP client to target (repeatable). Default: all detected.")
.choices([...MCP_CLIENT_CHOICES])
.argParser(collectOptionValues)
.default([] as string[]),
)
.option("--url <url>", "Override the MCP server URL (default: from active env profile)")
.option("--name <name>", 'Entry name in the client config (default: "clerk")')
.option("--all", "Install into every detected client without prompting")
.option("--force", "Overwrite an existing entry pointing at a different URL")
.option("--json", "Output as JSON")
.setExamples([
{
command: "clerk mcp install",
description: "Pick clients interactively (or all in agent mode)",
},
{ command: "clerk mcp install --all", description: "Install into every detected client" },
{
command: "clerk mcp install --client claude --client vscode",
description: "Install into specific clients",
},
])
.action((options) => mcpHandlers.install(options));

mcp
.command("list")
.description("List Clerk MCP entries registered across detected clients")
.option("--json", "Output as JSON")
.setExamples([{ command: "clerk mcp list", description: "List Clerk entries everywhere" }])
.action((options) => mcpHandlers.list(options));

mcp
.command("uninstall")
.description("Remove the Clerk MCP entry from supported clients")
.addOption(
createOption(
"--client <id>",
"MCP client to target (repeatable). Default in human mode: pick from installed; in agent mode: all clients.",
)
.choices([...MCP_CLIENT_CHOICES])
.argParser(collectOptionValues)
.default([] as string[]),
)
.option("--all", "Remove from every client without prompting")
.option("--name <name>", 'Entry name to remove (default: "clerk")')
.option("--json", "Output as JSON")
.setExamples([
{
command: "clerk mcp uninstall",
description: "Pick which installed clients to remove from",
},
{ command: "clerk mcp uninstall --all", description: "Remove from every client" },
{
command: "clerk mcp uninstall --client claude",
description: "Remove from Claude Code only",
},
])
.action((options) => mcpHandlers.uninstall(options));

const env = program
.command("env")
.description("Manage environment variables")
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 (skipped otherwise; warns, never fails) |

## Auto-Fix (`--fix`)

Expand Down
57 changes: 57 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,57 @@
/**
* `clerk doctor` MCP reachability check.
*
* Kept in its own file — rather than `checks.ts` — so the doctor check graph
* doesn't import `mcp/shared.ts` (env profiles, prompts) and the module cycle
* that comes with it. Imports only the light `collect`/`probe` helpers.
*/

import { collectEntries } from "../mcp/collect.ts";
import { probeMcp, type McpProbeResult } from "../mcp/probe.ts";
import type { CheckResult } from "./types.ts";

const NAME = "MCP server";

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

function describeReachable(probes: UrlProbe[]): string {
return probes
.map(({ url, result }) => (result.ok ? `${result.serverName} (${url})` : url))
.join(", ");
}

function describeFailure(result: McpProbeResult): string {
if (result.ok) return "unknown";
if (result.error !== undefined) return result.error;
return result.status !== undefined ? `HTTP ${result.status}` : "unknown";
}

export async function checkMcp(): Promise<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 = await collectEntries(process.cwd());
if (entries.length === 0) {
return { name: NAME, status: "pass", message: "Skipped (no Clerk MCP entry installed)" };
}

// Clients can point at different URLs (e.g. local dev in one, hosted in
// another), so probe every distinct one — a healthy first entry must not mask
// a broken second.
const urls = [...new Set(entries.map((e) => e.url))];
const probes = await Promise.all(urls.map(async (url) => ({ url, result: await probeMcp(url) })));

const unreachable = probes.find(({ result }) => !result.ok);
if (!unreachable) {
return { name: NAME, status: "pass", message: `Reachable — ${describeReachable(probes)}` };
}

const subject =
probes.length === 1 ? "Configured MCP server is" : "One or more configured MCP servers are";
return {
name: NAME,
status: "warn",
message: `${subject} not reachable (${unreachable.url})`,
detail: describeFailure(unreachable.result),
remedy: "Verify the server is running, or re-run `clerk mcp install` if the URL changed.",
};
}
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 @@ -16,6 +16,7 @@ import {
checkShellCompletion,
checkCliVersion,
} from "./checks.ts";
import { checkMcp } from "./check-mcp.ts";
import { formatCheckResult, formatJson } from "./format.ts";
import type { CheckFn, CheckResult, DoctorContext, DoctorOptions } from "./types.ts";

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

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