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`, `uninstall`, and `run` to connect the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) to Claude Code, Cursor, GitHub Copilot (VS Code; `--client vscode` or `--client copilot`), Windsurf, Gemini, and Codex. Each client is configured to launch `clerk mcp run` — a built-in stdio bridge that forwards the editor's stdio JSON-RPC to the remote server over HTTP (the job `npx mcp-remote` did, now with no npx dependency), so `clerk` must be on your PATH. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`, `~/.codex/config.toml`), available across every project regardless of where you run the CLI. `clerk doctor` gains an MCP reachability check that probes each configured server via the MCP `initialize` handshake when an entry is installed. The URL resolves in order: `--url` > the `CLERK_MCP_URL` override (for local worker development) > the active env profile's `mcpUrl` field > the hosted server, so `clerk mcp install` works with no flags. The bridge is transport-only for now; against an auth-required server it surfaces a clear error rather than signing in.
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.4",
"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 @@ -15,6 +15,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 @@ -65,6 +66,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 (skipped otherwise; warns, never fails) |

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

Expand Down
58 changes: 58 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,58 @@
/**
* `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 };
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";
}

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.filter((p): p is UrlProbe => !p.result.ok);
if (unreachable.length === 0) {
const reachable = probes.filter((p): p is ReachableProbe => p.result.ok);
return { name: NAME, status: "pass", message: `Reachable — ${describeReachable(reachable)}` };
}

const subject =
probes.length === 1 ? "Configured MCP server is" : "One or more configured MCP servers are";
return {
name: NAME,
status: "warn",
message: `${subject} not reachable (${unreachable.map((p) => p.url).join(", ")})`,
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
139 changes: 139 additions & 0 deletions packages/cli-core/src/commands/mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# `clerk mcp`

Manage the Clerk remote MCP server connection in supported AI clients.

The Clerk MCP server is hosted at `https://mcp.clerk.com/mcp` (source:
[clerk/cloudflare-workers/workers/remote-mcp-server](https://github.com/clerk/cloudflare-workers/tree/main/workers/remote-mcp-server)).
These subcommands register, list, remove, and probe that URL in each client's
own config file. The URL is resolved in order: `--url` > the `CLERK_MCP_URL`
environment variable > the active environment profile's `mcpUrl` field
(`switch-env` carries the profile value automatically) > Clerk'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 | Scope | Config file |
| -------------------- | ------------------------ | ----- | --------------------------------------- |
| `claude` | Claude Code | user | `~/.claude.json` (`mcpServers`) |
| `cursor` | Cursor | user | `~/.cursor/mcp.json` |
| `vscode` (`copilot`) | GitHub Copilot (VS Code) | user | VS Code user `mcp.json` (per-OS, below) |
| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` |
| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` |
| `codex` | Codex | user | `~/.codex/config.toml` (`mcp_servers`) |

GitHub Copilot's MCP server lives in VS Code's config, so `--client copilot` and
`--client vscode` are aliases for the same client. Its user config dir is
OS-specific: `~/Library/Application Support/Code/User/mcp.json` (macOS),
`%APPDATA%\Code\User\mcp.json` (Windows), `$XDG_CONFIG_HOME/Code/User/mcp.json`
(Linux) — the file behind **MCP: Open User Configuration**.

Codex is the one TOML-backed client (`[mcp_servers.<name>]`); rewriting
`config.toml` does not preserve comments.

## How clients connect (the stdio bridge)

Every client installs the same stdio descriptor — it launches `clerk mcp run
--url <url>` rather than pointing the editor at the remote URL directly:

```jsonc
{ "command": "clerk", "args": ["mcp", "run", "--url", "https://mcp.clerk.com/mcp"] }
```

`clerk mcp run` ([run.ts](./run.ts)) is a stdio↔Streamable-HTTP proxy — the same
job `npx mcp-remote` does, but built into the CLI so there's no npx dependency
and the bridge is pinned to the installed CLI version. Because the wiring lives
in the CLI, future auth support lands against this same command with no
re-install. `clerk` must be on the editor's `PATH`.

VS Code tags the entry with `"type": "stdio"`; the others omit it. Codex writes
the equivalent TOML (`command`/`args` under `[mcp_servers.<name>]`).

> **Auth (current limitation):** `clerk mcp run` is transport-only today — it
> does not perform OAuth. Against an auth-required server (including the hosted
> `mcp.clerk.com`) it surfaces a clear error rather than signing in. Point
> `--url` at a server that doesn't require auth (e.g. a local worker at
> `http://localhost:8787/mcp`) until built-in sign-in ships.

## Subcommands

### `clerk mcp install`

Register the Clerk MCP server in one or more clients.

| Flag | Description |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--client <id>` | Target a specific client. Repeat for multiple. Default in agent mode: all detected. Default in human mode: interactive multiselect over detected clients. |
| `--all` | Install into every detected client without prompting. |
| `--url <url>` | Override the MCP URL. Defaults to the active env profile's `mcpUrl`, then Clerk's hosted server. |
| `--name <name>` | Entry key in the client config. Default: `clerk`. |
| `--force` | Overwrite an entry already pointing at a different URL. Without it, the conflict is reported and skipped. |
| `--json` | Emit a JSON summary on stdout instead of human-formatted output. |

**Conflict policy:** if an entry with the same `--name` already exists and
points at the same URL, the install is a silent no-op (`status: unchanged`).
If it points at a different URL, the install is skipped with a `reason`
unless `--force` is passed.

**After install:** writing the config does not connect the server on its own.
In human mode, `install` prints per-client next steps — the server only goes
live once you **reload the editor**, which then spawns `clerk mcp run` (so
`clerk` must be on the editor's `PATH`).

### `clerk mcp list`

Print every Clerk-flavored MCP entry across all supported clients (entries
named `clerk` or pointing at any `*.clerk.com` host).

### `clerk mcp run`

The stdio bridge that installed clients spawn — **not meant to be run by hand**.
It reads newline-delimited JSON-RPC from stdin, forwards each message to the
remote server over the Streamable HTTP transport (POST; JSON or SSE responses),
threads the `Mcp-Session-Id`, opens the optional server→client SSE stream, and
writes replies to stdout. stdout carries **only** JSON-RPC frames; all
diagnostics go to stderr.

| Flag | Description |
| --------------- | ------------------------------------------------------------ |
| `--url <url>` | Server URL to bridge to. Same resolution order as `install`. |
| `--name <name>` | Accepted for parity with `install`. Default: `clerk`. |

Transport-only: a `401`/`403` from the upstream is surfaced as an error
(`mcp_client_config_invalid`) rather than triggering a sign-in flow.

### `clerk mcp uninstall`

Remove the entry. In human mode with no `--client`/`--all`, it prompts with a
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 <id>` (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

| Code | Meaning |
| --------------------------- | --------------------------------------------------------------- |
| `mcp_no_client_detected` | No supported client found on the system. |
| `mcp_client_not_supported` | `--client <id>` is not in the supported list. |
| `mcp_client_config_invalid` | An existing client config file is malformed. |
| `mcp_url_required` | The provided `--url` is malformed or uses a non-http(s) scheme. |
Loading