From 478b50ae569a873de3e3093d795d92e90b2cbd19 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 23 Jul 2026 13:19:33 -0500 Subject: [PATCH 1/3] =?UTF-8?q?feat(mcp):=20MCP=20doctor=20=E2=80=94=20ver?= =?UTF-8?q?ify=20every=20listed=20server=20is=20actually=20usable=20(cave-?= =?UTF-8?q?0kux)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The well-known servers grid listed 50+ registry entries with no way to tell which would actually work. Add an MCP doctor: - src/lib/mcp-doctor.ts: per-entry diagnosis — remote (http/sse) entries get a real JSON-RPC initialize probe, stdio entries a launcher-on-PATH check, and ${PLACEHOLDER} requirements surface by name (names only, never values; never spawns server processes) - GET /api/mcp/health: runs the doctor over marketplace/exports/mcp/mcp.json - familiar-tab MCP card: on-demand Check servers action pins honest verdict pills (ready / needs config / unavailable) on each grid card, detail + unmet requirement names in the tooltip - docs/mcp-doctor.md: what each verdict means and how to fix it Verified live: 53 servers diagnosed in ~1.3s through the running app — 31 ready, 21 needs-config (exact env names reported), 1 unavailable (dnx). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/mcp-doctor.md | 52 ++++++++++ scripts/run-tests.mjs | 1 + src/app/api/api-contracts.test.ts | 1 + src/app/api/mcp/health/route.ts | 39 +++++++ src/components/familiar-tab-mcp.test.ts | 19 ++++ src/components/familiar-tab-mcp.tsx | 61 ++++++++++- src/lib/mcp-doctor.test.ts | 129 ++++++++++++++++++++++++ src/lib/mcp-doctor.ts | 128 +++++++++++++++++++++++ src/styles/familiar-tab-mcp.css | 35 +++++++ 9 files changed, 463 insertions(+), 2 deletions(-) create mode 100644 docs/mcp-doctor.md create mode 100644 src/app/api/mcp/health/route.ts create mode 100644 src/lib/mcp-doctor.test.ts create mode 100644 src/lib/mcp-doctor.ts diff --git a/docs/mcp-doctor.md b/docs/mcp-doctor.md new file mode 100644 index 000000000..6330b464e --- /dev/null +++ b/docs/mcp-doctor.md @@ -0,0 +1,52 @@ +# MCP doctor — debugging the cave's MCP catalog + +The cave lists MCP servers in two places: the live per-harness capability scan +(what a familiar's runtime actually loaded) and the **well-known servers** grid +fed by the marketplace registry at `marketplace/exports/mcp/mcp.json`. The +registry alone says nothing about whether an entry would actually work on this +machine — the MCP doctor closes that gap. + +## What it checks + +`GET /api/mcp/health` runs `src/lib/mcp-doctor.ts` over every registry entry +and returns one honest verdict per server: + +| status | meaning | +| -------------- | -------------------------------------------------------------------------------------------------------------- | +| `ready` | remote (`http`/`sse`) endpoint answered a real JSON-RPC `initialize` probe, or the stdio launcher (`npx`, `uvx`, `docker`, …) is installed and nothing else is required | +| `needs-config` | the entry references `${PLACEHOLDER}` values (env keys, connection strings, roots) the user must supply first — nothing can be probed until then | +| `unavailable` | the endpoint did not respond, or the stdio launcher is not installed on the machine running the cave server | + +Each result carries a `detail` line and `requires`: the *names* of unmet +placeholders. Values are never read, echoed, or stored, and the doctor never +spawns a server process — stdio verification is launcher + configuration +readiness, by design (spawning ~40 `npx`/`uvx` processes from a route would +not be acceptable). + +## Where it surfaces + +In the familiar tab's **MCP & plugins** card, the well-known grid has a +**Check servers** action. It calls the route on demand (never on mount), +and pins a verdict pill on each server card; hovering shows the detail and any +unmet requirement names. Remote endpoints that answer 401/403 are `ready` — +remote MCP servers authenticate in-client via OAuth, so "sign in on connect" +is the healthy state. + +## Debugging from a terminal + +```bash +curl -s http://127.0.0.1:3000/api/mcp/health | jq '.servers[] | select(.status != "ready")' +``` + +Typical fixes: + +- `needs-config` — export the named `${PLACEHOLDER}` values in the environment + of the runtime that launches the server (the familiar's harness config, not + the cave), e.g. `GITHUB_PAT`, `COVEN_MCP_FILESYSTEM_ROOT`. +- `unavailable` (stdio) — install the launcher (`npm i -g`/`uv`/`docker`/…) + on the machine running the cave server. +- `unavailable` (remote) — endpoint outage or network/proxy issue; the probe + timeout is 5s per endpoint. + +The doctor's probe is `checkMcpEndpoint` in `src/lib/endpoint-validators.ts`, +shared with the marketplace's per-plugin **validate endpoint** action. diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index 7f36ca949..6b93f5e7d 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -70,6 +70,7 @@ export const SUITES = { "src/lib/perf/web-vitals-format.test.ts", "src/lib/app-version.test.ts", "src/lib/endpoint-validators.test.ts", + "src/lib/mcp-doctor.test.ts", "src/lib/user-profile-shared.test.ts", "src/lib/user-profile.test.ts", "src/lib/legacy-svg-avatar-hint.test.ts", diff --git a/src/app/api/api-contracts.test.ts b/src/app/api/api-contracts.test.ts index d486aff3d..a231639d4 100644 --- a/src/app/api/api-contracts.test.ts +++ b/src/app/api/api-contracts.test.ts @@ -128,6 +128,7 @@ const contracts: RouteContract[] = [ { route: "/mobile-handoff", methods: ["GET", "POST"], kind: "json", readsJson: true }, { route: "/mobile-token/refresh", methods: ["POST"], kind: "json" }, { route: "/mcp", methods: ["GET"], kind: "json" }, + { route: "/mcp/health", methods: ["GET"], kind: "json" }, { route: "/marketplace", methods: ["GET"], kind: "json" }, { route: "/marketplace/config", methods: ["GET", "POST", "DELETE"], kind: "json", readsJson: true, invalidJson: "guarded" }, { route: "/marketplace/config/validate", methods: ["POST"], kind: "json", readsJson: true, invalidJson: "guarded" }, diff --git a/src/app/api/mcp/health/route.ts b/src/app/api/mcp/health/route.ts new file mode 100644 index 000000000..8bc6ea753 --- /dev/null +++ b/src/app/api/mcp/health/route.ts @@ -0,0 +1,39 @@ +/** + * /api/mcp/health + * + * Runs the MCP doctor over the marketplace registry + * (`marketplace/exports/mcp/mcp.json`): remote (http/sse) entries get a real + * JSON-RPC `initialize` probe, stdio entries get a launcher-on-PATH check, and + * `${PLACEHOLDER}` requirements are surfaced by name. Read-only and advisory; + * never returns env/secret values and never spawns server processes. + */ + +import { NextResponse } from "next/server"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { diagnoseRegistry, systemDoctorDeps, type McpServerHealth } from "@/lib/mcp-doctor"; + +export type { McpServerHealth } from "@/lib/mcp-doctor"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export type McpHealthResponse = { + ok: boolean; + checkedAt: string; + servers: McpServerHealth[]; +}; + +const REGISTRY = path.join(process.cwd(), "marketplace", "exports", "mcp", "mcp.json"); + +export async function GET() { + const checkedAt = new Date().toISOString(); + let raw: unknown; + try { + raw = JSON.parse(await readFile(REGISTRY, "utf8")); + } catch { + return NextResponse.json({ ok: true, checkedAt, servers: [] as McpServerHealth[] }); + } + const servers = await diagnoseRegistry(raw, systemDoctorDeps); + return NextResponse.json({ ok: true, checkedAt, servers }); +} diff --git a/src/components/familiar-tab-mcp.test.ts b/src/components/familiar-tab-mcp.test.ts index 178f075c3..d833a31ff 100644 --- a/src/components/familiar-tab-mcp.test.ts +++ b/src/components/familiar-tab-mcp.test.ts @@ -94,3 +94,22 @@ test("plugin rows mirror the capabilities idiom: disabled dims + marker, mono co assert.match(src, /familiar-mcp__row-cmd" title=\{cmd\}/, "command line truncates with a tooltip"); assert.match(src, /No plugins or MCP servers yet — connect a well-known server below, or bring your own\./, "design empty copy verbatim"); }); + +test("health check is real and on-demand: GET /api/mcp/health, verdict pills, no invented states", () => { + // The doctor runs only when the user asks — never on mount — and the fetch + // is uncached so a re-check reflects the machine as it is now. + assert.match(src, /fetch\("\/api\/mcp\/health", \{ cache: "no-store" \}\)/, "health comes from the doctor route, uncached"); + assert.match(src, /\{checking \? "Checking…" : "Check servers"\}/, "busy label while the doctor runs"); + assert.match(src, /disabled=\{checking \|\| !catalog \|\| catalog\.length === 0\}/, "no dead check with an empty registry"); + // Verdicts render exactly what the route returned: status class + detail + // tooltip with unmet requirement *names* (never values). + assert.match(src, /familiar-mcp__health--\$\{h\.status\}/, "pill tone rides the doctor's verdict"); + assert.match(src, /requires \$\{h\.requires\.join\(", "\)\}/, "unmet requirement names surface in the tooltip"); + assert.match(src, /health\?\.\[server\.id\] \? : null/, "no pill until a check has run"); + assert.match(src, /Health check failed — the cave server did not respond\./, "failure is stated, not swallowed"); + assert.match(src, /Health check returned no servers — is the marketplace registry present\?/, "empty result is stated too"); + // Tones ride the semantic tokens — ready/needs-config/unavailable. + assert.match(css, /\.familiar-mcp__health--ready \{[^}]*var\(--color-success\)/, "ready rides the success token"); + assert.match(css, /\.familiar-mcp__health--needs-config \{[^}]*var\(--color-warning\)/, "needs-config rides the warning token"); + assert.match(css, /\.familiar-mcp__health--unavailable \{[^}]*var\(--color-danger\)/, "unavailable rides the danger token"); +}); diff --git a/src/components/familiar-tab-mcp.tsx b/src/components/familiar-tab-mcp.tsx index 39bed8596..fad0037ad 100644 --- a/src/components/familiar-tab-mcp.tsx +++ b/src/components/familiar-tab-mcp.tsx @@ -7,7 +7,10 @@ * "Connect custom server", the live plugin/MCP rows from the capability * manifest, and a "Well-known servers" grid fed by /api/mcp (the marketplace * registry — id/transport/target only; the registry carries no descriptions - * and we do not invent any). + * and we do not invent any). "Check servers" runs the MCP doctor + * (/api/mcp/health) on demand and pins an honest verdict on each grid card: + * ready / needs config / unavailable, with unmet requirement names in the + * tooltip — never values. * * The connect modal is honest about what the cave can do: there is no backend * that persists an MCP server connection, so the primary action copies a @@ -27,6 +30,7 @@ import { relativeTime } from "@/lib/relative-time"; import type { FamiliarSectionData } from "@/lib/familiar-tab-section-model"; import type { CapabilitiesResponse, HarnessCapabilityManifest, HarnessPlugin } from "@/app/api/capabilities/route"; import type { McpServerInfo } from "@/app/api/mcp/route"; +import type { McpHealthResponse, McpServerHealth } from "@/app/api/mcp/health/route"; import "@/styles/familiar-tab-mcp.css"; // ── Config-snippet helpers (exported for reuse; pure) ──────────────────────── @@ -77,6 +81,16 @@ function pluginCommandLine(p: HarnessPlugin): string { // ── Rows ───────────────────────────────────────────────────────────────────── +function HealthPill({ h }: { h: McpServerHealth }) { + const label = h.status === "needs-config" ? "needs config" : h.status; + const title = h.requires.length > 0 ? `${h.detail} — requires ${h.requires.join(", ")}` : h.detail; + return ( + + {label} + + ); +} + function PluginRow({ p }: { p: HarnessPlugin }) { const cmd = pluginCommandLine(p); return ( @@ -108,6 +122,9 @@ export function FamiliarMcpSection({ data }: { data: FamiliarSectionData }) { const [rescanning, setRescanning] = useState(false); const [rescanError, setRescanError] = useState(null); const [catalog, setCatalog] = useState(null); + const [health, setHealth] = useState | null>(null); + const [checking, setChecking] = useState(false); + const [healthError, setHealthError] = useState(null); const [modalOpen, setModalOpen] = useState(false); const [draft, setDraft] = useState(EMPTY_DRAFT); @@ -178,6 +195,28 @@ export function FamiliarMcpSection({ data }: { data: FamiliarSectionData }) { } }, [data.harnessId]); + // The MCP doctor probes remote endpoints and checks stdio launchers on the + // machine running the cave server — on demand only, never on mount. + const checkServers = useCallback(async () => { + setChecking(true); + setHealthError(null); + try { + const res = await fetch("/api/mcp/health", { cache: "no-store" }); + const body = (await res.json()) as McpHealthResponse; + const next: Record = {}; + for (const server of Array.isArray(body?.servers) ? body.servers : []) next[server.id] = server; + if (Object.keys(next).length === 0) { + setHealthError("Health check returned no servers — is the marketplace registry present?"); + } else { + setHealth(next); + } + } catch { + setHealthError("Health check failed — the cave server did not respond."); + } finally { + setChecking(false); + } + }, []); + const copyText = useCallback((value: string, field: "name" | "command" | "config") => { if (!value) return; let write: Promise; @@ -267,7 +306,24 @@ export function FamiliarMcpSection({ data }: { data: FamiliarSectionData }) { ) : null}
-
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); From 30ede065ea158b17904cb8a171788a6626747f18 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 23 Jul 2026 13:28:53 -0500 Subject: [PATCH 2/3] chore(sidecar): raise runtime fileCount pin for the /api/mcp/health trace CI measured 5,655 (Ubuntu) / 5,657 (Windows) with the new health route's mcp-doctor + endpoint-validators chunks; pin 5,667 per the documented ten-file-headroom convention. Byte ceiling unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/sidecar-runtime-closure.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/sidecar-runtime-closure.mjs b/scripts/sidecar-runtime-closure.mjs index ed6b848f0..49529f4ab 100644 --- a/scripts/sidecar-runtime-closure.mjs +++ b/scripts/sidecar-runtime-closure.mjs @@ -116,7 +116,11 @@ export const SIDECAR_RUNTIME_BUDGETS = Object.freeze({ // event-decoder, composer-add-menu, and subsequent alias/mobile fixes // land. Retain ten files of headroom over that measured maximum without // relaxing the byte ceiling. - fileCount: 5_654, + // 2026-07-23 (MCP doctor): the /api/mcp/health route traces the mcp-doctor + // and endpoint-validators chunks; CI measured 5,655 on Ubuntu and 5,657 on + // Windows. Retain ten files of headroom over the measured maximum without + // relaxing the byte ceiling. + fileCount: 5_667, unpackedBytes: 200 * 1024 * 1024 - 1, }); From 9b3c1df952cb35cd898509952d1d4fd6d26a0e74 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Thu, 23 Jul 2026 13:39:29 -0500 Subject: [PATCH 3/3] chore(sidecar): update remaining fileCount pins to 5,667 (tests + Rust extractor) The budget is deliberately cross-pinned: sidecar-bundle-deps.test.mjs (source regexes), sidecar-runtime-closure.test.mjs, sidecar-runtime-smoke.mjs, and the Windows archive extractor's MAX_FILE_COUNT. All now match the closure budget raised for the /api/mcp/health trace. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/sidecar-bundle-deps.test.mjs | 4 ++-- scripts/sidecar-runtime-closure.test.mjs | 4 ++-- scripts/sidecar-runtime-smoke.mjs | 2 +- src-tauri/src/sidecar_archive_manifest.rs | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/sidecar-bundle-deps.test.mjs b/scripts/sidecar-bundle-deps.test.mjs index 8de9df22a..71c534a7f 100644 --- a/scripts/sidecar-bundle-deps.test.mjs +++ b/scripts/sidecar-bundle-deps.test.mjs @@ -68,7 +68,7 @@ for (const forbiddenRoot of [ ]) { assert.match(closureSource, new RegExp(forbiddenRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `runtime verifier must exclude ${forbiddenRoot}`); } -assert.match(closureSource, /fileCount: 5_654/, "runtime closure must retain combined cross-platform headroom"); +assert.match(closureSource, /fileCount: 5_667/, "runtime closure must retain combined cross-platform headroom"); assert.match(closureSource, /unpackedBytes: 200 \* 1024 \* 1024 - 1/, "runtime closure must stay strictly below 200 MiB expanded"); // App-size: runtime bundles must drop test/dev packages and metadata that are @@ -171,7 +171,7 @@ assert.match( ); assert.match( rustArchiveSource, - /const MAX_FILE_COUNT: u64 = 5_654;/, + /const MAX_FILE_COUNT: u64 = 5_667;/, "Windows archive extractor must accept the shared runtime file-count budget", ); assert.match(manifestSource, /isSymbolicLink\(\)/, "archive input must reject symlinks"); diff --git a/scripts/sidecar-runtime-closure.test.mjs b/scripts/sidecar-runtime-closure.test.mjs index 7690291b6..d8dee36a0 100644 --- a/scripts/sidecar-runtime-closure.test.mjs +++ b/scripts/sidecar-runtime-closure.test.mjs @@ -97,10 +97,10 @@ try { await assembleSidecarRuntime(projectRoot, standaloneRoot, dependencyRoot, destination); const metrics = await verifySidecarRuntime(destination); - assert.ok(metrics.fileCount <= 5_654); + assert.ok(metrics.fileCount <= 5_667); assert.ok(metrics.unpackedBytes < 200 * 1024 * 1024); assert.deepEqual(SIDECAR_RUNTIME_BUDGETS, { - fileCount: 5_654, + fileCount: 5_667, unpackedBytes: 200 * 1024 * 1024 - 1, }); diff --git a/scripts/sidecar-runtime-smoke.mjs b/scripts/sidecar-runtime-smoke.mjs index 9eb2cf5b9..6b60e0aff 100644 --- a/scripts/sidecar-runtime-smoke.mjs +++ b/scripts/sidecar-runtime-smoke.mjs @@ -148,7 +148,7 @@ async function main() { assert.match(manifest.payloadSha256, /^[a-f0-9]{64}$/); assert.match(manifest.treeSha256, /^[a-f0-9]{64}$/); assert.match(manifest.archiveSha256, /^[a-f0-9]{64}$/); - assert.ok(manifest.fileCount > 0 && manifest.fileCount <= 5_654); + assert.ok(manifest.fileCount > 0 && manifest.fileCount <= 5_667); assert.ok(manifest.archiveBytes > 0 && manifest.archiveBytes <= 80 * 1024 * 1024); assert.ok(manifest.unpackedBytes > 0 && manifest.unpackedBytes < 200 * 1024 * 1024); extractedSidecarRoot = await mkdtemp(path.join(os.tmpdir(), "coven-cave-sidecar-archive-")); diff --git a/src-tauri/src/sidecar_archive_manifest.rs b/src-tauri/src/sidecar_archive_manifest.rs index 4b91cbf65..0ca16d8a8 100644 --- a/src-tauri/src/sidecar_archive_manifest.rs +++ b/src-tauri/src/sidecar_archive_manifest.rs @@ -6,7 +6,7 @@ pub(super) const MANIFEST_SCHEMA_VERSION: u32 = 3; pub(super) const ARCHIVE_FORMAT: &str = "tar.zst"; pub(super) const MAX_ARCHIVE_BYTES: u64 = 80 * 1024 * 1024; pub(super) const MAX_UNPACKED_BYTES: u64 = 200 * 1024 * 1024 - 1; -pub(super) const MAX_FILE_COUNT: u64 = 5_654; +pub(super) const MAX_FILE_COUNT: u64 = 5_667; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)]