feat(cli): websocket client + MCP tool integration

broker-client: full WS client with hello handshake + ack, auto-reconnect
with exponential backoff (1s → 30s capped), in-memory outbound queue
(max 100) during reconnect, 500-entry push buffer for check_messages.

MCP tool integration:
- send_message: "slug:target" prefix or single-mesh fast path
- check_messages: drains push buffers across all clients
- set_status: fans manual override across all connected meshes
- set_summary: stubbed (broker protocol extension needed)
- list_peers: stubbed — lists connected mesh slugs + statuses

manager module holds Map<meshId, BrokerClient>, starts on MCP server
boot for every joined mesh in ~/.claudemesh/config.json.

new CLI command: seed-test-mesh injects a mesh row for dev testing.

also fixes a broker-side hello race: handleHello sent hello_ack before
the caller closure assigned presenceId, so clients sending right after
the ack hit the no_hello check. Fix: return presenceId, caller sets
closure var, THEN sends hello_ack. Queue drain is fire-and-forget now.

round-trip verified: two clients, A→B, push received with correct
senderPubkey + ciphertext. 44/44 broker tests still pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-04-04 22:30:11 +01:00
parent 8931296e82
commit 20d968f989
8 changed files with 709 additions and 79 deletions

View File

@@ -0,0 +1,55 @@
/**
* Process-wide registry of BrokerClient connections, keyed by meshId.
*
* The MCP server lazily starts a client per joined mesh on startup,
* keeps them alive for the life of the process, and uses them to
* service MCP tool calls.
*/
import { BrokerClient } from "./client";
import type { Config, JoinedMesh } from "../state/config";
import { env } from "../env";
const clients = new Map<string, BrokerClient>();
/** Ensure a BrokerClient exists + is connecting/open for this mesh. */
export async function ensureClient(mesh: JoinedMesh): Promise<BrokerClient> {
const existing = clients.get(mesh.meshId);
if (existing) return existing;
const client = new BrokerClient(mesh, { debug: env.CLAUDEMESH_DEBUG });
clients.set(mesh.meshId, client);
try {
await client.connect();
} catch {
// Connect failed → client is in "reconnecting" state, leave it
// wired so tool calls can surface the status.
}
return client;
}
/** Start clients for every joined mesh. Called once on MCP server start. */
export async function startClients(config: Config): Promise<void> {
await Promise.allSettled(config.meshes.map(ensureClient));
}
/** Look up a client by mesh slug (human-friendly) or meshId. */
export function findClient(needle: string): BrokerClient | null {
// Try meshId first, then slug.
const byId = clients.get(needle);
if (byId) return byId;
for (const c of clients.values()) {
if (c.meshSlug === needle) return c;
}
return null;
}
/** All clients across all meshes. */
export function allClients(): BrokerClient[] {
return [...clients.values()];
}
/** Close every client (shutdown hook). */
export function stopAll(): void {
for (const c of clients.values()) c.close();
clients.clear();
}