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
52 changes: 52 additions & 0 deletions docs/mcp-doctor.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions scripts/sidecar-bundle-deps.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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");
Expand Down
6 changes: 5 additions & 1 deletion scripts/sidecar-runtime-closure.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
4 changes: 2 additions & 2 deletions scripts/sidecar-runtime-closure.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
2 changes: 1 addition & 1 deletion scripts/sidecar-runtime-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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-"));
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/sidecar_archive_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
1 change: 1 addition & 0 deletions src/app/api/api-contracts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
39 changes: 39 additions & 0 deletions src/app/api/mcp/health/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
19 changes: 19 additions & 0 deletions src/components/familiar-tab-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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\] \? <HealthPill h=\{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");
});
61 changes: 59 additions & 2 deletions src/components/familiar-tab-mcp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) ────────────────────────
Expand Down Expand Up @@ -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 (
<span className={`familiar-mcp__pill familiar-mcp__health familiar-mcp__health--${h.status}`} title={title}>
{label}
</span>
);
}

function PluginRow({ p }: { p: HarnessPlugin }) {
const cmd = pluginCommandLine(p);
return (
Expand Down Expand Up @@ -108,6 +122,9 @@ export function FamiliarMcpSection({ data }: { data: FamiliarSectionData }) {
const [rescanning, setRescanning] = useState(false);
const [rescanError, setRescanError] = useState<string | null>(null);
const [catalog, setCatalog] = useState<McpServerInfo[] | null>(null);
const [health, setHealth] = useState<Record<string, McpServerHealth> | null>(null);
const [checking, setChecking] = useState(false);
const [healthError, setHealthError] = useState<string | null>(null);

const [modalOpen, setModalOpen] = useState(false);
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
Expand Down Expand Up @@ -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<string, McpServerHealth> = {};
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);
}
}, []);
Comment on lines +200 to +218

const copyText = useCallback((value: string, field: "name" | "command" | "config") => {
if (!value) return;
let write: Promise<void>;
Expand Down Expand Up @@ -267,7 +306,24 @@ export function FamiliarMcpSection({ data }: { data: FamiliarSectionData }) {
) : null}

<div className="familiar-mcp__catalog">
<div className="familiar-mcp__catalog-title">Well-known servers</div>
<div className="familiar-mcp__catalog-title-row">
<div className="familiar-mcp__catalog-title">Well-known servers</div>
<Button
variant="ghost"
size="xs"
leadingIcon="ph:heartbeat"
onClick={checkServers}
disabled={checking || !catalog || catalog.length === 0}
aria-label="Check server health"
>
{checking ? "Checking…" : "Check servers"}
</Button>
</div>
{healthError ? (
<p className="familiar-mcp__catalog-note familiar-mcp__status-error" role="alert">
{healthError}
</p>
) : null}
{catalog === null ? (
<p className="familiar-mcp__catalog-note">Loading registry…</p>
) : catalog.length === 0 ? (
Expand All @@ -278,6 +334,7 @@ export function FamiliarMcpSection({ data }: { data: FamiliarSectionData }) {
<div key={server.id} className="familiar-mcp__server">
<span className="familiar-mcp__server-name">{server.id}</span>
<span className="familiar-mcp__pill">{server.transport}</span>
{health?.[server.id] ? <HealthPill h={health[server.id]} /> : null}
{server.target ? (
<span className="familiar-mcp__server-target" title={server.target}>
{server.target}
Expand Down
Loading
Loading