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/fx-mcp-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": minor
---

Add fx (https://fx.sh) as a supported client for `clerk mcp install`, `list`, and `uninstall`. The Clerk MCP server is written to fx's user-global `~/.fx/mcp.json` as a direct Streamable HTTP entry (`{ "type": "http", "url": … }` under top-level `mcp`) — fx connects to the URL natively, so no `clerk mcp run` bridge is involved. Detected via the presence of `~/.fx/`; target it explicitly with `--client fx`.
47 changes: 36 additions & 11 deletions packages/cli-core/src/commands/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ These subcommands register, list, and remove the Clerk entry per client, and
probe the server via `clerk doctor`. Clients that ship a **non-interactive** MCP
registration CLI (Claude Code, Gemini, Codex, VS Code, OpenClaw, Hermes) are
registered by shelling out to it — the client owns its config format and write
safety; for the rest (Cursor, Windsurf, Warp, opencode) we write the config
file directly. Reads (`list`, `doctor`, the uninstall picker) always parse the
safety; for the others (Cursor, Windsurf, Warp, opencode, fx) we write the
config file directly (fx has a registration CLI but is deliberately
file-written — see its dialect note). Reads (`list`, `doctor`, the uninstall picker) always parse the
config files directly. The server URL defaults to Clerk's hosted server
(`https://mcp.clerk.com/mcp`), so `clerk mcp install` works out of the box with
no flags or profile setup (see [Development](#development) for the override
Expand Down Expand Up @@ -35,6 +36,7 @@ directory you run the CLI from).
| `openclaw` | OpenClaw | `openclaw mcp add --no-probe` | `openclaw mcp unset` | `~/.openclaw/openclaw.json` (`mcp.servers`) |
| `warp` | Warp | direct file write (no CLI exists) | direct file write | `~/.warp/.mcp.json` |
| `hermes` | Hermes Agent | `hermes mcp add` | `hermes mcp remove` | `~/.hermes/config.yaml` (`mcp_servers`) |
| `fx` | fx | direct file write (note below) | direct file write | `~/.fx/mcp.json` (`mcp`) |

For CLI-registered clients there is **no file-write fallback**: if the client's
binary isn't on PATH (e.g. VS Code without the `code` shell command installed),
Expand All @@ -55,8 +57,8 @@ edits its `mcp.json` directly. Its user config dir is OS-specific:
**Configs owned by a client's CLI are read-only to us.** The file layer exists
for two different jobs: _reads_ (every client — `list`, `doctor`, and the
presence checks parse the config files, because no client CLI offers a stable
machine-readable listing) and _writes_ (only the clients with no usable
registration CLI: Cursor, Windsurf, Warp, opencode — plus VS Code's
machine-readable listing) and _writes_ (only the direct-write
clients: Cursor, Windsurf, Warp, opencode, fx — plus VS Code's
removal, since its CLI is add-only). For every CLI-delegated client
(Claude Code, Gemini, Codex, OpenClaw, Hermes) the file base is built
read-only and a write reaching it throws — Codex is the one TOML-backed
Expand All @@ -80,6 +82,25 @@ Per-client dialect notes:
- **Warp** ships no registration CLI (its `oz` CLI only attaches servers to
cloud-agent runs); `~/.warp/.mcp.json` is the documented file surface behind
`Settings → Agents → MCP servers`, standard `mcpServers` dialect.
- **fx** is registered in the user-global trusted profile `~/.fx/mcp.json` —
fx 0.0.7 also loads workspace `.mcp.json` servers, but those sit behind
per-workspace trust approval, while the profile needs none and follows the
user everywhere. fx does ship a non-interactive registration CLI
(`fx mcp add --transport http`, verified in fx 0.0.7), but we write the
file directly anyway: the command is newer than fx's own docs (fx.sh
documents only the in-session `/mcp` form), so the direct write keeps
registration working on fx binaries that predate it, and the entry shape is
trivial and version-stable. fx speaks Streamable HTTP natively, so it is
the one client that skips the stdio bridge: its entry is
`{ "type": "http", "url": "…" }` under top-level `mcp`, with the resolved
URL embedded at install time. Two consequences of that: fx accepts
`mcpServers` as a profile alias for `mcp` (ignored whenever `mcp` exists),
so the client migrates an alias-only profile to canonical `mcp` before any
read or write — otherwise our written `mcp` would shadow every aliased
server; and with no bridge argv to recognize, a `--name` entry pointing at
a `CLERK_MCP_URL` override is recognized as ours only while that override
is the resolved URL. fx applies hand-edited (or CLI-written) config via
`/mcp reload` inside an fx session, or on next start; `/mcp list` verifies.
- **Hermes** `mcp add` probes the server and then ends in a confirm prompt
("Enable all tools?" on success, "Save config anyway?" on failure) — and
cancelling on EOF exits **0** without saving. The CLI is therefore driven
Expand All @@ -90,8 +111,10 @@ Per-client dialect notes:

## How clients connect (the stdio bridge)

Every client installs the same stdio descriptor — it launches `clerk mcp run`
rather than pointing the editor at the remote URL directly:
Every client except fx installs the same stdio descriptor — it launches
`clerk mcp run` rather than pointing the editor at the remote URL directly (fx
connects over Streamable HTTP natively, so its entry carries the URL itself —
see the dialect note above):

```jsonc
{ "command": "clerk", "args": ["mcp", "run"] }
Expand Down Expand Up @@ -144,11 +167,13 @@ output.
**After install:** registering the entry 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`).
(so `clerk` must be on the editor's `PATH`). fx instead applies the config via
`/mcp reload` inside a session (or on next start) and needs no `clerk` binary
at connect time.

> **Concurrent writes:** for CLI-registered clients, write safety is the
> client's own responsibility — its CLI owns the config. The file-backed
> clients (Cursor, Windsurf, VS Code removal) are written atomically (temp
> clients (Cursor, Windsurf, fx, VS Code removal) are written atomically (temp
> file + rename), which prevents a torn read but not a lost update if the
> editor rewrites its own config concurrently — those writes are safest with
> the target client closed.
Expand All @@ -157,8 +182,8 @@ goes live once you **reload the editor**, which then spawns `clerk mcp run`

Print every Clerk MCP entry across all supported clients: any `clerk mcp run`
bridge entry, matched by its descriptor shape regardless of its name or
currently-resolved URL (plus, for opencode's remote dialect, entries named
`clerk` or pointing at a `*.clerk.com` host). Entries this CLI never wrote —
currently-resolved URL (plus, for the direct-URL dialects — opencode's remote
entries and fx — entries named `clerk` or pointing at a `*.clerk.com` host). Entries this CLI never wrote —
e.g. a hand-added direct-URL entry — are left alone. The `--json` (and
agent-mode) output is `{ entries, failures }`: a client whose config exists but
can't be read or parsed appears in `failures` (`{ client, error }`) rather than
Expand Down Expand Up @@ -191,7 +216,7 @@ and when the entry is present but the client's binary is missing, that client
fails with `mcp_client_cli_not_found`. After the remove command reports
success, the config is re-read — if the entry is somehow still present, the
client fails with `mcp_client_cli_failed` rather than reporting a removal that
didn't happen (the mirror of the add-side `verifyAdd` check). Cursor, Windsurf, Warp, opencode, and
didn't happen (the mirror of the add-side `verifyAdd` check). Cursor, Windsurf, Warp, opencode, fx, and
VS Code (add-only CLI) are removed by editing the config file directly.

In human mode with no `--client`/`--all`, it prompts with a
Expand Down
3 changes: 2 additions & 1 deletion packages/cli-core/src/commands/mcp/clients/clerk-run.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* The stdio descriptor every client now installs: each config launches
* The stdio descriptor every client except fx installs (fx speaks Streamable
* HTTP natively and stores the URL directly): each config launches
* `clerk mcp run`, the bridge in `../run.ts`. Centralized here so the command
* shape and its reverse parser stay in lockstep across clients.
*
Expand Down
158 changes: 156 additions & 2 deletions packages/cli-core/src/commands/mcp/clients/clients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const { opencodeClient } = await import("./opencode.ts");
const { openclawClient } = await import("./openclaw.ts");
const { warpClient } = await import("./warp.ts");
const { hermesClient } = await import("./hermes.ts");
const { fxClient } = await import("./fx.ts");
const { vscodeUserDir } = await import("./paths.ts");

useCaptureLog();
Expand Down Expand Up @@ -88,10 +89,16 @@ const pathCases = [
client: hermesClient,
expectedPath: () => join(mockHome, ".hermes", "config.yaml"),
},
{
name: "fx",
client: fxClient,
expectedPath: () => join(mockHome, ".fx", "mcp.json"),
},
];

// File-backed clients: we write the entry ourselves (no usable registration
// CLI exists — opencode's `mcp add` is an interactive wizard, Warp has none).
// File-backed clients: we write the entry ourselves (opencode's `mcp add` is
// an interactive wizard, Warp has none, and fx's is skipped for version
// coverage — see the README's fx note).
const fileCases = [
{ name: "cursor", client: cursorClient, topKey: "mcpServers", shape: RUN_SHAPE },
{ name: "windsurf", client: windsurfClient, topKey: "mcpServers", shape: RUN_SHAPE },
Expand All @@ -103,6 +110,15 @@ const fileCases = [
// opencode's stdio dialect: `type: "local"` and a single command array.
shape: { type: "local", command: ["clerk", "mcp", "run"] },
},
{
name: "fx",
client: fxClient,
topKey: "mcp",
// fx registers in the user-global `~/.fx/mcp.json` profile and connects
// over Streamable HTTP directly — no stdio bridge, so the URL is embedded
// at install time.
shape: { type: "http", url: DEFAULT_URL },
},
];

// CLI-backed clients: registration is delegated to the client's own CLI. The
Expand Down Expand Up @@ -329,6 +345,144 @@ describe("client contracts (homedir redirected)", () => {
expect(entries.every((e) => e.url === DEFAULT_URL)).toBe(true);
});

test("fx lists clerk-flavored direct-URL entries and ignores unrelated ones", async () => {
const configPath = fxClient.configPath("/ignored");
await mkdir(join(configPath, ".."), { recursive: true });
await writeFile(
configPath,
JSON.stringify({
mcp: {
clerk: { type: "http", url: DEFAULT_URL },
unrelated: { type: "http", url: "https://example.com/mcp" },
},
}),
);
const entries = await fxClient.list("/ignored");
expect(entries).toEqual([
expect.objectContaining({ client: "fx", name: "clerk", url: DEFAULT_URL }),
]);
});

// fx accepts `mcpServers` as a profile alias for `mcp` and ignores the
// alias whenever `mcp` exists — so installing a fresh `mcp` next to an
// alias-form profile would shadow every aliased server. The client migrates
// alias-only profiles to canonical `mcp` instead.
test("fx install migrates an alias-form profile without dropping its servers", async () => {
const configPath = fxClient.configPath("/ignored");
await mkdir(join(configPath, ".."), { recursive: true });
await writeFile(
configPath,
JSON.stringify({
mcpServers: { existing: { type: "http", url: "https://example.com/mcp" } },
theme: "dark",
}),
);
await fxClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored");
const parsed = JSON.parse(await readFile(configPath, "utf8")) as Record<string, unknown>;
expect(parsed.mcpServers).toBeUndefined();
expect(parsed.theme).toBe("dark");
expect(parsed.mcp).toEqual({
existing: { type: "http", url: "https://example.com/mcp" },
clerk: { type: "http", url: DEFAULT_URL },
});
});

test("fx list and remove see a clerk entry stored under the alias key", async () => {
const configPath = fxClient.configPath("/ignored");
await mkdir(join(configPath, ".."), { recursive: true });
await writeFile(
configPath,
JSON.stringify({ mcpServers: { clerk: { type: "http", url: DEFAULT_URL } } }),
);
const entries = await fxClient.list("/ignored");
expect(entries).toEqual([
expect.objectContaining({ client: "fx", name: "clerk", url: DEFAULT_URL }),
]);
const result = await fxClient.remove("clerk", "/ignored");
expect(result.removed).toBe(true);
});

test("fx leaves the ignored alias alone when canonical `mcp` exists", async () => {
const configPath = fxClient.configPath("/ignored");
await mkdir(join(configPath, ".."), { recursive: true });
const shadowed = { mcpServers: { shadowed: { type: "http", url: "https://example.com/mcp" } } };
await writeFile(configPath, JSON.stringify({ mcp: {}, ...shadowed }));
await fxClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored");
const parsed = JSON.parse(await readFile(configPath, "utf8")) as Record<string, unknown>;
expect(parsed.mcpServers).toEqual(shadowed.mcpServers);
expect(parsed.mcp).toEqual({ clerk: { type: "http", url: DEFAULT_URL } });
});

// Direct-URL entries carry no bridge argv, so provenance rides on the URL:
// a custom-name entry pointing at the active `CLERK_MCP_URL` override must
// stay visible to list/doctor/uninstall while that override is set.
test("fx lists a custom-name entry matching the resolved URL override", async () => {
const configPath = fxClient.configPath("/ignored");
await mkdir(join(configPath, ".."), { recursive: true });
await writeFile(
configPath,
JSON.stringify({ mcp: { local: { type: "http", url: "http://localhost:8787/mcp" } } }),
);
const origMcpUrl = process.env.CLERK_MCP_URL;
process.env.CLERK_MCP_URL = "http://localhost:8787/mcp";
try {
const entries = await fxClient.list("/ignored");
expect(entries).toEqual([
expect.objectContaining({ client: "fx", name: "local", url: "http://localhost:8787/mcp" }),
]);
} finally {
if (origMcpUrl === undefined) {
delete process.env.CLERK_MCP_URL;
} else {
process.env.CLERK_MCP_URL = origMcpUrl;
}
}
});

// Install stores `resolveUrl()`'s canonical `URL.href`; the raw override may
// differ in case or trailing slash and must still match.
test("fx matches a non-canonical but equivalent URL override", async () => {
const configPath = fxClient.configPath("/ignored");
await mkdir(join(configPath, ".."), { recursive: true });
await writeFile(
configPath,
JSON.stringify({ mcp: { local: { type: "http", url: "http://localhost:8787/mcp" } } }),
);
const origMcpUrl = process.env.CLERK_MCP_URL;
process.env.CLERK_MCP_URL = "HTTP://LOCALHOST:8787/mcp";
try {
const entries = await fxClient.list("/ignored");
expect(entries).toEqual([
expect.objectContaining({ client: "fx", name: "local", url: "http://localhost:8787/mcp" }),
]);
} finally {
if (origMcpUrl === undefined) {
delete process.env.CLERK_MCP_URL;
} else {
process.env.CLERK_MCP_URL = origMcpUrl;
}
}
});

// Normalization migrates by key presence, not value shape, so a malformed
// profile still fails validation the way fx itself would reject it.
test.each([
["invalid canonical key beside the alias", { mcp: null, mcpServers: { a: {} } }, "mcp"],
["invalid alias key alone", { mcpServers: null }, "mcp"],
])("fx rejects a profile with an %s", async (_label, profile) => {
const configPath = fxClient.configPath("/ignored");
await mkdir(join(configPath, ".."), { recursive: true });
await writeFile(configPath, JSON.stringify(profile));
await expect(
fxClient.upsert({ name: "clerk", url: DEFAULT_URL }, "/ignored"),
).rejects.toMatchObject({
code: "mcp_client_config_invalid",
});
await expect(fxClient.list("/ignored")).rejects.toMatchObject({
code: "mcp_client_config_invalid",
});
});

test("`copilot` resolves to the same client as `vscode`", async () => {
const { resolveClients } = await import("../shared.ts");
expect(resolveClients(["copilot"])).toEqual([vscodeClient]);
Expand Down
Loading