-
Well-known servers
+
+
Well-known servers
+
+
+ {healthError ? (
+
+ {healthError}
+
+ ) : null}
{catalog === null ? (
Loading registry…
) : catalog.length === 0 ? (
@@ -278,6 +334,7 @@ export function FamiliarMcpSection({ data }: { data: FamiliarSectionData }) {
{server.id}
{server.transport}
+ {health?.[server.id] ? : null}
{server.target ? (
{server.target}
diff --git a/src/lib/mcp-doctor.test.ts b/src/lib/mcp-doctor.test.ts
new file mode 100644
index 000000000..5797706b9
--- /dev/null
+++ b/src/lib/mcp-doctor.test.ts
@@ -0,0 +1,129 @@
+// @ts-nocheck
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { extractPlaceholders, diagnoseEntry, diagnoseRegistry } from "./mcp-doctor.ts";
+
+// Behavioral tests for the MCP doctor: verdicts must be honest (probed, not
+// assumed), requirement *names* must surface, and secret values must never
+// appear in any output.
+
+const liveProbe = async () => ({ reachable: true, detail: "endpoint live" });
+const authProbe = async () => ({ reachable: true, detail: "reachable — sign in on connect" });
+const deadProbe = async () => ({ reachable: false, error: "could not reach endpoint" });
+const haveCommand = async () => true;
+const noCommand = async () => false;
+
+test("extractPlaceholders: names from url, args, and env values — deduped and sorted", () => {
+ assert.deepEqual(
+ extractPlaceholders({
+ url: "${ACTIVEPIECES_MCP_URL}",
+ args: ["--http", "${NETDATA_MCP_URL}", "--root", "${NETDATA_MCP_URL}"],
+ env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PAT}" },
+ }),
+ ["ACTIVEPIECES_MCP_URL", "GITHUB_PAT", "NETDATA_MCP_URL"],
+ );
+ assert.deepEqual(extractPlaceholders({ command: "npx", args: ["-y", "some-pkg"] }), []);
+});
+
+test("http endpoint that answers the initialize probe is ready", async () => {
+ const h = await diagnoseEntry("linear", { type: "http", url: "https://mcp.linear.app/mcp" }, { probe: liveProbe, commandExists: noCommand });
+ assert.equal(h.status, "ready");
+ assert.equal(h.transport, "http");
+ assert.match(h.detail, /live/);
+});
+
+test("http endpoint behind auth is still ready — sign-in happens in the client", async () => {
+ const h = await diagnoseEntry("canva", { type: "http", url: "https://mcp.canva.com/mcp" }, { probe: authProbe, commandExists: noCommand });
+ assert.equal(h.status, "ready");
+ assert.match(h.detail, /sign in/);
+});
+
+test("unreachable http endpoint is unavailable, with the probe's error", async () => {
+ const h = await diagnoseEntry("vercel", { type: "http", url: "https://mcp.vercel.com" }, { probe: deadProbe, commandExists: noCommand });
+ assert.equal(h.status, "unavailable");
+ assert.match(h.detail, /could not reach/);
+});
+
+test("remote entry with a placeholder url needs config and is never probed", async () => {
+ let probed = 0;
+ const countingProbe = async () => {
+ probed += 1;
+ return { reachable: true };
+ };
+ const h = await diagnoseEntry("activepieces", { type: "sse", url: "${ACTIVEPIECES_MCP_URL}" }, { probe: countingProbe, commandExists: noCommand });
+ assert.equal(h.status, "needs-config");
+ assert.deepEqual(h.requires, ["ACTIVEPIECES_MCP_URL"]);
+ assert.equal(probed, 0, "a ${...} url must not be fetched");
+});
+
+test("stdio entry whose launcher is missing is unavailable, requirements still listed", async () => {
+ const h = await diagnoseEntry(
+ "git",
+ { type: "stdio", command: "uvx", args: ["mcp-server-git", "--repository", "${COVEN_MCP_GIT_REPOSITORY}"] },
+ { probe: liveProbe, commandExists: noCommand },
+ );
+ assert.equal(h.status, "unavailable");
+ assert.match(h.detail, /"uvx" is not installed/);
+ assert.deepEqual(h.requires, ["COVEN_MCP_GIT_REPOSITORY"]);
+});
+
+test("stdio entry with launcher installed but unmet placeholders needs config", async () => {
+ const h = await diagnoseEntry(
+ "github",
+ { type: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-github"], env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PAT}" } },
+ { probe: liveProbe, commandExists: haveCommand },
+ );
+ assert.equal(h.status, "needs-config");
+ assert.deepEqual(h.requires, ["GITHUB_PAT"]);
+ assert.match(h.detail, /set GITHUB_PAT/);
+});
+
+test("stdio entry with launcher installed and nothing to configure is ready", async () => {
+ const h = await diagnoseEntry("fetch", { type: "stdio", command: "uvx", args: ["mcp-server-fetch"] }, { probe: liveProbe, commandExists: haveCommand });
+ assert.equal(h.status, "ready");
+ assert.match(h.detail, /"uvx" installed/);
+});
+
+test("entries missing both url and command are flagged, not crashed on", async () => {
+ const remote = await diagnoseEntry("broken-remote", { type: "http" }, { probe: liveProbe, commandExists: haveCommand });
+ assert.equal(remote.status, "needs-config");
+ assert.match(remote.detail, /no url/);
+ const local = await diagnoseEntry("broken-stdio", {}, { probe: liveProbe, commandExists: haveCommand });
+ assert.equal(local.status, "needs-config");
+ assert.match(local.detail, /no command/);
+});
+
+test("diagnoseRegistry: tolerates malformed documents and sorts results by id", async () => {
+ assert.deepEqual(await diagnoseRegistry(null, { probe: liveProbe, commandExists: haveCommand }), []);
+ assert.deepEqual(await diagnoseRegistry({ mcpServers: "nope" }, { probe: liveProbe, commandExists: haveCommand }), []);
+ const out = await diagnoseRegistry(
+ {
+ mcpServers: {
+ zeta: { type: "http", url: "https://z.example/mcp" },
+ alpha: { type: "stdio", command: "npx", args: ["-y", "pkg"] },
+ },
+ },
+ { probe: liveProbe, commandExists: haveCommand },
+ );
+ assert.deepEqual(out.map((h) => h.id), ["alpha", "zeta"]);
+});
+
+test("no env values ever leak into the report — names only", async () => {
+ const out = await diagnoseRegistry(
+ {
+ mcpServers: {
+ leaky: {
+ type: "stdio",
+ command: "npx",
+ args: ["-y", "pkg"],
+ env: { API_TOKEN: "super-secret-value", OTHER: "${WANTED_NAME}" },
+ },
+ },
+ },
+ { probe: liveProbe, commandExists: haveCommand },
+ );
+ const serialized = JSON.stringify(out);
+ assert.doesNotMatch(serialized, /super-secret-value/, "concrete env values must never appear");
+ assert.match(serialized, /WANTED_NAME/, "placeholder names must appear");
+ assert.doesNotMatch(serialized, /API_TOKEN/, "only unmet placeholder names are reported, not env keys with concrete values");
+});
diff --git a/src/lib/mcp-doctor.ts b/src/lib/mcp-doctor.ts
new file mode 100644
index 000000000..0e59997de
--- /dev/null
+++ b/src/lib/mcp-doctor.ts
@@ -0,0 +1,128 @@
+/**
+ * MCP doctor — diagnoses every server in the marketplace MCP registry
+ * (`marketplace/exports/mcp/mcp.json`) so the cave can show which listed
+ * servers are actually usable, not merely listed.
+ *
+ * Three honest verdicts per entry:
+ * - "ready" a remote endpoint answered the MCP `initialize` probe, or
+ * the stdio launcher (npx/uvx/docker/…) is installed and the
+ * entry needs no user-supplied configuration
+ * - "needs-config" the entry references `${PLACEHOLDER}` values the user must
+ * supply before it can run, so nothing can be probed yet
+ * - "unavailable" the endpoint did not respond, or the launcher is not
+ * installed on this machine
+ *
+ * Only requirement *names* are ever reported — never values. The endpoint
+ * probe and PATH lookup are injectable so tests touch neither network nor
+ * filesystem.
+ */
+
+import { access } from "node:fs/promises";
+import { constants } from "node:fs";
+import path from "node:path";
+import { checkMcpEndpoint, type EndpointCheck } from "./endpoint-validators.ts";
+
+export type RegistryServerEntry = {
+ type?: string;
+ url?: string;
+ command?: string;
+ args?: string[];
+ env?: Record;
+};
+
+export type McpHealthStatus = "ready" | "needs-config" | "unavailable";
+
+export type McpServerHealth = {
+ id: string;
+ transport: string;
+ status: McpHealthStatus;
+ detail: string;
+ /** Names of `${PLACEHOLDER}` values the user must supply. Never values. */
+ requires: string[];
+};
+
+export type DoctorDeps = {
+ probe: (url: string) => Promise;
+ commandExists: (command: string) => Promise;
+};
+
+/** Collect `${PLACEHOLDER}` names from an entry's url, command, args, and env values. */
+export function extractPlaceholders(entry: RegistryServerEntry): string[] {
+ const names = new Set();
+ const scan = (value: unknown) => {
+ if (typeof value !== "string") return;
+ for (const match of value.matchAll(/\$\{([A-Za-z0-9_]+)\}/g)) names.add(match[1]);
+ };
+ scan(entry.url);
+ scan(entry.command);
+ for (const arg of Array.isArray(entry.args) ? entry.args : []) scan(arg);
+ for (const value of Object.values(entry.env ?? {})) scan(value);
+ return [...names].sort();
+}
+
+export async function diagnoseEntry(id: string, entry: RegistryServerEntry, deps: DoctorDeps): Promise {
+ const transport = typeof entry.type === "string" && entry.type ? entry.type : "stdio";
+ const requires = extractPlaceholders(entry);
+ const base = { id, transport, requires };
+
+ if (transport === "http" || transport === "sse") {
+ const url = typeof entry.url === "string" ? entry.url : "";
+ if (!url) return { ...base, status: "needs-config", detail: "registry entry has no url" };
+ if (requires.length > 0) {
+ return { ...base, status: "needs-config", detail: `set ${requires.join(", ")} to enable this endpoint` };
+ }
+ const check = await deps.probe(url);
+ if (!check.reachable) return { ...base, status: "unavailable", detail: check.error ?? "could not reach endpoint" };
+ return { ...base, status: "ready", detail: check.detail ?? "endpoint live" };
+ }
+
+ const command = typeof entry.command === "string" ? entry.command : "";
+ if (!command) return { ...base, status: "needs-config", detail: "registry entry has no command" };
+ const installed = await deps.commandExists(command);
+ if (!installed) return { ...base, status: "unavailable", detail: `launcher "${command}" is not installed` };
+ if (requires.length > 0) {
+ return { ...base, status: "needs-config", detail: `"${command}" installed — set ${requires.join(", ")} to run` };
+ }
+ return { ...base, status: "ready", detail: `launcher "${command}" installed — package resolves on launch` };
+}
+
+/** Diagnose a parsed registry document (`{ mcpServers: { id: entry } }`), sorted by id. */
+export async function diagnoseRegistry(registry: unknown, deps: DoctorDeps): Promise {
+ const obj = (registry && typeof registry === "object" ? registry : {}) as Record;
+ const servers = (obj.mcpServers && typeof obj.mcpServers === "object" ? obj.mcpServers : {}) as Record<
+ string,
+ RegistryServerEntry
+ >;
+ const results = await Promise.all(Object.entries(servers).map(([id, entry]) => diagnoseEntry(id, entry ?? {}, deps)));
+ return results.sort((a, b) => a.id.localeCompare(b.id));
+}
+
+/** Real PATH lookup for stdio launchers. Never spawns anything. */
+export async function systemCommandExists(command: string): Promise {
+ if (!command) return false;
+ const extensions =
+ process.platform === "win32" ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
+ const runnable = async (candidate: string) => {
+ try {
+ await access(candidate, process.platform === "win32" ? constants.F_OK : constants.X_OK);
+ return true;
+ } catch {
+ return false;
+ }
+ };
+ const candidates = command.includes(path.sep)
+ ? [command]
+ : (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).map((dir) => path.join(dir, command));
+ for (const candidate of candidates) {
+ for (const ext of extensions) {
+ if (await runnable(candidate + ext.toLowerCase()) || (ext && (await runnable(candidate + ext)))) return true;
+ }
+ }
+ return false;
+}
+
+/** Default deps: real MCP initialize probe + real PATH lookup. */
+export const systemDoctorDeps: DoctorDeps = {
+ probe: checkMcpEndpoint,
+ commandExists: systemCommandExists,
+};
diff --git a/src/styles/familiar-tab-mcp.css b/src/styles/familiar-tab-mcp.css
index 452745e61..6f53ffd27 100644
--- a/src/styles/familiar-tab-mcp.css
+++ b/src/styles/familiar-tab-mcp.css
@@ -147,6 +147,41 @@
color: var(--text-muted);
}
+/* ── Health check row + verdict pills (from /api/mcp/health, the MCP doctor) ── */
+
+.familiar-mcp__catalog-title-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+}
+
+.familiar-mcp__catalog-title-row .familiar-mcp__catalog-title {
+ margin-bottom: 0;
+}
+
+.familiar-mcp__health {
+ font-family: var(--font-mono), ui-monospace, monospace;
+}
+
+.familiar-mcp__health--ready {
+ border: 1px solid color-mix(in oklch, var(--color-success) 38%, var(--border-hairline));
+ background: color-mix(in oklch, var(--color-success) 14%, transparent);
+ color: var(--color-success);
+}
+
+.familiar-mcp__health--needs-config {
+ border: 1px solid color-mix(in oklch, var(--color-warning) 38%, var(--border-hairline));
+ background: color-mix(in oklch, var(--color-warning) 14%, transparent);
+ color: var(--color-warning);
+}
+
+.familiar-mcp__health--unavailable {
+ border: 1px solid color-mix(in oklch, var(--color-danger) 38%, var(--border-hairline));
+ background: color-mix(in oklch, var(--color-danger) 14%, transparent);
+ color: var(--color-danger);
+}
+
.familiar-mcp__catalog-note {
margin: 0;
font-size: var(--text-sm);