From 5398ca683310de8952c9881115c893030831e795 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:22:06 +0100 Subject: [PATCH] feat: make MCP server registrations persistent across peer disconnects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persistent MCP servers (opt-in via `persistent: true`) survive host disconnects — they appear as offline in mcp_list and auto-restore when the host reconnects. Ephemeral servers (default) still clean up on disconnect. Offline servers return a clear error on mcp_call with time-since-disconnect info. Co-Authored-By: Claude Sonnet 4.6 --- apps/broker/src/index.ts | 62 ++++++++++++++++++++++++++++++++++++-- apps/broker/src/types.ts | 3 ++ apps/cli/src/mcp/server.ts | 19 ++++++++---- apps/cli/src/mcp/tools.ts | 4 +++ apps/cli/src/ws/client.ts | 3 +- 5 files changed, 82 insertions(+), 9 deletions(-) diff --git a/apps/broker/src/index.ts b/apps/broker/src/index.ts index 9973409..d04f939 100644 --- a/apps/broker/src/index.ts +++ b/apps/broker/src/index.ts @@ -196,6 +196,14 @@ interface McpRegisteredServer { /** Keyed by "meshId:serverName" */ const mcpRegistry = new Map(); +/** Human-readable relative time string from an ISO timestamp. */ +function relativeTimeStr(iso: string): string { + const ms = Date.now() - new Date(iso).getTime(); + if (ms < 60_000) return `${Math.round(ms / 1000)}s ago`; + if (ms < 3_600_000) return `${Math.round(ms / 60_000)}m ago`; + return `${Math.round(ms / 3_600_000)}h ago`; +} + /** Pending MCP call forwards: callId → { resolve, timer } */ const mcpCallResolvers = new Map void; @@ -1236,6 +1244,34 @@ function handleConnection(ws: WebSocket): void { if (peer.meshId !== joinedConn.meshId) continue; sendToPeer(pid, joinMsg); } + // Restore persistent MCP servers owned by this member + for (const [, entry] of mcpRegistry) { + if (entry.memberId === joinedConn.memberId && entry.meshId === joinedConn.meshId && !entry.online) { + entry.online = true; + entry.presenceId = presenceId; + entry.offlineSince = undefined; + entry.hostedByName = joinedConn.displayName; + // Broadcast restoration + const restoreMsg: WSPushMessage = { + type: "push", + subtype: "system", + event: "mcp_restored", + eventData: { serverName: entry.serverName, hostedBy: joinedConn.displayName }, + messageId: crypto.randomUUID(), + meshId: joinedConn.meshId, + senderPubkey: "system", + priority: "low", + nonce: "", + ciphertext: "", + createdAt: new Date().toISOString(), + }; + for (const [pid2, peer2] of connections) { + if (peer2.meshId !== joinedConn.meshId) continue; + sendToPeer(pid2, restoreMsg); + } + log.info("mcp_restored", { server: entry.serverName, member: joinedConn.displayName }); + } + } } return; } @@ -2625,7 +2661,7 @@ function handleConnection(ws: WebSocket): void { description: mr.description, tools: mr.tools, hostedByName: conn.displayName, - persistent: !!(mr as any).persistent, + persistent: !!mr.persistent, online: true, memberId: conn.memberId, registeredAt: new Date().toISOString(), @@ -2700,6 +2736,8 @@ function handleConnection(ws: WebSocket): void { description: string; hostedBy: string; tools: Array<{ name: string; description: string }>; + online: boolean; + offlineSince?: string; }> = []; for (const [, entry] of mcpRegistry) { if (entry.meshId !== conn.meshId) continue; @@ -2708,6 +2746,8 @@ function handleConnection(ws: WebSocket): void { description: entry.description, hostedBy: entry.hostedByName, tools: entry.tools.map((t) => ({ name: t.name, description: t.description })), + online: entry.online, + ...(entry.offlineSince ? { offlineSince: entry.offlineSince } : {}), }); } sendToPeer(presenceId, { @@ -2733,10 +2773,28 @@ function handleConnection(ws: WebSocket): void { }); break; } + // Check if server is offline (persistent but host disconnected) + if (!server.online) { + const ago = server.offlineSince + ? ` who disconnected ${relativeTimeStr(server.offlineSince)}` + : ""; + sendToPeer(presenceId, { + type: "mcp_call_result", + error: `Server '${mc.serverName}' is offline — hosted by ${server.hostedByName}${ago}. It will restore when they reconnect.`, + ...(_reqId ? { _reqId } : {}), + }); + break; + } // Check hosting peer is still connected const hostConn = connections.get(server.presenceId); if (!hostConn) { - mcpRegistry.delete(callKey); + if (server.persistent) { + server.online = false; + server.offlineSince = new Date().toISOString(); + server.presenceId = ""; + } else { + mcpRegistry.delete(callKey); + } sendToPeer(presenceId, { type: "mcp_call_result", error: `MCP server "${mc.serverName}" host disconnected`, diff --git a/apps/broker/src/types.ts b/apps/broker/src/types.ts index ad17fa3..9f2c6a5 100644 --- a/apps/broker/src/types.ts +++ b/apps/broker/src/types.ts @@ -737,6 +737,7 @@ export interface WSMcpRegisterMessage { serverName: string; description: string; tools: Array<{ name: string; description: string; inputSchema: Record }>; + persistent?: boolean; _reqId?: string; } @@ -787,6 +788,8 @@ export interface WSMcpListResultMessage { description: string; hostedBy: string; tools: Array<{ name: string; description: string }>; + online: boolean; + offlineSince?: string; }>; _reqId?: string; } diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index e756967..e04ad12 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -1144,27 +1144,32 @@ Your message mode is "${messageMode}". // --- MCP Proxy --- case "mesh_mcp_register": { - const { server_name, description, tools: regTools } = (args ?? {}) as { + const { server_name, description, tools: regTools, persistent: regPersistent } = (args ?? {}) as { server_name?: string; description?: string; tools?: Array<{ name: string; description: string; inputSchema: Record }>; + persistent?: boolean; }; if (!server_name || !description || !regTools?.length) return text("mesh_mcp_register: `server_name`, `description`, and `tools` required", true); const client = allClients()[0]; if (!client) return text("mesh_mcp_register: not connected", true); - const result = await client.mcpRegister(server_name, description, regTools); + const result = await client.mcpRegister(server_name, description, regTools, regPersistent); if (!result) return text("mesh_mcp_register: broker did not acknowledge", true); - return text(`Registered MCP server "${result.serverName}" with ${result.toolCount} tool(s). Other peers can now call its tools via mesh_tool_call.`); + const persistLabel = regPersistent ? " (persistent — survives disconnect)" : ""; + return text(`Registered MCP server "${result.serverName}" with ${result.toolCount} tool(s)${persistLabel}. Other peers can now call its tools via mesh_tool_call.`); } case "mesh_mcp_list": { const client = allClients()[0]; if (!client) return text("mesh_mcp_list: not connected", true); const servers = await client.mcpList(); if (servers.length === 0) return text("No MCP servers registered in the mesh."); - const lines = servers.map((s) => { - const toolList = s.tools.map((t) => ` - **${t.name}**: ${t.description}`).join("\n"); - return `- **${s.name}** (hosted by ${s.hostedBy}): ${s.description}\n${toolList}`; + const lines = servers.map((s: any) => { + const toolList = s.tools.map((t: any) => ` - **${t.name}**: ${t.description}`).join("\n"); + const status = s.online === false + ? ` [OFFLINE${s.offlineSince ? ` since ${s.offlineSince}` : ""}]` + : ""; + return `- **${s.name}** (hosted by ${s.hostedBy})${status}: ${s.description}\n${toolList}`; }); return text(`${servers.length} MCP server(s) in mesh:\n${lines.join("\n")}`); } @@ -1383,6 +1388,8 @@ Your message mode is "${messageMode}". content = `[system] New MCP server available: "${data.serverName}" (hosted by ${data.hostedBy}). Tools: ${tools}. Use mesh_tool_call to invoke.`; } else if (eventName === "mcp_unregistered") { content = `[system] MCP server "${data.serverName}" removed (was hosted by ${data.hostedBy})`; + } else if (eventName === "mcp_restored") { + content = `[system] MCP server "${data.serverName}" is back online (hosted by ${data.hostedBy})`; } else { content = `[system] ${eventName}: ${JSON.stringify(data)}`; } diff --git a/apps/cli/src/mcp/tools.ts b/apps/cli/src/mcp/tools.ts index 0fb14db..ce72117 100644 --- a/apps/cli/src/mcp/tools.ts +++ b/apps/cli/src/mcp/tools.ts @@ -682,6 +682,10 @@ export const TOOLS: Tool[] = [ }, description: "Tool definitions to expose", }, + persistent: { + type: "boolean", + description: "If true, registration survives peer disconnect. Other peers see it as 'offline' until you reconnect. Default: false", + }, }, required: ["server_name", "description", "tools"], }, diff --git a/apps/cli/src/ws/client.ts b/apps/cli/src/ws/client.ts index dcba5ce..1fb9d74 100644 --- a/apps/cli/src/ws/client.ts +++ b/apps/cli/src/ws/client.ts @@ -931,6 +931,7 @@ export class BrokerClient { serverName: string, description: string, tools: Array<{ name: string; description: string; inputSchema: Record }>, + persistent?: boolean, ): Promise<{ serverName: string; toolCount: number } | null> { if (!this.ws || this.ws.readyState !== this.ws.OPEN) return null; return new Promise((resolve) => { @@ -938,7 +939,7 @@ export class BrokerClient { this.mcpRegisterResolvers.set(reqId, { resolve, timer: setTimeout(() => { if (this.mcpRegisterResolvers.delete(reqId)) resolve(null); }, 5_000) }); - this.ws!.send(JSON.stringify({ type: "mcp_register", serverName, description, tools, _reqId: reqId })); + this.ws!.send(JSON.stringify({ type: "mcp_register", serverName, description, tools, ...(persistent ? { persistent: true } : {}), _reqId: reqId })); }); }