claude-code keeps its MCP servers in state, not events (novox/hq ADR 0202)

One key per registration in the module's servers bucket — all.<server> or
<node>.<server> — watched by every node, so a node assigned after a
registration takes it at start, which the mcp.registered event could not do.
Also narrows apply()'s refusal by hand: the builder compiles without strict,
where the discriminated union does not narrow and the build failed.
This commit is contained in:
jochen
2026-10-04 03:48:41 +02:00
parent 295cc59e1e
commit 3668b02b94
6 changed files with 211 additions and 77 deletions
+96 -33
View File
@@ -9,8 +9,10 @@
// - 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 for more nodes than this one is an `mcp.registered` event every node's
// claude-code consumes, so a node that was off takes it when it is back.
// - an MCP server registered through this module is **state, not an event** (novox/hq ADR 0202): 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.
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
@@ -107,7 +109,7 @@ export function apply(p: Paths, handed: Current, write: WriteManaged): Record<st
const local = readCredentials(credentialsPath(p));
const d = decideApply(grantOf(local), grant, switched ? "switch" : "rotation");
if (d.apply) writeCredentials(credentialsPath(p), switched ? replacedBy(local, grant) : withGrant(local, grant));
else outcome = { applied: false, licence: handed.licence, reason: d.reason };
else outcome = { applied: false, licence: handed.licence, reason: "reason" in d ? d.reason : undefined }; // narrowed by hand: the build compiles without strict
// Away from the API key: it goes, with its helper.
rmSync(apiKeyPath(p), { force: true });
rmSync(helperPath(p), { force: true });
@@ -153,38 +155,109 @@ export interface Registration {
nodes?: "all" | string[];
}
function setRegistered(p: Paths, name: string, entry: Record<string, unknown> | null): boolean {
const list = { ...registered(p) } as Record<string, Record<string, unknown>>;
const before = JSON.stringify(list[name] ?? null);
if (entry) list[name] = entry;
else delete list[name];
if (JSON.stringify(list[name] ?? null) === before) return false;
writeFileSync(registryPath(p), JSON.stringify(list, null, 2) + "\n", { mode: 0o600 });
return true;
/** The `servers` state, as this module reaches it through the runtime (`state("servers")` in the SDK). */
export interface ServerState {
put(key: string, value: Record<string, unknown>): Promise<number>;
delete(key: string): Promise<void>;
keys(): Promise<string[]>;
}
const targets = (p: Paths, nodes: Registration["nodes"]) =>
nodes === "all" ? true : Array.isArray(nodes) ? nodes.includes(p.node) : false;
/** One change to the `servers` state, as a watch hands it over. */
export interface ServerChange {
key: string;
op: "put" | "delete";
value?: Record<string, unknown>;
}
/** Register (or with no entry, unregister) here, and announce it for the other nodes asked for. */
export async function registerServer(p: Paths, r: Registration, emit: Emit, write: WriteManaged,
others: () => Promise<string[]>): Promise<Record<string, unknown>> {
/** The key a registration lives at: `all.<server>` for every node, `<node>.<server>` for one. */
export const keyOf = (scope: string, name: string) => `${scope}.${name}`;
/**
* What this node takes from the `servers` state: the entries for every node and for this one, by key —
* kept in memory from the watch, and written through to the module's own file whenever what applies here
* changes, so the managed directory can be rendered without the bus.
*/
export class ServerView {
private readonly entries = new Map<string, Record<string, unknown>>();
constructor(private readonly p: Paths) {}
/** Take one change; answers whether what applies to this node changed. */
take(c: ServerChange): boolean {
const dot = c.key.indexOf(".");
const scope = c.key.slice(0, dot), name = c.key.slice(dot + 1);
if (dot <= 0 || (scope !== "all" && scope !== this.p.node)) return false;
if (c.op === "put" && c.value && entryProblem(name, c.value) === null) this.entries.set(c.key, c.value);
else this.entries.delete(c.key);
return this.writeThrough();
}
/** What applies here: every node's entries, with this node's own laid over them by server name. */
effective(): Servers {
const out: Record<string, Record<string, unknown>> = {};
for (const scope of ["all", this.p.node]) {
for (const [key, entry] of [...this.entries].sort(([a], [b]) => a.localeCompare(b))) {
if (key.startsWith(scope + ".")) out[key.slice(scope.length + 1)] = entry;
}
}
return out;
}
private writeThrough(): boolean {
const now = JSON.stringify(this.effective(), null, 2) + "\n";
let before = "";
try {
before = readFileSync(registryPath(this.p), "utf8");
} catch {
/* none yet */
}
if (now === before) return false;
writeFileSync(registryPath(this.p), now, { mode: 0o600 });
return true;
}
}
/** A change from the watch: take it, and render when what applies here changed. */
export function onServerChange(view: ServerView, c: ServerChange, p: Paths, write: WriteManaged): string | null {
if (!view.take(c)) return null;
renderNow(p, write);
return `${c.op === "put" ? "registered" : "unregistered"} ${c.key}`;
}
const scopesOf = (p: Paths, nodes: Registration["nodes"]): string[] =>
nodes === undefined ? [p.node] : nodes === "all" ? ["all"] : nodes;
/**
* Register (or with no entry, unregister) a server: a put (or delete) per scope in the `servers` state.
* Taken into this node's view at once, so the answer says what it did here; every other node takes it
* from its watch, and a node that joins later from the current state.
*/
export async function registerServer(p: Paths, r: Registration, servers: ServerState, view: ServerView,
write: WriteManaged, others: () => Promise<string[]>): Promise<Record<string, unknown>> {
if (r.entry) {
const problem = entryProblem(r.name, r.entry);
if (problem) return { registered: false, reason: problem };
}
const here = r.nodes === undefined || targets(p, r.nodes);
const changed = here ? setRegistered(p, r.name, r.entry ?? null) : false;
const rendered = here && changed ? renderNow(p, write) : [];
if (r.nodes !== undefined) {
await emit(r.entry ? "mcp.registered" : "mcp.unregistered", { name: r.name, entry: r.entry ?? null, nodes: r.nodes });
const scopes = scopesOf(p, r.nodes);
// Compared before and after rather than read from take(): this node's own watch may hand the view the
// same change first, and then take() here finds nothing new although this call made it.
const before = JSON.stringify(view.effective());
for (const scope of scopes) {
const key = keyOf(scope, r.name);
if (r.entry) await servers.put(key, r.entry);
else await servers.delete(key);
view.take({ key, op: r.entry ? "put" : "delete", value: r.entry });
}
const changedHere = JSON.stringify(view.effective()) !== before;
const here = scopes.includes("all") || scopes.includes(p.node);
const answer: Record<string, unknown> = {
[r.entry ? "registered" : "unregistered"]: r.name,
on: r.nodes === undefined ? [p.node] : r.nodes,
here: here ? (changed ? "changed" : "already so") : "not this node",
rendered,
here: here ? (changedHere ? "changed" : "already so") : "not this node",
rendered: changedHere ? renderNow(p, write) : [],
};
if (!r.entry && view.effective()[r.name]) {
answer.still = `${r.name} still applies here from another registration (for every node, or for this one); unregister that too`;
}
if (r.nodes === undefined) {
// The question the operator wanted asked: here only, or more?
const elsewhere = (await others().catch(() => [] as string[])).filter((n) => n !== p.node);
@@ -195,14 +268,4 @@ export async function registerServer(p: Paths, r: Registration, emit: Emit, writ
return answer;
}
/** An `mcp.registered`/`mcp.unregistered` event from any node's claude-code: apply it if it names this node. */
export function onServerEvent(p: Paths, type: string, body: Registration, write: WriteManaged): string | null {
if (!body?.name || !targets(p, body.nodes)) return null;
const entry = type.endsWith("mcp.registered") ? body.entry ?? null : null;
if (entry && entryProblem(body.name, entry)) return null;
if (!setRegistered(p, body.name, entry)) return null;
renderNow(p, write);
return `${entry ? "registered" : "unregistered"} ${body.name} from an event`;
}
export { MANAGED_DIR };