Skip to content

Commit 8a62c01

Browse files
committed
feat(mcp): add Codex client, GitHub Copilot alias, refine uninstall/doctor
- Add Codex MCP client backed by ~/.codex/config.toml (smol-toml + a makeTomlClient codec on the shared makeFileClient factory); uses Codex's native Streamable HTTP transport, no mcp-remote bridge - Rename the VS Code client display to "GitHub Copilot"; accept `--client copilot` as an alias for `vscode` (same config) - uninstall: prompt lists only clients that have the entry, nothing pre-checked (select-to-remove), and warns how to install instead of erroring when nothing is registered - doctor: probe every distinct configured MCP URL (not just the first); lower the probe timeout to 5s - resolveClients dedupes aliases/repeats; remove dead projectPath export
1 parent 2250430 commit 8a62c01

23 files changed

Lines changed: 384 additions & 119 deletions

.changeset/mcp-install.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22
"clerk": minor
33
---
44

5-
Add `clerk mcp install`, `list`, and `uninstall` to register the Clerk remote MCP server (`https://mcp.clerk.com/mcp`) in Claude Code, Cursor, VS Code, Windsurf, and Gemini. Entries are written to each client's user-global config (e.g. `~/.claude.json`, `~/.cursor/mcp.json`), 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.
5+
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.

bun.lock

Lines changed: 4 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/cli-core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"external-editor": "^3.1.0",
2727
"magicast": "^0.5.3",
2828
"semver": "^7.8.1",
29+
"smol-toml": "^1.6.1",
2930
"yaml": "^2.9.0"
3031
},
3132
"devDependencies": {

packages/cli-core/src/cli-program.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@ import { link } from "./commands/link/index.ts";
1515
import { unlink } from "./commands/unlink/index.ts";
1616
import { apps as appsHandlers } from "./commands/apps/index.ts";
1717
import { users as usersHandlers } from "./commands/users/index.ts";
18-
import { mcp as mcpHandlers, CLIENT_IDS as MCP_CLIENT_IDS } from "./commands/mcp/index.ts";
18+
import {
19+
mcp as mcpHandlers,
20+
CLIENT_ID_CHOICES as MCP_CLIENT_CHOICES,
21+
} from "./commands/mcp/index.ts";
1922
import { doctor } from "./commands/doctor/index.ts";
2023
import { switchEnv } from "./commands/switch-env/index.ts";
2124
import { openDashboard } from "./commands/open/index.ts";
@@ -485,7 +488,7 @@ export function createProgram() {
485488
.description("Register the Clerk remote MCP server in supported clients")
486489
.addOption(
487490
createOption("--client <id>", "MCP client to target (repeatable). Default: all detected.")
488-
.choices([...MCP_CLIENT_IDS])
491+
.choices([...MCP_CLIENT_CHOICES])
489492
.argParser(collectOptionValues)
490493
.default([] as string[]),
491494
)
@@ -522,7 +525,7 @@ export function createProgram() {
522525
"--client <id>",
523526
"MCP client to target (repeatable). Default in human mode: pick from installed; in agent mode: all clients.",
524527
)
525-
.choices([...MCP_CLIENT_IDS])
528+
.choices([...MCP_CLIENT_CHOICES])
526529
.argParser(collectOptionValues)
527530
.default([] as string[]),
528531
)

packages/cli-core/src/commands/doctor/README.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,17 @@ clerk doctor --fix # Offer to auto-fix issues
2525

2626
## Checks
2727

28-
| Check | Category | What it verifies |
29-
| --------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
30-
| Authentication token | Authentication | Credential store has a stored token |
31-
| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) |
32-
| Project linkage | Project | Current directory is linked to a Clerk app |
33-
| Linked application | Project | Linked application ID is accessible via the API |
34-
| Instances | Project | Configured dev/prod instance IDs match the application's instances |
35-
| Environment variables | Environment | .env.local or .env has Clerk keys |
36-
| CLI configuration | Configuration | CLI config file exists and parses |
37-
| Shell completion | Configuration | Shell autocompletion is installed for the detected shell |
38-
| MCP server | Integration | If a Clerk MCP entry is installed, the configured server answers the `initialize` handshake (skipped otherwise; warns, never fails) |
28+
| Check | Category | What it verifies |
29+
| --------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
30+
| Authentication token | Authentication | Credential store has a stored token |
31+
| Token validity | Authentication | Token is still valid (calls `/oauth/userinfo`) |
32+
| Project linkage | Project | Current directory is linked to a Clerk app |
33+
| Linked application | Project | Linked application ID is accessible via the API |
34+
| Instances | Project | Configured dev/prod instance IDs match the application's instances |
35+
| Environment variables | Environment | .env.local or .env has Clerk keys |
36+
| CLI configuration | Configuration | CLI config file exists and parses |
37+
| Shell completion | Configuration | Shell autocompletion is installed for the detected shell |
38+
| MCP server | Integration | If a Clerk MCP entry is installed, every distinct configured server answers the `initialize` handshake (skipped otherwise; warns, never fails) |
3939

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

packages/cli-core/src/commands/doctor/check-mcp.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,25 @@
77
*/
88

99
import { collectEntries } from "../mcp/collect.ts";
10-
import { probeMcp } from "../mcp/probe.ts";
10+
import { probeMcp, type McpProbeResult } from "../mcp/probe.ts";
1111
import type { CheckResult } from "./types.ts";
1212

1313
const NAME = "MCP server";
1414

15+
type UrlProbe = { url: string; result: McpProbeResult };
16+
17+
function describeReachable(probes: UrlProbe[]): string {
18+
return probes
19+
.map(({ url, result }) => (result.ok ? `${result.serverName} (${url})` : url))
20+
.join(", ");
21+
}
22+
23+
function describeFailure(result: McpProbeResult): string {
24+
if (result.ok) return "unknown";
25+
if (result.error !== undefined) return result.error;
26+
return result.status !== undefined ? `HTTP ${result.status}` : "unknown";
27+
}
28+
1529
export async function checkMcp(): Promise<CheckResult> {
1630
// Only meaningful if the user actually registered a Clerk MCP entry —
1731
// otherwise skip silently rather than probing a server they don't use.
@@ -20,19 +34,24 @@ export async function checkMcp(): Promise<CheckResult> {
2034
return { name: NAME, status: "pass", message: "Skipped (no Clerk MCP entry installed)" };
2135
}
2236

23-
const url = entries[0]!.url;
24-
const result = await probeMcp(url);
25-
if (result.ok) {
26-
return { name: NAME, status: "pass", message: `Reachable — ${result.serverName} (${url})` };
37+
// Clients can point at different URLs (e.g. local dev in one, hosted in
38+
// another), so probe every distinct one — a healthy first entry must not mask
39+
// a broken second.
40+
const urls = [...new Set(entries.map((e) => e.url))];
41+
const probes = await Promise.all(urls.map(async (url) => ({ url, result: await probeMcp(url) })));
42+
43+
const unreachable = probes.find(({ result }) => !result.ok);
44+
if (!unreachable) {
45+
return { name: NAME, status: "pass", message: `Reachable — ${describeReachable(probes)}` };
2746
}
2847

29-
const detail =
30-
result.error ?? (result.status !== undefined ? `HTTP ${result.status}` : "unknown");
48+
const subject =
49+
probes.length === 1 ? "Configured MCP server is" : "One or more configured MCP servers are";
3150
return {
3251
name: NAME,
3352
status: "warn",
34-
message: `Configured MCP server is not reachable (${url})`,
35-
detail,
53+
message: `${subject} not reachable (${unreachable.url})`,
54+
detail: describeFailure(unreachable.result),
3655
remedy: "Verify the server is running, or re-run `clerk mcp install` if the URL changed.",
3756
};
3857
}

packages/cli-core/src/commands/doctor/context.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ describe("createDoctorContext", () => {
6666
const p1 = ctx.getToken();
6767
const p2 = ctx.getToken();
6868

69-
expect(p1).toBe(p2); // Same promise reference
69+
expect(p1).toBe(p2);
7070
expect(await p1).toBe("test_token");
7171
expect(mockGetToken).toHaveBeenCalledTimes(1);
7272
});

packages/cli-core/src/commands/mcp/README.md

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,34 @@ environment variable > the active environment profile's `mcpUrl` field
1414
(e.g. `http://localhost:8787/mcp`).
1515

1616
No Clerk API endpoints are called. To verify the server is reachable, run
17-
`clerk doctor` — its MCP check performs the `initialize` handshake against the
18-
configured URL whenever a Clerk MCP entry is installed.
17+
`clerk doctor` — its MCP check performs the `initialize` handshake against each
18+
distinct configured URL whenever a Clerk MCP entry is installed.
1919

2020
## Supported clients
2121

2222
All entries are written to each client's **user-global** config, so the server
2323
is available in every project (no per-project approval, no dependence on which
2424
directory you run the CLI from).
2525

26-
| ID | Client | Scope | Config file |
27-
| ---------- | ------------------------ | ----- | --------------------------------------- |
28-
| `claude` | Claude Code | user | `~/.claude.json` (`mcpServers`) |
29-
| `cursor` | Cursor | user | `~/.cursor/mcp.json` |
30-
| `vscode` | VS Code (Copilot) | user | VS Code user `mcp.json` (per-OS, below) |
31-
| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` |
32-
| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` |
33-
34-
VS Code's user config dir is OS-specific: `~/Library/Application Support/Code/User/mcp.json`
35-
(macOS), `%APPDATA%\Code\User\mcp.json` (Windows), `$XDG_CONFIG_HOME/Code/User/mcp.json`
26+
| ID | Client | Scope | Config file |
27+
| -------------------- | ------------------------ | ----- | --------------------------------------- |
28+
| `claude` | Claude Code | user | `~/.claude.json` (`mcpServers`) |
29+
| `cursor` | Cursor | user | `~/.cursor/mcp.json` |
30+
| `vscode` (`copilot`) | GitHub Copilot (VS Code) | user | VS Code user `mcp.json` (per-OS, below) |
31+
| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` |
32+
| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` |
33+
| `codex` | Codex | user | `~/.codex/config.toml` (`mcp_servers`) |
34+
35+
GitHub Copilot's MCP server lives in VS Code's config, so `--client copilot` and
36+
`--client vscode` are aliases for the same client. Its user config dir is
37+
OS-specific: `~/Library/Application Support/Code/User/mcp.json` (macOS),
38+
`%APPDATA%\Code\User\mcp.json` (Windows), `$XDG_CONFIG_HOME/Code/User/mcp.json`
3639
(Linux) — the file behind **MCP: Open User Configuration**.
3740

41+
Codex is the one TOML-backed client; the entry uses Codex's native Streamable
42+
HTTP transport (`url = "…"` under `[mcp_servers.<name>]`), so it needs no
43+
`mcp-remote` bridge. Rewriting `config.toml` does not preserve comments.
44+
3845
## Subcommands
3946

4047
### `clerk mcp install`
@@ -68,17 +75,20 @@ named `clerk` or pointing at any `*.clerk.com` host).
6875

6976
### `clerk mcp uninstall`
7077

71-
Remove the named entry. In human mode with no `--client`/`--all`, it prompts
72-
with a multiselect of the clients that **currently have the entry**, so you
73-
choose exactly which to remove from. `--all` removes from every client without
74-
prompting; agent mode targets all clients; `--client <id>` (repeatable) targets
75-
specific clients. Throws `mcp_not_installed` (exit code 1) when nothing matched.
76-
Removing the entry doesn't drop a live editor session, so (in human mode) it
77-
prints a next step to reload each affected editor.
78+
Remove the entry. In human mode with no `--client`/`--all`, it prompts with a
79+
multiselect of the clients that **currently have the entry**, all unchecked:
80+
check the clients to remove the entry from and leave the rest unchecked, so the
81+
default (nothing checked) removes nothing. `--all` removes from every client
82+
without prompting; agent mode targets all clients; `--client <id>` (repeatable)
83+
targets specific clients. When nothing matches, it prints a warm hint to run
84+
`clerk mcp install` (no error, exit 0). Removing the entry doesn't drop a live
85+
editor session, so (in human mode) it prints a next step to reload each affected
86+
editor.
7887

7988
> **Reachability:** there is no `mcp doctor` subcommand. Server health is part
80-
> of `clerk doctor`, which probes the configured MCP URL via the `initialize`
81-
> handshake when an entry is installed (warns, does not fail, when unreachable).
89+
> of `clerk doctor`, which probes each distinct configured MCP URL via the
90+
> `initialize` handshake when an entry is installed (warns, does not fail, when
91+
> any is unreachable).
8292
8393
## Error codes
8494

@@ -88,4 +98,3 @@ prints a next step to reload each affected editor.
8898
| `mcp_client_not_supported` | `--client <id>` is not in the supported list. |
8999
| `mcp_client_config_invalid` | An existing client config file is malformed. |
90100
| `mcp_url_required` | The provided `--url` is malformed or uses a non-http(s) scheme. |
91-
| `mcp_not_installed` | `uninstall` removed nothing because no entry matched. |

packages/cli-core/src/commands/mcp/clients/clients.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,4 +111,16 @@ describe("client config paths + encoded shapes (homedir redirected)", () => {
111111
expect(parsed.servers).toBeDefined();
112112
expect(parsed.mcpServers).toBeUndefined();
113113
});
114+
115+
test("`copilot` resolves to the same client as `vscode`", async () => {
116+
const { resolveClients } = await import("../shared.ts");
117+
expect(resolveClients(["copilot"])).toEqual([vscodeClient]);
118+
expect(resolveClients(["copilot"])).toEqual(resolveClients(["vscode"]));
119+
});
120+
121+
test("resolveClients dedupes an alias and its canonical id to one client", async () => {
122+
const { resolveClients } = await import("../shared.ts");
123+
expect(resolveClients(["copilot", "vscode"])).toEqual([vscodeClient]);
124+
expect(resolveClients(["cursor", "cursor"])).toEqual([cursorClient]);
125+
});
114126
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Writes to `~/.codex/config.toml` under the `[mcp_servers.<name>]` table.
3+
* Codex supports Streamable HTTP MCP servers directly, so the descriptor is
4+
* just `{ url }` — no `mcp-remote` stdio bridge (unlike Gemini).
5+
*/
6+
7+
import { hasStringProp, makeTomlClient } from "./make-json-client.ts";
8+
import { pathExists, userPath } from "./paths.ts";
9+
10+
export const codexClient = makeTomlClient({
11+
id: "codex",
12+
displayName: "Codex",
13+
scope: "user",
14+
activation: "Restart Codex; it opens a browser to sign in if the server requires it.",
15+
topKey: "mcp_servers",
16+
encode: (url) => ({ url }),
17+
extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined),
18+
configPath: () => userPath(".codex", "config.toml"),
19+
detect: () => pathExists(userPath(".codex")),
20+
});

0 commit comments

Comments
 (0)