feat(mcps): probe MCP servers for real, and tell the agent what is dead - #131
Merged
Conversation
`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>
This was referenced Aug 7, 2026
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
The bug this fixes
cue mcps healthcould not detect a dead server.cmdHealth()read the config'scommandfield and ranwhichon it:A wrapper-launched MCP —
bash -lc '… exec …/python -m x',npx …,docker …— therefore probed asbash/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, thentools/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" intomissing 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 healthchanges--shallowkeeps the old fast path, now with the wrapper check in front so it is at least not actively misleadingNew 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 whoserequires.mcpslists it, plus the removal command. Fail-open, throttled by~/.config/cue/mcp-health-stamp, disabled withCUE_MCP_HEALTH_OFF=1. It offers the removal; it never runs it.Verified on this machine
The old check reported all four ✅ in 2–7ms.
Hook output, with the skills cross-reference exercised against
secret-mcp:Second run in the same day: silent, as intended.
Test plan
bun test src/lib/mcp-probe.test.ts— 8 pass / 0 fail. Covers the dangling-symlink-inside-bash -lcshape, 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— cleanbiome lint— cleanbash -non the hook, JSON validatesNote
One jq bug was caught during hook testing and fixed:
select(type == "object" and (.requires.mcps // []) | index($id))parses asselect((… and …) | index(…))because|binds looser thanand, so jq piped a boolean intoindex()and died. With2>/dev/nullon 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