First-line self-help for the most common mcp-coordinator failure modes.
Each entry is symptom -> likely cause -> fix. If none of these match,
open an issue on GitHub
with the relevant log lines (~/.mcp-coordinator/logs/server.log in daemon
mode).
References:
docs/onboarding-self-host.md – full Phase 2 setup walkthrough.env.example – environment-variable referencedocs/ops/key-rotation.md – JWT signing key rotationQuick facts you will need below:
~/.mcp-coordinator/ (PID file, logs, SQLite data).127.0.0.1 (loopback), not
0.0.0.0.Symptom. server start (or the daemon log) shows
Error: listen EADDRINUSE: address already in use 127.0.0.1:3100 (or
:1883), and the process exits. Since issue #273 the daemon-mode CLI waits
for the child to accept a connection before saying anything, so it reports
Coordinator failed to start with the tail of the log and exits non-zero.
Older releases printed Coordinator started in background and exited 0 –
on those, the crash is only visible in ~/.mcp-coordinator/logs/server.log.
A bind can also be refused rather than busy: a command launched from an agent
running under a sandboxed Bash may be denied the listen outright. It lands in
the same place, with the daemon’s own error at the end of server.log.
Since issue #280, only 3100 is fatal. A busy MQTT port no longer stops the
coordinator: it logs
MQTT broker failed to start - continuing without MQTT at warn and carries on.
The three MQTT tools (wait_for_message, get_queued_messages, mqtt_publish)
then report themselves unavailable; the other 23 tools, the REST API, the
dashboard SSE feed and every stdio-mode session are unaffected. If you would
rather the boot fail loudly, set COORDINATOR_MQTT_REQUIRED=true.
So a coordinator that starts but whose MQTT tools all refuse is usually this,
not a broken install – grep server.log for that warning before looking
further.
Likely cause. Both listeners fail closed on bind: the HTTP server
(httpServer.listen(port, bindHost)) and the embedded MQTT TCP broker
(tcpServer.listen(tcpPort, "127.0.0.1")) each reject on the error event,
so a busy port aborts boot. Usually a previous coordinator is still running,
or another service holds 3100/1883.
Fix. Point at free ports, or free the busy one:
# Move the HTTP port
mcp-coordinator server start --port 3200
# Move the MQTT TCP port
COORDINATOR_MQTT_TCP_PORT=1884 mcp-coordinator server start
To find and stop the current holder, use mcp-coordinator server stop
(see section 3) before re-launching, or inspect the port with lsof -i :3100
(macOS/Linux) / netstat -ano | findstr :3100 (Windows).
These guards fire only when COORDINATOR_OAUTH_ENABLED=true. Phase 1
(the default) does not require them.
Symptom: secret entropy: .... Boot aborts with e.g.
secret entropy: contains dictionary word "changeme",
secret entropy: all bytes identical, or
secret entropy: 74.2 bits estimated, minimum 128.
assertSecretEntropy() rejects COORDINATOR_JWT_SECRET (and a
rotation COORDINATOR_JWT_SECRET_PREV, if set) below 128 bits of
estimated Shannon entropy, plus an explicit blocklist of dictionary words
(secret, password, changeme, default, …).export COORDINATOR_JWT_SECRET="$(openssl rand -base64 32)".Symptom: BootValidationError: ... must be set. Boot aborts naming a
missing variable.
COORDINATOR_JWT_SECRET and
COORDINATOR_PUBLIC_URL are required, and at least one IdP provider
must be configured. Provider credentials are both-or-neither
(COORDINATOR_GITHUB_CLIENT_ID + ..._CLIENT_SECRET; Google client
id/secret; the three COORDINATOR_OIDC_* vars together). A configured
GitHub OAuth app also requires COORDINATOR_GITHUB_ORG (the membership
allowlist).docs/onboarding-self-host.md and .env.example.Symptom: OAuth (Phase 2) is not supported on the Bun runtime.
better-sqlite3 APIs absent from bun:sqlite. Boot
fails fast rather than crashing later inside a request handler.COORDINATOR_OAUTH_ENABLED unset on the standalone binary.server stop says “no PID” or the server seems stuckSymptom: No server PID file found. Is the server running? (exit 1).
~/.mcp-coordinator/server.pid. The server was never started
via the CLI, or the file was already cleaned up.curl -s localhost:3100/livez); if
it is, it was started outside the CLI – stop it by its OS process.Symptom: Server (PID N) is not running. Cleaning up PID file.
kill -9‘d). In daemon mode
the PID file is written immediately when the child is spawned, so a child
that dies during boot leaves the file pointing at a dead PID.stop removes the stale file for you. You can now
start cleanly.Symptom: Server did not stop gracefully. Sending SIGKILL.
SIGTERM, then waits (default 5s) for the
ordered teardown – Phase 2 audit drain, HTTP, MQTT, DB – before
escalating to SIGKILL. A slow audit/quota flush can exceed the grace
window.mcp-coordinator server stop --timeout 30 # allow a slow audit flush
mcp-coordinator server stop --force # SIGKILL now, no graceful shutdown
If all else fails, delete ~/.mcp-coordinator/server.pid by hand and stop the
OS process directly.
The coordinator embeds an MQTT broker (Aedes). See the connection contract in
docs/ for the full topic list. Common connection failures:
Symptom: connection refused / times out from another machine or a LAN IP.
127.0.0.1 explicitly (loopback only) –
IPv6 (::) default binding was dropped because clients that resolve
localhost -> 127.0.0.1 would hang. It is not reachable off-host.mqtt://127.0.0.1:1883 from the same host. For remote
access, front the WebSocket endpoint (ws://localhost:3100/mqtt) with a
reverse proxy that terminates TLS – do not expose the raw port.Symptom: WebSocket handshake fails (browser / strict WS client).
/mqtt); it does not negotiate an MQTT subprotocol. A client that
sends Sec-WebSocket-Protocol: mqtt expects the server to echo it back,
and strict clients fail the handshake when it does not.ws://localhost:3100/mqtt without requesting the
mqtt subprotocol. Keep frames under the 1 MiB cap.Symptom: CONNECT rejected (MQTT auth rejected in the log).
COORDINATOR_AUTH_ENABLED=true, every CONNECT must carry a
Phase-1 JWT in the password field (username is ignored); an empty
password is rejected. Note a Phase-2 OAuth token will not work for
MQTT – the broker verifies only the Phase-1 HS256 secret.POST /api/auth/register
({ agent_name, registration_secret }, needs
COORDINATOR_REGISTRATION_SECRET) and pass it as the CONNECT password:
mosquitto_sub -h 127.0.0.1 -p 1883 -u agent -P "$JWT" -t 'coordinator/default/#'.
With auth disabled (the default) connect anonymously, no credentials.Symptom: connected, subscribed, but no messages arrive.
default (coordinator/default/...); there is no per-org routing yet.
Subscribing to any other org path – or using a token whose org claim is
not default, which the subscribe ACL then blocks – receives nothing.coordinator/default/# (registration tokens already
carry org=default, matching the publisher)./readyz returns not_ready (503)Symptom. GET /readyz returns HTTP 503 with
{"status":"not_ready","checks":{...}}. (/livez stays 200 – it never
checks downstream deps, so orchestrators do not restart the pod over a
transient dependency blip.)
Likely cause. Only db and mqtt gate readiness (tree_sitter
and git_cochange are reported but optional). Look at the failing check:
checks.db.ok = false – the SELECT 1 probe threw: DB handle closed, or
the SQLite file is locked beyond busy_timeout (or not writable).checks.mqtt.ok = false with error: "not connected" – the internal MQTT
bridge is not connected to the embedded broker, typically because the
broker failed to bind its TCP port (see section 1).Fix. Inspect the failing check and its error string, then resolve the
underlying dependency – free the MQTT port, or make the data directory
writable. /readyz flips back to 200 ready once both are green.
git_cochange reports unavailable (does not cause the 503)Layer 4 never gates readiness, so this check explains a degraded conflict detector, not a failing pod:
"git_cochange": {
"available": false,
"status": "false",
"last_error": "SQLITE_READONLY: attempt to write a readonly database",
"scheduler_circuit_open": true,
"optional": true
}
status distinguishes the cases: "false" (no .git, a shallow clone, or
git log failed), "stale_partial" (git log timed out – earlier results
are still served), "unavailable" (never built; normal when
COORDINATOR_REPO_ROOT is unset, since Layer 4 is opt-in).last_error is the reason for the most recent failure. It is cleared from
the response as soon as a build succeeds, so anything shown here is live.scheduler_circuit_open is the one that needs you. Five consecutive
database failures stop the refresh loop permanently – the same
threshold the sweeper uses. Layer 4 keeps answering from its last
successful build and silently goes stale. Fix what last_error names,
then restart the coordinator; there is no runtime reset.Git-side failures never reach the breaker: build() handles those itself and
simply retries at the normal refresh interval. A tripped breaker always means
SQLite.
Metrics: mcp_coordinator_git_cochange_scheduler_failures_total,
mcp_coordinator_git_cochange_scheduler_circuit_open, and
mcp_coordinator_git_cochange_builds_total{outcome="scheduler_error"}.
Symptom. REST or MCP requests return 401 with a
WWW-Authenticate: Bearer realm="coordinator", error="invalid_token" header.
Likely cause. COORDINATOR_AUTH_ENABLED=true requires a valid Bearer JWT
on protected routes; the token is missing, malformed, or expired. A common
gotcha: EventSource/SSE cannot set headers, so a browser /api/events
connection 401s unless the token is passed as a query param.
Fix.
Authorization: Bearer <jwt> on REST/MCP calls.GET /api/events?token=<jwt>.POST /api/auth/register (needs
COORDINATOR_REGISTRATION_SECRET). If the token expired, register again./metrics? That endpoint uses its own optional
COORDINATOR_METRICS_BEARER, separate from agent tokens.pnpm: command not found / CorepackSymptom. Building your own image (or running pnpm locally) fails with
pnpm: command not found or a Corepack error, even though packageManager
is pinned in package.json.
Likely cause. Node 25+ no longer bundles Corepack. The provided
Dockerfile (Node 26 alpine) installs it explicitly before enabling it.
Fix. Install Corepack, then enable it so the pinned pnpm version is used:
RUN npm install -g corepack@latest && corepack enable
Locally, run corepack enable once and let it resolve the right pnpm.
Symptom. Running the downloaded binary shows “cannot be opened because the developer cannot be verified”, or it is silently killed on first launch.
Likely cause. The release binaries are Bun-compiled and ad-hoc signed
(codesign --sign -), not notarized by Apple. Gatekeeper quarantines
unnotarized downloads and blocks the first run.
Fix. Clear the quarantine attribute, then run it:
xattr -d com.apple.quarantine ./mcp-coordinator
./mcp-coordinator --version
(Or right-click the binary in Finder and choose Open once to approve it.) Two related macOS notes: OAuth is unsupported on this Bun binary (section 2), and the Anthropic quota reader is macOS-only (it reads the Keychain; on Linux/Windows the quota endpoint returns 503 fail-open).
list_agents(online_only: true), coordinator_status and wait_for_peers all
report agents that are flagged online and have been seen recently.
“Seen” is refreshed by real work, not only by the explicit heartbeat tool:
announce_work and post_to_thread both count. An agent that has done none of
those for longer than the TTL drops out of the online list.
The TTL defaults to 900 seconds (15 minutes) and is tunable:
COORDINATOR_AGENT_ONLINE_TTL_SECONDS=1800 # more patient
Two things this deliberately does not do:
list_agents without online_only,
and the dashboard’s agent list, still show the agent — this is a liveness
filter, not a cull.If an agent vanishes mid-task, it is idle longer than the TTL between
coordination calls: raise the value, or have it call heartbeat periodically.
register_agent fails with “already registered in org …”Symptom. Registering an agent id that works fine in one org is rejected in another:
agent id 'builder' is already registered in org 'acme'. Agent ids are globally
unique in this release, not per-org — choose a different id.
Cause. You are on a pre-v11 schema. The agents primary key was already
(org_id, id), but a global UNIQUE index on agents(id) survived because
five foreign keys referenced agents(id) rather than the composite key — so
ids were effectively global. (Older still: the same situation surfaced as a raw
UNIQUE constraint failed: agents.id, naming neither the id nor the owning
org.)
Fix. Upgrade. Schema v11 (#231)
rewrites those foreign keys as (org_id, <col>) REFERENCES agents(org_id, id)
and drops the global index: agent ids are now unique within an org, and two
orgs may both use builder. The migration runs automatically on first open. To
stay on the old schema, keep prefixing ids with the org (acme-builder).
Two things the upgrade can report:
Cannot migrate to per-org agent ids (issue #231): rows reference agent ids
that do not exist in 'agents' … — a row points at an agent absent from
agents, so there is no org to re-parent it to. Such a database already
violates the current foreign key, and would have failed boot anyway — the
whole-database integrity check described in section 8e catches it too. Boot
refuses rather than delete your rows; the message carries
per-table counts. Back up the data directory, then either re-create the
missing agent rows / remove the orphaned ones yourself, or set
COORDINATOR_ALLOW_MIGRATION_REPAIR=true and restart: boot then recreates
the missing agents for you — one per dangling id, in the org most of its
referencing rows carry, named <id> (recovered by migration v11) and
offline. Nothing is deleted either way. Unset the variable once you are
through; it is an unblock switch, not a setting. The repair is recorded in
audit_log under migration.agent_fk_recover (per-table row counts, how
many agents were recreated, and a sample of up to 50).migration.agent_fk_reparent row in audit_log — rows whose org_id
disagreed with their agent’s actual org (possible only because the old
foreign key never checked it) were corrected. Informational, written once,
with per-table counts in metadata_json.claim-task returns success: false with a conflict fieldSymptom. A claim is refused even though the thread itself is unclaimed —
claimed_by is null, assigned_to is null, status is "open":
{ "success": false, "claimed_by": null, "assigned_to": null, "status": "open",
"conflict": { "thread_id": "…", "files": ["src/shared.ts"] } }
Cause. Another agent already holds a different thread whose
target_files overlap yours. The claim CAS used to predicate on thread id
alone, so two agents could claim two different threads touching the same file
and both start editing it. The overlap guard closes that
(#258).
conflict names the thread holding the files and which files collided — the
other fields all look normal precisely because nothing is wrong with your
thread.
Fix. Nothing to repair: claim a different thread and come back. Clients that
already handle success: false by refetching need no change. One agent holding
two overlapping threads is still allowed — a single worker serializes itself.
Symptom. Startup fails with a message naming one or more constraints and row counts, e.g.:
The database has 3 foreign key violation(s) this migration did not create and
cannot repair — thread_messages.thread_id -> threads (3 rows). These rows
already violate the current schema, so this is a data problem rather than a
coordinator bug. Back up the data directory, list them with
`sqlite3 <data-dir>/coordinator.db 'PRAGMA foreign_key_check;'`, then delete or
re-point them and restart. Aborting; the database is left unchanged.
Cause. The v0.9 schema migration ends with a PRAGMA foreign_key_check
over the whole database, and the boot sequence re-enters that migration on
every start — so in practice it is the coordinator’s startup integrity gate,
not a one-off migration self-check. Any violation anywhere in the file stops
boot, fail-closed.
A violation usually means rows were written while foreign keys were off, or a parent row was deleted directly in SQLite. The message distinguishes the two possible culprits:
org_id -> orgs violation on a table
the migration just rebuilt) — that one is ours. Please
open an issue with the
full message.Before this was split out (#285) every violation was reported as “this indicates a migration bug” with no table, constraint or count — which sent operators looking for a coordinator bug that was not there.
Fix. Back up <data-dir> first, then list the offending rows:
sqlite3 ~/.mcp-coordinator/coordinator.db 'PRAGMA foreign_key_check;'
Each result row is table|rowid|parent|fkid. Delete or re-point those rows
(DELETE FROM <table> WHERE rowid = <rowid>;, or set the column to a row that
exists), then restart. The coordinator never deletes them for you — losing
coordination history to make a schema check pass is not its call.
—
Cannot find module '@babel/runtime/...' — a broken dependency treeSymptom. The daemon stops starting, with a stack trace naming a package you never installed:
Error: Cannot find module '@babel/runtime/helpers/defineProperty'
Require stack:
- node_modules/.pnpm/broker-factory@3.1.15/.../bundle.js
- node_modules/.pnpm/worker-timers@8.0.33/.../bundle.js
- node_modules/.pnpm/mqtt@5.15.2/node_modules/mqtt/build/lib/client.js
Since #282 the CLI recognises this and prints the fix instead of the stack. If you are on an older build, the stack above is what you get.
Fix.
pnpm install --frozen-lockfile
It restores the missing links without touching package.json or
pnpm-lock.yaml (“Lockfile is up to date, resolution step is skipped”).
mcp-coordinator doctor reports the same thing without waiting for a failed
start — its dependency probe runs first and stays usable when the tree is
broken.
Cause. Not your code, and rarely this repo. Two mechanisms are known:
node starting at
that moment fails.node_modules junction. If a git
worktree gets its node_modules as an NTFS junction to the main checkout’s,
git worktree remove --force deletes through the junction and empties the
target. Reproduced twice. Remove the junction first:Remove-Item "<worktree>
ode_modules" -Force # deletes the link, not the target
A test that proves nothing. Running
node -e "require.resolve('@babel/runtime/helpers/defineProperty')"from the repo root fails even when everything works: under pnpm’s strict layout a transitive package is not hoisted to the root. It costs twenty minutes to chase. The only meaningful checks aremcp-coordinator doctor, an actual start, ornode -e "import('./dist/src/serve-http.js')". —
path is outside repoRoot, or an agent that coordinates with nobodyBoth come from the same place: the coordinator turns every declared path into
a repo-relative key, and everything downstream — hot_files,
check_file_conflict, the Layer 1 score — joins those keys by exact string
equality. A path it cannot map to the right key either gets refused or, worse,
gets filed under a key nothing else shares.
Since #379 the roots come from git worktree list, run by the coordinator on
its own COORDINATOR_REPO_ROOT, so an absolute path from any worktree of the
repository maps to the same key as the main checkout — whether the worktree
sits outside the root (git worktree add ../feature-x) or inside it, as Claude
Code’s own do at .claude/worktrees/<name>/.
The in-root case is the one worth knowing about, because it never announced
itself. Before #379 an agent there was accepted and silently stored under
.claude/worktrees/<name>/src/foo.ts while everyone else stored src/foo.ts.
No error, no log line. If you ran agents in native worktrees on an older
build, their file_activity and working_files rows carry those long keys and
will not match anything; they age out with their normal retention.
If you still get the rejection:
COORDINATOR_REPO_ROOT is not the git toplevel. git worktree list
still works from a subdirectory, but Layer 4 reads git log --name-only,
whose paths are always toplevel-relative — set the root to the toplevel or
the two will key differently. git -C "$COORDINATOR_REPO_ROOT" rev-parse
--show-toplevel tells you.git is not on the daemon’s PATH.
The enumeration fails silently by design and you are back to the single
configured root. Check with git -C "$COORDINATOR_REPO_ROOT" worktree list.Note this is per repository, not per project: one daemon still has one
COORDINATOR_REPO_ROOT, so two unrelated repos on one coordinator still merge
their hot_files rows — #279,
still open. One daemon per project remains the supported answer.
EBUSY during tests. A handful of Windows file-handle teardown flakes
in the test suite are known and ignored per project convention – they are a
test-teardown artifact, not a runtime fault. Re-run the suite.chmod 0600 on the SQLite file is
skipped silently on native Windows; secure ~/.mcp-coordinator/data with
NTFS ACLs instead. Or run via the Docker image (Node on Debian slim).server start --daemon exit code. The CLI waits up to 15s for the child
to accept a connection before reporting success, so a busy port or a bind
refused by a sandbox now fails loudly with exit 1 instead of printing a green
message (see section 1). If the daemon is merely slow – a first boot running
schema migrations against a large database – you may see did not accept
connections ... within 15s while it is still coming up. The PID is recorded
either way, so mcp-coordinator server status will show it once it lands,
and server stop can always reach it.</content> </invoke>