An MCP server that keeps a jest or vitest watch process warm and lets a coding agent pilot it: rerun all, rerun failed, filter by file/test name, and read structured results — without paying cold-start on every run.
Agents normally run jest / vitest run fresh each time, eating the full cold start (transform + module graph) on every check. Watch mode keeps that warm, but its command channel is keyboard-only and dies without a TTY. This server gives the watcher a real PTY (via node-pty) and exposes the commands as MCP tools, so the agent gets watch-mode speed through normal tool calls.
Requires Node ≥ 20. node-pty builds a native addon on install (needs a C++ toolchain; prebuilds cover common platforms).
The recommended setup uses npx (below), which runs the server from npm's own cache — node-pty builds there regardless of your project's package manager, so pnpm and yarn users need no extra steps.
The native build only matters if you install test-warden as a project dependency. With pnpm v10 that build is skipped by default (server then fails with Failed to load native module: pty.node); approve it once with pnpm approve-builds (select node-pty) and pnpm rebuild node-pty, or add "pnpm": { "onlyBuiltDependencies": ["node-pty"] } to package.json and reinstall.
The fastest setup — run once in your project root to register the MCP server (.mcp.json) and the failure hook (.claude/settings.json), merging into any existing config:
npx -y test-warden initinit also bootstraps test-warden.config.js in the project root — a best-effort detection of your setup (jest or vitest, plus any inline env in the test script) that you then own and edit. See Configuration: the file is required, and the server validates it on startup and refuses to start without it.
Or configure the MCP client manually:
Everything the server knows about your tests lives in one file, test-warden.config.js, next to .mcp.json in the project root. There is no runtime auto-detection: the server reads and validates this file once at startup (zod schema) and exits with an explanatory error if it is missing or invalid — a wrong config fails loudly instead of guessing.
// test-warden.config.js — one entry per package/workspace whose tests can be run
export default [
{
dir: ".",
runner: "vitest",
args: "",
env: { TZ: "UTC" },
},
{
dir: "packages/api",
runner: "jest",
args: "--config jest.integration.config.js",
env: { DATABASE_URL: "postgres://localhost:5432/test" },
bin: "packages/api/tools/jest-wrapper",
},
];The file is a JS module (use module.exports = [...] in CommonJS projects, export default [...] under "type": "module") exporting an array of entries — at least one. Keys:
| Key | Required | Meaning |
|---|---|---|
dir |
no (default ".") |
Directory the tests run in, relative to this file. This is the cwd tools target; one watch session per entry. Monorepo: one entry per package. |
runner |
yes | "jest" or "vitest" — the only supported runners. The server drives the runner's own watch mode, so the entry must name which one. |
args |
no (default "") |
Extra CLI flags appended to every run in this dir — custom config file (--config …), --runInBand, a project selector, etc. |
env |
no (default {}) |
Env vars (string values) set for the runner. Put here what your test script or a .env file normally provides (TZ, NODE_OPTIONS, database URLs, …); bootstrap pre-fills inline vars from the test script. |
bin |
no | Path to the runner binary, relative to this file — for wrappers or unusual layouts. Default: the nearest node_modules/.bin/<runner> walking up from dir (hoisted monorepo installs resolve). |
Unknown keys are rejected (validation catches typos). To (re)generate the file from detection, run npx test-warden bootstrap — unlike init (which only writes it when absent), bootstrap overwrites the existing file and prints a warning when it does. After editing the config, restart the MCP server (e.g. reconnect the client) so it re-reads the file.
Note for agents/LLMs editing this file: to make a new package's tests runnable, append an entry with its
dirandrunner; to fix a failing setup, adjustargs/env/binon the existing entry — never invent other keys. The server error message tells you which entry and key failed validation.
Every dir in test-warden.config.js is watched automatically from server startup — start_watch is rarely needed (use it to restart a session or pass per-call args/env). The other tools auto-start a watch if none is running.
| Tool | Args | Does |
|---|---|---|
start_watch |
cwd, args?, env? |
Restart a warm watch session in cwd (already auto-started at boot), which must match a configured dir. args/env extend that entry per-call. |
run_all |
cwd? |
Rerun the whole suite. |
run_failed |
cwd? |
Rerun only previously failed tests. |
run_filtered |
pattern, by (path|name), cwd? |
Rerun tests matching a filter. |
get_results |
cwd? |
Latest run as JSON: { total, passed, failed, suitesFailed, ok, failures[] }. |
tail_log |
cwd? |
Recent raw watcher output (debugging). |
stop_watch |
cwd? |
Stop one session, or all when cwd is omitted. |
get_results reads jest's AggregatedResult (via a bundled reporter) and vitest's --reporter=json — normalized to the same shape.
Env vars: tests often rely on env set outside jest/vitest config (e.g. "test": "TZ=UTC jest" for stable dates, or a .env loaded by dotenv-cli). The server launches the runner binary directly, so those vars must be listed in the config entry's env — bootstrap pre-fills the inline ones (including a cross-env prefix) from the test script; add file-loaded ones by hand. Env set inside jest/vitest config already works. For one-off overrides, pass env to start_watch.
Suite setup/teardown: watchers are stopped gracefully — the server presses q in the watch UI and waits for the runner to exit — so globalSetup teardowns run to completion (a postgres started by your vitest setup gets stopped and releases its port before anything else starts). A hard kill only happens if the runner ignores q for 10s (TEST_WARDEN_QUIT_GRACE_MS overrides). This applies to stop_watch, restarts, the 30-minute idle stop, and session shutdown alike.
Crashes: a watcher can die for good — a globalSetup that can't reach a migrated
database, a new test importing a dependency that isn't installed yet, an OOM. Its exit
reaps the session and its log, so instead of answering "no watch session" (cause already
gone) or reporting the aborted run's empty JSON as a green suite, every tool then returns
the exit code plus the watcher's last output, and says to fix the cause and rerun. The
next run_all (no cwd needed) reinitializes the watch from cold, so the agent recovers
in one round-trip; if the cause isn't fixed, it gets the fresh crash output again.
Monorepos: one warm session per cwd, so you can watch several at once (e.g. mobile on jest and api on vitest concurrently). Add one test-warden.config.js entry per package (dir: "packages/app", …). The cwd arg on the other tools picks which session — omit it when only one is running. The failure hook reports each workspace independently, so a green run in one never hides a red run in another.
The bundled hooks are Claude Code specific. The MCP server (the tools above) is agent-agnostic standard MCP; only the optional hooks below target Claude Code. They emit Claude's
additionalContextenvelope, the only hook channel that reaches the model. Agent-specific output lives behind one function,hooks/emit.mjs— that's the seam to branch for another agent later.
MCP servers can't push to the agent on their own — the agent only reacts to a tool
return or a client-side hook. So failures are surfaced via a bundled PostToolUse
hook: after any tool call, it peeks at the latest run and, if a new run is failing,
injects a one-line note into the agent's context (non-blocking — the agent decides).
Add to your project's .claude/settings.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/test-warden/notify-on-fail.mjs\""
}
]
}
]
}
}Fires once per failing run (deduped by results-file mtime); silent while green. npx test-warden init adds this for you — it copies the hook files into .claude/hooks/test-warden/ so the commands are stable and committable (no dependence on node_modules layout or the npx cache). Installed as a dependency, node node_modules/test-warden/hooks/notify-on-fail.mjs works too.
A second bundled hook, nudge-watch.mjs (matcher Edit|Write, also Claude Code specific), removes the "did I start the watcher?" step: when you edit a file inside a dir that test-warden.config.js declares testable but that isn't being watched, it nudges the agent to call start_watch for that exact entry (cwd + configured runner). It follows the config only — silent for files outside every configured dir; if the config is invalid, it surfaces the validation error to the agent instead, so it gets fixed. A hook can't call an MCP tool or reach the server's in-memory session, so it can't start the watcher itself — it prompts the agent, which then makes the call. npx test-warden init wires it up:
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/test-warden/nudge-watch.mjs\"" }
]
}The PostToolUse notify hook above surfaces failures during a turn, but it always
runs one tool call behind the watcher: a broken edit followed immediately by "done"
(no further tool call) slips past it, and the red run only surfaces on the next turn.
A third bundled hook, block-on-fail.mjs, closes that gap on the Stop event —
the moment the agent tries to end its turn. It waits out any in-flight run (via the
.running marker the server drops while a run is executing), reads the fresh results,
and if any suite is red, blocks the stop and hands the failures back so the agent
fixes them this turn instead of you finding them next turn. npx test-warden init
wires it up:
{
"Stop": [
{
"hooks": [
{ "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/test-warden/block-on-fail.mjs\"" }
]
}
]
}It blocks at most once per turn (it no-ops when stop_hook_active is set), so the
agent is never trapped — one forced round-trip, then it may finish. Silent while
green, and gated on the same live-watcher check as notify (a dead session's leftover
results never block). Cost: a ~500ms debounce on every turn end (even green ones), the
window the watcher needs to start a run after the agent's last edit — lower DEBOUNCE_MS
in the hook if it drags.
Warm watchers are real processes with real side effects (a suite that boots a database binds its port), so stale ones must never outlive their session. Three layers, newest session always wins:
- The server dies with its client. When the session ends (stdin closes, or SIGTERM/SIGINT), the server kills its watchers, removes their live markers, and exits — no zombie keeps rerunning tests after the session is gone.
start_watchtakes over. If another live server still watches the target dir (a session you forgot open), the new server SIGTERMs it — triggering that same cleanup — and starts its own watcher.- A
SessionStarthook surfaces leftovers up front.reset-watch.mjs(matcherstartup|resume, wired byinit) detects watchers other sessions still hold on this project before the new session runs anything. It never kills by itself — the other session may be a window you're still using — it tells the agent to ask you first, explaining the caveat of keeping it (the old watcher auto-reruns tests on every edit, colliding on shared resources like a fixed DB port). Markers of dead servers are reaped silently.
Out of band from any MCP client, these subcommands read the on-disk instance markers to inspect or kill the running servers (useful for the zombie/collision cases in Session lifecycle):
npx test-warden ls # list running servers: PID, watched dir, last-results age
npx test-warden kill <dir> # SIGTERM the server watching <dir> (graceful — teardowns run)
npx test-warden kill --all # SIGTERM every running server
npx test-warden logs <dir> # print the watcher's raw log
npx test-warden logs <dir> -f # follow it (Ctrl-C to stop)- POSIX only: the watcher is launched through
/bin/sh, so only macOS and Linux are supported. Windows is not. - A run takes a moment; after a
run_*call, pollget_resultsuntil it returns counts.
MIT