Merge pull request 'claude-code watches its MCP servers beside the handshake, and retries' (#257) from fix/claude-code-watches-without-blocking into main

This commit was merged in pull request #257.
This commit is contained in:
2026-10-04 09:21:26 +00:00
3 changed files with 21 additions and 12 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ this node a subscription token. Nothing else under the home is read or written.
Everything between this module and the rest of the mesh is NATS, in three kinds: an **event** says that
something happened and carries no secret, because a stream keeps it; a **request** carries a token,
because nothing keeps it (hq design 32 §10); and **state** is the current value of something every node
must see, a node that joins later included — kept, so it carries no secret either (hq ADR 0202).
must see, a node that joins later included — kept, so it carries no secret either (hq ADR 0201).
| what | how |
|---|---|
+1 -1
View File
@@ -9,7 +9,7 @@
// - a login a person made here — a refresh token this module never writes — is offered to the seat at
// once, sealed to the seat's key: the one moment a refresh token travels, because the login made the
// manager's stale;
// - an MCP server registered through this module is **state, not an event** (novox/hq ADR 0202): one
// - an MCP server registered through this module is **state, not an event** (novox/hq ADR 0201): one
// key per server in the module's `servers` bucket — `all.<server>` for every node, `<node>.<server>`
// for one — which every node watches. A node that joins later, or was off, reads the whole current set
// at start; unregistering is a delete. A secret never goes in an entry: the runtime refuses one.
+19 -10
View File
@@ -5,7 +5,7 @@
//
// At start it renders the agent's managed directory, asks the licence manager for this node's token,
// begins watching the credentials file for a login, takes the manager's licence events, and watches the
// module's `servers` state — every node's MCP server registrations (novox/hq ADR 0202). node.ts holds the
// module's `servers` state — every node's MCP server registrations (novox/hq ADR 0201). node.ts holds the
// logic.
import { readFileSync, watchFile } from "node:fs";
@@ -92,7 +92,7 @@ function status(p: Paths): Record<string, unknown> {
};
}
/** The module's MCP servers on the bus (ADR 0202): its own state, which every node of it watches. */
/** The module's MCP servers on the bus (ADR 0201): its own state, which every node of it watches. */
const servers = () => state<Record<string, unknown>>("servers") as unknown as ServerState;
/** What this node takes from that state, kept from the watch. One per process. */
@@ -180,20 +180,29 @@ if (p) {
say(JSON.stringify(await pull(p, ask, writeManaged).catch((e) => ({ failed: String(e) }))));
}).catch(loud("the licence events"));
// Every node's MCP servers: the whole current set first, then each change (ADR 0202). Awaited, so the
// managed directory holds every server that applies here before the bundle says what it serves.
try {
await state<Record<string, unknown>>("servers").watch((c) => {
// Every node's MCP servers: the whole current set first, then each change (ADR 0201). **Not awaited
// where the module is imported**: the runtime waits on the handshake, and a bucket that is not on the
// bus yet — or a grant the bus has not reloaded — answers late; awaited here, that left the bundle
// unable to answer `initialize` in time and the module unserved (found on its first assignment). So it
// watches beside the handshake and asks again until the state answers; until then the managed
// directory holds what the file kept from the last run.
const watchServers = (attempt = 0): void => {
state<Record<string, unknown>>("servers").watch((c) => {
try {
const done = onServerChange(viewOf(p), c as ServerChange, p, writeManaged);
if (done) say(done);
} catch (err) {
loud(`taking ${c.op} ${c.key}`)(err); // the view took it; the next render writes it
}
});
} catch (err) {
loud("watching the MCP servers")(err);
}
}).then(
() => say(`watching the MCP servers${attempt ? ` (after ${attempt} refusal(s))` : ""}`),
(err) => {
const wait = [2, 5, 10, 30][attempt] ?? 60;
say(`the MCP servers cannot be watched yet (${err instanceof Error ? err.message : String(err)}); asking again in ${wait}s`);
setTimeout(() => watchServers(attempt + 1), wait * 1000);
});
};
watchServers();
// Catch up once at start: a node that was off takes its current token now.
void pull(p, ask, writeManaged).then((r) => say(`at start: ${JSON.stringify(r)}`), loud("asking for this node's token at start"));