Skip to content

feat(mcps): probe MCP servers for real, and tell the agent what is dead - #131

Merged
NagyVikt merged 1 commit into
mainfrom
feat/mcp-health-deep-probe
Aug 7, 2026
Merged

feat(mcps): probe MCP servers for real, and tell the agent what is dead#131
NagyVikt merged 1 commit into
mainfrom
feat/mcp-health-deep-probe

Conversation

@NagyVikt

@NagyVikt NagyVikt commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

The bug this fixes

cue mcps health could not detect a dead server. cmdHealth() read the config's command field and ran which on it:

const check = spawnSync("which", [expandedCmd.split("/").pop() ?? cmd], ...)

A wrapper-launched MCP — bash -lc '… exec …/python -m x', npx …, docker … — therefore probed as bash/npx/docker, which always exist. Every such server reported ✅ in a few milliseconds regardless of whether it could start.

Not hypothetical: secret-mcp was declared by eight profiles while its interpreter was a dangling symlink to an uninstalled Python. Green for weeks (see #124). Running the new probe on this machine's profile also found dataforseo down (exited with code 254: npm error) — the old check had been passing it as healthy.

The failure mode is worse than a wrong dashboard. A broken MCP is invisible to the agent: the tools never appear, so it concludes the capability does not exist, works around it, or tells the user it is unavailable. Nobody ever learns the server is broken.

What changed

src/lib/mcp-probe.ts — spawn the server and speak MCP to it: initialize, then tools/list. "Up" means it completed the handshake, and the tool count comes back with it. "Down" carries a reason (exit code + last stderr line, or the timeout).

findMissingExecutable() — turns "no response" into missing executable: <path> by scanning a wrapper's argv for absolute paths that do not resolve. This is what catches the dangling-symlink case specifically, and it is what makes the report actionable instead of just red.

cue mcps health changes

  • Probes concurrently (a dozen servers would otherwise serialise into a minute of waiting)
  • Prints tool counts and a reason line under anything down
  • --shallow keeps the old fast path, now with the wrapper check in front so it is at least not actively misleading
  • Exits 1 when anything is down, so CI and hooks can gate on it

New SessionStart hook mcp-health-check.sh — runs the probe once a day; if anything is down it names it in context, along with any skills whose requires.mcps lists it, plus the removal command. Fail-open, throttled by ~/.config/cue/mcp-health-stamp, disabled with CUE_MCP_HEALTH_OFF=1. It offers the removal; it never runs it.

Verified on this machine

MCP Health Check (4 servers):

  ❌ dataforseo (8003ms)
      no MCP response within 8000ms
  ✅ codegraph (59ms, 9 tools)
  ✅ context7 (320ms, 2 tools)
  ✅ headroom (561ms, 3 tools)

1 server(s) down. Remove one with:  cue mcps remove <id>

The old check reported all four ✅ in 2–7ms.

Hook output, with the skills cross-reference exercised against secret-mcp:

⚠️  MCP health: 1 server(s) in this profile are not running.

   ✗ dataforseo — exited with code 254: npm error …

   ↳ skill medusa/higgsfield-to-medusa-products needs secret-mcp
   ↳ skill medusa/woocommerce-to-medusa-import needs secret-mcp

Second run in the same day: silent, as intended.

Test plan

  • bun test src/lib/mcp-probe.test.ts8 pass / 0 fail. Covers the dangling-symlink-inside-bash -lc shape, a wrapper whose interpreter exists, a missing absolute command, PATH-resolved commands, a fake server that completes the handshake (asserts tool count), a process that exits immediately, a broken interpreter reported by name rather than as a bare timeout, and the no-command case.
  • tsc --noEmit — clean
  • biome lint — clean
  • bash -n on the hook, JSON validates
  • Live run against this machine's profile (above)
  • shellcheck — not installed here; worth a CI pass

Note

One jq bug was caught during hook testing and fixed: select(type == "object" and (.requires.mcps // []) | index($id)) parses as select((… and …) | index(…)) because | binds looser than and, so jq piped a boolean into index() and died. With 2>/dev/null on the call it failed silently — producing no skill list rather than an error. The parens are now load-bearing and commented as such.

🤖 Generated with Claude Code

`cue mcps health` could not detect a dead server. cmdHealth read the config's
`command` field and ran `which` on it — so a wrapper-launched MCP probed as
`bash`, `npx` or `docker`, which always exist. Every such server reported ✅ in
a few milliseconds regardless of whether it could start.

That is not hypothetical: secret-mcp was declared by eight profiles while its
interpreter was a dangling symlink to an uninstalled Python. It reported green
for weeks. Running the new probe against this machine's profile also found
dataforseo down — `exited with code 254: npm error` — which the old check had
been passing as healthy.

The failure mode is worse than a wrong dashboard. A broken MCP is invisible to
the agent: the tools never appear, so it concludes the capability does not
exist and works around it, or tells the user it is unavailable. Nobody learns
the server is broken.

So:

- `src/lib/mcp-probe.ts` — spawn the server and speak MCP to it: `initialize`,
  then `tools/list`. Up means it completed the handshake, and the tool count
  comes back with it. Down carries a reason (exit code + last stderr line,
  or the timeout).
- `findMissingExecutable()` turns "no response" into "missing executable:
  <path>" by scanning a wrapper's argv for absolute paths that do not resolve.
  This is what catches the dangling-symlink case specifically.
- `--shallow` keeps the old fast path for hot loops, now with the wrapper
  check in front of it so it is at least not actively misleading.
- `health` exits 1 when anything is down, so CI and hooks can gate on it.
- New SessionStart hook `mcp-health-check.sh`: runs the probe once a day and,
  if anything is down, names it in context along with any skills whose
  `requires.mcps` lists it, plus `cue mcps remove <id>`. Fail-open, throttled
  by a stamp file, disabled with CUE_MCP_HEALTH_OFF=1.

Verified on this machine: probe reports codegraph 9 tools, context7 2 tools,
headroom 3 tools, dataforseo down. Hook emits the warning, writes its stamp,
and stays silent on the second run. 8 unit tests cover the dangling-symlink
shape, a handshaking fake server, immediate exit, and the no-command case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant