Skip to content

Commit d80abcd

Browse files
committed
fix(mcp): install to user-global config and default to Clerk's hosted server
Write Claude Code, Cursor, and VS Code entries to each editor's user-global config (~/.claude.json, ~/.cursor/mcp.json, VS Code's per-OS user mcp.json) instead of project-scoped files. Project scope tied "did it install?" to the run directory matching the editor's launch dir plus a trust prompt and restart; user scope makes the server available in every project, regardless of where the CLI is run. Default the MCP URL to the hosted server (https://mcp.clerk.com/mcp) when no profile defines mcpUrl, so install works out of the box on published builds whose injected env profile omits the field. Resolution order: --url > CLERK_MCP_URL > active profile mcpUrl > hosted server. Drop the localhost --url examples from --help (kept in the README for contributors), and tidy redundant docblock titles plus settleClients' loop. Tests migrated to redirect homedir to a tmpdir so user-scoped client writes stay isolated.
1 parent bdf8faf commit d80abcd

23 files changed

Lines changed: 242 additions & 178 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. `clerk doctor` gains an MCP reachability check that probes the configured server via the MCP `initialize` handshake when an entry is installed. The URL comes from the active env profile's new `mcpUrl` field (or the `CLERK_MCP_URL` override) and can be overridden per-invocation with `--url` for local worker development.
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.

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

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -473,10 +473,6 @@ export function createProgram() {
473473
.setExamples([
474474
{ command: "clerk mcp install", description: "Install into all detected MCP clients" },
475475
{ command: "clerk mcp install --client cursor", description: "Install into Cursor only" },
476-
{
477-
command: "clerk mcp install --url http://localhost:8787/mcp",
478-
description: "Use a local worker URL",
479-
},
480476
{ command: "clerk mcp list", description: "Show registered Clerk entries" },
481477
{ command: "clerk mcp uninstall", description: "Remove the Clerk entry from all clients" },
482478
]);
@@ -505,10 +501,6 @@ export function createProgram() {
505501
command: "clerk mcp install --client cursor --client vscode",
506502
description: "Install into specific clients",
507503
},
508-
{
509-
command: "clerk mcp install --url http://localhost:8787/mcp",
510-
description: "Target a local worker for development",
511-
},
512504
])
513505
.action((options) => mcpHandlers.install(options));
514506

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
/**
2-
* `clerk doctor` MCP reachability check (folded in from the former
3-
* `clerk mcp doctor` subcommand).
2+
* `clerk doctor` MCP reachability check.
43
*
54
* Kept in its own file — rather than `checks.ts` — so the doctor check graph
65
* doesn't import `mcp/shared.ts` (env profiles, prompts) and the module cycle

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

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,33 @@ The Clerk MCP server is hosted at `https://mcp.clerk.com/mcp` (source:
77
These subcommands register, list, remove, and probe that URL in each client's
88
own config file. The URL is resolved in order: `--url` > the `CLERK_MCP_URL`
99
environment variable > the active environment profile's `mcpUrl` field
10-
(`switch-env` carries the profile value automatically). `CLERK_MCP_URL` is the
11-
convenient override when developing the worker locally (e.g.
12-
`http://localhost:8787/mcp`).
10+
(`switch-env` carries the profile value automatically) > Clerk's hosted server
11+
(`https://mcp.clerk.com/mcp`). Because the hosted server is the final fallback,
12+
`clerk mcp install` works out of the box with no flags or profile setup.
13+
`CLERK_MCP_URL` is the convenient override when developing the worker locally
14+
(e.g. `http://localhost:8787/mcp`).
1315

1416
No Clerk API endpoints are called. To verify the server is reachable, run
1517
`clerk doctor` — its MCP check performs the `initialize` handshake against the
1618
configured URL whenever a Clerk MCP entry is installed.
1719

1820
## Supported clients
1921

20-
| ID | Client | Scope | Config file |
21-
| ------------- | ------------------------ | ------- | ------------------------------------- |
22-
| `claude-code` | Claude Code | project | `<cwd>/.mcp.json` |
23-
| `cursor` | Cursor | project | `<cwd>/.cursor/mcp.json` |
24-
| `vscode` | VS Code (Copilot) | project | `<cwd>/.vscode/mcp.json` |
25-
| `windsurf` | Windsurf | user | `~/.codeium/windsurf/mcp_config.json` |
26-
| `gemini` | Gemini Code Assist / CLI | user | `~/.gemini/settings.json` |
22+
All entries are written to each client's **user-global** config, so the server
23+
is available in every project (no per-project approval, no dependence on which
24+
directory you run the CLI from).
25+
26+
| ID | Client | Scope | Config file |
27+
| ------------- | ------------------------ | ----- | --------------------------------------- |
28+
| `claude-code` | 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`
36+
(Linux) — the file behind **MCP: Open User Configuration**.
2737

2838
## Subcommands
2939

@@ -35,7 +45,7 @@ Register the Clerk MCP server in one or more clients.
3545
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
3646
| `--client <id>` | Target a specific client. Repeat for multiple. Default in agent mode: all detected. Default in human mode: interactive multiselect over detected clients. |
3747
| `--all` | Install into every detected client without prompting. |
38-
| `--url <url>` | Override the MCP URL. Defaults to the active env profile's `mcpUrl`. |
48+
| `--url <url>` | Override the MCP URL. Defaults to the active env profile's `mcpUrl`, then Clerk's hosted server. |
3949
| `--name <name>` | Entry key in the client config. Default: `clerk`. |
4050
| `--force` | Overwrite an entry already pointing at a different URL. Without it, the conflict is reported and skipped. |
4151
| `--json` | Emit a JSON summary on stdout instead of human-formatted output. |
@@ -73,5 +83,5 @@ session, so (in human mode) it prints a next step to reload each affected editor
7383
| `mcp_no_client_detected` | No supported client found on the system. |
7484
| `mcp_client_not_supported` | `--client <id>` is not in the supported list. |
7585
| `mcp_client_config_invalid` | An existing client config file is malformed. |
76-
| `mcp_url_required` | No `--url` provided and the active env profile has no `mcpUrl`. |
86+
| `mcp_url_required` | The provided `--url` is malformed or uses a non-http(s) scheme. |
7787
| `mcp_not_installed` | `uninstall` removed nothing because no entry matched. |
Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
/**
2-
* Claude Code MCP client integration.
3-
*
4-
* Writes to `.mcp.json` in the current working directory — the project-scope
5-
* config Claude Code reads automatically. Schema follows the MCP spec's HTTP
6-
* transport form: `{ type: "http", url: "<endpoint>" }`.
2+
* Writes to the user-global `~/.claude.json` under `mcpServers`, so the server
3+
* is available in every project (the same store `claude mcp add -s user` uses)
4+
* rather than gated behind a per-project `.mcp.json` approval. Schema follows
5+
* the MCP spec's HTTP transport form: `{ type: "http", url: "<endpoint>" }`.
76
*/
87

98
import { hasStringProp, makeJsonClient } from "./make-json-client.ts";
10-
import { pathExists, projectPath, userPath } from "./paths.ts";
9+
import { pathExists, userPath } from "./paths.ts";
1110

1211
export const claudeCodeClient = makeJsonClient({
1312
id: "claude-code",
1413
displayName: "Claude Code",
15-
scope: "project",
14+
scope: "user",
1615
activation: "Restart Claude Code, then run `/mcp` to connect (sign in if prompted).",
1716
topKey: "mcpServers",
1817
encode: (url) => ({ type: "http", url }),
1918
extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined),
20-
configPath: (cwd) => projectPath(cwd, ".mcp.json"),
19+
configPath: () => userPath(".claude.json"),
2120
detect: () => pathExists(userPath(".claude")),
2221
});
Lines changed: 73 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,108 +1,97 @@
1-
import { describe, expect, test } from "bun:test";
1+
import { afterAll, afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
22
import { mkdtemp, readFile, rm } from "node:fs/promises";
3-
import { tmpdir, homedir } from "node:os";
3+
import * as realOs from "node:os";
44
import { join } from "node:path";
5-
import { claudeCodeClient } from "./claude-code.ts";
6-
import { cursorClient } from "./cursor.ts";
7-
import { vscodeClient } from "./vscode.ts";
8-
import { windsurfClient } from "./windsurf.ts";
9-
import { geminiClient } from "./gemini.ts";
105
import { useCaptureLog } from "../../../test/lib/stubs.ts";
116

7+
// Every client writes under the user's home now, so redirect homedir to a
8+
// tmpdir (Bun's os.homedir() ignores $HOME) — registered before the clients
9+
// load so paths.ts binds the redirected homedir.
10+
let mockHome = realOs.tmpdir();
11+
mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome }));
12+
afterAll(() => mock.restore());
13+
14+
const { claudeCodeClient } = await import("./claude-code.ts");
15+
const { cursorClient } = await import("./cursor.ts");
16+
const { vscodeClient } = await import("./vscode.ts");
17+
const { windsurfClient } = await import("./windsurf.ts");
18+
const { geminiClient } = await import("./gemini.ts");
19+
const { vscodeUserDir } = await import("./paths.ts");
20+
1221
useCaptureLog();
1322

1423
const URL = "https://mcp.clerk.com/mcp";
1524

16-
// Path shape is part of the public contract — each client targets a specific,
17-
// documented config file. Test against the format, not the absolute prefix
18-
// (which depends on cwd/homedir).
19-
const projectClients = [
20-
{ client: claudeCodeClient, suffix: ".mcp.json", topKey: "mcpServers" },
21-
{ client: cursorClient, suffix: join(".cursor", "mcp.json"), topKey: "mcpServers" },
22-
{ client: vscodeClient, suffix: join(".vscode", "mcp.json"), topKey: "servers" },
23-
];
24-
25-
const userClients = [
25+
// Path + entry shape are part of the public contract: each client targets a
26+
// specific user-global config file and encodes the server its own way.
27+
const cases = [
28+
{
29+
name: "claude-code",
30+
client: claudeCodeClient,
31+
expectedPath: () => join(mockHome, ".claude.json"),
32+
topKey: "mcpServers",
33+
shape: { type: "http", url: URL },
34+
},
35+
{
36+
name: "cursor",
37+
client: cursorClient,
38+
expectedPath: () => join(mockHome, ".cursor", "mcp.json"),
39+
topKey: "mcpServers",
40+
shape: { url: URL },
41+
},
2642
{
43+
name: "vscode",
44+
client: vscodeClient,
45+
expectedPath: () => join(vscodeUserDir(), "mcp.json"),
46+
topKey: "servers",
47+
shape: { type: "http", url: URL },
48+
},
49+
{
50+
name: "windsurf",
2751
client: windsurfClient,
28-
relPath: join(".codeium", "windsurf", "mcp_config.json"),
52+
expectedPath: () => join(mockHome, ".codeium", "windsurf", "mcp_config.json"),
53+
topKey: "mcpServers",
54+
shape: { serverUrl: URL },
55+
},
56+
{
57+
name: "gemini",
58+
client: geminiClient,
59+
expectedPath: () => join(mockHome, ".gemini", "settings.json"),
2960
topKey: "mcpServers",
61+
shape: { command: "npx", args: ["-y", "mcp-remote", URL] },
3062
},
31-
{ client: geminiClient, relPath: join(".gemini", "settings.json"), topKey: "mcpServers" },
3263
];
3364

34-
describe("project-scope client config paths", () => {
35-
test.each(projectClients)("$client.id resolves under cwd", ({ client, suffix }) => {
36-
const path = client.configPath("/tmp/foo");
37-
expect(path).toBe(join("/tmp/foo", suffix));
38-
expect(client.scope).toBe("project");
65+
describe("client config paths + encoded shapes (homedir redirected)", () => {
66+
beforeEach(async () => {
67+
mockHome = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-clients-"));
3968
});
40-
});
4169

42-
describe("user-scope client config paths", () => {
43-
test.each(userClients)("$client.id resolves under homedir", ({ client, relPath }) => {
44-
expect(client.configPath("/ignored")).toBe(join(homedir(), relPath));
45-
expect(client.scope).toBe("user");
70+
afterEach(async () => {
71+
await rm(mockHome, { recursive: true, force: true });
4672
});
47-
});
4873

49-
describe("per-client encoded shape (written JSON)", () => {
50-
// Project clients are easiest to exercise — write into a tmpdir-as-cwd and
51-
// assert what landed under their top-level key.
52-
test.each(projectClients)(
53-
"$client.id writes the expected entry shape",
54-
async ({ client, topKey }) => {
55-
const cwd = await mkdtemp(join(tmpdir(), `clerk-mcp-${client.id}-`));
56-
try {
57-
await client.upsert({ name: "clerk", url: URL }, cwd, false);
58-
const text = await readFile(client.configPath(cwd), "utf8");
59-
const parsed = JSON.parse(text) as Record<string, Record<string, unknown>>;
60-
expect(parsed[topKey]).toBeDefined();
61-
expect(parsed[topKey]?.clerk).toBeDefined();
62-
} finally {
63-
await rm(cwd, { recursive: true, force: true });
64-
}
65-
},
66-
);
67-
68-
test("claude-code emits the MCP-spec HTTP transport shape", async () => {
69-
const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cc-shape-"));
70-
try {
71-
await claudeCodeClient.upsert({ name: "clerk", url: URL }, cwd, false);
72-
const parsed = JSON.parse(await readFile(claudeCodeClient.configPath(cwd), "utf8")) as {
73-
mcpServers: { clerk: { type: string; url: string } };
74-
};
75-
expect(parsed.mcpServers.clerk).toEqual({ type: "http", url: URL });
76-
} finally {
77-
await rm(cwd, { recursive: true, force: true });
78-
}
74+
test.each(cases)("$name is user-scoped at its documented path", ({ client, expectedPath }) => {
75+
expect(client.scope).toBe("user");
76+
expect(client.configPath("/ignored")).toBe(expectedPath());
7977
});
8078

81-
test("cursor emits a bare {url} entry", async () => {
82-
const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cu-shape-"));
83-
try {
84-
await cursorClient.upsert({ name: "clerk", url: URL }, cwd, false);
85-
const parsed = JSON.parse(await readFile(cursorClient.configPath(cwd), "utf8")) as {
86-
mcpServers: { clerk: { url: string } };
87-
};
88-
expect(parsed.mcpServers.clerk).toEqual({ url: URL });
89-
} finally {
90-
await rm(cwd, { recursive: true, force: true });
91-
}
79+
test.each(cases)("$name writes the documented entry shape", async ({ client, topKey, shape }) => {
80+
await client.upsert({ name: "clerk", url: URL }, "/ignored", false);
81+
const parsed = JSON.parse(await readFile(client.configPath("/ignored"), "utf8")) as Record<
82+
string,
83+
Record<string, unknown>
84+
>;
85+
expect(parsed[topKey]?.clerk).toEqual(shape);
9286
});
9387

94-
test("vscode emits under top-level `servers`, not `mcpServers`", async () => {
95-
const cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-vs-shape-"));
96-
try {
97-
await vscodeClient.upsert({ name: "clerk", url: URL }, cwd, false);
98-
const parsed = JSON.parse(await readFile(vscodeClient.configPath(cwd), "utf8")) as {
99-
servers: { clerk: { type: string; url: string } };
100-
mcpServers?: unknown;
101-
};
102-
expect(parsed.servers.clerk).toEqual({ type: "http", url: URL });
103-
expect(parsed.mcpServers).toBeUndefined();
104-
} finally {
105-
await rm(cwd, { recursive: true, force: true });
106-
}
88+
test("vscode writes under `servers`, not `mcpServers`", async () => {
89+
await vscodeClient.upsert({ name: "clerk", url: URL }, "/ignored", false);
90+
const parsed = JSON.parse(await readFile(vscodeClient.configPath("/ignored"), "utf8")) as {
91+
servers?: unknown;
92+
mcpServers?: unknown;
93+
};
94+
expect(parsed.servers).toBeDefined();
95+
expect(parsed.mcpServers).toBeUndefined();
10796
});
10897
});
Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,20 @@
11
/**
2-
* Cursor MCP client integration.
3-
*
4-
* Writes to `.cursor/mcp.json` in the current working directory. Cursor's
5-
* MCP descriptor is a bare `{ url }` without a `type` discriminator.
2+
* Writes to the user-global `~/.cursor/mcp.json`, so the server is available in
3+
* every project rather than only the cwd it was installed from. Cursor's MCP
4+
* descriptor is a bare `{ url }` without a `type` discriminator.
65
*/
76

87
import { hasStringProp, makeJsonClient } from "./make-json-client.ts";
9-
import { pathExists, projectPath, userPath } from "./paths.ts";
8+
import { pathExists, userPath } from "./paths.ts";
109

1110
export const cursorClient = makeJsonClient({
1211
id: "cursor",
1312
displayName: "Cursor",
14-
scope: "project",
13+
scope: "user",
1514
activation: "Reload Cursor, then enable the server under `Settings → MCP` (sign in if prompted).",
1615
topKey: "mcpServers",
1716
encode: (url) => ({ url }),
1817
extractUrl: (d) => (hasStringProp(d, "url") ? d.url : undefined),
19-
configPath: (cwd) => projectPath(cwd, ".cursor", "mcp.json"),
18+
configPath: () => userPath(".cursor", "mcp.json"),
2019
detect: () => pathExists(userPath(".cursor")),
2120
});

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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
/**
2-
* Gemini Code Assist / Gemini CLI MCP client integration.
3-
*
42
* Writes to `~/.gemini/settings.json`. Gemini doesn't support HTTP transport
53
* directly — it requires `mcp-remote` as a stdio bridge, hence the
64
* `{ command: "npx", args: ["-y", "mcp-remote", <url>] }` shape.

packages/cli-core/src/commands/mcp/clients/json-config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/**
22
* Shared JSON read/write helper for MCP client configs.
33
*
4-
* Four of the five supported clients (Claude Code, Cursor, VS Code, Windsurf,
5-
* Gemini) store their MCP servers in a JSON file under a single top-level key
4+
* All five supported clients (Claude Code, Cursor, VS Code, Windsurf, Gemini)
5+
* store their MCP servers in a JSON file under a single top-level key
66
* (`mcpServers` for most, `servers` for VS Code). The entry shape varies
77
* (`url` vs `serverUrl` vs `command`+`args`) — that's per-client. This module
88
* only handles the surrounding I/O: read, parse, write back with stable

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

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
1+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
22
import { mkdtemp, readFile, rm, writeFile, mkdir } from "node:fs/promises";
3-
import { tmpdir } from "node:os";
3+
import * as realOs from "node:os";
44
import { join } from "node:path";
5-
import { cursorClient } from "./cursor.ts";
65
import { useCaptureLog } from "../../../test/lib/stubs.ts";
76

7+
// cursorClient writes under home now; redirect homedir to the cwd tmpdir so the
8+
// `join(cwd, ".cursor", ...)` reads below stay isolated. Mock before importing
9+
// the client so paths.ts binds the redirected homedir.
10+
let mockHome = realOs.tmpdir();
11+
mock.module("node:os", () => ({ ...realOs, homedir: () => mockHome }));
12+
13+
const { cursorClient } = await import("./cursor.ts");
14+
815
useCaptureLog();
916

1017
const URL_A = "https://mcp.clerk.com/mcp";
@@ -14,7 +21,8 @@ describe("make-json-client (via cursor)", () => {
1421
let cwd: string;
1522

1623
beforeEach(async () => {
17-
cwd = await mkdtemp(join(tmpdir(), "clerk-mcp-cursor-"));
24+
cwd = await mkdtemp(join(realOs.tmpdir(), "clerk-mcp-cursor-"));
25+
mockHome = cwd;
1826
});
1927

2028
afterEach(async () => {

0 commit comments

Comments
 (0)