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:
@@ -4,19 +4,21 @@
|
||||
// runtime's own words. **stdout is the MCP channel**: everything this module says, it says on stderr.
|
||||
//
|
||||
// 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, and takes the module's events: the manager's
|
||||
// licence events and every node's MCP server registrations. node.ts holds the logic.
|
||||
// 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
|
||||
// logic.
|
||||
|
||||
import { readFileSync, watchFile } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { broker } from "@novox/mesh-sdk/messaging";
|
||||
import { emit, on } from "@novox/mesh-sdk/events";
|
||||
import { on } from "@novox/mesh-sdk/events";
|
||||
import { state } from "@novox/mesh-sdk/state";
|
||||
|
||||
import {
|
||||
MANAGED_DIR, SEAT, concerns, keypair, offerLogin, onServerEvent, pull, readJson, registerServer,
|
||||
registered, renderNow, type Ask, type Paths, type Registration, type WriteManaged,
|
||||
MANAGED_DIR, SEAT, ServerView, concerns, keypair, offerLogin, onServerChange, pull, readJson, registerServer,
|
||||
registered, renderNow, type Ask, type Paths, type Registration, type ServerChange, type ServerState, type WriteManaged,
|
||||
} from "../node.js";
|
||||
import { grantOf, holdsLogin, readCredentials } from "../grant.js";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -90,6 +92,13 @@ 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. */
|
||||
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. */
|
||||
let view: ServerView | null = null;
|
||||
const viewOf = (p: Paths) => (view ??= new ServerView(p));
|
||||
|
||||
function tools(p: Paths): ToolDefinition[] {
|
||||
const nodesArg = { type: "string", description: 'more nodes: "all" for every node running claude-code, or a comma-separated list; absent is this node only' };
|
||||
const nodesOf = (v: unknown): Registration["nodes"] =>
|
||||
@@ -115,13 +124,13 @@ function tools(p: Paths): ToolDefinition[] {
|
||||
},
|
||||
{
|
||||
name: "claude_code_mcp_list",
|
||||
description: "The MCP servers registered on this node through this module, beside the console (`mesh`) and those set in the module's settings.",
|
||||
description: "The MCP servers registered through this module: those that apply on this node (beside the console, `mesh`, and those set in the module's settings), and every registration on the mesh, by key — `all.<server>` for every node, `<node>.<server>` for one.",
|
||||
input: {},
|
||||
run: async () => ({ registered: registered(p) }),
|
||||
run: async () => ({ here: registered(p), everywhere: await servers().keys() }),
|
||||
},
|
||||
{
|
||||
name: "claude_code_mcp_register",
|
||||
description: "Register an MCP server with Claude Code on this node — an http/sse server by url, or a stdio server by command — and say which other nodes run claude-code, so it can be registered there too.",
|
||||
description: "Register an MCP server with Claude Code on this node, every node, or a list — an http/sse server by url, or a stdio server by command. Kept on the bus, so a node that joins later takes it too. Never put a secret in env or headers: the mesh refuses one.",
|
||||
input: {
|
||||
name: { type: "string", description: "the server's name: letters, digits, - and _" },
|
||||
type: { type: "string", description: "http, sse or stdio (default stdio when a command is given, http when a url is)" },
|
||||
@@ -135,14 +144,14 @@ function tools(p: Paths): ToolDefinition[] {
|
||||
run: async (a) => {
|
||||
const entry: Record<string, unknown> = { type: a.type ?? (a.url ? "http" : "stdio") };
|
||||
for (const k of ["url", "command", "args", "env", "headers"]) if (a[k] !== undefined) entry[k] = a[k];
|
||||
return registerServer(p, { name: String(a.name ?? ""), entry, nodes: nodesOf(a.nodes) }, emit, writeManaged, nodesRunningMe);
|
||||
return registerServer(p, { name: String(a.name ?? ""), entry, nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "claude_code_mcp_unregister",
|
||||
description: "Remove an MCP server registered through this module, on this node or more.",
|
||||
input: { name: { type: "string", description: "the server's name" }, nodes: nodesArg },
|
||||
run: async (a) => registerServer(p, { name: String(a.name ?? ""), nodes: nodesOf(a.nodes) }, emit, writeManaged, nodesRunningMe),
|
||||
run: async (a) => registerServer(p, { name: String(a.name ?? ""), nodes: nodesOf(a.nodes) }, servers(), viewOf(p), writeManaged, nodesRunningMe),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -171,10 +180,20 @@ if (p) {
|
||||
say(JSON.stringify(await pull(p, ask, writeManaged).catch((e) => ({ failed: String(e) }))));
|
||||
}).catch(loud("the licence events"));
|
||||
|
||||
void on<Registration>("claude-code.mcp.*", async (event) => {
|
||||
const done = onServerEvent(p, event.type, event.body, writeManaged);
|
||||
if (done) say(done);
|
||||
}).catch(loud("the MCP server 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) => {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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"));
|
||||
|
||||
Reference in New Issue
Block a user