-
Notifications
You must be signed in to change notification settings - Fork 4
feat(mcp): clerk mcp install/list/uninstall/run + doctor MCP check #307
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
42ad04d
feat(mcp): add clerk mcp install/list/uninstall/run across 10 clients…
rafa-thayto c7b1cc0
fix(mcp): address review feedback — agent-only all-client targeting, …
rafa-thayto 7a043c3
refactor(mcp): drop legacy direct-url round-trip and inline the docto…
rafa-thayto e22734c
refactor(mcp): type the run bridge with SDK JSONRPCMessage and RequestId
rafa-thayto File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, Codex, opencode, OpenClaw, Warp, and Hermes Agent. Clients that ship a non-interactive MCP registration CLI are registered by shelling out to it — `claude mcp add`, `gemini mcp add`, `codex mcp add`, `code --add-mcp`, `openclaw mcp add --no-probe`, `hermes mcp add` — so each client owns its config format and write safety; for those, the client's binary must be on PATH (detection is PATH-based, no file-write fallback). Cursor, Windsurf, Warp (no CLI), and opencode (interactive-only CLI) get their user-global config file written directly, and VS Code removal (add-only CLI) edits its `mcp.json` in place. `clerk mcp list --json` reports `{ entries, failures }` so an unreadable client config surfaces structurally instead of reading as "nothing installed". Install always converges: an existing entry under the same name is replaced. 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. `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: 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
137 changes: 137 additions & 0 deletions
137
packages/cli-core/src/commands/doctor/check-mcp.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,137 @@ | ||
| import { beforeEach, describe, expect, mock, test } from "bun:test"; | ||
| import { useCaptureLog } from "../../test/lib/stubs.ts"; | ||
| import type { CollectResult } from "../mcp/collect.ts"; | ||
| import type { McpProbeResult } from "../mcp/probe.ts"; | ||
| import type { ListEntry } from "../mcp/clients/types.ts"; | ||
|
|
||
| let collected: CollectResult; | ||
| let probes: Record<string, McpProbeResult>; | ||
| let probedUrls: string[]; | ||
|
|
||
| // Registered at file top, before check-mcp.ts loads its imports (this file | ||
| // runs in its own subprocess via scripts/run-tests.ts). | ||
| mock.module("../mcp/collect.ts", () => ({ | ||
| collectEntries: async () => collected, | ||
| })); | ||
| mock.module("../mcp/probe.ts", () => ({ | ||
| probeMcp: async (url: string): Promise<McpProbeResult> => { | ||
| probedUrls.push(url); | ||
| return probes[url] ?? { ok: false, error: "unstubbed url" }; | ||
| }, | ||
| })); | ||
|
|
||
| const { checkMcp } = await import("./check-mcp.ts"); | ||
|
|
||
| const HOSTED = "https://mcp.clerk.com/mcp"; | ||
| const LOCAL = "http://localhost:9000/mcp"; | ||
|
|
||
| function entry(client: ListEntry["client"], url: string): ListEntry { | ||
| return { client, configPath: `/tmp/${client}.json`, name: "clerk", url }; | ||
| } | ||
|
|
||
| describe("checkMcp", () => { | ||
| useCaptureLog(); | ||
|
|
||
| beforeEach(() => { | ||
| collected = { entries: [], failures: [] }; | ||
| probes = {}; | ||
| probedUrls = []; | ||
| }); | ||
|
|
||
| test("passes as skipped when nothing is installed", async () => { | ||
| const result = await checkMcp(); | ||
|
|
||
| expect(result.status).toBe("pass"); | ||
| expect(result.message).toContain("Skipped"); | ||
| }); | ||
|
|
||
| test("warns naming the client when a config is unreadable, instead of passing as skipped", async () => { | ||
| collected = { | ||
| entries: [], | ||
| failures: [ | ||
| { | ||
| client: "claude", | ||
| displayName: "Claude Code", | ||
| message: "Could not parse ~/.claude.json as JSON", | ||
| }, | ||
| ], | ||
| }; | ||
|
|
||
| const result = await checkMcp(); | ||
|
|
||
| expect(result.status).toBe("warn"); | ||
| expect(result.message).toContain("Claude Code"); | ||
| expect(result.detail).toContain("Could not parse"); | ||
| expect(result.remedy).toContain("clerk mcp install"); | ||
| }); | ||
|
|
||
| test("still reports probe failures alongside an unreadable config", async () => { | ||
| collected = { | ||
| entries: [entry("cursor", LOCAL)], | ||
| failures: [{ client: "claude", displayName: "Claude Code", message: "unreadable" }], | ||
| }; | ||
| probes = { [LOCAL]: { ok: false, status: 502 } }; | ||
|
|
||
| const result = await checkMcp(); | ||
|
|
||
| expect(result.status).toBe("warn"); | ||
| expect(result.message).toContain("Claude Code"); | ||
| expect(result.detail).toContain(`${LOCAL}: HTTP 502`); | ||
| }); | ||
|
|
||
| test("passes with server names when every distinct URL is reachable, probing each once", async () => { | ||
| collected = { | ||
| entries: [entry("claude", HOSTED), entry("cursor", HOSTED)], | ||
| failures: [], | ||
| }; | ||
| probes = { [HOSTED]: { ok: true, status: 200, serverName: "Clerk MCP Server" } }; | ||
|
|
||
| const result = await checkMcp(); | ||
|
|
||
| expect(result.status).toBe("pass"); | ||
| expect(result.message).toBe(`Reachable — Clerk MCP Server (${HOSTED})`); | ||
| expect(probedUrls).toEqual([HOSTED]); | ||
| }); | ||
|
|
||
| test("passes as reachable (authentication required) when the probe answers 401", async () => { | ||
| // The hosted server currently accepts an unauthenticated initialize, but a | ||
| // server-side tightening to 401 must not flag a just-installed entry. | ||
| collected = { entries: [entry("claude", HOSTED)], failures: [] }; | ||
| probes = { [HOSTED]: { ok: false, status: 401, authRequired: true } }; | ||
|
|
||
| const result = await checkMcp(); | ||
|
|
||
| expect(result.status).toBe("pass"); | ||
| expect(result.message).toContain("authentication required"); | ||
| expect(result.message).toContain(HOSTED); | ||
| }); | ||
|
|
||
| test("warns with singular wording when the only configured server is unreachable", async () => { | ||
| collected = { entries: [entry("claude", LOCAL)], failures: [] }; | ||
| probes = { [LOCAL]: { ok: false, error: "fetch failed" } }; | ||
|
|
||
| const result = await checkMcp(); | ||
|
|
||
| expect(result.status).toBe("warn"); | ||
| expect(result.message).toContain("Configured MCP server is not reachable"); | ||
| expect(result.detail).toBe(`${LOCAL}: fetch failed`); | ||
| }); | ||
|
|
||
| test("a healthy first URL does not mask a broken second", async () => { | ||
| collected = { | ||
| entries: [entry("claude", HOSTED), entry("cursor", LOCAL)], | ||
| failures: [], | ||
| }; | ||
| probes = { | ||
| [HOSTED]: { ok: true, status: 200, serverName: "Clerk MCP Server" }, | ||
| [LOCAL]: { ok: false, error: "fetch failed" }, | ||
| }; | ||
|
|
||
| const result = await checkMcp(); | ||
|
|
||
| expect(result.status).toBe("warn"); | ||
| expect(result.message).toContain("One or more configured MCP servers are not reachable"); | ||
| expect(result.message).toContain(LOCAL); | ||
| expect(result.message).not.toContain(HOSTED); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| /** | ||
| * `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 { ListEntry } from "../mcp/clients/types.ts"; | ||
| import type { CheckResult } from "./types.ts"; | ||
|
|
||
| type UrlProbe = { url: string; result: McpProbeResult }; | ||
|
|
||
| // A 401/403 answer proves the server is there — it gates the handshake behind | ||
| // auth the editor performs itself — so it reads as reachable, not broken. | ||
| function isReachable(result: McpProbeResult): boolean { | ||
| return result.ok || result.authRequired === true; | ||
| } | ||
|
|
||
| function describeReachable(probes: UrlProbe[]): string { | ||
| return probes | ||
| .map(({ url, result }) => | ||
| result.ok ? `${result.serverName} (${url})` : `authentication required (${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"; | ||
| } | ||
|
|
||
| function describeUnreachable(unreachable: UrlProbe[], total: number): string { | ||
| const subject = | ||
| total === 1 ? "Configured MCP server is" : "One or more configured MCP servers are"; | ||
| return `${subject} not reachable (${unreachable.map((p) => p.url).join(", ")})`; | ||
| } | ||
|
|
||
| // 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. | ||
| async function probeEntries(entries: ListEntry[]): Promise<UrlProbe[]> { | ||
| const urls = [...new Set(entries.map((e) => e.url))]; | ||
| return Promise.all(urls.map(async (url) => ({ url, result: await probeMcp(url) }))); | ||
| } | ||
|
|
||
| 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, failures } = await collectEntries(process.cwd()); | ||
| const probes = await probeEntries(entries); | ||
| const unreachable = probes.filter((p) => !isReachable(p.result)); | ||
|
|
||
| // An unreadable client config is not "nothing installed" — a previously | ||
| // registered entry may be hiding inside it. The stderr warning alone gets | ||
| // clobbered by the doctor spinner, so it must surface in the check result. | ||
| if (failures.length > 0) { | ||
| const clients = failures.map((f) => f.displayName).join(", "); | ||
| return { | ||
| name: "MCP server", | ||
| status: "warn", | ||
| message: `Could not read the MCP config for ${clients}`, | ||
| detail: [ | ||
| ...failures.map((f) => `${f.displayName}: ${f.message}`), | ||
| ...unreachable.map((p) => `${p.url}: ${describeFailure(p.result)}`), | ||
| ].join("; "), | ||
| remedy: "Fix or remove the unreadable config file, then re-run `clerk mcp install`.", | ||
| }; | ||
| } | ||
|
|
||
| if (entries.length === 0) { | ||
| return { | ||
| name: "MCP server", | ||
| status: "pass", | ||
| message: "Skipped (no Clerk MCP entry installed)", | ||
| }; | ||
| } | ||
|
|
||
| if (unreachable.length === 0) { | ||
| return { | ||
| name: "MCP server", | ||
| status: "pass", | ||
| message: `Reachable — ${describeReachable(probes)}`, | ||
| }; | ||
| } | ||
|
|
||
| return { | ||
| name: "MCP server", | ||
| status: "warn", | ||
| message: describeUnreachable(unreachable, probes.length), | ||
| 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.", | ||
| }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.